├── app ├── .gitignore ├── magisk │ ├── updater-script │ ├── updates │ │ └── release │ │ │ ├── changelog.txt │ │ │ └── info.json │ ├── customize.sh │ ├── boot_common.sh │ ├── post-fs-data.sh │ ├── service.sh │ └── update-binary ├── src │ └── main │ │ ├── res │ │ ├── resources.properties │ │ ├── values │ │ │ ├── strings_notranslate.xml │ │ │ ├── dimens.xml │ │ │ ├── styles.xml │ │ │ ├── ids.xml │ │ │ ├── colors.xml │ │ │ └── themes.xml │ │ ├── menu │ │ │ └── record_rules.xml │ │ ├── mipmap-anydpi │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── drawable │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_quick_settings.xml │ │ │ └── ic_launcher_foreground.xml │ │ ├── layout │ │ │ ├── bottom_sheet_chip.xml │ │ │ ├── material_switch_preference.xml │ │ │ ├── settings_activity.xml │ │ │ ├── dialog_text_input.xml │ │ │ ├── output_format_bottom_sheet.xml │ │ │ └── output_directory_bottom_sheet.xml │ │ ├── xml │ │ │ ├── record_rules_preferences.xml │ │ │ └── root_preferences.xml │ │ ├── values-es │ │ │ └── strings.xml │ │ ├── values-ar │ │ │ └── strings.xml │ │ └── values-ur │ │ │ └── strings.xml │ │ ├── java │ │ └── com │ │ │ └── chiller3 │ │ │ └── bcr │ │ │ ├── rule │ │ │ ├── PickContactGroupAlert.kt │ │ │ ├── RecordRulesActivity.kt │ │ │ ├── PickContactGroupActivity.kt │ │ │ ├── PickContactGroup.kt │ │ │ ├── RecordRulesTouchHelperCallback.kt │ │ │ ├── PickContactGroupViewModel.kt │ │ │ ├── PickContactGroupFragment.kt │ │ │ ├── RecordRulesLayoutManager.kt │ │ │ ├── RecordRuleEditorViewModel.kt │ │ │ └── RecordRulesFragment.kt │ │ │ ├── extension │ │ │ ├── CursorExtensions.kt │ │ │ ├── CallDetailsExtensions.kt │ │ │ ├── AudioFormatExtensions.kt │ │ │ ├── MimeTypeMapExtensions.kt │ │ │ └── UriExtensions.kt │ │ │ ├── settings │ │ │ ├── SettingsAlert.kt │ │ │ ├── SettingsActivity.kt │ │ │ ├── DialerCodeReceiver.kt │ │ │ ├── OpenPersistentDocumentTree.kt │ │ │ ├── SettingsViewModel.kt │ │ │ └── OutputDirectoryBottomSheetFragment.kt │ │ │ ├── DirectBootMigrationReceiver.kt │ │ │ ├── TempFile.kt │ │ │ ├── IoHelpers.kt │ │ │ ├── format │ │ │ ├── MediaMuxerContainer.kt │ │ │ ├── FlacFormat.kt │ │ │ ├── AmrNbFormat.kt │ │ │ ├── AmrWbFormat.kt │ │ │ ├── Container.kt │ │ │ ├── WaveFormat.kt │ │ │ ├── Encoder.kt │ │ │ ├── OpusFormat.kt │ │ │ ├── PassthroughEncoder.kt │ │ │ ├── AacFormat.kt │ │ │ ├── AmrContainer.kt │ │ │ ├── FormatParamInfo.kt │ │ │ ├── WaveContainer.kt │ │ │ ├── MediaCodecEncoder.kt │ │ │ └── SampleRateInfo.kt │ │ │ ├── output │ │ │ ├── Retention.kt │ │ │ ├── OutputFile.kt │ │ │ ├── PhoneNumber.kt │ │ │ └── CallMetadata.kt │ │ │ ├── view │ │ │ ├── LongClickablePreference.kt │ │ │ └── ChipGroupCentered.kt │ │ │ ├── PreferenceBaseFragment.kt │ │ │ ├── Logcat.kt │ │ │ ├── RecorderApplication.kt │ │ │ ├── PreferenceBaseActivity.kt │ │ │ ├── Permissions.kt │ │ │ ├── RecorderProvider.kt │ │ │ ├── RecorderTileService.kt │ │ │ ├── standalone │ │ │ └── ClearPackageManagerCaches.kt │ │ │ ├── dialog │ │ │ ├── MinDurationDialogFragment.kt │ │ │ ├── FileRetentionDialogFragment.kt │ │ │ └── FormatParamDialogFragment.kt │ │ │ ├── NotificationActionService.kt │ │ │ └── template │ │ │ └── TemplateSyntaxHighlighter.kt │ │ └── AndroidManifest.xml ├── images │ ├── dark.png │ ├── light.png │ └── icon.svg └── proguard-rules.pro ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── libs.versions.toml └── update_verification.py ├── .gitignore ├── RELEASE.md ├── settings.gradle.kts ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── gradle.properties └── gradlew.bat /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/magisk/updater-script: -------------------------------------------------------------------------------- 1 | #MAGISK 2 | -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | unqualifiedResLocale=en-US 2 | -------------------------------------------------------------------------------- /app/images/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxiaolong/BCR/HEAD/app/images/dark.png -------------------------------------------------------------------------------- /app/images/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxiaolong/BCR/HEAD/app/images/light.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxiaolong/BCR/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/magisk/updates/release/changelog.txt: -------------------------------------------------------------------------------- 1 | The changelog can be found at: [`CHANGELOG.md`](https://github.com/chenxiaolong/BCR/blob/master/CHANGELOG.md). 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings_notranslate.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | BCR 8 | -------------------------------------------------------------------------------- /app/magisk/updates/release/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "zipUrl": "https://github.com/chenxiaolong/BCR/releases/download/v1.87/BCR-1.87-release.zip", 3 | "changelog": "https://github.com/chenxiaolong/BCR/raw/v1.87/app/magisk/updates/release/changelog.txt", 4 | "version": "1.87", 5 | "versionCode": 87808 6 | } -------------------------------------------------------------------------------- /app/magisk/customize.sh: -------------------------------------------------------------------------------- 1 | #!/system/bin/sh 2 | 3 | # SPDX-FileCopyrightText: 2024 Harin Lee 4 | # SPDX-License-Identifier: GPL-3.0-only 5 | 6 | # Delete addon.d script when installing as Magisk module to prevent 7 | # update_engine from executing it on A/B devices. 8 | [ -n "$MODPATH" ] && rm -r "$MODPATH/system/addon.d" 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/PickContactGroupAlert.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | sealed interface PickContactGroupAlert { 9 | data class QueryFailed(val error: String) : PickContactGroupAlert 10 | } 11 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | The changelog can be found at: [`CHANGELOG.md`](./CHANGELOG.md). 2 | 3 | --- 4 | 5 | See [`README.md`](./README.md) for information on how to install and use BCR. 6 | 7 | The downloads are digitally signed. Please consider [verifying the digital signatures](./README.md#verifying-digital-signatures) because BCR is installed as a privileged system app. 8 | -------------------------------------------------------------------------------- /app/src/main/res/menu/record_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/extension/CursorExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.extension 7 | 8 | import android.database.Cursor 9 | 10 | fun Cursor.asSequence() = generateSequence(seed = takeIf { it.moveToFirst() }) { 11 | takeIf { it.moveToNext() } 12 | } 13 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 28dp 8 | 16dp 9 | 28dp 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/layout/bottom_sheet_chip.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/settings/SettingsAlert.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.settings 7 | 8 | import android.net.Uri 9 | 10 | sealed interface SettingsAlert { 11 | data class LogcatSucceeded(val uri: Uri) : SettingsAlert 12 | 13 | data class LogcatFailed(val uri: Uri, val error: String) : SettingsAlert 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/res/values/ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | pluginManagement { 7 | repositories { 8 | gradlePluginPortal() 9 | google() 10 | mavenCentral() 11 | } 12 | } 13 | dependencyResolutionManagement { 14 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | rootProject.name = "BCR" 21 | include(":app") 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/extension/CallDetailsExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.extension 7 | 8 | import android.telecom.Call 9 | import com.chiller3.bcr.output.PhoneNumber 10 | 11 | val Call.Details.phoneNumber: PhoneNumber? 12 | get() = handle?.phoneNumber?.let { 13 | try { 14 | PhoneNumber(it) 15 | } catch (e: IllegalArgumentException) { 16 | null 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/settings/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.settings 7 | 8 | import androidx.fragment.app.Fragment 9 | import com.chiller3.bcr.PreferenceBaseActivity 10 | import com.chiller3.bcr.R 11 | 12 | class SettingsActivity : PreferenceBaseActivity() { 13 | override val titleResId: Int = R.string.app_name_full 14 | 15 | override val showUpButton: Boolean = false 16 | 17 | override fun createFragment(): Fragment = SettingsFragment() 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/RecordRulesActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import androidx.fragment.app.Fragment 9 | import com.chiller3.bcr.PreferenceBaseActivity 10 | import com.chiller3.bcr.R 11 | 12 | class RecordRulesActivity : PreferenceBaseActivity() { 13 | override val titleResId: Int = R.string.pref_record_rules_name 14 | 15 | override val showUpButton: Boolean = true 16 | 17 | override fun createFragment(): Fragment = RecordRulesFragment() 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/extension/AudioFormatExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.extension 7 | 8 | import android.media.AudioFormat 9 | import android.os.Build 10 | 11 | val AudioFormat.frameSizeInBytesCompat: Int 12 | get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 13 | frameSizeInBytes 14 | } else{ 15 | // Hardcoded for Android 9 compatibility only 16 | assert(encoding == AudioFormat.ENCODING_PCM_16BIT) 17 | 2 * channelCount 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | #ff008000 9 | #ff0000ff 10 | #ffff00ff 11 | #ffff0000 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/material_switch_preference.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/DirectBootMigrationReceiver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.content.BroadcastReceiver 9 | import android.content.Context 10 | import android.content.Intent 11 | 12 | class DirectBootMigrationReceiver : BroadcastReceiver() { 13 | override fun onReceive(context: Context, intent: Intent?) { 14 | if (intent?.action != Intent.ACTION_BOOT_COMPLETED) { 15 | return 16 | } 17 | 18 | context.startService(Intent(context, DirectBootMigrationService::class.java)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/TempFile.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.content.Context 9 | import java.nio.file.Path 10 | import kotlin.io.path.deleteExisting 11 | 12 | fun withTempFile(context: Context, prefix: String? = null, block: (Path) -> R): R { 13 | // Kotlin/Java has no O_CREAT|O_EXCL-based mechanism for securely creating temp files 14 | val temp = kotlin.io.path.createTempFile(context.cacheDir.toPath(), prefix) 15 | try { 16 | return block(temp) 17 | } finally { 18 | temp.deleteExisting() 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 10 | 11 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/PickContactGroupActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import androidx.fragment.app.Fragment 9 | import com.chiller3.bcr.PreferenceBaseActivity 10 | import com.chiller3.bcr.R 11 | 12 | class PickContactGroupActivity : PreferenceBaseActivity() { 13 | override val titleResId: Int = R.string.pick_contact_group_title 14 | 15 | override val showUpButton: Boolean = true 16 | 17 | override fun createFragment(): Fragment = PickContactGroupFragment() 18 | 19 | companion object { 20 | const val RESULT_CONTACT_GROUP = "contact_group" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/settings/DialerCodeReceiver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2025 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.settings 7 | 8 | import android.content.BroadcastReceiver 9 | import android.content.Context 10 | import android.content.Intent 11 | import android.telephony.TelephonyManager 12 | 13 | class DialerCodeReceiver : BroadcastReceiver() { 14 | override fun onReceive(context: Context, intent: Intent?) { 15 | if (intent?.action != TelephonyManager.ACTION_SECRET_CODE) { 16 | return 17 | } 18 | 19 | context.startActivity(Intent(context, SettingsActivity::class.java).apply { 20 | setAction(Intent.ACTION_MAIN) 21 | setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/res/xml/record_rules_preferences.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 17 | 18 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | jobs: 7 | build: 8 | name: Build project 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out repository 12 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Validate gradle wrapper checksum 17 | uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 18 | 19 | - name: Set up JDK 21 20 | uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 21 | with: 22 | distribution: 'temurin' 23 | java-version: 21 24 | cache: gradle 25 | 26 | - name: Build and test 27 | # Debug build only since release builds require a signing key 28 | run: ./gradlew --no-daemon build zipDebug -x assembleRelease 29 | -------------------------------------------------------------------------------- /app/magisk/boot_common.sh: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Andrew Gunnerson 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | # 4 | # source "${0%/*}/boot_common.sh" 5 | 6 | exec >"${1}" 2>&1 7 | 8 | mod_dir=${0%/*} 9 | 10 | header() { 11 | echo "----- ${*} -----" 12 | } 13 | 14 | module_prop() { 15 | grep "^${1}=" "${mod_dir}/module.prop" | cut -d= -f2 16 | } 17 | 18 | run_cli_apk() { 19 | CLASSPATH="${cli_apk}" app_process / "${@}" & 20 | pid=${!} 21 | wait "${pid}" 22 | echo "Exit status: ${?}" 23 | echo "Logcat:" 24 | logcat -d --pid "${pid}" 25 | } 26 | 27 | app_id=$(module_prop id) 28 | app_version=$(module_prop version) 29 | cli_apk=$(echo "${mod_dir}"/system/priv-app/"${app_id}"/app-*.apk) 30 | 31 | header Environment 32 | echo "Timestamp: $(date)" 33 | echo "Script: ${0}" 34 | echo "App ID: ${app_id}" 35 | echo "App version: ${app_version}" 36 | echo "CLI APK: ${cli_apk}" 37 | echo "UID/GID/Context: $(id)" 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/settings/OpenPersistentDocumentTree.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.settings 7 | 8 | import android.content.Context 9 | import android.content.Intent 10 | import android.net.Uri 11 | import androidx.activity.result.contract.ActivityResultContracts 12 | 13 | /** 14 | * A small wrapper around [ActivityResultContracts.OpenDocumentTree] that requests write-persistable 15 | * URIs when opening directories. 16 | */ 17 | class OpenPersistentDocumentTree : ActivityResultContracts.OpenDocumentTree() { 18 | override fun createIntent(context: Context, input: Uri?): Intent { 19 | val intent = super.createIntent(context, input) 20 | 21 | intent.addFlags( 22 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 23 | or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION 24 | or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION 25 | ) 26 | 27 | return intent 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/extension/MimeTypeMapExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.extension 7 | 8 | import android.webkit.MimeTypeMap 9 | 10 | private val MIME_TYPES_COMPAT = hashMapOf( 11 | // Android 9 and 10 didn't switch to mime-support [1] yet. Instead, their MimeTypeMap used 12 | // MimeUtils, which supported significantly fewer MIME types. 13 | // [1] https://android.googlesource.com/platform/external/mime-support/+/refs/heads/main/mime.types 14 | // [2] https://android.googlesource.com/platform/libcore/+/refs/tags/android-9.0.0_r61/luni/src/main/java/libcore/net/MimeUtils.java 15 | "audio/mp4" to "m4a", 16 | ) 17 | 18 | fun MimeTypeMap.hasExtensionCompat(extension: String): Boolean = 19 | hasExtension(extension) || extension in MIME_TYPES_COMPAT.values 20 | 21 | fun MimeTypeMap.getExtensionFromMimeTypeCompat(mimeType: String): String? = 22 | getExtensionFromMimeType(mimeType) ?: MIME_TYPES_COMPAT[mimeType] 23 | -------------------------------------------------------------------------------- /app/magisk/post-fs-data.sh: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 2 | # SPDX-License-Identifier: GPL-3.0-only 3 | 4 | # On some devices, the system time is set too late in the boot process. This, 5 | # for some reason, causes the package manager service to not update the cache 6 | # entry for BCR despite the mtime of the apk being newer than the mtime of the 7 | # cache entry [1]. This causes BCR to crash with an obscure error about the app 8 | # theme not being derived from Theme.AppCompat. This script works around the 9 | # issue by forcibly deleting BCR's package manager cache entry on every boot. 10 | # 11 | # [1] https://cs.android.com/android/platform/superproject/+/android-13.0.0_r42:frameworks/base/services/core/java/com/android/server/pm/parsing/PackageCacher.java;l=139 12 | 13 | source "${0%/*}/boot_common.sh" /data/local/tmp/bcr_post-fs-data.log 14 | 15 | header Timestamps 16 | ls -ldZ "${cli_apk%/*}" 17 | find /data/system/package_cache -name "${app_id}-*" -exec ls -ldZ {} \+ 18 | 19 | header Clear package manager caches 20 | run_cli_apk com.chiller3.bcr.standalone.ClearPackageManagerCachesKt 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/IoHelpers.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.system.Os 9 | import java.io.FileDescriptor 10 | import java.io.IOException 11 | import java.nio.ByteBuffer 12 | 13 | // These can't be extension methods (yet): https://github.com/Kotlin/KEEP/issues/348. 14 | 15 | fun writeFully(fd: FileDescriptor, buffer: ByteBuffer) { 16 | while (buffer.remaining() > 0) { 17 | val n = Os.write(fd, buffer) 18 | if (n == 0) { 19 | throw IOException("Unexpected EOF when writing data") 20 | } 21 | } 22 | } 23 | 24 | fun writeFully(fd: FileDescriptor, bytes: ByteArray, byteOffset: Int, byteCount: Int) { 25 | var offset = byteOffset 26 | var remaining = byteCount 27 | 28 | while (remaining > 0) { 29 | val n = Os.write(fd, bytes, offset, remaining) 30 | if (n == 0) { 31 | throw IOException("Unexpected EOF when writing data") 32 | } 33 | 34 | offset += n 35 | remaining -= n 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Uncomment to test against a branch 4 | #branches: 5 | # - ci 6 | tags: 7 | - 'v*' 8 | jobs: 9 | create_release: 10 | name: Create Github release 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - name: Get version from tag 16 | id: get_version 17 | run: | 18 | if [[ "${GITHUB_REF}" == refs/tags/* ]]; then 19 | version=${GITHUB_REF#refs/tags/v} 20 | else 21 | version=0.0.0.${GITHUB_REF#refs/heads/} 22 | fi 23 | echo "version=${version}" >> "${GITHUB_OUTPUT}" 24 | 25 | - name: Check out repository 26 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 27 | 28 | - name: Create release 29 | uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 30 | with: 31 | tag_name: v${{ steps.get_version.outputs.version }} 32 | name: Version ${{ steps.get_version.outputs.version }} 33 | body_path: RELEASE.md 34 | draft: true 35 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/PickContactGroup.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import android.app.Activity 9 | import android.content.Context 10 | import android.content.Intent 11 | import androidx.activity.result.contract.ActivityResultContract 12 | import androidx.core.content.IntentCompat 13 | import com.chiller3.bcr.ContactGroupInfo 14 | 15 | /** Launch our own picker for contact groups. There is no standard Android component for this. */ 16 | class PickContactGroup : ActivityResultContract() { 17 | override fun createIntent(context: Context, input: Void?): Intent { 18 | return Intent(context, PickContactGroupActivity::class.java) 19 | } 20 | 21 | override fun parseResult(resultCode: Int, intent: Intent?): ContactGroupInfo? { 22 | return intent.takeIf { resultCode == Activity.RESULT_OK }?.let { 23 | IntentCompat.getParcelableExtra( 24 | it, 25 | PickContactGroupActivity.RESULT_CONTACT_GROUP, 26 | ContactGroupInfo::class.java, 27 | ) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/res/layout/settings_activity.xml: -------------------------------------------------------------------------------- 1 | 5 | 10 | 11 | 15 | 16 | 21 | 22 | 23 | 28 | 29 | -------------------------------------------------------------------------------- /app/magisk/service.sh: -------------------------------------------------------------------------------- 1 | # READ_CALL_LOG is a hard-restricted permission in Android 10+. It cannot be 2 | # granted by the user unless it is exempted by the system. The most common way 3 | # to do this is via the installer, but that's not applicable when adding new 4 | # system apps. Instead, we talk to the permission service directly over binder 5 | # to alter the flags. This command blocks for an arbitrary amount of time 6 | # because it needs to wait until the primary user unlocks the device. 7 | 8 | source "${0%/*}/boot_common.sh" /data/local/tmp/bcr_service.log 9 | 10 | header Remove hard restrictions 11 | run_cli_apk com.chiller3.bcr.standalone.RemoveHardRestrictionsKt 12 | 13 | header Package state 14 | dumpsys package "${app_id}" 15 | 16 | # Manually fix the SELinux-label for the device-protected data directory. 17 | # OxygenOS one OnePlus devices seems to initially create the directory with the 18 | # wrong label. For example, `u:object_r:app_data_file:s0:c79,c257,c512,c768` 19 | # instead of `u:object_r:privapp_data_file:s0:c512,c768`. This requires the 20 | # module to be flashed twice because we don't know what the expected label 21 | # should be until Android creates /data/data/. 22 | header Fixing DP storage SELinux label 23 | restorecon -RDv /data/user_de/0/"${app_id}" 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/MediaMuxerContainer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.format 7 | 8 | import android.media.MediaCodec 9 | import android.media.MediaFormat 10 | import android.media.MediaMuxer 11 | import java.io.FileDescriptor 12 | import java.nio.ByteBuffer 13 | 14 | /** 15 | * A thin wrapper around [MediaMuxer]. 16 | * 17 | * @param fd Output file descriptor. This class does not take ownership of the file descriptor. 18 | * @param containerFormat A valid [MediaMuxer.OutputFormat] value for the output container format. 19 | */ 20 | class MediaMuxerContainer( 21 | fd: FileDescriptor, 22 | containerFormat: Int, 23 | ) : Container { 24 | private val muxer = MediaMuxer(fd, containerFormat) 25 | 26 | override fun start() = 27 | muxer.start() 28 | 29 | override fun stop() = 30 | muxer.stop() 31 | 32 | override fun release() = 33 | muxer.release() 34 | 35 | override fun addTrack(mediaFormat: MediaFormat): Int = 36 | muxer.addTrack(mediaFormat) 37 | 38 | override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, 39 | bufferInfo: MediaCodec.BufferInfo) = 40 | muxer.writeSampleData(trackIndex, byteBuffer, bufferInfo) 41 | } 42 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | -keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # Disable obfuscation completely for BCR. As an open source project, 24 | # shrinking is the only goal of minification. 25 | -dontobfuscate 26 | 27 | # We construct TreeDocumentFile via reflection in DocumentFileExtensions 28 | # to speed up SAF performance when doing path lookups. 29 | -keepclassmembers class androidx.documentfile.provider.TreeDocumentFile { 30 | (androidx.documentfile.provider.DocumentFile, android.content.Context, android.net.Uri); 31 | } 32 | 33 | # Keep standalone CLI utilities 34 | -keep class com.chiller3.bcr.standalone.* { 35 | *; 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.media.MediaFormat 11 | import java.io.FileDescriptor 12 | 13 | class FlacFormat : Format() { 14 | override val name: String = "FLAC" 15 | override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC 16 | override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_FLAC 17 | override val passthrough: Boolean = false 18 | override val paramInfo: FormatParamInfo = RangedParamInfo( 19 | RangedParamType.CompressionLevel, 20 | 0u..8u, 21 | // Devices are fast enough nowadays to use the highest compression for realtime recording 22 | 8u, 23 | uintArrayOf(0u, 5u, 8u), 24 | ) 25 | override val sampleRateInfo: SampleRateInfo = 26 | SampleRateInfo.fromCodec(baseMediaFormat, 16_000u) 27 | 28 | override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { 29 | mediaFormat.apply { 30 | // Not relevant for lossless formats 31 | setInteger(MediaFormat.KEY_BIT_RATE, 0) 32 | setInteger(MediaFormat.KEY_FLAC_COMPRESSION_LEVEL, param.toInt()) 33 | } 34 | } 35 | 36 | override fun getContainer(fd: FileDescriptor): Container = 37 | FlacContainer(fd) 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/output/Retention.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.output 7 | 8 | import android.content.Context 9 | import com.chiller3.bcr.Preferences 10 | import com.chiller3.bcr.R 11 | import java.time.Duration 12 | 13 | sealed interface Retention { 14 | fun toFormattedString(context: Context): String 15 | 16 | fun toRawPreferenceValue(): UInt 17 | 18 | companion object { 19 | val default = NoRetention 20 | 21 | fun fromRawPreferenceValue(value: UInt): Retention = if (value == 0u) { 22 | NoRetention 23 | } else { 24 | DaysRetention(value) 25 | } 26 | 27 | fun fromPreferences(prefs: Preferences): Retention = prefs.outputRetention ?: default 28 | } 29 | } 30 | 31 | data object NoRetention : Retention { 32 | override fun toFormattedString(context: Context): String = 33 | context.getString(R.string.retention_keep_all) 34 | 35 | override fun toRawPreferenceValue(): UInt = 0u 36 | } 37 | 38 | @JvmInline 39 | value class DaysRetention(val days: UInt) : Retention { 40 | override fun toFormattedString(context: Context): String = 41 | context.resources.getQuantityString(R.plurals.retention_days, days.toInt(), days.toInt()) 42 | 43 | override fun toRawPreferenceValue(): UInt = days 44 | 45 | fun toDuration(): Duration = Duration.ofDays(days.toLong()) 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/output/OutputFile.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2023 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.output 7 | 8 | import android.content.ContentResolver 9 | import android.content.Context 10 | import android.net.Uri 11 | import android.os.Parcelable 12 | import androidx.core.net.toFile 13 | import androidx.documentfile.provider.DocumentFile 14 | import kotlinx.parcelize.Parcelize 15 | 16 | @Parcelize 17 | data class OutputFile( 18 | /** 19 | * URI to a single file, which may have a [ContentResolver.SCHEME_FILE] or 20 | * [ContentResolver.SCHEME_CONTENT] scheme. 21 | */ 22 | val uri: Uri, 23 | 24 | /** String representation of [uri] with private information redacted. */ 25 | val redacted: String, 26 | 27 | /** 28 | * Path of the file referred to by [uri]. Displayed to the user when [uri] does not contain a 29 | * meaningful path. 30 | */ 31 | val path: String, 32 | 33 | /** MIME type of [uri]'s contents. */ 34 | val mimeType: String, 35 | ) : Parcelable { 36 | fun toDocumentFile(context: Context): DocumentFile = 37 | when (uri.scheme) { 38 | ContentResolver.SCHEME_FILE -> DocumentFile.fromFile(uri.toFile()) 39 | // Only returns null on API <19 40 | ContentResolver.SCHEME_CONTENT -> DocumentFile.fromSingleUri(context, uri)!! 41 | else -> throw IllegalArgumentException("Invalid URI scheme: $redacted") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/settings/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.settings 7 | 8 | import android.net.Uri 9 | import android.util.Log 10 | import androidx.lifecycle.ViewModel 11 | import androidx.lifecycle.viewModelScope 12 | import com.chiller3.bcr.Logcat 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.flow.asStateFlow 16 | import kotlinx.coroutines.flow.update 17 | import kotlinx.coroutines.launch 18 | import kotlinx.coroutines.withContext 19 | 20 | class SettingsViewModel : ViewModel() { 21 | companion object { 22 | private val TAG = SettingsViewModel::class.java.simpleName 23 | } 24 | 25 | private val _alerts = MutableStateFlow>(emptyList()) 26 | val alerts = _alerts.asStateFlow() 27 | 28 | fun acknowledgeFirstAlert() { 29 | _alerts.update { it.drop(1) } 30 | } 31 | 32 | fun saveLogs(uri: Uri) { 33 | viewModelScope.launch { 34 | try { 35 | withContext(Dispatchers.IO) { 36 | Logcat.dump(uri) 37 | } 38 | _alerts.update { it + SettingsAlert.LogcatSucceeded(uri) } 39 | } catch (e: Exception) { 40 | Log.e(TAG, "Failed to dump logs to $uri", e) 41 | _alerts.update { it + SettingsAlert.LogcatFailed(uri, e.toString()) } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/AmrNbFormat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.media.MediaFormat 11 | import java.io.FileDescriptor 12 | 13 | class AmrNbFormat : Format() { 14 | override val name: String = "AMR-NB" 15 | override val mimeTypeContainer: String = "audio/amr" 16 | override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_AMR_NB 17 | override val passthrough: Boolean = false 18 | override val paramInfo: FormatParamInfo = RangedParamInfo( 19 | RangedParamType.Bitrate, 20 | 4_750u..12_200u, 21 | 12_200u, 22 | // AMR-NB only supports 8 possible bit rates. If the user picks a bit rate that's not one 23 | // of the 8 possibilities, then Android will fall back to 7950 bits/second. 24 | uintArrayOf( 25 | 4_750u, 26 | 7_950u, 27 | 12_200u, 28 | ), 29 | ) 30 | override val sampleRateInfo: SampleRateInfo = 31 | SampleRateInfo.fromCodec(baseMediaFormat, 8_000u) 32 | 33 | override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { 34 | mediaFormat.apply { 35 | val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) 36 | setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) 37 | } 38 | } 39 | 40 | override fun getContainer(fd: FileDescriptor): Container = AmrContainer(fd, false) 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/AmrWbFormat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.media.MediaFormat 11 | import java.io.FileDescriptor 12 | 13 | class AmrWbFormat : Format() { 14 | override val name: String = "AMR-WB" 15 | override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_AMR_WB 16 | override val mimeTypeAudio: String = mimeTypeContainer 17 | override val passthrough: Boolean = false 18 | override val paramInfo: FormatParamInfo = RangedParamInfo( 19 | RangedParamType.Bitrate, 20 | 6_600u..23_850u, 21 | 23_850u, 22 | // AMR-WB only supports 9 possible bit rates. If the user picks a bit rate that's not one 23 | // of the 9 possibilities, then Android will fall back to 23050 bits/second. 24 | uintArrayOf( 25 | 6_600u, 26 | 15_850u, 27 | 23_850u, 28 | ), 29 | ) 30 | override val sampleRateInfo: SampleRateInfo = 31 | SampleRateInfo.fromCodec(baseMediaFormat, 16_000u) 32 | 33 | override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { 34 | mediaFormat.apply { 35 | val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) 36 | setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) 37 | } 38 | } 39 | 40 | override fun getContainer(fd: FileDescriptor): Container = AmrContainer(fd, true) 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/view/LongClickablePreference.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.view 7 | 8 | import android.content.Context 9 | import android.util.AttributeSet 10 | import androidx.preference.Preference 11 | import androidx.preference.PreferenceViewHolder 12 | 13 | /** 14 | * A thin shell over [Preference] that allows registering a long click listener. 15 | */ 16 | class LongClickablePreference : Preference { 17 | var onPreferenceLongClickListener: OnPreferenceLongClickListener? = null 18 | 19 | @Suppress("unused") 20 | constructor(context: Context) : super(context) 21 | 22 | @Suppress("unused") 23 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) 24 | 25 | @Suppress("unused") 26 | constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : 27 | super(context, attrs, defStyleAttr) 28 | 29 | override fun onBindViewHolder(holder: PreferenceViewHolder) { 30 | super.onBindViewHolder(holder) 31 | 32 | val listener = onPreferenceLongClickListener 33 | if (listener == null) { 34 | holder.itemView.setOnLongClickListener(null) 35 | holder.itemView.isLongClickable = false 36 | } else { 37 | holder.itemView.setOnLongClickListener { 38 | listener.onPreferenceLongClick(this) 39 | } 40 | } 41 | } 42 | } 43 | 44 | interface OnPreferenceLongClickListener { 45 | fun onPreferenceLongClick(preference: Preference): Boolean 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/Container.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.format 7 | 8 | import android.media.MediaCodec 9 | import android.media.MediaFormat 10 | import java.nio.ByteBuffer 11 | 12 | /** 13 | * Abstract class for writing encoded samples to a container format. 14 | */ 15 | interface Container { 16 | /** 17 | * Start the muxer process. 18 | * 19 | * Must be called before [writeSamples]. 20 | */ 21 | fun start() 22 | 23 | /** 24 | * Stop the muxer process. 25 | * 26 | * Must not be called if [start] did not complete successfully. 27 | */ 28 | fun stop() 29 | 30 | /** 31 | * Free resources used by the muxer process. 32 | * 33 | * Can be called in any state. If the muxer process is started, it will be stopped. 34 | */ 35 | fun release() 36 | 37 | /** 38 | * Add a track to the container with the specified format. 39 | * 40 | * Must not be called after the muxer process is started. 41 | * 42 | * @param mediaFormat Must be the instance returned by [MediaCodec.getOutputFormat] 43 | */ 44 | fun addTrack(mediaFormat: MediaFormat): Int 45 | 46 | /** 47 | * Write encoded samples to the output container. 48 | * 49 | * Must not be called unless the muxer process is started. 50 | * 51 | * @param trackIndex Must be an index returned by [addTrack] 52 | */ 53 | fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.format 7 | 8 | import android.media.MediaFormat 9 | import java.io.FileDescriptor 10 | 11 | data object WaveFormat : Format() { 12 | override val name: String = "WAV/PCM" 13 | // Should be "audio/vnd.wave" [1], but Android only recognizes "audio/x-wav" [2] for the 14 | // purpose of picking an appropriate file extension when creating a file via SAF. 15 | // [1] https://datatracker.ietf.org/doc/html/rfc2361 16 | // [2] https://android.googlesource.com/platform/external/mime-support/+/refs/tags/android-12.1.0_r5/mime.types#571 17 | override val mimeTypeContainer: String = "audio/x-wav" 18 | override val mimeTypeAudio: String = "audio/x-wav" 19 | override val passthrough: Boolean = true 20 | override val paramInfo: FormatParamInfo = NoParamInfo 21 | override val sampleRateInfo: SampleRateInfo = RangedSampleRateInfo( 22 | // WAV sample rate field is a 4-byte integer and there's nothing that theoretically prevents 23 | // using an absurdly large sample rate. However, let's stick to a range that AudioRecord 24 | // actually supports. See system/media/audio/include/system/audio.h in AOSP. 25 | arrayOf(4_000u..192_000u), 26 | 16_000u, 27 | ) 28 | 29 | override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { 30 | // Not needed 31 | } 32 | 33 | override fun getContainer(fd: FileDescriptor): Container = 34 | WaveContainer(fd) 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/Encoder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.format 7 | 8 | import android.media.MediaFormat 9 | import java.nio.ByteBuffer 10 | 11 | abstract class Encoder( 12 | mediaFormat: MediaFormat, 13 | ) { 14 | protected val frameSize = mediaFormat.getInteger(Format.KEY_X_FRAME_SIZE_IN_BYTES) 15 | private val sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) 16 | 17 | /** Number of frames encoded so far. */ 18 | protected var numFrames = 0L 19 | 20 | /** Presentation timestamp given [numFrames] already being encoded */ 21 | protected val timestampUs 22 | get() = numFrames * 1_000_000L / sampleRate 23 | 24 | /** 25 | * Start the encoder process. 26 | * 27 | * Can only be called if the encoder process is not already started. 28 | */ 29 | abstract fun start() 30 | 31 | /** 32 | * Stop the encoder process. 33 | * 34 | * Can only be called if the encoder process is started. 35 | */ 36 | abstract fun stop() 37 | 38 | /** 39 | * Release resources used by the encoder process. 40 | * 41 | * If the encoder process is not already stopped, then it will be stopped. 42 | */ 43 | abstract fun release() 44 | 45 | /** 46 | * Submit a buffer to be encoded. 47 | * 48 | * @param buffer Must be in the PCM format expected by the encoder and [ByteBuffer.position] 49 | * and [ByteBuffer.limit] must correctly represent the bounds of the data. 50 | * @param isEof No more data can be submitted after this method is called once with EOF == true. 51 | */ 52 | abstract fun encode(buffer: ByteBuffer, isEof: Boolean) 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/PreferenceBaseFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.os.Bundle 9 | import android.view.LayoutInflater 10 | import android.view.ViewGroup 11 | import androidx.core.view.ViewCompat 12 | import androidx.core.view.WindowInsetsCompat 13 | import androidx.core.view.updatePadding 14 | import androidx.preference.PreferenceFragmentCompat 15 | import androidx.recyclerview.widget.RecyclerView 16 | 17 | abstract class PreferenceBaseFragment : PreferenceFragmentCompat() { 18 | override fun onCreateRecyclerView( 19 | inflater: LayoutInflater, 20 | parent: ViewGroup, 21 | savedInstanceState: Bundle? 22 | ): RecyclerView { 23 | val view = super.onCreateRecyclerView(inflater, parent, savedInstanceState) 24 | 25 | view.clipToPadding = false 26 | 27 | ViewCompat.setOnApplyWindowInsetsListener(view) { v, windowInsets -> 28 | val insets = windowInsets.getInsets( 29 | WindowInsetsCompat.Type.systemBars() 30 | or WindowInsetsCompat.Type.displayCutout() 31 | ) 32 | 33 | // This is a little bit ugly in landscape mode because the divider lines for categories 34 | // extend into the inset area. However, it's worth applying the left/right padding here 35 | // anyway because it allows the inset area to be used for scrolling instead of just 36 | // being a useless dead zone. 37 | v.updatePadding( 38 | bottom = insets.bottom, 39 | left = insets.left, 40 | right = insets.right, 41 | ) 42 | 43 | WindowInsetsCompat.CONSUMED 44 | } 45 | 46 | return view 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_quick_settings.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 11 | 12 | 13 | 20 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/dialog_text_input.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 11 | 17 | 18 | 23 | 24 | 29 | 30 | 34 | 35 | 36 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/Logcat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.content.ContentResolver 9 | import android.content.Context 10 | import android.net.Uri 11 | import android.util.Log 12 | import androidx.core.net.toFile 13 | import java.io.File 14 | import java.io.IOException 15 | import kotlin.io.path.inputStream 16 | 17 | object Logcat { 18 | private val TAG = Logcat::class.java.simpleName 19 | 20 | const val FILENAME_DEFAULT = "logcat.log" 21 | const val FILENAME_CRASH = "crash.log" 22 | const val MIMETYPE = "text/plain" 23 | 24 | // We only need this for opening file descriptors. Configuration changes aren't relevant here. 25 | private lateinit var applicationContext: Context 26 | 27 | fun init(context: Context) { 28 | applicationContext = context.applicationContext 29 | } 30 | 31 | fun dump(file: File) { 32 | Log.d(TAG, "Dumping logs to $file") 33 | 34 | ProcessBuilder("logcat", "-d", "*:V") 35 | .redirectOutput(file) 36 | .redirectErrorStream(true) 37 | .start() 38 | .waitFor() 39 | } 40 | 41 | fun dump(uri: Uri) { 42 | if (uri.scheme == ContentResolver.SCHEME_FILE) { 43 | dump(uri.toFile()) 44 | return 45 | } 46 | 47 | Log.d(TAG, "Dumping logs to $uri") 48 | 49 | withTempFile(applicationContext, FILENAME_DEFAULT) { tempFile -> 50 | dump(tempFile.toFile()) 51 | 52 | Log.d(TAG, "Moving $tempFile to $uri") 53 | 54 | val out = applicationContext.contentResolver.openOutputStream(uri) 55 | ?: throw IOException("Failed to open URI: $uri") 56 | out.use { outStream -> 57 | tempFile.inputStream().use { inStream -> 58 | inStream.copyTo(outStream) 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/RecorderApplication.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.app.Application 9 | import android.util.Log 10 | import androidx.core.net.toFile 11 | import com.chiller3.bcr.output.OutputDirUtils 12 | import com.google.android.material.color.DynamicColors 13 | 14 | class RecorderApplication : Application() { 15 | override fun onCreate() { 16 | super.onCreate() 17 | 18 | Logcat.init(this) 19 | 20 | val oldCrashHandler = Thread.getDefaultUncaughtExceptionHandler() 21 | 22 | Thread.setDefaultUncaughtExceptionHandler { t, e -> 23 | try { 24 | val redactor = OutputDirUtils.NULL_REDACTOR 25 | val dirUtils = OutputDirUtils(this, redactor) 26 | val logcatPath = listOf(Logcat.FILENAME_CRASH) 27 | val logcatFile = dirUtils.createFileInDefaultDir(logcatPath, "text/plain") 28 | 29 | Log.e(TAG, "Saving logcat to ${redactor.redact(logcatFile.uri)} due to uncaught exception in $t", e) 30 | 31 | try { 32 | Logcat.dump(logcatFile.uri.toFile()) 33 | } finally { 34 | dirUtils.tryMoveToOutputDir(logcatFile, logcatPath, "text/plain") 35 | } 36 | } finally { 37 | oldCrashHandler?.uncaughtException(t, e) 38 | } 39 | } 40 | 41 | // Enable Material You colors 42 | DynamicColors.applyToActivitiesIfAvailable(this) 43 | 44 | Notifications(this).updateChannels() 45 | 46 | // Move preferences to device-protected storage for direct boot support. 47 | Preferences.migrateToDeviceProtectedStorage(this) 48 | 49 | // Migrate legacy preferences. 50 | val prefs = Preferences(this) 51 | prefs.migrateLegacyRules() 52 | } 53 | 54 | companion object { 55 | private val TAG = RecorderApplication::class.java.simpleName 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.media.MediaFormat 11 | import android.media.MediaMuxer 12 | import android.os.Build 13 | import androidx.annotation.RequiresApi 14 | import java.io.FileDescriptor 15 | 16 | class OpusFormat : Format() { 17 | init { 18 | require(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 19 | "Only supported on Android 10 and newer" 20 | } 21 | } 22 | 23 | override val name: String = "OGG/Opus" 24 | // https://datatracker.ietf.org/doc/html/rfc7845#section-9 25 | override val mimeTypeContainer: String = "audio/ogg" 26 | override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_OPUS 27 | override val passthrough: Boolean = false 28 | override val paramInfo: FormatParamInfo = RangedParamInfo( 29 | RangedParamType.Bitrate, 30 | 6_000u..510_000u, 31 | 48_000u, 32 | // https://wiki.hydrogenaud.io/index.php?title=Opus 33 | uintArrayOf( 34 | // "Medium bandwidth, better than telephone quality" 35 | 12_000u, 36 | // "Near transparent speech" 37 | 24_000u, 38 | // "Essentially transparent mono or stereo speech, reasonable music" 39 | 48_000u, 40 | ), 41 | ) 42 | override val sampleRateInfo: SampleRateInfo = 43 | SampleRateInfo.fromCodec(baseMediaFormat, 16_000u) 44 | 45 | override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { 46 | mediaFormat.apply { 47 | val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) 48 | setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) 49 | } 50 | } 51 | 52 | @RequiresApi(Build.VERSION_CODES.Q) 53 | override fun getContainer(fd: FileDescriptor): Container = 54 | MediaMuxerContainer(fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG) 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/PassthroughEncoder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.format 7 | 8 | import android.media.MediaCodec 9 | import android.media.MediaFormat 10 | import java.nio.ByteBuffer 11 | 12 | /** 13 | * Create a passthrough encoder for the specified format. 14 | * 15 | * @param mediaFormat The [MediaFormat] instance returned by [Format.getMediaFormat]. 16 | * @param container The container for storing the raw PCM audio stream. 17 | */ 18 | class PassthroughEncoder( 19 | private val mediaFormat: MediaFormat, 20 | private val container: Container, 21 | ) : Encoder(mediaFormat) { 22 | private var isStarted = false 23 | private val bufferInfo = MediaCodec.BufferInfo() 24 | private var trackIndex = -1 25 | 26 | override fun start() { 27 | if (isStarted) { 28 | throw IllegalStateException("Encoder is already started") 29 | } 30 | 31 | isStarted = true 32 | trackIndex = container.addTrack(mediaFormat) 33 | container.start() 34 | } 35 | 36 | override fun stop() { 37 | if (!isStarted) { 38 | throw IllegalStateException("Encoder is not started") 39 | } 40 | 41 | isStarted = false 42 | } 43 | 44 | override fun release() { 45 | if (isStarted) { 46 | stop() 47 | } 48 | } 49 | 50 | override fun encode(buffer: ByteBuffer, isEof: Boolean) { 51 | if (!isStarted) { 52 | throw IllegalStateException("Encoder is not started") 53 | } 54 | 55 | val frames = buffer.remaining() / frameSize 56 | 57 | bufferInfo.offset = buffer.position() 58 | bufferInfo.size = buffer.limit() 59 | bufferInfo.presentationTimeUs = timestampUs 60 | bufferInfo.flags = if (isEof) { 61 | MediaCodec.BUFFER_FLAG_END_OF_STREAM 62 | } else { 63 | 0 64 | } 65 | 66 | container.writeSamples(trackIndex, buffer, bufferInfo) 67 | 68 | numFrames += frames 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 16 | 17 | 18 | 22 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/RecordRulesTouchHelperCallback.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import androidx.recyclerview.widget.ItemTouchHelper 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.chiller3.bcr.rule.RecordRulesAdapter.CustomViewHolder 11 | 12 | internal class RecordRulesTouchHelperCallback(private val adapter: RecordRulesAdapter) : 13 | ItemTouchHelper.SimpleCallback( 14 | ItemTouchHelper.UP or ItemTouchHelper.DOWN, 15 | ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 16 | ) { 17 | override fun onMove( 18 | recyclerView: RecyclerView, 19 | viewHolder: RecyclerView.ViewHolder, 20 | target: RecyclerView.ViewHolder, 21 | ): Boolean { 22 | adapter.onRuleMove(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) 23 | return true 24 | } 25 | 26 | override fun onSwiped( 27 | viewHolder: RecyclerView.ViewHolder, 28 | direction: Int, 29 | ) = adapter.onRuleRemove(viewHolder.bindingAdapterPosition) 30 | 31 | override fun canDropOver( 32 | recyclerView: RecyclerView, 33 | current: RecyclerView.ViewHolder, 34 | target: RecyclerView.ViewHolder, 35 | ): Boolean { 36 | // Don't allow moving over something that is not ours. 37 | if (target !is CustomViewHolder) { 38 | return false 39 | } 40 | 41 | // Prevent moving an item into where the default rule lives. 42 | return !adapter.isDefaultRule(target.bindingAdapterPosition) 43 | } 44 | 45 | override fun getMovementFlags( 46 | recyclerView: RecyclerView, 47 | viewHolder: RecyclerView.ViewHolder, 48 | ): Int { 49 | // Don't allow moving something that is not ours. 50 | if (viewHolder !is CustomViewHolder) { 51 | return makeMovementFlags(0, 0) 52 | } 53 | 54 | // Prevent moving or dismissing the default rule. 55 | if (adapter.isDefaultRule(viewHolder.bindingAdapterPosition)) { 56 | return makeMovementFlags(0, 0) 57 | } 58 | 59 | return super.getMovementFlags(recyclerView, viewHolder) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/AacFormat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.media.MediaCodecInfo 11 | import android.media.MediaFormat 12 | import android.media.MediaMuxer 13 | import java.io.FileDescriptor 14 | 15 | class AacFormat : Format() { 16 | override val name: String = "M4A/AAC" 17 | // https://datatracker.ietf.org/doc/html/rfc6381#section-3.1 18 | override val mimeTypeContainer: String = "audio/mp4" 19 | override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_AAC 20 | override val passthrough: Boolean = false 21 | override val paramInfo: FormatParamInfo = RangedParamInfo( 22 | RangedParamType.Bitrate, 23 | // The format has no hard limits, so the lower bound is ffmpeg's recommended minimum bitrate 24 | // for HE-AAC: 24kbps/channel. The upper bound is twice the bitrate for audible transparency 25 | // with AAC-LC: 2 * 64kbps/channel. 26 | // https://trac.ffmpeg.org/wiki/Encode/AAC 27 | 24_000u..128_000u, 28 | 64_000u, 29 | uintArrayOf( 30 | 24_000u, 31 | // "As a rule of thumb, for audible transparency, use 64 kBit/s for each channel" 32 | 64_000u, 33 | 128_000u, 34 | ), 35 | ) 36 | override val sampleRateInfo: SampleRateInfo = 37 | SampleRateInfo.fromCodec(baseMediaFormat, 16_000u) 38 | 39 | override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { 40 | mediaFormat.apply { 41 | val profile = if (param >= 32_000u) { 42 | MediaCodecInfo.CodecProfileLevel.AACObjectLC 43 | } else { 44 | MediaCodecInfo.CodecProfileLevel.AACObjectHE 45 | } 46 | val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) 47 | 48 | setInteger(MediaFormat.KEY_AAC_PROFILE, profile) 49 | setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) 50 | } 51 | } 52 | 53 | override fun getContainer(fd: FileDescriptor): Container = 54 | MediaMuxerContainer(fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) 55 | } 56 | -------------------------------------------------------------------------------- /app/images/icon.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/AmrContainer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.format 7 | 8 | import android.media.MediaCodec 9 | import android.media.MediaFormat 10 | import android.system.Os 11 | import com.chiller3.bcr.writeFully 12 | import java.io.FileDescriptor 13 | import java.nio.ByteBuffer 14 | 15 | class AmrContainer(private val fd: FileDescriptor, private val isWideband: Boolean) : Container { 16 | private var isStarted = false 17 | private var track = -1 18 | 19 | override fun start() { 20 | if (isStarted) { 21 | throw IllegalStateException("Container already started") 22 | } 23 | 24 | Os.ftruncate(fd, 0) 25 | 26 | val header = if (isWideband) { HEADER_WB } else { HEADER_NB } 27 | val headerBytes = header.toByteArray(Charsets.US_ASCII) 28 | 29 | writeFully(fd, headerBytes, 0, headerBytes.size) 30 | 31 | isStarted = true 32 | } 33 | 34 | override fun stop() { 35 | if (!isStarted) { 36 | throw IllegalStateException("Container not started") 37 | } 38 | 39 | isStarted = false 40 | } 41 | 42 | override fun release() { 43 | if (isStarted) { 44 | stop() 45 | } 46 | } 47 | 48 | override fun addTrack(mediaFormat: MediaFormat): Int { 49 | if (isStarted) { 50 | throw IllegalStateException("Container already started") 51 | } else if (track >= 0) { 52 | throw IllegalStateException("Track already added") 53 | } 54 | 55 | track = 0 56 | 57 | @Suppress("KotlinConstantConditions") 58 | return track 59 | } 60 | 61 | override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, 62 | bufferInfo: MediaCodec.BufferInfo) { 63 | if (!isStarted) { 64 | throw IllegalStateException("Container not started") 65 | } else if (track < 0) { 66 | throw IllegalStateException("No track has been added") 67 | } else if (track != trackIndex) { 68 | throw IllegalStateException("Invalid track: $trackIndex") 69 | } 70 | 71 | writeFully(fd, byteBuffer) 72 | } 73 | 74 | companion object { 75 | private const val HEADER_WB = "#!AMR-WB\n" 76 | private const val HEADER_NB = "#!AMR\n" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.output 7 | 8 | import android.content.Context 9 | import android.telephony.PhoneNumberUtils 10 | import android.telephony.TelephonyManager 11 | import android.util.Log 12 | import java.util.Locale 13 | 14 | data class PhoneNumber(private val number: String) { 15 | init { 16 | require(number.isNotEmpty()) { "Number cannot be empty" } 17 | } 18 | 19 | fun format(context: Context, format: Format) = when (format) { 20 | Format.DIGITS_ONLY -> number.filter { Character.digit(it, 10) != -1 } 21 | Format.COUNTRY_SPECIFIC -> { 22 | val country = getIsoCountryCode(context) 23 | if (country == null) { 24 | Log.w(TAG, "Failed to detect country") 25 | null 26 | } else { 27 | val formatted = PhoneNumberUtils.formatNumber(number, country) 28 | if (formatted == null) { 29 | Log.w(TAG, "Phone number cannot be formatted for country $country") 30 | null 31 | } else { 32 | formatted 33 | } 34 | } 35 | } 36 | } 37 | 38 | override fun toString(): String = number 39 | 40 | companion object { 41 | private val TAG = PhoneNumber::class.java.simpleName 42 | 43 | /** 44 | * Get the current ISO country code for phone number formatting. 45 | */ 46 | private fun getIsoCountryCode(context: Context): String? { 47 | val telephonyManager = context.getSystemService(TelephonyManager::class.java) 48 | var result: String? = null 49 | 50 | if (telephonyManager.phoneType == TelephonyManager.PHONE_TYPE_GSM) { 51 | result = telephonyManager.networkCountryIso 52 | } 53 | if (result.isNullOrEmpty()) { 54 | result = telephonyManager.simCountryIso 55 | } 56 | if (result.isNullOrEmpty()) { 57 | result = Locale.getDefault().country 58 | } 59 | if (result.isNullOrEmpty()) { 60 | return null 61 | } 62 | return result.uppercase() 63 | } 64 | } 65 | 66 | enum class Format { 67 | DIGITS_ONLY, 68 | COUNTRY_SPECIFIC, 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/PreferenceBaseActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.os.Bundle 9 | import android.view.MenuItem 10 | import android.view.ViewGroup 11 | import androidx.activity.enableEdgeToEdge 12 | import androidx.appcompat.app.AppCompatActivity 13 | import androidx.core.view.ViewCompat 14 | import androidx.core.view.WindowInsetsCompat 15 | import androidx.core.view.updateLayoutParams 16 | import androidx.fragment.app.Fragment 17 | import com.chiller3.bcr.databinding.SettingsActivityBinding 18 | 19 | abstract class PreferenceBaseActivity : AppCompatActivity() { 20 | protected abstract val titleResId: Int 21 | 22 | protected abstract val showUpButton: Boolean 23 | 24 | protected abstract fun createFragment(): Fragment 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | enableEdgeToEdge() 28 | super.onCreate(savedInstanceState) 29 | 30 | val binding = SettingsActivityBinding.inflate(layoutInflater) 31 | setContentView(binding.root) 32 | 33 | if (savedInstanceState == null) { 34 | supportFragmentManager 35 | .beginTransaction() 36 | .replace(R.id.settings, createFragment()) 37 | .commit() 38 | } 39 | 40 | ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, windowInsets -> 41 | val insets = windowInsets.getInsets( 42 | WindowInsetsCompat.Type.systemBars() 43 | or WindowInsetsCompat.Type.displayCutout() 44 | ) 45 | 46 | v.updateLayoutParams { 47 | leftMargin = insets.left 48 | topMargin = insets.top 49 | rightMargin = insets.right 50 | } 51 | 52 | WindowInsetsCompat.CONSUMED 53 | } 54 | 55 | setSupportActionBar(binding.toolbar) 56 | supportActionBar!!.setDisplayHomeAsUpEnabled(showUpButton) 57 | 58 | setTitle(titleResId) 59 | } 60 | 61 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 62 | return when (item.itemId) { 63 | android.R.id.home -> { 64 | onBackPressedDispatcher.onBackPressed() 65 | true 66 | } 67 | else -> super.onOptionsItemSelected(item) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/PickContactGroupViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import android.app.Application 9 | import android.util.Log 10 | import androidx.lifecycle.AndroidViewModel 11 | import androidx.lifecycle.viewModelScope 12 | import com.chiller3.bcr.ContactGroupInfo 13 | import com.chiller3.bcr.withContactGroups 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.asStateFlow 17 | import kotlinx.coroutines.flow.update 18 | import kotlinx.coroutines.launch 19 | import kotlinx.coroutines.withContext 20 | 21 | class PickContactGroupViewModel(application: Application) : AndroidViewModel(application) { 22 | private val _alerts = MutableStateFlow>(emptyList()) 23 | val alerts = _alerts.asStateFlow() 24 | 25 | private val _groups = MutableStateFlow>(emptyList()) 26 | val groups = _groups.asStateFlow() 27 | 28 | init { 29 | refreshGroups() 30 | } 31 | 32 | private fun refreshGroups() { 33 | viewModelScope.launch { 34 | withContext(Dispatchers.IO) { 35 | try { 36 | val groups = withContactGroups(getApplication()) { contactGroups -> 37 | contactGroups 38 | .sortedWith { o1, o2 -> 39 | compareValuesBy( 40 | o1, 41 | o2, 42 | { it.title }, 43 | { it.accountName }, 44 | { it.rowId }, 45 | { it.sourceId }, 46 | ) 47 | } 48 | .toList() 49 | } 50 | 51 | _groups.update { groups } 52 | } catch (e: Exception) { 53 | Log.w(TAG, "Failed to list all contact groups", e) 54 | _alerts.update { it + PickContactGroupAlert.QueryFailed(e.toString()) } 55 | } 56 | } 57 | } 58 | } 59 | 60 | fun acknowledgeFirstAlert() { 61 | _alerts.update { it.drop(1) } 62 | } 63 | 64 | companion object { 65 | private val TAG = PickContactGroupViewModel::class.java.simpleName 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | android-gradle-plugin = "8.13.1" 3 | androidx-activity = "1.12.0" 4 | androidx-appcompat = "1.7.1" 5 | androidx-core = "1.17.0" 6 | androidx-documentfile = "1.1.0" 7 | androidx-fragment = "1.8.9" 8 | androidx-preference = "1.2.1" 9 | androidx-recyclerview = "1.4.0" 10 | jgit = "7.4.0.202509020913-r" 11 | json = "20250517" 12 | junit = "1.3.0" 13 | kotlin = "2.2.21" 14 | kotlinx-serialization = "1.9.0" 15 | kudzu = "6.1.0" 16 | material = "1.13.0" 17 | 18 | [libraries] 19 | androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity" } 20 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } 21 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } 22 | androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" } 23 | androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidx-fragment" } 24 | androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "androidx-preference" } 25 | androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidx-preference" } 26 | androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview" } 27 | kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 28 | kudzu = { group = "io.github.copper-leaf", name = "kudzu-core", version.ref = "kudzu" } 29 | material = { group = "com.google.android.material", name = "material", version.ref = "material" } 30 | 31 | # Test 32 | junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } 33 | 34 | # Build script 35 | jgit = { group = "org.eclipse.jgit", name = "org.eclipse.jgit", version.ref = "jgit" } 36 | jgit-archive = { group = "org.eclipse.jgit", name = "org.eclipse.jgit.archive", version.ref = "jgit" } 37 | json = { group = "org.json", name = "json", version.ref = "json" } 38 | 39 | [plugins] 40 | android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } 41 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 42 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } 43 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.content.Context 11 | import com.chiller3.bcr.R 12 | 13 | sealed class FormatParamInfo( 14 | val default: UInt, 15 | /** Handful of handpicked parameter choices to show in the UI as presets. */ 16 | val presets: UIntArray, 17 | ) { 18 | /** 19 | * Ensure that [param] is valid. 20 | * 21 | * @throws IllegalArgumentException if [param] is invalid 22 | */ 23 | abstract fun validate(param: UInt) 24 | 25 | /** 26 | * Convert a potentially-invalid [param] value to the nearest valid value. 27 | */ 28 | abstract fun toNearest(param: UInt): UInt 29 | 30 | /** 31 | * Format [param] to present as a user-facing string. 32 | */ 33 | abstract fun format(context: Context, param: UInt): String 34 | } 35 | 36 | enum class RangedParamType { 37 | CompressionLevel, 38 | Bitrate, 39 | } 40 | 41 | class RangedParamInfo( 42 | val type: RangedParamType, 43 | val range: UIntRange, 44 | default: UInt, 45 | presets: UIntArray, 46 | ) : FormatParamInfo(default, presets) { 47 | override fun validate(param: UInt) { 48 | if (param !in range) { 49 | throw IllegalArgumentException("Parameter $param is not in the range: " + 50 | "[${range.first}, ${range.last}]") 51 | } 52 | } 53 | 54 | /** Clamp [param] to [range]. */ 55 | override fun toNearest(param: UInt): UInt = param.coerceIn(range) 56 | 57 | override fun format(context: Context, param: UInt): String = 58 | when (type) { 59 | RangedParamType.CompressionLevel -> 60 | context.getString(R.string.format_param_compression_level, param.toString()) 61 | RangedParamType.Bitrate -> { 62 | if (param % 1_000U == 0U) { 63 | context.getString(R.string.format_param_bitrate_kbps, (param / 1_000U).toString()) 64 | } else { 65 | context.getString(R.string.format_param_bitrate_bps, param.toString()) 66 | } 67 | } 68 | } 69 | } 70 | 71 | data object NoParamInfo : FormatParamInfo(0u, uintArrayOf()) { 72 | override fun validate(param: UInt) { 73 | // Always valid 74 | } 75 | 76 | override fun toNearest(param: UInt): UInt = param 77 | 78 | override fun format(context: Context, param: UInt): String = "" 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/Permissions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2023 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.Manifest 9 | import android.annotation.SuppressLint 10 | import android.content.Context 11 | import android.content.Intent 12 | import android.content.pm.PackageManager 13 | import android.net.Uri 14 | import android.os.Build 15 | import android.os.PowerManager 16 | import android.provider.Settings 17 | import androidx.core.content.ContextCompat 18 | 19 | object Permissions { 20 | private val NOTIFICATION: Array = 21 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 22 | arrayOf(Manifest.permission.POST_NOTIFICATIONS) 23 | } else { 24 | arrayOf() 25 | } 26 | 27 | val REQUIRED: Array = arrayOf(Manifest.permission.RECORD_AUDIO) + NOTIFICATION 28 | val OPTIONAL: Array = arrayOf( 29 | Manifest.permission.READ_CALL_LOG, 30 | Manifest.permission.READ_CONTACTS, 31 | Manifest.permission.READ_PHONE_STATE, 32 | ) 33 | 34 | /** 35 | * Check if all permissions required for call recording have been granted. 36 | */ 37 | fun haveRequired(context: Context): Boolean = 38 | REQUIRED.all { 39 | ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED 40 | } 41 | 42 | /** 43 | * Check if battery optimizations are currently disabled for this app. 44 | */ 45 | fun isInhibitingBatteryOpt(context: Context): Boolean { 46 | val pm: PowerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager 47 | return pm.isIgnoringBatteryOptimizations(context.packageName) 48 | } 49 | 50 | /** 51 | * Get intent for opening the app info page in the system settings. 52 | */ 53 | fun getAppInfoIntent(context: Context) = Intent( 54 | Settings.ACTION_APPLICATION_DETAILS_SETTINGS, 55 | Uri.fromParts("package", context.packageName, null), 56 | ) 57 | 58 | /** 59 | * Get intent for requesting the disabling of battery optimization for this app. 60 | */ 61 | @SuppressLint("BatteryLife") 62 | fun getInhibitBatteryOptIntent(context: Context) = Intent( 63 | Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, 64 | Uri.fromParts("package", context.packageName, null), 65 | ) 66 | 67 | /** 68 | * Get intent for opening the battery optimization settings so the user can re-enable it. 69 | */ 70 | fun getBatteryOptSettingsIntent() = Intent( 71 | Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/RecorderProvider.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.content.ContentProvider 9 | import android.content.ContentResolver 10 | import android.content.ContentValues 11 | import android.content.Intent 12 | import android.database.Cursor 13 | import android.net.Uri 14 | import android.os.ParcelFileDescriptor 15 | import androidx.core.net.toUri 16 | 17 | /** 18 | * This is an extremely minimal content provider so that BCR can provide an openable/shareable URI 19 | * to other applications. SAF URIs cannot be shared directly because permission grants cannot be 20 | * propagated to the target app. 21 | * 22 | * This content provider is not exported and access is only granted to specific URIs via 23 | * [Intent.FLAG_GRANT_READ_URI_PERMISSION]. 24 | */ 25 | class RecorderProvider : ContentProvider() { 26 | companion object { 27 | private const val QUERY_ORIG = "orig" 28 | 29 | fun fromOrigUri(origUri: Uri): Uri = 30 | Uri.Builder().run { 31 | scheme(ContentResolver.SCHEME_CONTENT) 32 | authority(BuildConfig.PROVIDER_AUTHORITY) 33 | appendQueryParameter(QUERY_ORIG, origUri.toString()) 34 | 35 | build() 36 | } 37 | 38 | private fun extractOrigUri(uri: Uri): Uri? { 39 | val param = uri.getQueryParameter(QUERY_ORIG) 40 | if (param.isNullOrBlank()) { 41 | return null 42 | } 43 | 44 | return try { 45 | param.toUri() 46 | } catch (_: Exception) { 47 | null 48 | } 49 | } 50 | } 51 | 52 | override fun onCreate(): Boolean = true 53 | 54 | override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? = 55 | extractOrigUri(uri)?.let { 56 | context?.contentResolver?.openFileDescriptor(it, mode) 57 | } 58 | 59 | override fun getType(uri: Uri): String? = 60 | extractOrigUri(uri)?.let { 61 | context?.contentResolver?.getType(it) 62 | } 63 | 64 | override fun query( 65 | uri: Uri, 66 | projection: Array?, 67 | selection: String?, 68 | selectionArgs: Array?, 69 | sortOrder: String? 70 | ): Cursor? = 71 | extractOrigUri(uri)?.let { 72 | context?.contentResolver?.query( 73 | it, projection, selection, selectionArgs, sortOrder) 74 | } 75 | 76 | override fun insert(uri: Uri, values: ContentValues?): Uri? = null 77 | 78 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 79 | 80 | override fun update( 81 | uri: Uri, 82 | values: ContentValues?, 83 | selection: String?, 84 | selectionArgs: Array?, 85 | ): Int = 0 86 | } 87 | -------------------------------------------------------------------------------- /app/magisk/update-binary: -------------------------------------------------------------------------------- 1 | #!/sbin/sh 2 | 3 | # SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 4 | # SPDX-License-Identifier: GPL-3.0-only 5 | 6 | OUTFD=${2} 7 | ZIPFILE=${3} 8 | 9 | umask 022 10 | 11 | ui_print() { 12 | printf "ui_print %s\nui_print\n" "${*}" > /proc/self/fd/"${OUTFD}" 13 | } 14 | 15 | if [ -f /sbin/recovery ] || [ -f /system/bin/recovery ]; then 16 | # Installing via recovery. Always do a direct install. 17 | set -exu 18 | 19 | ui_print 'Mounting system' 20 | 21 | slot=$(getprop ro.boot.slot_suffix) 22 | 23 | if [[ -e /dev/block/mapper/system"${slot}" ]]; then 24 | ui_print "- Device uses dynamic partitions" 25 | block_dev=/dev/block/mapper/system"${slot}" 26 | blockdev --setrw "${block_dev}" 27 | elif [[ -e /dev/block/bootdevice/by-name/system"${slot}" ]]; then 28 | ui_print "- Device uses static partitions" 29 | block_dev=/dev/block/bootdevice/by-name/system"${slot}" 30 | else 31 | ui_print "- System block device not found" 32 | exit 1 33 | fi 34 | 35 | ui_print "- System block device: ${block_dev}" 36 | 37 | if [[ -d /mnt/system ]]; then 38 | mount_point=/mnt/system 39 | root_dir=${mount_point} 40 | elif [[ -d /system_root ]]; then 41 | mount_point=/system_root 42 | root_dir=${mount_point} 43 | else 44 | mount_point=/system 45 | if [[ "$(getprop ro.build.system_root_image)" == true ]]; then 46 | root_dir=${mount_point} 47 | else 48 | root_dir=/ 49 | fi 50 | fi 51 | 52 | ui_print "- System mount point: ${mount_point}" 53 | ui_print "- Root directory: ${root_dir}" 54 | 55 | if mountpoint -q "${mount_point}"; then 56 | mount -o remount,rw "${mount_point}" 57 | else 58 | mount "${block_dev}" "${mount_point}" 59 | fi 60 | 61 | ui_print 'Extracting files' 62 | 63 | # Just overwriting isn't sufficient because the apk filenames are different 64 | # between debug and release builds 65 | app_id=$(unzip -p "${ZIPFILE}" module.prop | grep '^id=' | cut -d= -f2) 66 | 67 | # rm on some custom recoveries doesn't exit with 0 on ENOENT, even with -f 68 | rm -rf "${root_dir}/system/priv-app/${app_id}" || : 69 | 70 | unzip -o "${ZIPFILE}" 'system/*' -d "${root_dir}" 71 | 72 | ui_print 'Done!' 73 | else 74 | # Installing via Magisk Manager. 75 | 76 | . /data/adb/magisk/util_functions.sh 77 | 78 | has_overlays() { 79 | local mnt="${1}" count 80 | count=$(awk -v mnt="${mnt}" '$9 == "overlay" && $5 ~ mnt' /proc/self/mountinfo | wc -l) 81 | [ "${count}" -gt 0 ] 82 | } 83 | 84 | # https://github.com/topjohnwu/Magisk/pull/6588 85 | if [ -n "${MAGISK_VER_CODE}" ]; then 86 | ui_print "Magisk version: ${MAGISK_VER_CODE}" 87 | if has_overlays /system && [ "${MAGISK_VER_CODE}" -lt 26000 ]; then 88 | ui_print "Magisk v26.0 (26000) or newer is required because this device uses overlayfs" 89 | exit 1 90 | fi 91 | fi 92 | 93 | install_module 94 | fi 95 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/RecorderTileService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.annotation.SuppressLint 9 | import android.app.PendingIntent 10 | import android.content.Intent 11 | import android.content.SharedPreferences 12 | import android.os.Build 13 | import android.service.quicksettings.Tile 14 | import android.service.quicksettings.TileService 15 | import android.util.Log 16 | import com.chiller3.bcr.settings.SettingsActivity 17 | 18 | class RecorderTileService : TileService(), SharedPreferences.OnSharedPreferenceChangeListener { 19 | private lateinit var prefs: Preferences 20 | 21 | override fun onCreate() { 22 | super.onCreate() 23 | 24 | prefs = Preferences(this) 25 | } 26 | 27 | override fun onStartListening() { 28 | super.onStartListening() 29 | prefs.prefs.registerOnSharedPreferenceChangeListener(this) 30 | 31 | refreshTileState() 32 | } 33 | 34 | override fun onStopListening() { 35 | super.onStopListening() 36 | prefs.prefs.unregisterOnSharedPreferenceChangeListener(this) 37 | } 38 | 39 | @SuppressLint("StartActivityAndCollapseDeprecated") 40 | override fun onClick() { 41 | super.onClick() 42 | 43 | if (!Permissions.haveRequired(this)) { 44 | val intent = Intent(this, SettingsActivity::class.java).apply { 45 | flags = Intent.FLAG_ACTIVITY_NEW_TASK 46 | } 47 | 48 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 49 | startActivityAndCollapse(PendingIntent.getActivity( 50 | this, 0, intent, PendingIntent.FLAG_IMMUTABLE)) 51 | } else { 52 | @Suppress("DEPRECATION") 53 | startActivityAndCollapse(intent) 54 | } 55 | } else { 56 | prefs.isCallRecordingEnabled = !prefs.isCallRecordingEnabled 57 | } 58 | 59 | refreshTileState() 60 | } 61 | 62 | override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { 63 | refreshTileState() 64 | } 65 | 66 | private fun refreshTileState() { 67 | val tile = qsTile 68 | if (tile == null) { 69 | Log.w(TAG, "Tile was null during refreshTileState") 70 | return 71 | } 72 | 73 | // Tile.STATE_UNAVAILABLE is intentionally not used when permissions haven't been granted. 74 | // Clicking the tile in that state does not invoke the click handler, so it wouldn't be 75 | // possible to launch SettingsActivity to grant the permissions. 76 | if (Permissions.haveRequired(this) && prefs.isCallRecordingEnabled) { 77 | tile.state = Tile.STATE_ACTIVE 78 | } else { 79 | tile.state = Tile.STATE_INACTIVE 80 | } 81 | 82 | tile.updateTile() 83 | } 84 | 85 | companion object { 86 | private val TAG = RecorderTileService::class.java.simpleName 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/extension/UriExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.extension 7 | 8 | import android.content.ContentResolver 9 | import android.content.Context 10 | import android.net.Uri 11 | import android.provider.DocumentsContract 12 | import android.telecom.PhoneAccount 13 | import androidx.core.net.toFile 14 | import androidx.documentfile.provider.DocumentFile 15 | 16 | const val DOCUMENTSUI_AUTHORITY = "com.android.externalstorage.documents" 17 | 18 | val Uri.formattedString: String 19 | get() = when (scheme) { 20 | ContentResolver.SCHEME_FILE -> path!! 21 | ContentResolver.SCHEME_CONTENT -> { 22 | val prefix = when (authority) { 23 | DOCUMENTSUI_AUTHORITY -> "" 24 | // Include the authority to reduce ambiguity when this isn't a SAF URI provided by 25 | // Android's local filesystem document provider 26 | else -> "[$authority] " 27 | } 28 | val segments = pathSegments 29 | 30 | // If this looks like a SAF tree/document URI, then try and show the document ID. This 31 | // cannot be implemented in a way that prevents all false positives. 32 | if (segments.size == 4 && segments[0] == "tree" && segments[2] == "document") { 33 | prefix + segments[3] 34 | } else if (segments.size == 2 && (segments[0] == "tree" || segments[0] == "document")) { 35 | prefix + segments[1] 36 | } else { 37 | toString() 38 | } 39 | } 40 | else -> toString() 41 | } 42 | 43 | val Uri.phoneNumber: String? 44 | get() = when (scheme) { 45 | PhoneAccount.SCHEME_TEL -> schemeSpecificPart 46 | else -> null 47 | } 48 | 49 | fun Uri.safTreeToDocument(): Uri { 50 | require(scheme == ContentResolver.SCHEME_CONTENT) { "Not a content URI" } 51 | 52 | val documentId = DocumentsContract.getTreeDocumentId(this) 53 | return DocumentsContract.buildDocumentUri(authority, documentId) 54 | } 55 | 56 | fun Uri.toDocumentFile(context: Context): DocumentFile = 57 | when (scheme) { 58 | ContentResolver.SCHEME_FILE -> DocumentFile.fromFile(toFile()) 59 | ContentResolver.SCHEME_CONTENT -> { 60 | val segments = pathSegments 61 | 62 | // These only return null on API <21. 63 | if (segments.size == 4 && segments[0] == "tree" && segments[2] == "document") { 64 | DocumentFile.fromSingleUri(context, this)!! 65 | } else if (segments.size == 2 && segments[0] == "document") { 66 | DocumentFile.fromSingleUri(context, this)!! 67 | } else if (segments.size == 2 && segments[0] == "tree") { 68 | DocumentFile.fromTreeUri(context, this)!! 69 | } else { 70 | throw IllegalStateException("Unsupported content URI: $this") 71 | } 72 | } 73 | else -> throw IllegalArgumentException("Unsupported URI: $this") 74 | } 75 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/standalone/ClearPackageManagerCaches.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:Suppress("SameParameterValue") 7 | 8 | package com.chiller3.bcr.standalone 9 | 10 | import android.util.Log 11 | import com.chiller3.bcr.BuildConfig 12 | import java.lang.invoke.MethodHandles 13 | import java.nio.file.Path 14 | import java.nio.file.Paths 15 | import kotlin.io.path.ExperimentalPathApi 16 | import kotlin.io.path.deleteIfExists 17 | import kotlin.io.path.isRegularFile 18 | import kotlin.io.path.readBytes 19 | import kotlin.io.path.walk 20 | import kotlin.system.exitProcess 21 | 22 | private val TAG = MethodHandles.lookup().lookupClass().simpleName 23 | 24 | private val PACKAGE_CACHE_DIR = Paths.get("/data/system/package_cache") 25 | 26 | private var dryRun = false 27 | 28 | private fun delete(path: Path) { 29 | if (dryRun) { 30 | Log.i(TAG, "Would have deleted: $path") 31 | } else { 32 | Log.i(TAG, "Deleting: $path") 33 | path.deleteIfExists() 34 | } 35 | } 36 | 37 | private fun ByteArray.indexOfSubarray(needle: ByteArray, start: Int = 0): Int { 38 | require(start >= 0) { "start must be non-negative" } 39 | 40 | if (needle.isEmpty()) { 41 | return 0 42 | } 43 | 44 | outer@ for (i in 0 until size - needle.size + 1) { 45 | for (j in needle.indices) { 46 | if (this[i + j] != needle[j]) { 47 | continue@outer 48 | } 49 | } 50 | return i 51 | } 52 | 53 | return -1 54 | } 55 | 56 | @OptIn(ExperimentalPathApi::class) 57 | private fun clearPackageManagerCache(appId: String): Boolean { 58 | // The current implementation of the package cache uses PackageImpl.writeToParcel(), which 59 | // serializes the cache entry to the file as a Parcel. The current Parcel implementation stores 60 | // string values as null-terminated little-endian UTF-16. One of the string values stored is 61 | // manifestPackageName, which we can match on. 62 | // 63 | // This is a unique enough search that there should never be a false positive, but even if there 64 | // is, the package manager will just repopulate the cache. 65 | val needle = "\u0000$appId\u0000".toByteArray(Charsets.UTF_16LE) 66 | var ret = true 67 | 68 | for (path in PACKAGE_CACHE_DIR.walk()) { 69 | if (!path.isRegularFile()) { 70 | continue 71 | } 72 | 73 | try { 74 | // Not the most efficient, but these are tiny files that Android is later going to read 75 | // entirely into memory anyway 76 | if (path.readBytes().indexOfSubarray(needle) >= 0) { 77 | delete(path) 78 | } 79 | } catch (e: Exception) { 80 | Log.w(TAG, "Failed to delete $path", e) 81 | ret = false 82 | } 83 | } 84 | 85 | return ret 86 | } 87 | 88 | private fun mainInternal() { 89 | clearPackageManagerCache(BuildConfig.APPLICATION_ID) 90 | } 91 | 92 | fun main(args: Array) { 93 | if ("--dry-run" in args) { 94 | dryRun = true 95 | } 96 | 97 | try { 98 | mainInternal() 99 | } catch (e: Exception) { 100 | Log.e(TAG, "Failed to clear caches", e) 101 | exitProcess(1) 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/output_format_bottom_sheet.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 11 | 17 | 18 | 24 | 25 | 31 | 32 | 38 | 45 | 46 | 52 | 53 | 54 | 61 | 62 | 68 | 69 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/dialog/MinDurationDialogFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.dialog 7 | 8 | import android.app.Dialog 9 | import android.content.DialogInterface 10 | import android.os.Bundle 11 | import android.text.InputType 12 | import androidx.appcompat.app.AlertDialog 13 | import androidx.core.os.bundleOf 14 | import androidx.core.widget.addTextChangedListener 15 | import androidx.fragment.app.DialogFragment 16 | import androidx.fragment.app.setFragmentResult 17 | import com.chiller3.bcr.Preferences 18 | import com.chiller3.bcr.R 19 | import com.chiller3.bcr.databinding.DialogTextInputBinding 20 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 21 | 22 | class MinDurationDialogFragment : DialogFragment() { 23 | companion object { 24 | val TAG: String = MinDurationDialogFragment::class.java.simpleName 25 | 26 | const val RESULT_SUCCESS = "success" 27 | } 28 | 29 | private lateinit var prefs: Preferences 30 | private lateinit var binding: DialogTextInputBinding 31 | private var minDuration: Int? = null 32 | private var success: Boolean = false 33 | 34 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 35 | val context = requireContext() 36 | prefs = Preferences(context) 37 | minDuration = prefs.minDuration 38 | 39 | binding = DialogTextInputBinding.inflate(layoutInflater) 40 | 41 | binding.message.setText(R.string.min_duration_dialog_message) 42 | 43 | binding.text.inputType = InputType.TYPE_CLASS_NUMBER 44 | binding.text.addTextChangedListener { 45 | minDuration = if (it!!.isEmpty()) { 46 | 0 47 | } else { 48 | try { 49 | val seconds = it.toString().toInt() 50 | if (seconds >= 0) { 51 | seconds 52 | } else { 53 | null 54 | } 55 | } catch (e: NumberFormatException) { 56 | null 57 | } 58 | } 59 | 60 | refreshHelperText() 61 | refreshOkButtonEnabledState() 62 | } 63 | if (savedInstanceState == null) { 64 | binding.text.setText(minDuration?.toString()) 65 | } 66 | 67 | refreshHelperText() 68 | 69 | return MaterialAlertDialogBuilder(requireContext()) 70 | .setTitle(R.string.min_duration_dialog_title) 71 | .setView(binding.root) 72 | .setPositiveButton(android.R.string.ok) { _, _ -> 73 | prefs.minDuration = minDuration!! 74 | success = true 75 | } 76 | .setNegativeButton(android.R.string.cancel, null) 77 | .create() 78 | .apply { 79 | setCanceledOnTouchOutside(false) 80 | } 81 | } 82 | 83 | override fun onStart() { 84 | super.onStart() 85 | refreshOkButtonEnabledState() 86 | } 87 | 88 | override fun onDismiss(dialog: DialogInterface) { 89 | super.onDismiss(dialog) 90 | 91 | setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success)) 92 | } 93 | 94 | private fun refreshHelperText() { 95 | val context = requireContext() 96 | 97 | binding.textLayout.helperText = minDuration?.let { 98 | context.resources.getQuantityString(R.plurals.min_duration_dialog_seconds, it, it) 99 | } 100 | } 101 | 102 | private fun refreshOkButtonEnabledState() { 103 | (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = 104 | minDuration != null 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/res/values-es/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Basic Call Recorder 3 | 4 | 5 | General 6 | Acerca de 7 | 8 | 9 | Grabacion de llamada 10 | Grabar llamadas telefónicas entrantes y salientes. Se requieren permisos de micrófono y notificación para grabar en segundo plano. 11 | 12 | Directorio de salida 13 | Elija un directorio para almacenar grabaciones. 14 | 15 | Formato de salida 16 | Seleccione un formato de codificación para las grabaciones. 17 | 18 | Deshabilitar la optimización de la batería 19 | Reduce la posibilidad de que el sistema elimine la aplicación. 20 | 21 | 22 | Versión 23 | 24 | 25 | Cambiar directorio 26 | retención de archivo 27 | Mantener todo 28 | 29 | Mantener %d día 30 | Mantener %d días 31 | 32 | 33 | 34 | Formato de salida 35 | Nivel de compresión 36 | Bitrate 37 | Frecuencia de muestreo 38 | 39 | Restablecer los valores predeterminados 40 | 41 | 42 | Servicios en segundo plano 43 | Notificación persistente para grabación de llamadas en segundo plano 44 | Alertas de fallas 45 | Alertas de errores durante la grabación de llamadas 46 | Alertas de éxito 47 | Alertas para grabaciones de llamadas exitosas 48 | Grabación de llamadas en curso 49 | Grabación de llamadas en pausa 50 | No se pudo grabar la llamada 51 | Llamada grabada con éxito 52 | La grabación falló en un componente interno de Android (%s). Es posible que este dispositivo o firmware no admita la grabación de llamadas. 53 | Abrir 54 | Compartir 55 | Eliminar 56 | Pausar 57 | Reanudar 58 | 59 | 60 | Grabación de llamada 61 | 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/PickContactGroupFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import android.app.Activity 9 | import android.content.Intent 10 | import android.os.Bundle 11 | import androidx.fragment.app.viewModels 12 | import androidx.lifecycle.Lifecycle 13 | import androidx.lifecycle.lifecycleScope 14 | import androidx.lifecycle.repeatOnLifecycle 15 | import androidx.preference.Preference 16 | import androidx.preference.get 17 | import androidx.preference.size 18 | import com.chiller3.bcr.ContactGroupInfo 19 | import com.chiller3.bcr.PreferenceBaseFragment 20 | import com.chiller3.bcr.R 21 | import com.google.android.material.snackbar.Snackbar 22 | import kotlinx.coroutines.launch 23 | 24 | class PickContactGroupFragment : PreferenceBaseFragment(), Preference.OnPreferenceClickListener { 25 | private val viewModel: PickContactGroupViewModel by viewModels() 26 | 27 | override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 28 | setPreferencesFromResource(R.xml.record_rules_preferences, rootKey) 29 | 30 | lifecycleScope.launch { 31 | repeatOnLifecycle(Lifecycle.State.STARTED) { 32 | viewModel.alerts.collect { 33 | it.firstOrNull()?.let { alert -> 34 | onAlert(alert) 35 | } 36 | } 37 | } 38 | } 39 | 40 | lifecycleScope.launch { 41 | repeatOnLifecycle(Lifecycle.State.STARTED) { 42 | viewModel.groups.collect { 43 | updateGroups(it) 44 | } 45 | } 46 | } 47 | } 48 | 49 | private fun updateGroups(newGroups: List) { 50 | val context = requireContext() 51 | 52 | for (i in (0 until preferenceScreen.size).reversed()) { 53 | val p = preferenceScreen[i] 54 | preferenceScreen.removePreference(p) 55 | } 56 | 57 | for ((i, group) in newGroups.withIndex()) { 58 | val p = Preference(context).apply { 59 | key = PREF_GROUP_PREFIX + i 60 | isPersistent = false 61 | title = group.title 62 | summary = group.accountName ?: getString(R.string.pick_contact_group_local_group) 63 | isIconSpaceReserved = false 64 | onPreferenceClickListener = this@PickContactGroupFragment 65 | } 66 | preferenceScreen.addPreference(p) 67 | } 68 | } 69 | 70 | override fun onPreferenceClick(preference: Preference): Boolean { 71 | when { 72 | preference.key.startsWith(PREF_GROUP_PREFIX) -> { 73 | val index = preference.key.substring(PREF_GROUP_PREFIX.length).toInt() 74 | val activity = requireActivity() 75 | 76 | activity.setResult(Activity.RESULT_OK, Intent().putExtra( 77 | PickContactGroupActivity.RESULT_CONTACT_GROUP, 78 | viewModel.groups.value[index], 79 | )) 80 | activity.finish() 81 | 82 | return true 83 | } 84 | } 85 | 86 | return false 87 | } 88 | 89 | private fun onAlert(alert: PickContactGroupAlert) { 90 | val msg = when (alert) { 91 | is PickContactGroupAlert.QueryFailed -> 92 | getString(R.string.alert_contact_group_query_failure, alert.error) 93 | } 94 | 95 | Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG) 96 | .addCallback(object : Snackbar.Callback() { 97 | override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { 98 | viewModel.acknowledgeFirstAlert() 99 | } 100 | }) 101 | .show() 102 | } 103 | 104 | companion object { 105 | private const val PREF_GROUP_PREFIX = "group_" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/dialog/FileRetentionDialogFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.dialog 7 | 8 | import android.app.Dialog 9 | import android.content.DialogInterface 10 | import android.os.Bundle 11 | import android.text.InputType 12 | import androidx.appcompat.app.AlertDialog 13 | import androidx.core.os.bundleOf 14 | import androidx.core.widget.addTextChangedListener 15 | import androidx.fragment.app.DialogFragment 16 | import androidx.fragment.app.setFragmentResult 17 | import com.chiller3.bcr.Preferences 18 | import com.chiller3.bcr.R 19 | import com.chiller3.bcr.databinding.DialogTextInputBinding 20 | import com.chiller3.bcr.output.DaysRetention 21 | import com.chiller3.bcr.output.NoRetention 22 | import com.chiller3.bcr.output.Retention 23 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 24 | 25 | class FileRetentionDialogFragment : DialogFragment() { 26 | companion object { 27 | val TAG: String = FileRetentionDialogFragment::class.java.simpleName 28 | 29 | const val RESULT_SUCCESS = "success" 30 | } 31 | 32 | private lateinit var prefs: Preferences 33 | private lateinit var binding: DialogTextInputBinding 34 | private var retention: Retention? = null 35 | private var success: Boolean = false 36 | 37 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 38 | val context = requireContext() 39 | prefs = Preferences(context) 40 | retention = Retention.fromPreferences(prefs) 41 | 42 | binding = DialogTextInputBinding.inflate(layoutInflater) 43 | 44 | binding.message.setText(R.string.file_retention_dialog_message) 45 | 46 | binding.text.inputType = InputType.TYPE_CLASS_NUMBER 47 | binding.text.addTextChangedListener { 48 | retention = if (it!!.isEmpty()) { 49 | NoRetention 50 | } else { 51 | try { 52 | val days = it.toString().toUInt() 53 | if (days == 0U) { 54 | NoRetention 55 | } else { 56 | DaysRetention(days) 57 | } 58 | } catch (e: NumberFormatException) { 59 | binding.textLayout.error = getString(R.string.file_retention_error_too_large) 60 | null 61 | } 62 | } 63 | 64 | refreshHelperText() 65 | refreshOkButtonEnabledState() 66 | } 67 | if (savedInstanceState == null) { 68 | when (val r = retention!!) { 69 | is DaysRetention -> binding.text.setText(r.days.toString()) 70 | NoRetention -> binding.text.setText("") 71 | } 72 | } 73 | 74 | refreshHelperText() 75 | 76 | return MaterialAlertDialogBuilder(requireContext()) 77 | .setTitle(R.string.file_retention_dialog_title) 78 | .setView(binding.root) 79 | .setPositiveButton(android.R.string.ok) { _, _ -> 80 | prefs.outputRetention = retention!! 81 | success = true 82 | } 83 | .setNegativeButton(android.R.string.cancel, null) 84 | .create() 85 | .apply { 86 | setCanceledOnTouchOutside(false) 87 | } 88 | } 89 | 90 | override fun onStart() { 91 | super.onStart() 92 | refreshOkButtonEnabledState() 93 | } 94 | 95 | override fun onDismiss(dialog: DialogInterface) { 96 | super.onDismiss(dialog) 97 | 98 | setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success)) 99 | } 100 | 101 | private fun refreshHelperText() { 102 | binding.textLayout.helperText = retention?.toFormattedString(requireContext()) 103 | } 104 | 105 | private fun refreshOkButtonEnabledState() { 106 | (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = 107 | retention != null 108 | } 109 | } -------------------------------------------------------------------------------- /gradle/update_verification.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 4 | # SPDX-License-Identifier: GPL-3.0-only 5 | 6 | import hashlib 7 | import io 8 | import os 9 | import subprocess 10 | import sys 11 | import tempfile 12 | import urllib.request 13 | import xml.etree.ElementTree as ET 14 | 15 | 16 | GOOGLE_MAVEN_REPO = 'https://dl.google.com/android/maven2' 17 | 18 | 19 | def add_source_exclusions(ns, root): 20 | configuration = root.find(f'{{{ns}}}configuration') 21 | trusted_artifacts = ET.SubElement( 22 | configuration, f'{{{ns}}}trusted-artifacts') 23 | 24 | for regex in [ 25 | r'.*-javadoc[.]jar', 26 | r'.*-sources[.]jar', 27 | r'.*-src[.]zip', 28 | ]: 29 | ET.SubElement(trusted_artifacts, f'{{{ns}}}trust', attrib={ 30 | 'file': regex, 31 | 'regex': 'true', 32 | }) 33 | 34 | 35 | def add_missing_aapt2_platforms(ns, root): 36 | components = root.find(f'{{{ns}}}components') 37 | aapt2 = components.find(f'{{{ns}}}component[@name="aapt2"]') 38 | 39 | for platform in ['linux', 'osx', 'windows']: 40 | group = aapt2.attrib['group'] 41 | name = aapt2.attrib['name'] 42 | version = aapt2.attrib['version'] 43 | filename = f'{name}-{version}-{platform}.jar' 44 | 45 | if aapt2.find(f'{{{ns}}}artifact[@name="{filename}"]') is not None: 46 | continue 47 | 48 | path = f'{group.replace(".", "/")}/{name}/{version}/{filename}' 49 | url = f'{GOOGLE_MAVEN_REPO}/{path}' 50 | 51 | with urllib.request.urlopen(url) as r: 52 | if r.status != 200: 53 | raise Exception(f'{url} returned HTTP {r.status}') 54 | 55 | digest = hashlib.file_digest(r, 'sha512') 56 | 57 | artifact = ET.SubElement(aapt2, f'{{{ns}}}artifact', 58 | attrib={'name': filename}) 59 | 60 | ET.SubElement(artifact, f'{{{ns}}}sha512', attrib={ 61 | 'value': digest.hexdigest(), 62 | 'origin': 'Generated by Gradle', 63 | }) 64 | 65 | aapt2[:] = sorted(aapt2, key=lambda child: child.attrib['name']) 66 | 67 | 68 | def patch_xml(path): 69 | tree = ET.parse(path) 70 | root = tree.getroot() 71 | 72 | ns = 'https://schema.gradle.org/dependency-verification' 73 | ET.register_namespace('', ns) 74 | 75 | # Add exclusions to allow Android Studio to download sources. 76 | add_source_exclusions(ns, root) 77 | 78 | # Gradle only adds the aapt2 entry for the host OS. We have to manually add 79 | # the checksums for the other major desktop OSs. 80 | add_missing_aapt2_platforms(ns, root) 81 | 82 | # Match gradle's formatting exactly. 83 | ET.indent(tree, ' ') 84 | root.tail = '\n' 85 | 86 | with io.BytesIO() as f: 87 | # etree's xml_declaration=True uses single quotes in the header. 88 | f.write(b'\n') 89 | tree.write(f) 90 | serialized = f.getvalue().replace(b' />', b'/>') 91 | 92 | with open(path, 'wb') as f: 93 | f.write(serialized) 94 | 95 | 96 | def main(): 97 | root_dir = os.path.join(sys.path[0], '..') 98 | xml_file = os.path.join(sys.path[0], 'verification-metadata.xml') 99 | 100 | try: 101 | os.remove(xml_file) 102 | except FileNotFoundError: 103 | pass 104 | 105 | # Gradle will sometimes fail to add verification entries for artifacts that 106 | # are already cached. 107 | with tempfile.TemporaryDirectory() as temp_dir: 108 | env = os.environ | {'GRADLE_USER_HOME': temp_dir} 109 | 110 | subprocess.check_call( 111 | [ 112 | './gradlew' + ('.bat' if os.name == 'nt' else ''), 113 | '--write-verification-metadata', 'sha512', 114 | '--no-daemon', 115 | 'build', 116 | 'zipDebug', 117 | # Requires signing. 118 | '-x', 'assembleRelease', 119 | ], 120 | env=env, 121 | cwd=root_dir, 122 | ) 123 | 124 | patch_xml(xml_file) 125 | 126 | 127 | if __name__ == '__main__': 128 | main() 129 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/RecordRulesLayoutManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import android.content.Context 9 | import android.os.Bundle 10 | import android.view.View 11 | import androidx.core.view.accessibility.AccessibilityNodeInfoCompat 12 | import androidx.recyclerview.widget.ConcatAdapter 13 | import androidx.recyclerview.widget.LinearLayoutManager 14 | import androidx.recyclerview.widget.RecyclerView 15 | import com.chiller3.bcr.R 16 | 17 | internal class RecordRulesLayoutManager( 18 | context: Context, 19 | private val globalAdapter: ConcatAdapter, 20 | ) : LinearLayoutManager(context) { 21 | private val actionMoveUp = AccessibilityNodeInfoCompat.AccessibilityActionCompat( 22 | R.id.record_rule_drag_move_up, 23 | context.getString(R.string.record_rules_list_action_move_up), 24 | ) 25 | private val actionMoveDown = AccessibilityNodeInfoCompat.AccessibilityActionCompat( 26 | R.id.record_rule_drag_move_down, 27 | context.getString(R.string.record_rules_list_action_move_down), 28 | ) 29 | private val actionRemove = AccessibilityNodeInfoCompat.AccessibilityActionCompat( 30 | R.id.record_rule_swipe_remove, 31 | context.getString(R.string.record_rules_list_action_remove), 32 | ) 33 | 34 | private fun getRecordRuleAdapterAndPosition(globalPosition: Int): 35 | Pair? { 36 | val pair = globalAdapter.getWrappedAdapterAndPosition(globalPosition) 37 | val adapter = pair.first 38 | 39 | return if (adapter is RecordRulesAdapter) { 40 | adapter to pair.second 41 | } else { 42 | null 43 | } 44 | } 45 | 46 | override fun onInitializeAccessibilityNodeInfoForItem( 47 | recycler: RecyclerView.Recycler, 48 | state: RecyclerView.State, 49 | host: View, 50 | info: AccessibilityNodeInfoCompat, 51 | ) { 52 | super.onInitializeAccessibilityNodeInfoForItem(recycler, state, host, info) 53 | 54 | val (adapter, position) = getRecordRuleAdapterAndPosition(getPosition(host)) ?: return 55 | 56 | if (canMoveUp(adapter, position)) { 57 | info.addAction(actionMoveUp) 58 | } 59 | if (canMoveDown(adapter, position)) { 60 | info.addAction(actionMoveDown) 61 | } 62 | if (canRemove(adapter, position)) { 63 | info.addAction(actionRemove) 64 | } 65 | } 66 | 67 | override fun performAccessibilityActionForItem( 68 | recycler: RecyclerView.Recycler, 69 | state: RecyclerView.State, 70 | view: View, 71 | action: Int, 72 | args: Bundle?, 73 | ): Boolean { 74 | val adapterAndPosition = getRecordRuleAdapterAndPosition(getPosition(view)) 75 | if (adapterAndPosition != null) { 76 | val (adapter, position) = adapterAndPosition 77 | 78 | when (action) { 79 | actionMoveUp.id -> if (canMoveUp(adapter, position)) { 80 | adapter.onRuleMove(position, position - 1) 81 | return true 82 | } 83 | actionMoveDown.id -> if (canMoveDown(adapter, position)) { 84 | adapter.onRuleMove(position, position + 1) 85 | return true 86 | } 87 | actionRemove.id -> if (canRemove(adapter, position)) { 88 | adapter.onRuleRemove(position) 89 | return true 90 | } 91 | } 92 | } 93 | 94 | return super.performAccessibilityActionForItem(recycler, state, view, action, args) 95 | } 96 | 97 | private fun canMoveUp(adapter: RecordRulesAdapter, position: Int) = position > 0 98 | && !adapter.isDefaultRule(position) && !adapter.isDefaultRule(position - 1) 99 | 100 | private fun canMoveDown(adapter: RecordRulesAdapter, position: Int) = position < itemCount - 1 101 | && !adapter.isDefaultRule(position) && !adapter.isDefaultRule(position + 1) 102 | 103 | private fun canRemove(adapter: RecordRulesAdapter, position: Int) = 104 | !adapter.isDefaultRule(position) 105 | } 106 | -------------------------------------------------------------------------------- /app/src/main/res/values-ar/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | تسجيل المكالمات 4 | عام 5 | معلومات 6 | تسجيل المكالمات 7 | اعدادات التسجيل الآلي 8 | اختيار اي من المكالمات يتم تسجيلها آليا 9 | مجلد حفظ التسجيلات 10 | اختيار مجلد حفظ التسجيلات ،اضغط من اجل فتح مستعرض مكان الحفظ 11 | صيغة حفظ التسجيل 12 | اختيار صيغة التسجيل 13 | تعطيل تحسين البطارية 14 | الاصدار 15 | الارقام المراد تسجيل مكالماتها 16 | الاسم: %s 17 | تغيير المجلد 18 | اسم القالب 19 | تعديل القالب 20 | الاحتفاظ بالملفات 21 | تعديل مده حفظ الملفات 22 | حفظ الكل 23 | صيغه الحفظ 24 | معدل BIT 25 | تردد الصوت 26 | تخصيص 27 | اعادة الضبط الافتراضي 28 | لا يمكن أن يكون القالب فارغاً 29 | متغير قالب غير معروف: 30 | لا يمكن أن يحتوي المتغير على وسيطة: 31 | وسيطة متغيرة غير صالحة: 32 | بناء جملة القالب غير صالح 33 | اعاده الضبط الافتراضي 34 | @string/output_dir_bottom_sheet_file_retention 35 | ادخل عدد الايام الافتراضيه للحفظ 36 | الرقم كبير جدا 37 | تعديل المدخلات 38 | معدل العينة المخصصة 39 | أدخل معدل العينة ضمن أحد هذه النطاقات: 40 | بداء تسجيل المكالمه 41 | تسجيل المكالمه جاري 42 | تم الانتهاء من التسجيل 43 | تسجيل المكالمه متوقف 44 | المكالمه معلقه 45 | فشل في تسجيل المكالمة 46 | تم التسجيل بنجاح 47 | المكالمه سوف تحذف في نهايه التسجيل اضغط استعاده للاحتفاظ 48 | فتح 49 | مشاركة 50 | حذف 51 | استعادة 52 | توقيف 53 | مواصلة 54 | تسجيل المكالمات 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/res/layout/output_directory_bottom_sheet.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 17 | 23 | 24 | 30 | 31 | 37 | 38 | 45 | 46 | 53 | 54 | 60 | 61 | 68 | 69 | 75 | 76 | 82 | 83 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/NotificationActionService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr 7 | 8 | import android.app.NotificationManager 9 | import android.app.Service 10 | import android.content.Context 11 | import android.content.Intent 12 | import android.net.Uri 13 | import android.os.Handler 14 | import android.os.IBinder 15 | import android.os.Looper 16 | import android.util.Log 17 | import androidx.core.content.IntentCompat 18 | import com.chiller3.bcr.output.OutputFile 19 | 20 | class NotificationActionService : Service() { 21 | companion object { 22 | private val TAG = NotificationActionService::class.java.simpleName 23 | 24 | private val ACTION_DELETE_URI = "${NotificationActionService::class.java.canonicalName}.delete_uri" 25 | private const val EXTRA_FILES = "files" 26 | private const val EXTRA_NOTIFICATION_ID = "notification_id" 27 | 28 | fun createDeleteUriIntent( 29 | context: Context, 30 | files: List, 31 | notificationId: Int, 32 | ) = Intent(context, NotificationActionService::class.java).apply { 33 | action = ACTION_DELETE_URI 34 | // Unused, but guarantees filterEquals() uniqueness for use with PendingIntents 35 | val uniqueSsp = files.asSequence().map { it.uri.toString() }.joinToString("\u0000") 36 | data = Uri.fromParts("unused", uniqueSsp, null) 37 | putExtra(EXTRA_FILES, ArrayList(files)) 38 | putExtra(EXTRA_NOTIFICATION_ID, notificationId) 39 | } 40 | } 41 | 42 | private val handler = Handler(Looper.getMainLooper()) 43 | 44 | private fun parseDeleteUriIntent(intent: Intent): Pair, Int> { 45 | // This uses IntentCompat because of an Android 13 bug where using the new APIs that take a 46 | // class option causes a NullPointerException in release builds. 47 | // https://issuetracker.google.com/issues/274185314 48 | val files = IntentCompat.getParcelableArrayListExtra( 49 | intent, EXTRA_FILES, OutputFile::class.java) 50 | ?: throw IllegalArgumentException("No files specified") 51 | 52 | val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) 53 | if (notificationId < 0) { 54 | throw IllegalArgumentException("Invalid notification ID: $notificationId") 55 | } 56 | 57 | return Pair(files, notificationId) 58 | } 59 | 60 | override fun onBind(intent: Intent?): IBinder? = null 61 | 62 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = 63 | try { 64 | when (intent?.action) { 65 | ACTION_DELETE_URI -> { 66 | val (files, notificationId) = parseDeleteUriIntent(intent) 67 | val notificationManager = getSystemService(NotificationManager::class.java) 68 | 69 | Thread { 70 | for (file in files) { 71 | val documentFile = file.toDocumentFile(this) 72 | 73 | Log.d(TAG, "Deleting: ${file.redacted}") 74 | try { 75 | documentFile.delete() 76 | } catch (e: Exception) { 77 | Log.w(TAG, "Failed to delete ${file.redacted}", e) 78 | } 79 | } 80 | 81 | handler.post { 82 | Log.i(TAG, "Cancelling notification $notificationId") 83 | notificationManager.cancel(notificationId) 84 | stopSelf(startId) 85 | } 86 | }.start() 87 | } 88 | else -> throw IllegalArgumentException("Invalid action: ${intent?.action}") 89 | } 90 | 91 | START_REDELIVER_INTENT 92 | } catch (e: Exception) { 93 | val redactedIntent = intent?.let { Intent(it) }?.apply { 94 | setDataAndType(Uri.fromParts("redacted", "", ""), type) 95 | } 96 | 97 | Log.w(TAG, "Failed to handle intent: $redactedIntent", e) 98 | stopSelf(startId) 99 | 100 | START_NOT_STICKY 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/res/xml/root_preferences.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 | 15 | 16 | 22 | 23 | 29 | 30 | 36 | 37 | 42 | 43 | 49 | 50 | 55 | 56 | 61 | 62 | 67 | 68 | 74 | 75 | 76 | 79 | 80 | 85 | 86 | 87 | 91 | 92 | 97 | 98 | 104 | 105 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/template/TemplateSyntaxHighlighter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.template 7 | 8 | import android.content.Context 9 | import android.content.res.Configuration 10 | import android.text.Spannable 11 | import android.text.style.ForegroundColorSpan 12 | import androidx.annotation.ColorRes 13 | import androidx.core.text.getSpans 14 | import com.chiller3.bcr.R 15 | import com.google.android.material.color.ColorRoles 16 | import com.google.android.material.color.MaterialColors 17 | 18 | class TemplateSyntaxHighlighter(context: Context) { 19 | private val colorVariableRefName = getHarmonizedColor( 20 | context, R.color.template_highlighting_variable_ref_name) 21 | private val colorVariableRefArg = getHarmonizedColor( 22 | context, R.color.template_highlighting_variable_ref_arg) 23 | private val colorFallbackChars = getHarmonizedColor( 24 | context, R.color.template_highlighting_fallback_chars) 25 | private val colorDirectorySeparator = getHarmonizedColor( 26 | context, R.color.template_highlighting_directory_separator) 27 | 28 | fun highlight( 29 | spannable: Spannable, 30 | templateStart: Int = 0, 31 | templateEnd: Int = spannable.length, 32 | ) { 33 | val template = if (templateStart == 0 && templateEnd == spannable.length) { 34 | spannable 35 | } else { 36 | spannable.subSequence(templateStart, templateEnd) 37 | } 38 | 39 | // Forcibly recolor every time. We don't rely on Android's span extensions, which aren't 40 | // sophisticated enough to keep the syntax highlighting correct. 41 | for (span in spannable.getSpans()) { 42 | val start = spannable.getSpanStart(span) 43 | val end = spannable.getSpanEnd(span) 44 | if (start >= templateStart && end <= templateEnd) { 45 | spannable.removeSpan(span) 46 | } 47 | } 48 | 49 | // This is intentionally not based on the AST because Template cannot parse incomplete 50 | // half-written templates and we don't want syntax highlighting to be removed as the user is 51 | // typing. 52 | for ((i, c) in template.withIndex()) { 53 | if (c == '[' || c == ']' || c == '|') { 54 | spannable.setSpan( 55 | ForegroundColorSpan(colorFallbackChars.accent), 56 | templateStart + i, 57 | templateStart + i + 1, 58 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, 59 | ) 60 | } 61 | } 62 | 63 | for (m in REGEX_VARIABLE_REF.findAll(template)) { 64 | spannable.setSpan( 65 | ForegroundColorSpan(colorVariableRefName.accent), 66 | templateStart + m.range.first, 67 | templateStart + m.range.last + 1, 68 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, 69 | ) 70 | 71 | val arg = m.groups[1] 72 | if (arg != null) { 73 | spannable.setSpan( 74 | ForegroundColorSpan(colorVariableRefArg.accent), 75 | templateStart + arg.range.first, 76 | templateStart + arg.range.last + 1, 77 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, 78 | ) 79 | } 80 | } 81 | 82 | // Color directory separators last to ensure their visual prominence. 83 | for ((i, c) in template.withIndex()) { 84 | if (c == '/') { 85 | spannable.setSpan( 86 | ForegroundColorSpan(colorDirectorySeparator.accent), 87 | templateStart + i, 88 | templateStart + i + 1, 89 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, 90 | ) 91 | } 92 | } 93 | } 94 | 95 | companion object { 96 | // Android's regex implementation requires the "redundant" escapes 97 | @Suppress("RegExpRedundantEscape") 98 | private val REGEX_VARIABLE_REF = "\\{[\\p{Alpha}_][\\p{Alpha}\\d_]*(:[^\\}]*)?\\}".toRegex() 99 | 100 | private fun getHarmonizedColor(context: Context, @ColorRes colorResId: Int): ColorRoles { 101 | val isLight = context.resources.configuration.uiMode and 102 | Configuration.UI_MODE_NIGHT_MASK != Configuration.UI_MODE_NIGHT_YES 103 | val color = context.getColor(colorResId) 104 | 105 | val blended = MaterialColors.harmonizeWithPrimary(context, color) 106 | return MaterialColors.getColorRoles(blended, isLight) 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/RecordRuleEditorViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import android.app.Application 9 | import android.net.Uri 10 | import android.util.Log 11 | import androidx.lifecycle.AndroidViewModel 12 | import androidx.lifecycle.viewModelScope 13 | import com.chiller3.bcr.ContactGroupInfo 14 | import com.chiller3.bcr.ContactInfo 15 | import com.chiller3.bcr.GroupLookup 16 | import com.chiller3.bcr.getContactByLookupKey 17 | import com.chiller3.bcr.getContactGroupById 18 | import com.chiller3.bcr.withContactsByUri 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.flow.MutableStateFlow 21 | import kotlinx.coroutines.flow.asStateFlow 22 | import kotlinx.coroutines.flow.getAndUpdate 23 | import kotlinx.coroutines.flow.update 24 | import kotlinx.coroutines.launch 25 | import kotlinx.coroutines.withContext 26 | 27 | class RecordRuleEditorViewModel(application: Application) : AndroidViewModel(application) { 28 | private val _contactInfoLookup = MutableStateFlow(null) 29 | val contactInfoLookup = _contactInfoLookup.asStateFlow() 30 | 31 | private val _contactGroupInfoLookup = MutableStateFlow(null) 32 | val contactGroupInfoLookup = _contactGroupInfoLookup.asStateFlow() 33 | 34 | private val _contactInfoSelection = MutableStateFlow(null) 35 | val contactInfoSelection = _contactInfoSelection.asStateFlow() 36 | 37 | private val _contactGroupInfoSelection = MutableStateFlow(null) 38 | val contactGroupInfoSelection = _contactGroupInfoSelection.asStateFlow() 39 | 40 | // NOTE: The functions that update the lookup fields are inherently racy. We do not try to abort 41 | // previous lookups when a new one is scheduled because they basically always complete faster 42 | // than the user is able to react. 43 | 44 | fun lookUpContact(lookupKey: String) { 45 | viewModelScope.launch { 46 | withContext(Dispatchers.IO) { 47 | val contact = try { 48 | getContactByLookupKey(getApplication(), lookupKey) 49 | } catch (e: SecurityException) { 50 | Log.w(TAG, "Permission denied when looking up contact", e) 51 | null 52 | } catch (e: Exception) { 53 | Log.w(TAG, "Failed to look up contact", e) 54 | null 55 | } 56 | 57 | _contactInfoLookup.update { contact } 58 | } 59 | } 60 | } 61 | 62 | fun lookUpContactGroup(rowId: Long, sourceId: String?) { 63 | viewModelScope.launch { 64 | withContext(Dispatchers.IO) { 65 | val groupLookup = if (sourceId != null) { 66 | GroupLookup.SourceId(sourceId) 67 | } else { 68 | GroupLookup.RowId(rowId) 69 | } 70 | 71 | val group = try { 72 | getContactGroupById(getApplication(), groupLookup) 73 | } catch (e: SecurityException) { 74 | Log.w(TAG, "Permission denied when looking up contact group", e) 75 | null 76 | } catch (e: Exception) { 77 | Log.w(TAG, "Failed to look up contact group", e) 78 | null 79 | } 80 | 81 | _contactGroupInfoLookup.update { group } 82 | } 83 | } 84 | } 85 | 86 | fun selectContact(uri: Uri) { 87 | viewModelScope.launch { 88 | withContext(Dispatchers.IO) { 89 | val contact = try { 90 | withContactsByUri(getApplication(), uri) { it.firstOrNull() } 91 | } catch (e: Exception) { 92 | Log.w(TAG, "Failed to query contact at $uri", e) 93 | null 94 | } 95 | if (contact == null) { 96 | Log.w(TAG, "Contact not found at $uri") 97 | } 98 | 99 | _contactInfoSelection.update { contact } 100 | } 101 | } 102 | } 103 | 104 | fun selectContactGroup(group: ContactGroupInfo) { 105 | _contactGroupInfoSelection.update { group } 106 | } 107 | 108 | fun useSelectedContact() { 109 | val contact = _contactInfoSelection.getAndUpdate { null } 110 | _contactInfoLookup.update { contact } 111 | } 112 | 113 | fun useSelectedContactGroup() { 114 | val group = _contactGroupInfoSelection.getAndUpdate { null } 115 | _contactGroupInfoLookup.update { group } 116 | } 117 | 118 | companion object { 119 | private val TAG = RecordRuleEditorViewModel::class.java.simpleName 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/output/CallMetadata.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2025 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.output 7 | 8 | import android.content.Context 9 | import com.chiller3.bcr.format.FormatParamInfo 10 | import com.chiller3.bcr.format.NoParamInfo 11 | import com.chiller3.bcr.format.RangedParamInfo 12 | import com.chiller3.bcr.format.RangedParamType 13 | import kotlinx.serialization.SerialName 14 | import kotlinx.serialization.Serializable 15 | import java.time.ZonedDateTime 16 | import java.time.format.DateTimeFormatter 17 | 18 | @Serializable 19 | enum class CallDirection { 20 | @SerialName("in") 21 | IN, 22 | @SerialName("out") 23 | OUT, 24 | @SerialName("conference") 25 | CONFERENCE, 26 | } 27 | 28 | data class CallPartyDetails( 29 | val phoneNumber: PhoneNumber?, 30 | val callerName: String?, 31 | val contactName: String?, 32 | ) 33 | 34 | @Serializable 35 | data class CallPartyDetailsJson( 36 | @SerialName("phone_number") 37 | val phoneNumber: String?, 38 | @SerialName("phone_number_formatted") 39 | val phoneNumberFormatted: String?, 40 | @SerialName("caller_name") 41 | val callerName: String?, 42 | @SerialName("contact_name") 43 | val contactName: String?, 44 | ) { 45 | constructor(context: Context, details: CallPartyDetails) : this( 46 | phoneNumber = details.phoneNumber?.toString(), 47 | phoneNumberFormatted = details.phoneNumber 48 | ?.format(context, PhoneNumber.Format.COUNTRY_SPECIFIC), 49 | callerName = details.callerName, 50 | contactName = details.contactName, 51 | ) 52 | } 53 | 54 | data class CallMetadata( 55 | val timestamp: ZonedDateTime, 56 | val direction: CallDirection?, 57 | val simCount: Int?, 58 | val simSlot: Int?, 59 | val callLogName: String?, 60 | val calls: List, 61 | ) 62 | 63 | @Serializable 64 | data class CallMetadataJson( 65 | @SerialName("timestamp_unix_ms") 66 | val timestampUnixMs: Long, 67 | val timestamp: String, 68 | val direction: CallDirection?, 69 | @SerialName("sim_slot") 70 | val simSlot: Int?, 71 | @SerialName("call_log_name") 72 | val callLogName: String?, 73 | val calls: List, 74 | val output: OutputJson, 75 | ) { 76 | constructor(context: Context, metadata: CallMetadata, output: OutputJson) : this( 77 | timestampUnixMs = metadata.timestamp.toInstant().toEpochMilli(), 78 | timestamp = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(metadata.timestamp), 79 | direction = metadata.direction, 80 | simSlot = metadata.simSlot, 81 | callLogName = metadata.callLogName, 82 | calls = metadata.calls.map { CallPartyDetailsJson(context, it) }, 83 | output = output, 84 | ) 85 | } 86 | 87 | @Serializable 88 | enum class ParameterType { 89 | @SerialName("none") 90 | NONE, 91 | @SerialName("compression_level") 92 | COMPRESSION_LEVEL, 93 | @SerialName("bitrate") 94 | BITRATE, 95 | ; 96 | 97 | companion object { 98 | fun fromParamInfo(info: FormatParamInfo): ParameterType = when (info) { 99 | NoParamInfo -> NONE 100 | is RangedParamInfo -> when (info.type) { 101 | RangedParamType.CompressionLevel -> COMPRESSION_LEVEL 102 | RangedParamType.Bitrate -> BITRATE 103 | } 104 | } 105 | } 106 | } 107 | 108 | @Serializable 109 | data class FormatJson( 110 | val type: String, 111 | @SerialName("mime_type_container") 112 | val mimeTypeContainer: String, 113 | @SerialName("mime_type_audio") 114 | val mimeTypeAudio: String, 115 | @SerialName("parameter_type") 116 | val parameterType: ParameterType, 117 | val parameter: UInt, 118 | ) 119 | 120 | @Serializable 121 | data class RecordingJson( 122 | @SerialName("frames_total") 123 | val framesTotal: Long, 124 | @SerialName("frames_encoded") 125 | val framesEncoded: Long, 126 | @SerialName("sample_rate") 127 | val sampleRate: Int, 128 | @SerialName("channel_count") 129 | val channelCount: Int, 130 | @SerialName("duration_secs_wall") 131 | val durationSecsWall: Double, 132 | @SerialName("duration_secs_total") 133 | val durationSecsTotal: Double, 134 | @SerialName("duration_secs_encoded") 135 | val durationSecsEncoded: Double, 136 | @SerialName("buffer_frames") 137 | val bufferFrames: Long, 138 | @SerialName("buffer_overruns") 139 | val bufferOverruns: Int, 140 | @SerialName("was_ever_paused") 141 | val wasEverPaused: Boolean, 142 | @SerialName("was_ever_holding") 143 | val wasEverHolding: Boolean, 144 | ) 145 | 146 | @Serializable 147 | data class OutputJson( 148 | val format: FormatJson, 149 | val recording: RecordingJson?, 150 | ) 151 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.media.MediaCodec 11 | import android.media.MediaFormat 12 | import android.system.Os 13 | import android.system.OsConstants 14 | import com.chiller3.bcr.writeFully 15 | import java.io.FileDescriptor 16 | import java.nio.ByteBuffer 17 | import java.nio.ByteOrder 18 | 19 | class WaveContainer(private val fd: FileDescriptor) : Container { 20 | private var isStarted = false 21 | private var track = -1 22 | private var frameSize = 0 23 | private var channelCount = 0 24 | private var sampleRate = 0 25 | 26 | override fun start() { 27 | if (isStarted) { 28 | throw IllegalStateException("Container already started") 29 | } 30 | 31 | Os.ftruncate(fd, 0) 32 | 33 | // Skip header 34 | Os.lseek(fd, HEADER_SIZE.toLong(), OsConstants.SEEK_SET) 35 | 36 | isStarted = true 37 | } 38 | 39 | override fun stop() { 40 | if (!isStarted) { 41 | throw IllegalStateException("Container not started") 42 | } 43 | 44 | isStarted = false 45 | 46 | if (track >= 0) { 47 | val fileSize = Os.lseek(fd, 0, OsConstants.SEEK_CUR) 48 | val header = buildHeader(fileSize) 49 | Os.lseek(fd, 0, OsConstants.SEEK_SET) 50 | writeFully(fd, header) 51 | } 52 | } 53 | 54 | override fun release() { 55 | if (isStarted) { 56 | stop() 57 | } 58 | } 59 | 60 | override fun addTrack(mediaFormat: MediaFormat): Int { 61 | if (isStarted) { 62 | throw IllegalStateException("Container already started") 63 | } else if (track >= 0) { 64 | throw IllegalStateException("Track already added") 65 | } 66 | 67 | track = 0 68 | frameSize = mediaFormat.getInteger(Format.KEY_X_FRAME_SIZE_IN_BYTES) 69 | channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) 70 | sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) 71 | 72 | return track 73 | } 74 | 75 | override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, 76 | bufferInfo: MediaCodec.BufferInfo) { 77 | if (!isStarted) { 78 | throw IllegalStateException("Container not started") 79 | } else if (track < 0) { 80 | throw IllegalStateException("No track has been added") 81 | } else if (track != trackIndex) { 82 | throw IllegalStateException("Invalid track: $trackIndex") 83 | } 84 | 85 | writeFully(fd, byteBuffer) 86 | } 87 | 88 | private fun buildHeader(fileSize: Long): ByteBuffer = 89 | ByteBuffer.allocate(HEADER_SIZE).apply { 90 | order(ByteOrder.LITTLE_ENDIAN) 91 | 92 | val (chunkSize, dataSize) = if (fileSize >= Int.MAX_VALUE) { 93 | // If, for some reason, the recording is excessively huge, don't set a size and just 94 | // let the audio player guess 95 | Pair(0, 0) 96 | } else { 97 | Pair(fileSize.toInt() - 8, fileSize.toInt() - HEADER_SIZE) 98 | } 99 | 100 | // 0-3: Chunk ID 101 | put(RIFF_MAGIC.asByteArray()) 102 | // 4-7: Chunk size 103 | putInt(chunkSize) 104 | // 8-11: Format 105 | put(WAVE_MAGIC.asByteArray()) 106 | // 12-15: Subchunk 1 ID 107 | put(FMT_MAGIC.asByteArray()) 108 | // 16-19: Subchunk 1 size 109 | putInt(16) 110 | // 20-21: Audio format 111 | putShort(1) 112 | // 22-23: Number of channels 113 | putShort(channelCount.toShort()) 114 | // 24-27: Sample rate 115 | putInt(sampleRate) 116 | // 28-31: Byte rate 117 | putInt(sampleRate * frameSize) 118 | // 32-33: Block align 119 | putShort(frameSize.toShort()) 120 | // 34-35: Bits per sample 121 | putShort(((frameSize / channelCount) * 8).toShort()) 122 | // 36-39: Subchunk 2 ID 123 | put(DATA_MAGIC.asByteArray()) 124 | // 40-43: Subchunk 2 size 125 | putInt(dataSize) 126 | 127 | flip() 128 | } 129 | 130 | companion object { 131 | private const val HEADER_SIZE = 44 132 | private val RIFF_MAGIC = ubyteArrayOf(0x52u, 0x49u, 0x46u, 0x46u) // RIFF 133 | private val WAVE_MAGIC = ubyteArrayOf(0x57u, 0x41u, 0x56u, 0x45u) // WAVE 134 | private val FMT_MAGIC = ubyteArrayOf(0x66u, 0x6du, 0x74u, 0x20u) // "fmt " 135 | private val DATA_MAGIC = ubyteArrayOf(0x64u, 0x61u, 0x74u, 0x61u) // data 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.format 7 | 8 | import android.media.MediaCodec 9 | import android.media.MediaCodecList 10 | import android.media.MediaFormat 11 | import android.util.Log 12 | import java.lang.Integer.min 13 | import java.nio.ByteBuffer 14 | 15 | /** 16 | * Create a [MediaCodec]-based encoder for the specified format. 17 | * 18 | * @param mediaFormat The [MediaFormat] instance returned by [Format.getMediaFormat]. 19 | * @param container The container for storing the encoded audio stream. 20 | * 21 | * @throws Exception if the device does not support encoding with the parameters set in the format 22 | * or if configuring the [MediaCodec] fails. 23 | */ 24 | class MediaCodecEncoder( 25 | mediaFormat: MediaFormat, 26 | private val container: Container, 27 | ) : Encoder(mediaFormat) { 28 | private val codec = createCodec(mediaFormat) 29 | private val bufferInfo = MediaCodec.BufferInfo() 30 | private var trackIndex = -1 31 | 32 | override fun start() = 33 | codec.start() 34 | 35 | override fun stop() = 36 | codec.stop() 37 | 38 | override fun release() = 39 | codec.release() 40 | 41 | override fun encode(buffer: ByteBuffer, isEof: Boolean) { 42 | while (true) { 43 | var waitForever = false 44 | 45 | val inputBufferId = codec.dequeueInputBuffer(TIMEOUT) 46 | if (inputBufferId >= 0) { 47 | val inputBuffer = codec.getInputBuffer(inputBufferId)!! 48 | // Maximum non-overflowing buffer size that is a multiple of the frame size 49 | val toCopy = min(buffer.remaining(), inputBuffer.remaining()) / frameSize * frameSize 50 | 51 | // Temporarily change buffer limit to avoid overflow 52 | val oldLimit = buffer.limit() 53 | buffer.limit(buffer.position() + toCopy) 54 | inputBuffer.put(buffer) 55 | buffer.limit(oldLimit) 56 | 57 | // Submit EOF if the entire buffer has been consumed 58 | val flags = if (isEof && !buffer.hasRemaining()) { 59 | Log.d(TAG, "On final buffer; submitting EOF") 60 | waitForever = true 61 | MediaCodec.BUFFER_FLAG_END_OF_STREAM 62 | } else { 63 | 0 64 | } 65 | 66 | codec.queueInputBuffer(inputBufferId, 0, toCopy, timestampUs, flags) 67 | 68 | numFrames += toCopy / frameSize 69 | } else { 70 | Log.w(TAG, "Unexpected input buffer dequeue error: $inputBufferId") 71 | } 72 | 73 | flush(waitForever) 74 | 75 | if (!buffer.hasRemaining()) { 76 | break 77 | } 78 | } 79 | } 80 | 81 | /** Flush [MediaCodec]'s pending encoded data to [container]. */ 82 | private fun flush(waitForever: Boolean) { 83 | while (true) { 84 | val timeout = if (waitForever) { -1 } else { TIMEOUT } 85 | val outputBufferId = codec.dequeueOutputBuffer(bufferInfo, timeout) 86 | if (outputBufferId >= 0) { 87 | val buffer = codec.getOutputBuffer(outputBufferId)!! 88 | 89 | container.writeSamples(trackIndex, buffer, bufferInfo) 90 | 91 | codec.releaseOutputBuffer(outputBufferId, false) 92 | 93 | if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { 94 | Log.d(TAG, "Received EOF; fully flushed") 95 | // Output has been fully written 96 | break 97 | } 98 | } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { 99 | val outputFormat = codec.outputFormat 100 | Log.d(TAG, "Output format changed to: $outputFormat") 101 | trackIndex = container.addTrack(outputFormat) 102 | container.start() 103 | } else if (outputBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) { 104 | break 105 | } else { 106 | Log.w(TAG, "Unexpected output buffer dequeue error: $outputBufferId") 107 | break 108 | } 109 | } 110 | } 111 | 112 | companion object { 113 | private val TAG = MediaCodecEncoder::class.java.simpleName 114 | private const val TIMEOUT = 500L 115 | 116 | fun createCodec(mediaFormat: MediaFormat): MediaCodec { 117 | val encoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(mediaFormat) 118 | ?: throw Exception("No suitable encoder found for $mediaFormat") 119 | Log.d(TAG, "Audio encoder: $encoder") 120 | 121 | val codec = MediaCodec.createByCodecName(encoder) 122 | 123 | try { 124 | codec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) 125 | } catch (e: Exception) { 126 | codec.release() 127 | throw e 128 | } 129 | 130 | return codec 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/format/SampleRateInfo.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | @file:OptIn(ExperimentalUnsignedTypes::class) 7 | 8 | package com.chiller3.bcr.format 9 | 10 | import android.content.Context 11 | import android.media.MediaCodecList 12 | import android.media.MediaFormat 13 | import android.util.Log 14 | import com.chiller3.bcr.R 15 | 16 | sealed class SampleRateInfo( 17 | val default: UInt, 18 | /** Fixed sample rate choices to show in the UI as presets. */ 19 | val presets: UIntArray, 20 | ) { 21 | /** 22 | * Ensure that [rate] is valid. 23 | * 24 | * @throws IllegalArgumentException if [rate] is invalid 25 | */ 26 | abstract fun validate(rate: UInt) 27 | 28 | /** 29 | * Convert a potentially-invalid [rate] value to the nearest valid value. 30 | */ 31 | abstract fun toNearest(rate: UInt): UInt 32 | 33 | /** 34 | * Format [rate] to present as a user-facing string. 35 | */ 36 | fun format(context: Context, rate: UInt): String = 37 | context.getString(R.string.format_sample_rate, rate.toString()) 38 | 39 | companion object { 40 | private val TAG = SampleRateInfo::class.java.simpleName 41 | 42 | fun fromCodec(format: MediaFormat, tryDefault: UInt): SampleRateInfo { 43 | val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) 44 | 45 | for (info in codecList.codecInfos) { 46 | if (!info.isEncoder) { 47 | continue 48 | } 49 | 50 | val capabilities = try { 51 | info.getCapabilitiesForType(format.getString(MediaFormat.KEY_MIME)) 52 | } catch (e: IllegalArgumentException) { 53 | continue 54 | } 55 | 56 | if (capabilities == null || !capabilities.isFormatSupported(format)) { 57 | continue 58 | } 59 | 60 | val audioCapabilities = capabilities.audioCapabilities 61 | ?: throw IllegalArgumentException("${info.name} encoder has no audio capabilities") 62 | 63 | val rates = try { 64 | audioCapabilities.supportedSampleRates 65 | } catch (e: NullPointerException) { 66 | Log.w(TAG, "OS is missing 2e961480fe0f4e87f2b7f621b276035864124f69 fix", e) 67 | null 68 | } 69 | if (rates != null && rates.isNotEmpty()) { 70 | val ratesUnsigned = rates.toUIntArray() 71 | val default = DiscreteSampleRateInfo.toNearest(ratesUnsigned, tryDefault)!! 72 | 73 | return DiscreteSampleRateInfo(ratesUnsigned, default) 74 | } 75 | 76 | val rateRanges = audioCapabilities.supportedSampleRateRanges 77 | if (rateRanges.isNotEmpty()) { 78 | val rateRangesUnsigned = rateRanges 79 | .map { it.lower.toUInt()..it.upper.toUInt() } 80 | .toTypedArray() 81 | val default = RangedSampleRateInfo.toNearest(rateRangesUnsigned, tryDefault)!! 82 | 83 | return RangedSampleRateInfo(rateRangesUnsigned, default) 84 | } 85 | } 86 | 87 | throw IllegalArgumentException("No suitable encoder found for $format") 88 | } 89 | } 90 | } 91 | 92 | private fun absDiff(a: UInt, b: UInt): UInt = if (a > b) { 93 | a - b 94 | } else { 95 | b - a 96 | } 97 | 98 | class RangedSampleRateInfo( 99 | val ranges: Array, 100 | default: UInt, 101 | ) : SampleRateInfo(default, uintArrayOf(default)) { 102 | override fun validate(rate: UInt) { 103 | if (!ranges.any { rate in it }) { 104 | throw IllegalArgumentException( 105 | "Sample rate $rate is not in the ranges: ${ranges.contentToString()}") 106 | } 107 | } 108 | 109 | /** Clamp [rate] to the nearest range in [ranges]. */ 110 | override fun toNearest(rate: UInt): UInt = toNearest(ranges, rate)!! 111 | 112 | companion object { 113 | fun toNearest(ranges: Array, rate: UInt): UInt? = ranges 114 | .asSequence() 115 | .map { rate.coerceIn(it) } 116 | .minByOrNull { absDiff(rate, it) } 117 | } 118 | } 119 | 120 | class DiscreteSampleRateInfo( 121 | /** For simplicity, all choices are used as presets. */ 122 | private val choices: UIntArray, 123 | default: UInt, 124 | ) : SampleRateInfo(default, choices) { 125 | init { 126 | require(choices.isNotEmpty()) { "List of choices cannot be empty" } 127 | } 128 | 129 | override fun validate(rate: UInt) { 130 | if (rate !in choices) { 131 | throw IllegalArgumentException("Sample rate $rate is not supported: " + 132 | choices.contentToString()) 133 | } 134 | } 135 | 136 | /** Find closest sample rate in [choices] to [rate]. */ 137 | override fun toNearest(rate: UInt): UInt = toNearest(choices, rate)!! 138 | 139 | companion object { 140 | fun toNearest(choices: UIntArray, rate: UInt): UInt? = 141 | choices.minByOrNull { absDiff(rate, it) } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/view/ChipGroupCentered.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.view 7 | 8 | import android.annotation.SuppressLint 9 | import android.content.Context 10 | import android.util.AttributeSet 11 | import android.view.View 12 | import androidx.core.view.isGone 13 | import com.google.android.material.chip.ChipGroup 14 | import java.lang.Integer.max 15 | import java.lang.Integer.min 16 | 17 | /** Hacky wrapper around [ChipGroup] to make every row individually centered. */ 18 | class ChipGroupCentered : ChipGroup { 19 | private val _rowCountField = javaClass.superclass.superclass.getDeclaredField("rowCount") 20 | private var rowCountField 21 | get() = _rowCountField.getInt(this) 22 | set(value) = _rowCountField.setInt(this, value) 23 | 24 | init { 25 | _rowCountField.isAccessible = true 26 | } 27 | 28 | constructor(context: Context) : super(context) 29 | 30 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) 31 | 32 | constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : 33 | super(context, attrs, defStyleAttr) 34 | 35 | @SuppressLint("RestrictedApi") 36 | override fun onLayout(sizeChanged: Boolean, left: Int, top: Int, right: Int, bottom: Int) { 37 | if (isSingleLine) { 38 | return super.onLayout(sizeChanged, left, top, right, bottom) 39 | } 40 | 41 | val maxWidth = right - left - paddingRight - paddingLeft 42 | var offsetTop = paddingTop 43 | var rowStartIndex = 0 44 | 45 | while (rowStartIndex < childCount) { 46 | val (rowEndIndex, rowWidth, rowHeight) = getFittingRow(rowStartIndex, maxWidth) 47 | 48 | layoutRow( 49 | rowStartIndex..rowEndIndex, 50 | paddingLeft + (maxWidth - rowWidth) / 2, 51 | offsetTop, 52 | rowCountField, 53 | ) 54 | 55 | offsetTop += rowHeight + lineSpacing 56 | rowStartIndex = rowEndIndex + 1 57 | rowCountField += 1 58 | } 59 | } 60 | 61 | /** 62 | * Find the last index starting from [indexStart] that will fit in the row. 63 | * 64 | * @return (Index of last fitting element, width of row, height of row) 65 | */ 66 | @SuppressLint("RestrictedApi") 67 | private fun getFittingRow(indexStart: Int, maxWidth: Int): Triple { 68 | var indexEnd = indexStart 69 | var childStart = 0 70 | var rowHeight = 0 71 | 72 | while (true) { 73 | val child = getChildAt(indexEnd) 74 | if (child.isGone) { 75 | continue 76 | } 77 | 78 | val (marginStart, marginEnd) = getMargins(child) 79 | val childWidth = marginStart + child.measuredWidth + marginEnd 80 | val separator = if (indexEnd > indexStart) { itemSpacing } else { 0 } 81 | 82 | // If even one child can't fit, force it to do so anyway 83 | if (indexEnd != indexStart && childStart + separator + childWidth > maxWidth) { 84 | --indexEnd 85 | break 86 | } 87 | 88 | childStart += separator + childWidth 89 | rowHeight = max(rowHeight, child.measuredHeight) 90 | 91 | if (indexEnd == childCount - 1) { 92 | break 93 | } else { 94 | ++indexEnd 95 | } 96 | } 97 | 98 | return Triple(indexEnd, min(childStart, maxWidth), rowHeight) 99 | } 100 | 101 | /** 102 | * Lay out [childIndices] children in a row positioned at [offsetLeft] and [offsetTop]. 103 | */ 104 | @SuppressLint("RestrictedApi") 105 | private fun layoutRow(childIndices: IntRange, offsetLeft: Int, offsetTop: Int, rowIndex: Int) { 106 | val range = if (layoutDirection == LAYOUT_DIRECTION_RTL) { 107 | childIndices.reversed() 108 | } else { 109 | childIndices 110 | } 111 | var childStart = offsetLeft 112 | 113 | for (i in range) { 114 | val child = getChildAt(i) 115 | if (child.isGone) { 116 | child.setTag(com.google.android.material.R.id.row_index_key, -1) 117 | continue 118 | } else { 119 | child.setTag(com.google.android.material.R.id.row_index_key, rowIndex) 120 | } 121 | 122 | val (marginStart, marginEnd) = getMargins(child) 123 | 124 | child.layout( 125 | childStart + marginStart, 126 | offsetTop, 127 | childStart + marginStart + child.measuredWidth, 128 | offsetTop + child.measuredHeight, 129 | ) 130 | 131 | childStart += marginStart + child.measuredWidth + marginEnd + itemSpacing 132 | } 133 | } 134 | 135 | companion object { 136 | private fun getMargins(view: View): Pair { 137 | val lp = view.layoutParams 138 | 139 | return if (lp is MarginLayoutParams) { 140 | Pair(lp.marginStart, lp.marginEnd) 141 | } else { 142 | Pair(0, 0) 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/settings/OutputDirectoryBottomSheetFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022-2023 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.settings 7 | 8 | import android.os.Bundle 9 | import android.text.SpannableString 10 | import android.view.LayoutInflater 11 | import android.view.View 12 | import android.view.ViewGroup 13 | import androidx.fragment.app.setFragmentResultListener 14 | import com.chiller3.bcr.Preferences 15 | import com.chiller3.bcr.R 16 | import com.chiller3.bcr.databinding.OutputDirectoryBottomSheetBinding 17 | import com.chiller3.bcr.dialog.FileRetentionDialogFragment 18 | import com.chiller3.bcr.dialog.FilenameTemplateDialogFragment 19 | import com.chiller3.bcr.extension.formattedString 20 | import com.chiller3.bcr.output.OutputFilenameGenerator 21 | import com.chiller3.bcr.output.Retention 22 | import com.chiller3.bcr.template.Template 23 | import com.chiller3.bcr.template.TemplateSyntaxHighlighter 24 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment 25 | 26 | class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment() { 27 | private lateinit var binding: OutputDirectoryBottomSheetBinding 28 | private lateinit var prefs: Preferences 29 | private lateinit var highlighter: TemplateSyntaxHighlighter 30 | 31 | private val requestSafOutputDir = 32 | registerForActivityResult(OpenPersistentDocumentTree()) { uri -> 33 | prefs.outputDir = uri 34 | refreshOutputDir() 35 | } 36 | 37 | override fun onCreateView( 38 | inflater: LayoutInflater, 39 | container: ViewGroup?, 40 | savedInstanceState: Bundle? 41 | ): View { 42 | binding = OutputDirectoryBottomSheetBinding.inflate(inflater, container, false) 43 | 44 | val context = requireContext() 45 | 46 | prefs = Preferences(context) 47 | highlighter = TemplateSyntaxHighlighter(context) 48 | 49 | binding.selectNewDir.setOnClickListener { 50 | requestSafOutputDir.launch(null) 51 | } 52 | binding.selectNewDir.setOnLongClickListener { 53 | prefs.outputDir = null 54 | refreshOutputDir() 55 | true 56 | } 57 | 58 | binding.editTemplate.setOnClickListener { 59 | FilenameTemplateDialogFragment().show( 60 | parentFragmentManager.beginTransaction(), FilenameTemplateDialogFragment.TAG) 61 | } 62 | binding.editTemplate.setOnLongClickListener { 63 | prefs.filenameTemplate = null 64 | refreshFilenameTemplate() 65 | refreshOutputRetention() 66 | true 67 | } 68 | 69 | binding.editRetention.setOnClickListener { 70 | FileRetentionDialogFragment().show( 71 | parentFragmentManager.beginTransaction(), FileRetentionDialogFragment.TAG) 72 | } 73 | binding.editRetention.setOnLongClickListener { 74 | prefs.outputRetention = null 75 | refreshOutputRetention() 76 | true 77 | } 78 | 79 | binding.reset.setOnClickListener { 80 | prefs.outputDir = null 81 | refreshOutputDir() 82 | prefs.filenameTemplate = null 83 | refreshFilenameTemplate() 84 | prefs.outputRetention = null 85 | refreshOutputRetention() 86 | } 87 | 88 | setFragmentResultListener(FilenameTemplateDialogFragment.TAG) { _, _ -> 89 | refreshFilenameTemplate() 90 | refreshOutputRetention() 91 | } 92 | setFragmentResultListener(FileRetentionDialogFragment.TAG) { _, _ -> 93 | refreshOutputRetention() 94 | } 95 | 96 | refreshFilenameTemplate() 97 | refreshOutputDir() 98 | refreshOutputRetention() 99 | 100 | return binding.root 101 | } 102 | 103 | private fun refreshOutputDir() { 104 | val outputDirUri = prefs.outputDirOrDefault 105 | binding.outputDir.text = outputDirUri.formattedString 106 | } 107 | 108 | private fun refreshFilenameTemplate() { 109 | val template = prefs.filenameTemplate ?: Preferences.DEFAULT_FILENAME_TEMPLATE 110 | val highlightedTemplate = SpannableString(template.toString()) 111 | highlighter.highlight(highlightedTemplate) 112 | 113 | binding.filenameTemplate.text = highlightedTemplate 114 | } 115 | 116 | private fun refreshOutputRetention() { 117 | // Disable retention options if the template makes it impossible for the feature to work 118 | val template = prefs.filenameTemplate ?: Preferences.DEFAULT_FILENAME_TEMPLATE 119 | val locations = template.findVariableRef(OutputFilenameGenerator.DATE_VAR) 120 | val retentionUsable = locations != null && 121 | locations.second != setOf(Template.VariableRefLocation.Arbitrary) 122 | 123 | binding.retention.isEnabled = retentionUsable 124 | binding.editRetention.isEnabled = retentionUsable 125 | 126 | if (retentionUsable) { 127 | val retention = Retention.fromPreferences(prefs) 128 | binding.retention.text = retention.toFormattedString(requireContext()) 129 | } else { 130 | binding.retention.setText(R.string.retention_unusable) 131 | } 132 | } 133 | 134 | companion object { 135 | val TAG: String = OutputDirectoryBottomSheetFragment::class.java.simpleName 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/dialog/FormatParamDialogFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.dialog 7 | 8 | import android.app.Dialog 9 | import android.content.DialogInterface 10 | import android.os.Bundle 11 | import android.text.InputType 12 | import android.view.View 13 | import androidx.appcompat.app.AlertDialog 14 | import androidx.core.os.bundleOf 15 | import androidx.core.widget.addTextChangedListener 16 | import androidx.fragment.app.DialogFragment 17 | import androidx.fragment.app.setFragmentResult 18 | import com.chiller3.bcr.Preferences 19 | import com.chiller3.bcr.R 20 | import com.chiller3.bcr.databinding.DialogTextInputBinding 21 | import com.chiller3.bcr.format.Format 22 | import com.chiller3.bcr.format.RangedParamInfo 23 | import com.chiller3.bcr.format.RangedParamType 24 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 25 | import java.lang.NumberFormatException 26 | 27 | class FormatParamDialogFragment : DialogFragment() { 28 | companion object { 29 | val TAG: String = FormatParamDialogFragment::class.java.simpleName 30 | 31 | const val RESULT_SUCCESS = "success" 32 | } 33 | 34 | private lateinit var prefs: Preferences 35 | private lateinit var format: Format 36 | private lateinit var binding: DialogTextInputBinding 37 | private var value: UInt? = null 38 | private var success: Boolean = false 39 | 40 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 41 | val context = requireContext() 42 | prefs = Preferences(context) 43 | format = Format.fromPreferences(prefs).first 44 | 45 | val paramInfo = format.paramInfo 46 | if (paramInfo !is RangedParamInfo) { 47 | throw IllegalStateException("Selected format is not configurable") 48 | } 49 | 50 | val multiplier = when (paramInfo.type) { 51 | RangedParamType.CompressionLevel -> 1U 52 | RangedParamType.Bitrate -> { 53 | if (paramInfo.range.first % 1_000U == 0U && paramInfo.range.last % 1_000U == 0U) { 54 | 1000U 55 | } else { 56 | 1U 57 | } 58 | } 59 | } 60 | 61 | binding = DialogTextInputBinding.inflate(layoutInflater) 62 | 63 | binding.message.text = getString( 64 | R.string.format_param_dialog_message, 65 | paramInfo.format(context, paramInfo.range.first), 66 | paramInfo.format(context, paramInfo.range.last), 67 | ) 68 | 69 | // Try to detect if the displayed format is a prefix or suffix since it's not the same in 70 | // every language (eg. "Level 8" vs "8级") 71 | val translated = when (paramInfo.type) { 72 | RangedParamType.CompressionLevel -> 73 | getString(R.string.format_param_compression_level, "\u0000") 74 | RangedParamType.Bitrate -> if (multiplier == 1_000U) { 75 | getString(R.string.format_param_bitrate_kbps, "\u0000") 76 | } else { 77 | getString(R.string.format_param_bitrate_bps, "\u0000") 78 | } 79 | } 80 | val placeholder = translated.indexOf('\u0000') 81 | val hasPrefix = placeholder > 0 82 | val hasSuffix = placeholder < translated.length - 1 83 | if (hasPrefix) { 84 | binding.textLayout.prefixText = translated.substring(0, placeholder).trimEnd() 85 | } 86 | if (hasSuffix) { 87 | binding.textLayout.suffixText = translated.substring(placeholder + 1).trimStart() 88 | } 89 | if (hasPrefix && hasSuffix) { 90 | binding.text.textAlignment = View.TEXT_ALIGNMENT_CENTER 91 | } else if (hasSuffix) { 92 | binding.text.textAlignment = View.TEXT_ALIGNMENT_TEXT_END 93 | } 94 | 95 | binding.text.inputType = InputType.TYPE_CLASS_NUMBER 96 | binding.text.addTextChangedListener { 97 | value = null 98 | 99 | if (it!!.isNotEmpty()) { 100 | try { 101 | val newValue = it.toString().toUInt().times(multiplier.toULong()) 102 | if (newValue in paramInfo.range) { 103 | value = newValue.toUInt() 104 | } 105 | } catch (e: NumberFormatException) { 106 | // Ignore 107 | } 108 | } 109 | 110 | refreshOkButtonEnabledState() 111 | } 112 | 113 | return MaterialAlertDialogBuilder(requireContext()) 114 | .setTitle(R.string.format_param_dialog_title) 115 | .setView(binding.root) 116 | .setPositiveButton(android.R.string.ok) { _, _ -> 117 | prefs.setFormatParam(format, value!!) 118 | success = true 119 | } 120 | .setNegativeButton(android.R.string.cancel, null) 121 | .create() 122 | .apply { 123 | setCanceledOnTouchOutside(false) 124 | } 125 | } 126 | 127 | override fun onStart() { 128 | super.onStart() 129 | refreshOkButtonEnabledState() 130 | } 131 | 132 | override fun onDismiss(dialog: DialogInterface) { 133 | super.onDismiss(dialog) 134 | 135 | setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success)) 136 | } 137 | 138 | private fun refreshOkButtonEnabledState() { 139 | (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = value != null 140 | } 141 | } -------------------------------------------------------------------------------- /app/src/main/java/com/chiller3/bcr/rule/RecordRulesFragment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson 3 | * SPDX-License-Identifier: GPL-3.0-only 4 | */ 5 | 6 | package com.chiller3.bcr.rule 7 | 8 | import android.os.Bundle 9 | import android.view.LayoutInflater 10 | import android.view.Menu 11 | import android.view.MenuInflater 12 | import android.view.MenuItem 13 | import android.view.View 14 | import android.view.ViewGroup 15 | import androidx.core.os.BundleCompat 16 | import androidx.core.view.MenuProvider 17 | import androidx.fragment.app.setFragmentResultListener 18 | import androidx.fragment.app.viewModels 19 | import androidx.lifecycle.Lifecycle 20 | import androidx.lifecycle.lifecycleScope 21 | import androidx.lifecycle.repeatOnLifecycle 22 | import androidx.preference.Preference 23 | import androidx.preference.PreferenceScreen 24 | import androidx.recyclerview.widget.ConcatAdapter 25 | import androidx.recyclerview.widget.ItemTouchHelper 26 | import androidx.recyclerview.widget.RecyclerView 27 | import com.chiller3.bcr.PreferenceBaseFragment 28 | import com.chiller3.bcr.Preferences 29 | import com.chiller3.bcr.R 30 | import kotlinx.coroutines.launch 31 | 32 | class RecordRulesFragment : PreferenceBaseFragment(), Preference.OnPreferenceClickListener, 33 | RecordRulesAdapter.Listener { 34 | private val viewModel: RecordRulesViewModel by viewModels() 35 | 36 | private lateinit var prefAddNewRule: Preference 37 | 38 | private val globalAdapter = ConcatAdapter() 39 | private lateinit var rulesAdapter: RecordRulesAdapter 40 | 41 | override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 42 | setPreferencesFromResource(R.xml.record_rules_preferences, rootKey) 43 | 44 | val context = requireContext() 45 | 46 | prefAddNewRule = findPreference(Preferences.PREF_ADD_NEW_RULE)!! 47 | prefAddNewRule.onPreferenceClickListener = this 48 | 49 | rulesAdapter = RecordRulesAdapter(context, this) 50 | 51 | lifecycleScope.launch { 52 | repeatOnLifecycle(Lifecycle.State.STARTED) { 53 | viewModel.rules.collect { 54 | rulesAdapter.setDisplayableRules(it) 55 | } 56 | } 57 | } 58 | 59 | setFragmentResultListener(RecordRuleEditorBottomSheet.TAG) { _, result -> 60 | val position = result.getInt(RecordRuleEditorBottomSheet.RESULT_POSITION) 61 | val recordRule = BundleCompat.getParcelable( 62 | result, 63 | RecordRuleEditorBottomSheet.RESULT_RECORD_RULE, 64 | RecordRule::class.java, 65 | )!! 66 | 67 | if (position >= 0) { 68 | viewModel.replaceRule(position, recordRule) 69 | } else { 70 | viewModel.addRule(recordRule) 71 | } 72 | } 73 | } 74 | 75 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 76 | super.onViewCreated(view, savedInstanceState) 77 | 78 | requireActivity().addMenuProvider(object : MenuProvider { 79 | override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { 80 | menuInflater.inflate(R.menu.record_rules, menu) 81 | } 82 | 83 | override fun onMenuItemSelected(menuItem: MenuItem): Boolean { 84 | return when (menuItem.itemId) { 85 | R.id.reset -> { 86 | viewModel.reset() 87 | true 88 | } 89 | else -> false 90 | } 91 | } 92 | }, viewLifecycleOwner, Lifecycle.State.RESUMED) 93 | } 94 | 95 | override fun onCreateRecyclerView( 96 | inflater: LayoutInflater, 97 | parent: ViewGroup, 98 | savedInstanceState: Bundle? 99 | ): RecyclerView = super.onCreateRecyclerView(inflater, parent, savedInstanceState).apply { 100 | ItemTouchHelper(RecordRulesTouchHelperCallback(rulesAdapter)).attachToRecyclerView(this) 101 | } 102 | 103 | override fun onCreateLayoutManager(): RecyclerView.LayoutManager { 104 | return RecordRulesLayoutManager(requireContext(), globalAdapter) 105 | } 106 | 107 | override fun onCreateAdapter(preferenceScreen: PreferenceScreen): RecyclerView.Adapter<*> { 108 | globalAdapter.addAdapter(super.onCreateAdapter(preferenceScreen)) 109 | globalAdapter.addAdapter(rulesAdapter) 110 | return globalAdapter 111 | } 112 | 113 | override fun onPreferenceClick(preference: Preference): Boolean { 114 | when (preference) { 115 | prefAddNewRule -> { 116 | val newRule = RecordRule( 117 | callNumber = RecordRule.CallNumber.Any, 118 | callType = RecordRule.CallType.ANY, 119 | simSlot = RecordRule.SimSlot.Any, 120 | action = RecordRule.Action.SAVE, 121 | ) 122 | 123 | RecordRuleEditorBottomSheet.newInstance(-1, newRule, false) 124 | .show(parentFragmentManager.beginTransaction(), RecordRuleEditorBottomSheet.TAG) 125 | return true 126 | } 127 | } 128 | 129 | return false 130 | } 131 | 132 | override fun onRulesChanged(rules: List) { 133 | viewModel.replaceRules(rules) 134 | } 135 | 136 | override fun onRuleSelected(position: Int, rule: RecordRule, isDefault: Boolean) { 137 | RecordRuleEditorBottomSheet.newInstance(position, rule, isDefault) 138 | .show(parentFragmentManager.beginTransaction(), RecordRuleEditorBottomSheet.TAG) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 8 | 9 | 10 | 13 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 44 | 45 | 48 | 49 | 53 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | 69 | 70 | 71 | 72 | 73 | 74 | 81 | 84 | 85 | 86 | 87 | 88 | 89 | 94 | 95 | 96 | 99 | 100 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 124 | 125 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /app/src/main/res/values-ur/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | کال ریکارڈنگ 4 | جنرل 5 | کے بارے میں 6 | کال ریکارڈنگ 7 | آمدنی اور روانگی کی فون کالوں کو ریکارڈ کریں۔ پس منظر میں ریکارڈنگ کے لئے مائیکروفون اور اطلاعاتی اجازتیں ضروری ہیں۔ 8 | خود بخود ریکارڈ قواعد 9 | خود بخود ریکارڈ کون سی کالوں کو کنفیگر کریں۔ 10 | آؤٹپٹ ڈائریکٹری 11 | ریکارڈنگز کو ذخیرہ کرنے کے لئے ایک ڈائریکٹری منتخب کریں۔ فائل مینیجر میں کھولنے کے لئے لانگ پریس کریں۔ 12 | آؤٹپٹ فارمیٹ 13 | ریکارڈنگز کے لئے ایک انکوڈنگ فارمیٹ منتخب کریں۔ 14 | بیٹری انسداد کو غیر فعال کریں 15 | سسٹم کے ذریعہ ایپ کو بند کر دینے کی ممکنیت کو کم کرتا ہے۔ 16 | میٹا ڈیٹا فائل لکھیں 17 | کال کے بارے میں تفصیلات شامل کرنے والی ایک JSON فائل بنائیں اور اسے آڈیو فائل کے بجائے رکھیں۔ 18 | ورژن 19 | قواعد 20 | رابطہ: %s 21 | ڈائریکٹری تبدیل کریں 22 | فائل کا نام ٹیمپلیٹ 23 | ٹیمپلیٹ میں ترمیم کریں 24 | فائل کی رکاوٹ 25 | رکاوٹ کو ترمیم کریں 26 | سب کو رکھیں 27 | فائل کی رکاوٹ غیر فعال ہے کیونکہ موجودہ فائل نام ٹیمپلیٹ اس خصوصیت کے ساتھ مطابقت نہیں رکھتا۔ 28 | آؤٹپٹ فارمیٹ 29 | آڈیو فارمیٹ کی فشن کی سطح 30 | بٹ ریٹ 31 | سینپل ریٹ 32 | اپنی مرضی کے مطابق 33 | پہلے کی طرح کریں 34 | فائل کا نام ٹیمپلیٹ 35 | ٹیمپلیٹ خالی نہیں ہو سکتا۔ 36 | ٹیمپلیٹ سنٹیکس غلط ہے۔ 37 | پہلے کی طرح کریں 38 | ریکارڈنگز کو رکھنے کے دنوں کی تعداد درج کریں۔ 39 | نمبر بہت بڑا ہے 40 | خصوصی پیرامیٹر 41 | مقدار میں رینج میں درج کریں [%1$s, %2$s]. 42 | خصوصی آڈیو سینپل ریٹ 43 | آڈیو سینپل ریٹ میں ان رینجز میں سے ایک درج کریں: 44 | پس منظری خدمات 45 | پس منظر کال ریکارڈنگ کے لئے مستقل اطلاع 46 | ناکامی کی اطلاعات 47 | کال ریکارڈنگ کے دوران خرابیوں کے لئے اطلاعات 48 | کامیابی کی اطلاعات 49 | کال ریکارڈنگ کی کامیابی کے لئے اطلاعات 50 | کال ریکارڈنگ کی ابتدائی تیاری 51 | کال ریکارڈنگ جاری ہے 52 | کال ریکارڈنگ مکمل ہو رہی ہے 53 | کال ریکارڈنگ معطل ہوئی ہے 54 | کال معطل ہے 55 | کال ریکارڈ کرنے میں ناکامی 56 | کامیابی سے کال ریکارڈ ہوئی 57 | کال کے آخر میں ریکارڈ حذف کر دی جائے گی۔ ریکارڈ کو بچانے کے لئے ریسٹور پر ٹیپ کریں۔ 58 | ریکارڈنگ ایک اندروئیڈ کے اندرونی جز میں ناکام ہوگئی (%s)۔ شاید یہ آلہ یا فرم ویئر کال ریکارڈنگ کا معاونت نہیں کرتا ہو۔ 59 | کھولیں 60 | شیئر کریں 61 | حذف کریں 62 | بحال کریں۔ 63 | توقف 64 | دوبارہ شروع کریں۔ 65 | کال ریکارڈنگ 66 | Android کا بلٹ ان فائل مینیجر (DocumentsUI) دستیاب نہیں ہے۔ 67 | --------------------------------------------------------------------------------