├── 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 |
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 |
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 |
--------------------------------------------------------------------------------