diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..55edfe24f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + target-branch: "master" + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "daily" + target-branch: "master" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..73c0dc98d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,18 @@ +name: Build application + +on: [pull_request, push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 25 + cache: gradle + - name: Build with Gradle + run: ./gradlew build --no-daemon diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f1db7deb1..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,37 +0,0 @@ ---- -on: - push: - # Uncomment to test against a branch - #branches: - # - ci - tags: - - 'v*' -jobs: - create_release: - name: Create Github release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Get version from tag - id: get_version - run: | - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - version=${GITHUB_REF#refs/tags/v} - else - version=0.0.0.${GITHUB_REF#refs/heads/} - fi - echo "version=${version}" >> "${GITHUB_OUTPUT}" - - - name: Check out repository - uses: actions/checkout@v3 - - - name: Create release - uses: softprops/action-gh-release@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - tag_name: v${{ steps.get_version.outputs.version }} - name: Version ${{ steps.get_version.outputs.version }} - body_path: RELEASE.md - draft: true - prerelease: false diff --git a/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml new file mode 100644 index 000000000..5481f65f7 --- /dev/null +++ b/.github/workflows/validate-gradle-wrapper.yml @@ -0,0 +1,11 @@ +name: Validate Gradle Wrapper + +on: [pull_request, push] + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: gradle/wrapper-validation-action@v5 diff --git a/.gitignore b/.gitignore index faf530b2d..a7c60a12c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,7 @@ -*.iml -.gradle +/.gradle +/.kotlin /local.properties -/.idea/ +/.idea .DS_Store /build -/captures -.externalNativeBuild -.cxx -local.properties +/keystore.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index 3582c0952..0358e1eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,320 +1,3 @@ -### Version 1.39 - -* Update Russian translations (PR: #277, @bogachenko) -* Add Simplified Chinese translations (PR: #283, @Yee2) - -### Version 1.38 - -* Update all dependencies (PR: #266, #272, @PatrykMis) -* Mark quick settings tile as toggleable for accessibility (PR: #270, @PatrykMis) -* Add support for Android 13's per-app language preferences (PR: #271, @PatrykMis) -* Fix crash when changing the output directory if the previous output directory was associated with a cloud provider app that is no longer installed (PR: #273, @chenxiaolong) -* Show friendly path name instead of `content://` when the output directory points to a cloud provider app (PR: #274, @chenxiaolong) - -### Version 1.37 - -* Fix custom filename templates breaking after version 1.35 in release builds (Issue: #260, PR: #261, @chenxiaolong) - -### Version 1.36 - -* Fix loss of file extension when the output file needs to be renamed (PR: #259, @chenxiaolong) - -### Version 1.35 - -* Fix missing BCR app when doing a direct (non-Magisk module) installation (Issue: #253, PR: #254, @chenxiaolong) - * This bug was introduced in version 1.34 and was caused by an oversight when adding the workaround for overlayfs. -* Work around absurdly slow SAF (Android Storage Access Framework) on some devices (Issue: #252, PR: #257, @chenxiaolong) - * This fixes audio being chopped off the beginning of the call recording. Some devices' SAF implementations are slow to the point where checking the existence of a file may take upwards of 8 seconds (vs. 2ms with native file access). - * This only affected users who picked a custom output directory. The default output directory uses native file access instead of SAF. -* Fix caller ID and contact name potentially ending up in the log file if they change during the middle of a call (PR: #258, @chenxiaolong) - -### Version 1.34 - -* Write `crash.log` to output directory if BCR crashes outside of the scope of a phone call (Issue: #243, PR: #245, @chenxiaolong) -* Set default notification importance to high for the persistent notification during a all (Issue: #248, PR: #249, @chenxiaolong) - * This makes it easier to access the pause/resume button in the notification. - * This change only affects new installs and the user's notification preferences in Android's settings will always take precedence. -* Work around broken NFC and possible bootloops on MIUI when `/system` contains overlayfs mount points (Issue: #242, #246, PR: #250, @chenxiaolong) - * This is only a workaround for a bug in Magisk's mount logic. The actual Magisk bug will be fixed by: https://github.com/topjohnwu/Magisk/pull/6588. - -### Version 1.33 - -* Fix crash caused by a workaround for a old material3 library bug that has since been fixed (Issue: #240, PR: #241, @chenxiaolong) - -### Version 1.32 - -* Add Hebrew translations (PR: #232, @Mosheh65) - -### Version 1.31 - -* For the OGG/Opus bitrate option, reduce the number of steps between 6 kbps and 510 kbps from 253 to 25 (Issue: #237, PR: #239, @chenxiaolong) - -Non-user-facing changes: - -* Updated all dependencies (PR: #238, @chenxiaolong) - -Signing changes: - -* Switch from GPG signing to SSH signing for new release zips, git commits, and git tags (PR: #229, @chenxiaolong) - * The goal is to switch to stronger cryptography and rely on a simpler tool that everybody already has installed (including on Windows). For folks who were previously verifying signatures using GPG, please see the updated documentation for how to verify signatures with SSH. - * For folks who want to verify that this change is legitimate, see commit 0bc3935fe2a1b6e3d56049503db521877501edc1. That commit, which introduced this change, was signed using the original GPG key. - * The APK signing key remains unchanged. - -### Version 1.30 - -* Update Slovak translations (PR: #217, @pvagner) -* Update Polish translations (PR: #219, @Twoomatch) -* Improve English description of `initially paused` preference (PR: #220, @Twoomatch) -* Update Spanish translations (PR: #222, @nmayorga092) - -Magisk module updater changes: - -* Show changelog for the correct version, excluding unreleased changes (PR: #223, @chenxiaolong) - -Documentation changes: - -* README.md: Fix typos (PR: #221, @Twoomatch) - -### Version 1.29 - -* Add a new option for starting the recording in the paused state (Issue: #198, PR: #211, @chenxiaolong) - * If enabled, this allows the user to choose whether a call is recorded. If a recording starts in the paused state and is never resumed, then the empty output file is not saved. - -Documentation changes: - -* README.md: Document hidden/advanced features (Issue: #212, PR: #213, @chenxiaolong) - -### Version 1.28 - -* Inform the user that the device/firmware might not support call recording if an error origates from Android's internal components (PR: #206, @chenxiaolong) -* Fix crash if any filename template value (eg. caller/contact name) contains \ or $ (Issue: #207, PR: #209, @chenxiaolong) -* Add support for pausing/resuming the recording (Issue: #198, PR: #210, @chenxiaolong) - * The button is in the `Call recording in progress` notification - -### Version 1.27 - -* Update Polish translations (PR: #192, @Twoomatch) -* Update Spanish translations (PR: #201, @nmayorga092) -* Fix silent crash causing recording to not happen when debug mode is enabled (Issue: #195, PR: #203, @chenxiaolong) - * The bug was introduced in version 1.26 -* Add support for changing the filename timestamp format (Issue: #204, PR: #205, @chenxiaolong) - * Similar to the existing output filename options, this can only be done via the `bcr.properties` config file - -Non-user-facing changes: - -* Update Kotlin and AndroidX (PR: #196, @PatrykMis) -* Update Build Tools (PR: #199, @PatrykMis) - -### Version 1.26 - -* Update Turkish and Russian translations (PR: #188, @EleoXDA) -* Add hidden feature to customize the output filename (PR: #189, @chenxiaolong) - * To avoid complicating BCR's code, there is no UI option for this - * To customize the output filenames, copy [the default template](./app/src/main/res/raw/filename_template.properties) to `bcr.properties` in the output directory and edit the file - -### Version 1.25 - -* Add SIM slot ID to the filename if there are multiple active SIMs (Issue: #177, PR: #178, @chenxiaolong) -* Fix share action in the recording complete notification always referencing an old recording (PR: #181, @chenxiaolong) -* Add a new Delete action alongside Open and Share in the notifications (Issue: #179, PR: #182, @chenxiaolong) -* Allow changing output settings when call recording is disabled (PR: #183, @chenxiaolong) - -Documentation changes: - -* README.md: Explain what every permission is used for (PR: #180, @chenxiaolong) - -Non-user-facing changes: - -* Update gradle wrapper to 7.6.0 (PR: #174, @PatrykMis) - -### Version 1.24 - -* Notification improvements (PR: #169, @chenxiaolong) - * A notification is now shown when a recording completes, with options for opening or sharing the recording in a 3rd party app. - * **NOTE: Manual action required.** For opening/sharing recordings to work, reset the output directory to the default and then select the output directory again. This is required because BCR previously only requested write access to the output directory, but not read access. - * These new notifications can be disabled in Android's settings by turning off the `Success alerts` notification channel. - * The file path in error notifications is now human readable instead of a URL-encoded `content://...`. -* BCR will explicitly vibrate if vibration is enabled for its notification channels (PR: #167, #171, @quyenvsp, @chenxiaolong) - * This is needed because Android will not respect the notification vibration option during a phone call. - -Non-user-facing changes: - -* Updated all dependencies (PR: #160, @PatrykMis) -* Fixed Gradle non-laziness, causing the execution of specific tasks to be slower (PR: #168, @chenxiaolong) - -### Version 1.23 - -* Update all dependencies and fix build system lint issues (PR: #155, @PatrykMis) -* Add Slovak translation (PR: #161, @pvagner) - -### Version 1.22 - -* (Direct installs only) Add `/system/addon.d/` script to persist installation across OS updates (Issue: #142, PR: #144, @chenxiaolong) - * Only applies to LineageOS-based firmware -* Improve logging in debug mode (Issue: #143, PR: #145, #147, #148, @chenxiaolong) - * Run logcat interactively for the duration of the call to ensure no lost log messages due to logcat overflow - * Include BCR version number in the logs -* Improve output file writing reliability (Issue: #143, PR: #146, #149, #150, @chenxiaolong) -* Improve call disconnection detection on buggy firmware (Issue: #143, PR: #151, @chenxiaolong) - * Works around Samsung OneUI's telephony framework bug where Android does not notify apps (including their own) when a call disconnects -* Use non-blocking reads from call audio stream (Issue: #143, PR: #152, @chenxiaolong) - * Fixes recordings not stopping until another call becomes active on Samsung OneUI because `AudioRecord.read()` blocks forever as soon as a call disconnects - -### Version 1.21 - -* (Direct installs only) Explicitly remount system as writable and ignore `ENOENT` errors during cleanup (Issue: #108, #138, PR: #139, @chenxiaolong) -* Updated all dependencies (PR: #140, @chenxiaolong) - -### Version 1.20 - -* Update dependencies (PR: #121, #132, @PatrykMis) -* Perform a direct install if `/system/bin/recovery` exists in the environment (Issue: #131, PR: #133, @chenxiaolong) - * Previously, only `/sbin/recovery` was used to detect if booted into recovery - -### Version 1.19 - -* Add support for flashing via recovery (Issue: #128, PR: #130, @chenxiaolong) - * This is for unrooted (non-Magisk) installs only. BCR will be installed to the system partition directly when flashed via recovery. - -### Version 1.18 - -* Update gradle wrapper to 7.5.1 (PR: #116, @PatrykMis) -* Fix plurals in Russian translations (PR: #117, @EleoXDA) -* Add French translation (PR: #120, @NSO73) - -### Version 1.17 - -* Update dependencies and gradle wrapper (PR: #112, @PatrykMis) -* Update Polish translations (PR: #112, @PatrykMis) -* Fix silent crash when receiving a call from a private number (Issue: #111, PR: #114, @chenxiaolong) - -### Version 1.16 - -* Update Turkish translations (PR: #106, @EleoXDA) -* Update Russian translations (PR: #107, @EleoXDA) - -### Version 1.15 - -* Update Spanish translations (PR: #92, @nmayorga092) -* Update Turkish translations (PR: #97, @EleoXDA) - -### Version 1.14 - -* Add support for a basic file retention policy (Issue: #25 #81 #88, PR: #90, @chenxiaolong) - -Non-user-facing changes: - -* Improve type-safety when loading and saving preferences (PR: #91, @chenxiaolong) - -### Version 1.13 - -* Add Polish translations (PR: #76, @uvzen) -* Target stable API 33 (Tiramisu) (PR: #82, @chenxiaolong) -* Add optional contacts permission to initial permissions prompt (Issue: #78 #80, PR: #84, @chenxiaolong) - * If allowed, contact names are added to the output filenames. - * This feature was implemented in version 1.5, but required the user to manually enable from the system settings. - -Non-user-facing changes: - -* Update Android gradle plugin to 7.2.1 and Kotlin to 1.7.0 (PR: #83, @chenxiaolong) - -### Version 1.12 - -* Fix potential crash when showing user-friendly output directory path (PR: #74, @chenxiaolong) - * Fixes regression in version 1.11 - -### Version 1.11 - -* Fix persistent notification icon being too small (PR: #67, @chenxiaolong) -* Increase internal buffer sizes to reduce chance of encoding slowdowns causing audible artifacts (Issue: #39 #54, PR: #69 #73, @chenxiaolong) - * The native sample rate option had to be removed for this change -* Show user-friendly path instead of a raw URI for the output directory (PR: #71, @chenxiaolong) -* Work around SAF slowness by recording to the default directory and then moving to the user directory after recording is completed (Issue: #39 #54, PR: #72, @chenxiaolong) - -Non-user-facing changes: - -* Change Container abstract class to an interface (PR: #68, @chenxiaolong) -* Update all gradle dependencies (PR: #70, @chenxiaolong) - -### Version 1.10 - -* Update Spanish translations (PR: #64, @nmayorga092) -* Add hidden debug mode, which saves logs for each recording (long press version number to enable) (PR: #65, @chenxiaolong) -* Set recording thread priority to THREAD_PRIORITY_URGENT_AUDIO (Issue: #39 #54, PR: #66, @chenxiaolong) - -### Version 1.9 - -* Improve buffering to reduce chance of audio drops (Issue: #39 #54, PR: #61, @chenxiaolong) - -### Version 1.8 - -* Update Turkish translations (PR: #58, @fnldstntn) -* Fix overlapping audio and other audible artifacts when using an encoded format (OPUS, AAC, or FLAC) (Issue: #39 #54, PR: #59, @chenxiaolong) - -### Version 1.7 - -* Change output format button group to material chips to prevent text from being cut off with narrower screen widths (Issue: #52, PR: #55, @chenxiaolong) -* Add support for configuring the capture sample rate (PR: #56, @chenxiaolong) -* Send notification if an error occurs during call recording (PR: #57, @chenxiaolong) - -### Version 1.6 - -* Enable minification (without obfuscation) to shrink the download size by ~64% (PR: #45, @chenxiaolong) -* Update Turkish translations (PR: #46, @fnldstntn) -* Add support for WAV/PCM output for troubleshooting (bypasses encoding/compression pipeline) (PR: #48, @chenxiaolong) - -Non-user-facing changes: - -* Improve output format parameter abstraction (PR: #49, @chenxiaolong) -* Use view binding instead of findViewById where possible (PR: #50, @chenxiaolong) - -### Version 1.5 - -* Optionally add contact name to output filename if Contacts permission is granted (Android 11+) (Issue: #28, PR: #42, @chenxiaolong) -* Add Spanish translation (PR: #41, @nmayorga092) -* Redact sensitive information from logcat logs (PR: #43, @chenxiaolong) - -### Version 1.4 - -* Add support for configurable output formats: OGG/Opus (Android 10+), M4A/AAC, FLAC (Issue: #21, PR: #29, #32, #34, #35, #38, @chenxiaolong) -* README.md: Remove mention of cloud storage. Android's Storage Access Framework does not support cloud storage when opening folders (Issue: #30, PR: #31, @chenxiaolong) -* Add full changelog text for updates from Magisk Manager (PR: #36, @chenxiaolong) - -Non-user-facing changes: - -* Fix minor compiler warnings (PR: #37, @chenxiaolong) - -### Version 1.3 - -* Write audio duration to FLAC metadata after recording is complete (Issue: #19, PR: #20, @chenxiaolong) -* Add Turkish translations (Issue: #18, PR: #22, @fnldstntn) - -Non-user-facing changes: - -* Don't add irrelevant update metadata to release zips (PR: #23, @chenxiaolong) -* Fix serialization exception when running the `updateJson` gradle tasks (PR: #24, @chenxiaolong) - -### Version 1.2 - -* Fix typo and improve wording of battery optimization preference (PR: #4, @EleoXDA) -* Add Russian translations (PR: #7, @marat2509) -* Add support for API 28 (Android 9) (Issue: #6, PR: #10, @chenxiaolong) -* Add incoming/outgoing tag to filenames (Issue: #3, PR: #11, @chenxiaolong) -* Add caller ID to filenames for incoming calls (Android 10+ only) (Issue: #3, PR: #13, @chenxiaolong) -* Fix filename timestamps to match the call log exactly (Issue: #3, PR: #12, @chenxiaolong) -* The about link in the app now links to the exact commit the version was built from (PR: #15, @chenxiaolong) -* Add support for Magisk's built-in module updater (PR: #16, @chenxiaolong) - -Non-user-facing changes: - -* Update gradle and Android gradle plugin dependencies (PR: #9, @chenxiaolong) -* Add git commit to version number for debug builds (PR: #14, @chenxiaolong) -* Ensure custom gradle tasks (`moduleProp`, `permissionsXml`, `zip`, and `updateJson`) rebuild when input variables (eg. git commit) change (PR: #17, @chenxiaolong) - -### Version 1.1 - -* Target Android SDK 32. BCR was previously targeting the Tiramisu (33) preview SDK, which made it not installable on stable Android versions. (Issue: #1, PR: #2, @chenxiaolong) - ### Version 1.0 * Initial release diff --git a/README.md b/README.md index 956677ca9..4f92bdd4a 100644 --- a/README.md +++ b/README.md @@ -1,200 +1,56 @@ -# Basic Call Recorder +# Basic Audio Recorder -app icon +![license badge](https://img.shields.io/github/license/PatrykMis/BAR) -![latest release badge](https://img.shields.io/github/v/release/chenxiaolong/BCR?sort=semver) -![license badge](https://img.shields.io/github/license/chenxiaolong/BCR) +BAR is a simple Android audio recording app forked from [BCR](https://github.com/chenxiaolong/BCR) with an addition of [this pull request from the original author](https://github.com/chenxiaolong/BCR/pull/165). This fork has stripped out functionality / code related to call recording and uses the same codebase as BCR. -BCR is a simple Android call recording app for rooted devices or devices running custom firmware. Once enabled, it stays out of the way and automatically records incoming and outgoing calls in the background. - -light mode screenshot dark mode screenshot +I've decided to fork because BCR has a robust audio recording/encoding pipeline that supports multiple output formats and accounts for many edge cases and failure conditions that other apps may ignore. It records from Android's MIC audio source (todo: source selector) and passes the audio through the same encoding pipeline as with call recording. The output files are saved with a _mic suffix in the output directory. ### Features -* Supports Android 9 through 13 +* Supports Android 13 through 16 * Supports output in various formats: - * OGG/Opus - Lossy, smallest files, default on Android 10+ - * M4A/AAC - Lossy, smaller files, default on Android 9 + * OGG/Opus - Lossy, smallest files, default + * M4A/AAC - Lossy, smaller files * FLAC - Lossless, larger files * WAV/PCM - Lossless, largest files, least CPU usage * Supports Android's Storage Access Framework (can record to SD cards, USB devices, etc.) * Quick settings toggle * Material You dynamic theming -* No persistent notification unless a recording is in progress * No network access permission * No third party dependencies -* Works with call screening on Pixel devices (records the caller, but not the automated system) ### Non-features -As the name alludes, BCR intends to be a basic as possible. The project will have succeeded at its goal if the only updates it ever needs are for compatibility with new Android versions. Thus, many potentially useful features will never be implemented, such as: +As the name alludes, BAR intends to be a basic as possible. The project will have succeeded at its goal if the only updates it ever needs are for compatibility with new Android versions. Thus, many potentially useful features will never be implemented, such as: -* Support for old Android versions (support is dropped as soon as maintenance becomes cumbersome) -* Workarounds for [OEM-specific battery optimization and app killing behavior](https://dontkillmyapp.com/) -* Workarounds for devices that don't support the [`VOICE_CALL` audio source](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_CALL) (eg. using microphone + speakerphone) +* Changing the filename format +* Support for old (unsupported) Android versions (support is dropped as soon as maintenance becomes cumbersome) * Support for direct boot mode (the state before the device is initially unlocked after reboot) -* Support for stock, unrooted firmware ### Usage -1. Download the latest version from the [releases page](https://github.com/chenxiaolong/BCR/releases). To verify the digital signature, see the [verifying digital signatures](#verifying-digital-signatures) section. - -2. Install BCR as a system app. - - * **For devices rooted with Magisk**, simply flash the zip as a Magisk module from within the Magisk app. - * **For OnePlus and Realme devices running the stock firmware (or custom firmware based on the stock firmware)**, also extract the `.apk` from the zip and install it manually before rebooting. This is necessary to work around a bug in the firmware where the app data directory does not get created, causing BCR to open up to a blank screen. - - * **For unrooted custom firmware**, flash the zip while booted into recovery. - * **NOTE**: If the custom firmware's `system` partition is formatted with `erofs`, then the filesystem is read-only and it is not possible to use this method. - * Manually extracting the files from the `system/` folder in the zip will also work as long as the files have `644` permissions and the `u:object_r:system_file:s0` SELinux label. - -3. Reboot and open BCR. - -4. Enable call recording and pick an output directory. If no output directory is selected or if the output directory is no longer accessible, then recordings will be saved to `/sdcard/Android/data/com.chiller3.bcr/files`. - - When enabling call recording the first time, BCR will prompt for microphone, notification (Android 13+), contacts, and phone permissions. Only microphone and notification permissions are required basic call recording functionality. If additional permissions are granted, more information is added to the output filename. For example, the contacts permission will cause the contact name to be added to the filename and the phone permission will cause the SIM slot (if multiple SIMs are active) to be added to the filename. - - See the [permissions section](#permissions) below for more details about the permissions. - -5. To install future updates, there are a couple methods: - - * If installed via Magisk, the module can be updated right from Magisk Manager's modules tab. Flashing the new version in Magisk manually also works just as well. - * The `.apk` can also be extracted from the zip and be directly installed. With this method, the old version exists as a system app and the new version exists as a user-installed update to the system app. This method is more convenient if BCR is baked into the Android firmware image. - -### Permissions - -* `CAPTURE_AUDIO_OUTPUT` (**automatically granted by system app permissions**) - * Needed to capture the call audio stream. -* `CONTROL_INCALL_EXPERIENCE` (**automatically granted by system app permissions**) - * Needed to monitor the phone call state for starting and stopping the recording and gathering call information for the output filename. -* `RECORD_AUDIO` (**must be granted by the user**) - * Needed to capture the call audio stream. -* `FOREGROUND_SERVICE` (**automatically granted at install time**) - * Needed to run the call recording service. -* `POST_NOTIFICATIONS` (**must be granted by the user on Android 13+**) - * Needed to show notifications. - * A notification is required for running the call recording service in foreground mode or else Android will not allow access to the call audio stream. -* `READ_CONTACTS` (**optional**) - * If allowed, the contact name is added to the output filename. -* `READ_PHONE_STATE` (**optional**) - * If allowed, the SIM slot for devices with multiple active SIMs is added to the output filename. -* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (**optional**) - * If allowed, request Android to disable battery optimizations (app killing) for BCR. - * This is usually not needed. The way BCR hooks into the telephony system makes it unlikely to be killed. - * OEM Android builds that stray further from AOSP may ignore this. -* `VIBRATE` (**automatically granted at install time**) - * If vibration is enabled for BCR's notifications in Android's settings, BCR will perform the vibration. Android itself does not respect the vibration option when a phone call is active. - -Note that `INTERNET` is _not_ in the list. BCR does not and will never access the network. BCR will never communicate with other apps either, except if the user explicitly taps on the `Open` or `Share` buttons in the notification shown when a recording completes. In that scenario, the target app is granted access to that single recording only. - -### Advanced features - -This section describes BCR's advanced features that are hidden or only accessible via a config file. - -#### Debug mode - -BCR has a hidden debug mode that can be enabled or disabled by long pressing the version number. - -When debug mode is enabled, BCR will write a log file to the output directory after a call recording completes. It is named the same way as the audio file. The log file contains the same messages as what `adb logcat` would show, except messages not relevant to BCR are filtered out (BCR does not have permission to access those messages anyway). - -Within the log file, BCR aims to never log any sensitive information. Information about the current call, like the phone number, are replaced with placeholders instead, like ``. However, other information can't be easily redacted this way will be truncated instead. For example, when the file retention feature cleans up old files, filenames like `20230101_010203.456+0000_out_1234567890_John_Doe.oga` are logged as `20<...>ga`. - -When reporting bugs, please include the log file as it is extremely helpful for identifying what might be wrong. (But please double check the log file to ensure there's no sensitive information!) - -#### Customizing the output filename - -By default, BCR uses a filename template that includes the call timestamp, call direction, SIM slot, phone number, caller ID, and contact name. This can be customized, but only by editing a config file. To do so, the easiest way is to copy [the default config](./app/src/main/res/raw/filename_template.properties) to `bcr.properties` in the output directory and then edit it to your liking. Details about the available fields are documented in the default config file. - -For example, to customize the filename template to `__`, use the following config: - -```properties -filename.0.text = ${date:yyyyMMdd_HHmmss} - -filename.1.text = ${phone_number} -filename.1.prefix = _ - -filename.2.text = ${caller_name} -filename.2.prefix = _ -``` - -The are a couple limitations to note: - -* The date must always be at the beginning of the filename. This is required for the file retention feature to work. -* If the date format is changed (eg. from the default to `yyyyMMdd_HHmmss`), then you must manually rename the old recordings to use the new date format or they may be handled incorrectly by the file retention feature. To be safe, move the old recordings to a different folder while testing (or set the file retention to `Keep all`). - -If the config file has any error, BCR will use the default configuration. This ensures that recordings won't fail if the configuration is incorrect. To troubleshoot issues with the filename template, [enable debug mode](#debug-mode), and make a call. Then, search the log file for `FilenameTemplate`. - -### How it works +1. Download the latest version from the [todo: releases page](https://github.com/PatrykMis/BAR/releases). To verify the digital signature, see the [verifying digital signatures](#verifying-digital-signatures) section. -BCR relies heavily on system app permissions in order to function properly. This is primarily because of two permissions: +2. Install BAR. -* `CONTROL_INCALL_EXPERIENCE` +3. Open BAR. - This permission allows Android's telephony service to bind to BCR's `InCallService` without BCR being a wearable companion app, a car UI, or the default dialer. Once bound, the service will receive callbacks for call change events (eg. incoming call in the ringing state). This method is much more reliable than using the `READ_PHONE_STATE` permission and relying on `android.intent.action.PHONE_STATE` broadcasts. +4. Pick an output directory. If no output directory is selected or if the output directory is no longer accessible, then recordings will be saved to `/sdcard/Android/data/com.patrykmis.bar/files`. - This method has a couple additional benefits. Due to the way that the telephony service binds to BCR's `InCallService`, the service can bring itself in and out of the foreground as needed when a call is in progress and access the audio stream without hitting Android 12+'s background microphone access limitations. It also does not require the service to be manually started from an `ACTION_BOOT_COMPLETED` broadcast receiver and thus is not affected by that broadcast's delays during initial boot. - -* `CAPTURE_AUDIO_OUTPUT` - - This permission is used to record from the `VOICE_CALL` audio stream. This stream, along with some others, like `VOICE_DOWNLINK` and `VOICE_UPLINK`, cannot be accessed without this system permission. - -With these two permissions, BCR can reliably detect phone calls and record from the call's audio stream. The recording process pulls PCM s16le raw audio and uses Android's built-in encoders to produce the compressed output file. - -### Verifying digital signatures - -Both the zip file and the APK contained within are digitally signed. **NOTE**: The zip file signing mechanism switched from GPG to SSH as of version 1.31. To verify signatures for old versions, see version 1.30's [`README.md`](https://github.com/chenxiaolong/BCR/blob/v1.30/README.md#verifying-digital-signatures). - -#### Verifying zip file signature - -First save the public key to a file that lists which keys should be trusted. - -```bash -echo 'bcr ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDOe6/tBnO7xZhAWXRj3ApUYgn+XZ0wnQiXM8B7tPgv4' > bcr_trusted_keys -``` - -Then, verify the signature of the zip file using the list of trusted keys. - -```bash -ssh-keygen -Y verify -f bcr_trusted_keys -I bcr -n file -s BCR--release.zip.sig < BCR--release.zip -``` - -If the file is successfully verified, the output will be: - -``` -Good "file" signature for bcr with ED25519 key SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA -``` - -#### Verifying apk signature - -First, extract the apk from the zip and then run: - -``` -apksigner verify --print-certs system/priv-app/com.chiller3.bcr/app-release.apk -``` - -Then, check that the SHA-256 digest of the APK signing certificate is: - -``` -d16f9b375df668c58ef4bb855eae959713d6d02e45f7f2c05ce2c27ae944f4f9 -``` +For the first time, BAR will prompt for microphone, and notification (Android 13+) permissions. They are required for BAR to be able to record in the background. ### Building from source -BCR can be built like most other Android apps using Android Studio or the gradle command line. +BAR can be built like most other Android apps using Android Studio or the gradle command line. -To build the APK: +To build the debug APK: ```bash ./gradlew assembleDebug ``` -To build the Magisk module zip (which automatically runs the `assembleDebug` task if needed): - -```bash -./gradlew zipDebug -``` - -The output file is written to `app/build/distributions/debug/`. The APK will be signed with the default autogenerated debug key. +The output file is written to `app/build/outputs/apk/debug/`. The APK will be signed with the default autogenerated debug key. To create a release build with a specific signing key, set up the following environment variables: @@ -208,18 +64,18 @@ export RELEASE_KEYSTORE_PASSPHRASE export RELEASE_KEY_PASSPHRASE ``` -and then build the release zip: +and then build the release APK: ```bash -./gradlew zipRelease +./gradlew assembleRelease ``` ### Contributing Bug fix and translation pull requests are welcome and much appreciated! -If you are interested in implementing a new feature and would like to see it included in BCR, please open an issue to discuss it first. I intend for BCR to be as simple and low-maintenance as possible, so I am not too inclined to add any new features, but I could be convinced otherwise. +If you are interested in implementing a new feature and would like to see it included in BAR, please open an issue to discuss it first. I intend for BAR to be as simple and low-maintenance as possible, so I am not too inclined to add any new features, but I could be convinced otherwise. ### License -BCR is licensed under GPLv3. Please see [`LICENSE`](./LICENSE) for the full license text. +BAR is licensed under GPLv3. Please see [`LICENSE`](./LICENSE) for the full license text. diff --git a/RELEASE.md b/RELEASE.md index 6dac8d524..372e30dca 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,6 +2,4 @@ The changelog can be found at: [`CHANGELOG.md`](./CHANGELOG.md). --- -See [`README.md`](./README.md) for information on how to install and use BCR. - -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. +See [`README.md`](./README.md) for information on how to install and use BAR. diff --git a/app/.gitignore b/app/.gitignore index 42afabfd2..3543521e9 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1 @@ -/build \ No newline at end of file +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4521e516..4b27bd997 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,117 +1,47 @@ -import org.eclipse.jgit.api.ArchiveCommand -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.archive.TarFormat -import org.eclipse.jgit.lib.ObjectId -import org.jetbrains.kotlin.backend.common.pop -import org.json.JSONObject +import java.io.FileInputStream +import java.util.Properties plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.android.application) } -buildscript { - dependencies { - classpath("org.eclipse.jgit:org.eclipse.jgit:6.5.0.202303070854-r") - classpath("org.eclipse.jgit:org.eclipse.jgit.archive:6.5.0.202303070854-r") - classpath("org.json:json:20230227") - } +val keystorePropertiesFile = rootProject.file("keystore.properties") +val useKeystoreProperties = keystorePropertiesFile.canRead() +val keystoreProperties = Properties() +if (useKeystoreProperties) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } -typealias VersionTriple = Triple - -fun describeVersion(git: Git): VersionTriple { - // jgit doesn't provide a nice way to get strongly-typed objects from its `describe` command - val describeStr = git.describe().setLong(true).call() - - return if (describeStr != null) { - val pieces = describeStr.split('-').toMutableList() - val commit = git.repository.resolve(pieces.pop().substring(1)) - val count = pieces.pop().toInt() - val tag = pieces.joinToString("-") - - Triple(tag, count, commit) - } else { - val log = git.log().call().iterator() - val head = log.next() - var count = 1 - - while (log.hasNext()) { - log.next() - ++count - } - - Triple(null, count, head.id) - } +kotlin { + jvmToolchain(17) } -fun getVersionCode(triple: VersionTriple): Int { - val tag = triple.first - val (major, minor) = if (tag != null) { - if (!tag.startsWith('v')) { - throw IllegalArgumentException("Tag does not begin with 'v': $tag") - } - - val pieces = tag.substring(1).split('.') - if (pieces.size != 2) { - throw IllegalArgumentException("Tag is not in the form 'v.': $tag") +android { + if (useKeystoreProperties) { + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"]!!) + storePassword = keystoreProperties["storePassword"] as String + enableV2Signing = false + enableV3Signing = true + enableV4Signing = true + } } - - Pair(pieces[0].toInt(), pieces[1].toInt()) - } else { - Pair(0, 0) } - // 8 bits for major version, 8 bits for minor version, and 8 bits for git commit count - assert(major in 0 until 1.shl(8)) - assert(minor in 0 until 1.shl(8)) - assert(triple.second in 0 until 1.shl(8)) - - return major.shl(16) or minor.shl(8) or triple.second -} - -fun getVersionName(git: Git, triple: VersionTriple): String { - val tag = triple.first?.replace(Regex("^v"), "") ?: "NONE" + namespace = "com.patrykmis.bar" - return buildString { - append(tag) - - if (triple.second > 0) { - append(".r") - append(triple.second) - - append(".g") - git.repository.newObjectReader().use { - append(it.abbreviate(triple.third).name()) - } + compileSdk { + version = release(36) { + minorApiLevel = 1 } } -} + buildToolsVersion = "36.1.0" -val git = Git.open(File(rootDir, ".git"))!! -val gitVersionTriple = describeVersion(git) -val gitVersionCode = getVersionCode(gitVersionTriple) -val gitVersionName = getVersionName(git, gitVersionTriple) - -val projectUrl = "https://github.com/chenxiaolong/BCR" -val releaseMetadataBranch = "master" - -val extraDir = File(buildDir, "extra") -val archiveDir = File(extraDir, "archive") - -android { - namespace = "com.chiller3.bcr" - - compileSdk = 33 - buildToolsVersion = "33.0.2" - - defaultConfig { - applicationId = "com.chiller3.bcr" - minSdk = 28 - targetSdk = 33 - versionCode = gitVersionCode - versionName = gitVersionName - resourceConfigurations.addAll(listOf( + androidResources { + localeFilters += listOf( "en", "es", "fr", @@ -121,33 +51,23 @@ android { "sk", "tr", "zh-rCN" - )) - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField("String", "PROJECT_URL_AT_COMMIT", - "\"${projectUrl}/tree/${gitVersionTriple.third.name}\"") + ) + } - buildConfigField("String", "PROVIDER_AUTHORITY", - "APPLICATION_ID + \".provider\"") + defaultConfig { + applicationId = "com.patrykmis.bar" + minSdk = 33 + targetSdk = 36 + versionCode = 1 + versionName = versionCode.toString() + + buildConfigField( + "String", "PROVIDER_AUTHORITY", + "APPLICATION_ID + \".provider\"" + ) resValue("string", "provider_authority", "$applicationId.provider") } - sourceSets { - getByName("main") { - assets { - srcDir(archiveDir) - } - } - } - signingConfigs { - create("release") { - val keystore = System.getenv("RELEASE_KEYSTORE") - storeFile = if (keystore != null) { File(keystore) } else { null } - storePassword = System.getenv("RELEASE_KEYSTORE_PASSPHRASE") - keyAlias = System.getenv("RELEASE_KEY_ALIAS") - keyPassword = System.getenv("RELEASE_KEY_PASSPHRASE") - } - } + buildTypes { getByName("debug") { buildConfigField("boolean", "FORCE_DEBUG_MODE", "true") @@ -158,7 +78,10 @@ android { isMinifyEnabled = true isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) signingConfig = signingConfigs.getByName("debug") } @@ -168,271 +91,44 @@ android { isMinifyEnabled = true isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) - signingConfig = signingConfigs.getByName("release") + if (useKeystoreProperties) { + signingConfig = signingConfigs.getByName("release") + } } } - compileOptions { - sourceCompatibility(JavaVersion.VERSION_11) - targetCompatibility(JavaVersion.VERSION_11) - } - kotlinOptions { - jvmTarget = "11" - } buildFeatures { buildConfig = true + resValues = true viewBinding = true } -} - -dependencies { - implementation("androidx.activity:activity-ktx:1.7.2") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("androidx.core:core-ktx:1.10.1") - implementation("androidx.documentfile:documentfile:1.0.1") - implementation("androidx.fragment:fragment-ktx:1.5.7") - implementation("androidx.preference:preference-ktx:1.2.0") - implementation("com.google.android.material:material:1.9.0") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") -} - -val archive = tasks.register("archive") { - inputs.property("gitVersionTriple.third", gitVersionTriple.third) - - val outputFile = File(archiveDir, "archive.tar") - outputs.file(outputFile) - - doLast { - val format = "tar_${Thread.currentThread().id}" - - ArchiveCommand.registerFormat(format, TarFormat()) - try { - outputFile.outputStream().use { - git.archive() - .setTree(git.repository.resolve(gitVersionTriple.third.name)) - .setFormat(format) - .setOutputStream(it) - .call() - } - } finally { - ArchiveCommand.unregisterFormat(format) - } - } -} - -android.applicationVariants.all { - val variant = this - val capitalized = variant.name.capitalize() - val variantDir = File(extraDir, variant.name) - - variant.preBuildProvider.configure { - dependsOn(archive) - } - - val moduleProp = tasks.register("moduleProp${capitalized}") { - inputs.property("projectUrl", projectUrl) - inputs.property("releaseMetadataBranch", releaseMetadataBranch) - inputs.property("variant.applicationId", variant.applicationId) - inputs.property("variant.name", variant.name) - inputs.property("variant.versionCode", variant.versionCode) - inputs.property("variant.versionName", variant.versionName) - - val outputFile = File(variantDir, "module.prop") - outputs.file(outputFile) - - doLast { - val props = LinkedHashMap() - props["id"] = variant.applicationId - props["name"] = "BCR" - props["version"] = "v${variant.versionName}" - props["versionCode"] = variant.versionCode.toString() - props["author"] = "chenxiaolong" - props["description"] = "Basic Call Recorder" - - if (variant.name == "release") { - props["updateJson"] = "${projectUrl}/raw/${releaseMetadataBranch}/app/magisk/updates/${variant.name}/info.json" - } - - outputFile.writeText(props.map { "${it.key}=${it.value}" }.joinToString("\n")) - } - } - val permissionsXml = tasks.register("permissionsXml${capitalized}") { - inputs.property("variant.applicationId", variant.applicationId) - - val outputFile = File(variantDir, "privapp-permissions-${variant.applicationId}.xml") - outputs.file(outputFile) - - doLast { - outputFile.writeText(""" - - - - - - - - """.trimIndent()) - } + dependenciesInfo { + includeInApk = false + includeInBundle = false } - - val addonD = tasks.register("addonD${capitalized}") { - inputs.property("variant.applicationId", variant.applicationId) - - // To get output apk filename - dependsOn.add(variant.assembleProvider) - - val outputFile = File(variantDir, "51-${variant.applicationId}.sh") - outputs.file(outputFile) - - val backupFiles = variant.outputs.map { - "priv-app/${variant.applicationId}/${it.outputFile.name}" - } + listOf( - "etc/permissions/privapp-permissions-${variant.applicationId}.xml" + packaging { + resources.excludes.addAll( + listOf( + "DebugProbesKt.bin", + "META-INF/**.version", + "kotlin-tooling-metadata.json", + "kotlin/**.kotlin_builtins" + ) ) - - doLast { - outputFile.writeText(""" - #!/sbin/sh - # ADDOND_VERSION=2 - - . /tmp/backuptool.functions - - files="${backupFiles.joinToString(" ")}" - - case "${'$'}{1}" in - backup|restore) - for f in ${'$'}{files}; do - "${'$'}{1}_file" "${'$'}{S}/${'$'}{f}" - done - ;; - esac - """.trimIndent()) - } - } - - tasks.register("zip${capitalized}") { - inputs.property("variant.applicationId", variant.applicationId) - inputs.property("variant.name", variant.name) - inputs.property("variant.versionName", variant.versionName) - - archiveFileName.set("BCR-${variant.versionName}-${variant.name}.zip") - // Force instantiation of old value or else this will cause infinite recursion - destinationDirectory.set(destinationDirectory.dir(variant.name).get()) - - // Make the zip byte-for-byte reproducible (note that the APK is still not reproducible) - isPreserveFileTimestamps = false - isReproducibleFileOrder = true - - dependsOn.add(variant.assembleProvider) - - from(moduleProp.map { it.outputs }) - from(addonD.map { it.outputs }) { - fileMode = 0b111_101_101 // 0o755; kotlin doesn't support octal literals - into("system/addon.d") - } - from(permissionsXml.map { it.outputs }) { - into("system/etc/permissions") - } - from(variant.outputs.map { it.outputFile }) { - into("system/priv-app/${variant.applicationId}") - } - - val magiskDir = File(projectDir, "magisk") - - for (script in arrayOf("update-binary", "updater-script")) { - from(File(magiskDir, script)) { - into("META-INF/com/google/android") - } - } - - from(File(magiskDir, "customize.sh")) - - from(File(rootDir, "LICENSE")) - from(File(rootDir, "README.md")) } - - tasks.register("updateJson${capitalized}") { - inputs.property("gitVersionTriple.first", gitVersionTriple.first) - inputs.property("projectUrl", projectUrl) - inputs.property("variant.name", variant.name) - inputs.property("variant.versionCode", variant.versionCode) - inputs.property("variant.versionName", variant.versionName) - - val magiskDir = File(projectDir, "magisk") - val updatesDir = File(magiskDir, "updates") - val variantUpdateDir = File(updatesDir, variant.name) - val jsonFile = File(variantUpdateDir, "info.json") - - outputs.file(jsonFile) - - doLast { - if (gitVersionTriple.second != 0) { - throw IllegalStateException("The release tag must be checked out") - } - - val root = JSONObject() - root.put("version", variant.versionName) - root.put("versionCode", variant.versionCode) - root.put("zipUrl", "${projectUrl}/releases/download/${gitVersionTriple.first}/BCR-${variant.versionName}-release.zip") - root.put("changelog", "${projectUrl}/raw/${gitVersionTriple.first}/app/magisk/updates/${variant.name}/changelog.txt") - - jsonFile.writer().use { - root.write(it, 4, 0) - } - } - } -} - -fun updateChangelog(version: String?, replaceFirst: Boolean) { - val file = File(rootDir, "CHANGELOG.md") - val expected = if (version != null) { "### Version $version" } else { "### Unreleased" } - - val changelog = mutableListOf().apply { - // This preserves a trailing newline, unlike File.readLines() - addAll(file.readText().lineSequence()) - } - - if (changelog.firstOrNull() != expected) { - if (replaceFirst) { - changelog[0] = expected - } else { - changelog.addAll(0, listOf(expected, "")) - } - } - - file.writeText(changelog.joinToString("\n")) -} - -fun updateMagiskChangelog(gitRef: String) { - File(File(File(File(projectDir, "magisk"), "updates"), "release"), "changelog.txt") - .writeText("The changelog can be found at: [`CHANGELOG.md`]($projectUrl/blob/$gitRef/CHANGELOG.md).\n") } -tasks.register("changelogPreRelease") { - doLast { - val version = project.property("releaseVersion") - - updateChangelog(version.toString(), true) - updateMagiskChangelog("v$version") - } -} - -tasks.register("changelogPostRelease") { - doLast { - updateChangelog(null, false) - updateMagiskChangelog(releaseMetadataBranch) - } -} - -tasks.register("preRelease") { - dependsOn("changelogPreRelease") -} - -tasks.register("postRelease") { - dependsOn("updateJsonRelease") - dependsOn("changelogPostRelease") +dependencies { + implementation(libs.androidx.activity) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.preference.ktx) + implementation(libs.material) } diff --git a/app/images/icon.svg b/app/images/icon.svg index e4ef5d297..3059f923e 100644 --- a/app/images/icon.svg +++ b/app/images/icon.svg @@ -1,21 +1,16 @@ - + - + - + - + diff --git a/app/magisk/customize.sh b/app/magisk/customize.sh deleted file mode 100644 index 5404cc7a5..000000000 --- a/app/magisk/customize.sh +++ /dev/null @@ -1,43 +0,0 @@ -# Until Magisk supports overlayfs, we'll try to install to a non-overlayfs path -# that still supports privileged apps. -# https://github.com/topjohnwu/Magisk/pull/6588 - -has_overlays() { - local mnt="${1}" count - count=$(awk -v mnt="${mnt}" '$9 == "overlay" && $5 ~ mnt' /proc/self/mountinfo | wc -l) - [ "${count}" -gt 0 ] -} - -target= - -for mountpoint in /system /product /system_ext /vendor; do - if has_overlays "^${mountpoint}"; then - echo "Cannot use ${mountpoint}: contains overlayfs mounts" - # Magisk fails to mount files when the parent directory does not exist - elif [ ! -d "${mountpoint}/etc/permissions" ]; then - echo "Cannot use ${mountpoint}: etc/permissions/ does not exist" - elif [ ! -d "${mountpoint}/priv-app" ]; then - echo "Cannot use ${mountpoint}: priv-app/ does not exist" - else - echo "Using ${mountpoint} as the installation target" - target=${mountpoint} - break - fi -done - -if [ -z "${target}" ]; then - echo 'No suitable installation target found' - echo 'This OS is not supported' - rm -rv "${MODPATH}" 2>&1 - exit 1 -fi - -if [ "${target}" != /system ]; then - echo 'Removing addon.d script since installation target is not /system' - rm -rv "${MODPATH}/system/addon.d" 2>&1 || exit 1 - - echo "Adjusting overlay for installation to ${target}" - mv -v "${MODPATH}/system" "${MODPATH}/${target#/}" 2>&1 || exit 1 - mkdir -v "${MODPATH}/system" 2>&1 || exit 1 - mv -v "${MODPATH}/${target#/}" "${MODPATH}/system/${target#/}" 2>&1 || exit 1 -fi diff --git a/app/magisk/update-binary b/app/magisk/update-binary deleted file mode 100644 index 911344361..000000000 --- a/app/magisk/update-binary +++ /dev/null @@ -1,44 +0,0 @@ -#!/sbin/sh - -OUTFD=${2} -ZIPFILE=${3} - -umask 022 - -ui_print() { - printf "ui_print %s\nui_print\n" "${*}" > /proc/self/fd/"${OUTFD}" -} - -if [ -f /sbin/recovery ] || [ -f /system/bin/recovery ]; then - # Installing via recovery. Always do a direct install. - set -exu - - ui_print 'Mounting system' - if mount /system_root; then - mount -o remount,rw /system_root - root_dir=/system_root - else - mount /system - mount -o remount,rw /system - root_dir=/ - fi - - ui_print 'Extracting files' - - # Just overwriting isn't sufficient because the apk filenames are different - # between debug and release builds - app_id=$(unzip -p "${ZIPFILE}" module.prop | grep '^id=' | cut -d= -f2) - - # rm on some custom recoveries doesn't exit with 0 on ENOENT, even with -f - rm -rf "${root_dir}/system/priv-app/${app_id}" || : - - unzip -o "${ZIPFILE}" 'system/*' -d "${root_dir}" - - ui_print 'Done!' -else - # Installing via Magisk Manager. - - . /data/adb/magisk/util_functions.sh - - install_module -fi diff --git a/app/magisk/updater-script b/app/magisk/updater-script deleted file mode 100644 index 11d5c96e0..000000000 --- a/app/magisk/updater-script +++ /dev/null @@ -1 +0,0 @@ -#MAGISK diff --git a/app/magisk/updates/release/changelog.txt b/app/magisk/updates/release/changelog.txt deleted file mode 100644 index 2c57b7634..000000000 --- a/app/magisk/updates/release/changelog.txt +++ /dev/null @@ -1 +0,0 @@ -The changelog can be found at: [`CHANGELOG.md`](https://github.com/chenxiaolong/BCR/blob/v1.39/CHANGELOG.md). diff --git a/app/magisk/updates/release/info.json b/app/magisk/updates/release/info.json deleted file mode 100644 index a43a75d90..000000000 --- a/app/magisk/updates/release/info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "zipUrl": "https://github.com/chenxiaolong/BCR/releases/download/v1.38/BCR-1.38-release.zip", - "changelog": "https://github.com/chenxiaolong/BCR/raw/v1.38/app/magisk/updates/release/changelog.txt", - "version": "1.38", - "versionCode": 75264 -} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e396b8a1b..006574e40 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,16 +14,12 @@ # Uncomment this to preserve the line number information for # debugging stack traces. --keepattributes SourceFile,LineNumberTable +# -keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile -# Disable obfuscation completely for BCR. As an open source project, -# shrinking is the only goal of minification. --dontobfuscate - # We construct TreeDocumentFile via reflection in DocumentFileExtensions # to speed up SAF performance when doing path lookups. -keepclassmembers class androidx.documentfile.provider.TreeDocumentFile { diff --git a/app/src/androidTest/java/com/chiller3/bcr/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/chiller3/bcr/ExampleInstrumentedTest.kt deleted file mode 100644 index 2c8cc925d..000000000 --- a/app/src/androidTest/java/com/chiller3/bcr/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.chiller3.bcr - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.chiller3.bcr", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..8c743c871 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/debugOpt/AndroidManifest.xml b/app/src/debugOpt/AndroidManifest.xml new file mode 100644 index 000000000..8c743c871 --- /dev/null +++ b/app/src/debugOpt/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 003b3edc5..ae9165885 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,96 +2,65 @@ - - - - - + - - - - - - - + - - - - - + android:name=".RecorderService" + android:exported="false" + android:foregroundServiceType="microphone" /> - + android:grantUriPermissions="true"> - + + - \ No newline at end of file + diff --git a/app/src/main/java/com/chiller3/bcr/AudioFormatExtension.kt b/app/src/main/java/com/chiller3/bcr/AudioFormatExtension.kt deleted file mode 100644 index 1986a3aef..000000000 --- a/app/src/main/java/com/chiller3/bcr/AudioFormatExtension.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.chiller3.bcr - -import android.media.AudioFormat -import android.os.Build - -val AudioFormat.frameSizeInBytesCompat: Int - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - frameSizeInBytes - } else{ - // Hardcoded for Android 9 compatibility only - assert(encoding == AudioFormat.ENCODING_PCM_16BIT) - 2 * channelCount - } \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/ChipGroupCentered.kt b/app/src/main/java/com/chiller3/bcr/ChipGroupCentered.kt deleted file mode 100644 index c56da53a8..000000000 --- a/app/src/main/java/com/chiller3/bcr/ChipGroupCentered.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.chiller3.bcr - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.View -import androidx.core.view.ViewCompat -import com.google.android.material.chip.ChipGroup -import java.lang.Integer.max -import java.lang.Integer.min - -/** Hacky wrapper around [ChipGroup] to make every row individually centered. */ -class ChipGroupCentered(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : - ChipGroup(context, attrs, defStyleAttr) { - private val _rowCountField = javaClass.superclass.superclass.getDeclaredField("rowCount") - private var rowCountField - get() = _rowCountField.getInt(this) - set(value) = _rowCountField.setInt(this, value) - - init { - _rowCountField.isAccessible = true - } - - constructor(context: Context, attrs: AttributeSet?) : - this(context, attrs, com.google.android.material.R.attr.chipGroupStyle) - - constructor(context: Context) : this(context, null) - - @SuppressLint("RestrictedApi") - override fun onLayout(sizeChanged: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - if (isSingleLine) { - return super.onLayout(sizeChanged, left, top, right, bottom) - } - - val maxWidth = right - left - paddingRight - paddingLeft - var offsetTop = paddingTop - var rowStartIndex = 0 - - while (rowStartIndex < childCount) { - val (rowEndIndex, rowWidth, rowHeight) = getFittingRow(rowStartIndex, maxWidth) - - layoutRow( - rowStartIndex..rowEndIndex, - paddingLeft + (maxWidth - rowWidth) / 2, - offsetTop, - rowCountField, - ) - - offsetTop += rowHeight + lineSpacing - rowStartIndex = rowEndIndex + 1 - rowCountField += 1 - } - } - - /** - * Find the last index starting from [indexStart] that will fit in the row. - * - * @return (Index of last fitting element, width of row, height of row) - */ - @SuppressLint("RestrictedApi") - private fun getFittingRow(indexStart: Int, maxWidth: Int): Triple { - var indexEnd = indexStart - var childStart = 0 - var rowHeight = 0 - - while (true) { - val child = getChildAt(indexEnd) - if (child.visibility == GONE) { - continue - } - - val (marginStart, marginEnd) = getMargins(child) - val childWidth = marginStart + child.measuredWidth + marginEnd - val separator = if (indexEnd > indexStart) { itemSpacing } else { 0 } - - // If even one child can't fit, force it to do so anyway - if (indexEnd != indexStart && childStart + separator + childWidth > maxWidth) { - --indexEnd - break - } - - childStart += separator + childWidth - rowHeight = max(rowHeight, child.measuredHeight) - - if (indexEnd == childCount - 1) { - break - } else { - ++indexEnd - } - } - - return Triple(indexEnd, min(childStart, maxWidth), rowHeight) - } - - /** - * Lay out [childIndices] children in a row positioned at [offsetLeft] and [offsetTop]. - */ - @SuppressLint("RestrictedApi") - private fun layoutRow(childIndices: IntRange, offsetLeft: Int, offsetTop: Int, rowIndex: Int) { - val range = if (layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL) { - childIndices.reversed() - } else { - childIndices - } - var childStart = offsetLeft - - for (i in range) { - val child = getChildAt(i) - if (child.visibility == GONE) { - child.setTag(com.google.android.material.R.id.row_index_key, -1) - continue - } else { - child.setTag(com.google.android.material.R.id.row_index_key, rowIndex) - } - - val (marginStart, marginEnd) = getMargins(child) - - child.layout( - childStart + marginStart, - offsetTop, - childStart + marginStart + child.measuredWidth, - offsetTop + child.measuredHeight, - ) - - childStart += marginStart + child.measuredWidth + marginEnd + itemSpacing - } - } - - companion object { - private fun getMargins(view: View): Pair { - val lp = view.layoutParams - - return if (lp is MarginLayoutParams) { - Pair(lp.marginStart, lp.marginEnd) - } else { - Pair(0, 0) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt b/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt deleted file mode 100644 index 693cd3d8b..000000000 --- a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt +++ /dev/null @@ -1,319 +0,0 @@ -package com.chiller3.bcr - -import android.content.Intent -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.telecom.Call -import android.telecom.InCallService -import android.util.Log -import kotlin.random.Random - -class RecorderInCallService : InCallService(), RecorderThread.OnRecordingCompletedListener { - companion object { - private val TAG = RecorderInCallService::class.java.simpleName - - private val ACTION_PAUSE = "${RecorderInCallService::class.java.canonicalName}.pause" - private val ACTION_RESUME = "${RecorderInCallService::class.java.canonicalName}.resume" - private const val EXTRA_TOKEN = "token" - } - - private lateinit var prefs: Preferences - private lateinit var notifications: Notifications - private val handler = Handler(Looper.getMainLooper()) - - /** - * Recording threads for each active call. When a call is disconnected, it is immediately - * removed from this map and [pendingExit] is incremented. - */ - private val recorders = HashMap() - - /** - * Number of threads pending exit after the call has been disconnected. This can be negative if - * the recording thread fails before the call is disconnected. - */ - private var pendingExit = 0 - - /** - * Token value for all intents received by this instance of the service. - * - * For the pause/resume functionality, we cannot use a bound service because [InCallService] - * uses its own non-extensible [onBind] implementation. So instead, we rely on [onStartCommand]. - * However, because this service is required to be exported, the intents could potentially come - * from third party apps and we don't want those interfering with the recordings. - */ - private val token = Random.Default.nextBytes(128) - - private val callback = object : Call.Callback() { - override fun onStateChanged(call: Call, state: Int) { - super.onStateChanged(call, state) - Log.d(TAG, "onStateChanged: $call, $state") - - handleStateChange(call, state) - } - - override fun onDetailsChanged(call: Call, details: Call.Details) { - super.onDetailsChanged(call, details) - Log.d(TAG, "onDetailsChanged: $call, $details") - - handleDetailsChange(call, details) - - // Due to firmware bugs, on older Samsung firmware, this callback (with the DISCONNECTED - // state) is the only notification we receive that a call ended - handleStateChange(call, null) - } - - override fun onCallDestroyed(call: Call) { - super.onCallDestroyed(call) - Log.d(TAG, "onCallDestroyed: $call") - - requestStopRecording(call) - } - } - - private fun createBaseIntent(): Intent = - Intent(this, RecorderInCallService::class.java).apply { - putExtra(EXTRA_TOKEN, token) - } - - private fun createPauseIntent(): Intent = - createBaseIntent().apply { - action = ACTION_PAUSE - } - - private fun createResumeIntent(): Intent = - createBaseIntent().apply { - action = ACTION_RESUME - } - - override fun onCreate() { - super.onCreate() - - prefs = Preferences(this) - notifications = Notifications(this) - } - - /** Handle intents triggered from notification actions for pausing and resuming. */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - try { - val receivedToken = intent?.getByteArrayExtra(EXTRA_TOKEN) - if (!receivedToken.contentEquals(token)) { - throw IllegalArgumentException("Invalid token") - } - - when (val action = intent?.action) { - ACTION_PAUSE, ACTION_RESUME -> { - for ((_, recorder) in recorders) { - recorder.isPaused = action == ACTION_PAUSE - } - updateForegroundState() - } - else -> throw IllegalArgumentException("Invalid action: $action") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to handle intent: $intent", e) - } - - // All actions are oneshot actions that should not be redelivered if a restart occurs - stopSelf(startId) - return START_NOT_STICKY - } - - /** - * Always called when the telephony framework becomes aware of a new call. - * - * This is the entry point for a new call. [callback] is always registered to keep track of - * state changes. - */ - override fun onCallAdded(call: Call) { - super.onCallAdded(call) - Log.d(TAG, "onCallAdded: $call") - - // The callback is unregistered in requestStopRecording() - call.registerCallback(callback) - - // In case the call is already in the active state - handleStateChange(call, null) - } - - /** - * Called when the telephony framework destroys a call. - * - * This will request the cancellation of the recording, even if [call] happens to not be in one - * of the disconnecting states. - * - * This is NOT guaranteed to be called, notably on older Samsung firmware, due to bugs in the - * telephony framework. As a result, [handleStateChange] stop the recording if the call enters a - * disconnecting state. - */ - override fun onCallRemoved(call: Call) { - super.onCallRemoved(call) - Log.d(TAG, "onCallRemoved: $call") - - // Unconditionally request the recording to stop, even if it's not in a disconnecting state - // since no further events will be received for the call. - requestStopRecording(call) - } - - /** - * Start or stop recording based on the [call] state. - * - * If the state is [Call.STATE_ACTIVE], then recording will begin. If the state is either - * [Call.STATE_DISCONNECTING] or [Call.STATE_DISCONNECTED], then the cancellation of the active - * recording will be requested. If [state] is null, then the call state is queried from [call]. - */ - private fun handleStateChange(call: Call, state: Int?) { - val callState = state ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - call.details.state - } else { - @Suppress("DEPRECATION") - call.state - } - - Log.d(TAG, "handleStateChange: $call, $state, $callState") - - if (callState == Call.STATE_ACTIVE) { - startRecording(call) - } else if (callState == Call.STATE_DISCONNECTING || callState == Call.STATE_DISCONNECTED) { - // This is necessary because onCallRemoved() might not be called due to firmware bugs - requestStopRecording(call) - } - } - - /** - * Start a [RecorderThread] for [call]. - * - * If call recording is disabled or the required permissions aren't granted, then no - * [RecorderThread] will be created. - * - * This function is idempotent. - */ - private fun startRecording(call: Call) { - if (!prefs.isCallRecordingEnabled) { - Log.v(TAG, "Call recording is disabled") - } else if (!Permissions.haveRequired(this)) { - Log.v(TAG, "Required permissions have not been granted") - } else if (!recorders.containsKey(call)) { - val recorder = try { - RecorderThread(this, this, call) - } catch (e: Exception) { - notifyFailure(e.message, null) - throw e - } - recorders[call] = recorder - - updateForegroundState() - recorder.start() - } - } - - /** - * Request the cancellation of the [RecorderThread]. - * - * The [RecorderThread] is immediately removed from [recorders], but [pendingExit] will be - * incremented to keep the foreground notification alive until the [RecorderThread] exits and - * reports its status. The thread may exit, decrementing [pendingExit], before this function is - * called if an error occurs during recording. - * - * This function will also unregister [callback] from the call since it's no longer necessary to - * track further state changes. - * - * This function is idempotent. - */ - private fun requestStopRecording(call: Call) { - // This is safe to call multiple times in the AOSP implementation and also in heavily - // modified builds, like Samsung's firmware. If this ever becomes a problem, we can keep - // track of which calls have callbacks registered. - call.unregisterCallback(callback) - - val recorder = recorders[call] - if (recorder != null) { - recorder.cancel() - - recorders.remove(call) - - // Don't change the foreground state until the thread has exited - ++pendingExit - } - } - - /** - * Notify the recording thread of call details changes. - * - * The recording thread uses call details for generating filenames. - */ - private fun handleDetailsChange(call: Call, details: Call.Details) { - recorders[call]?.onCallDetailsChanged(details) - } - - /** - * Move to foreground, creating a persistent notification, when there are active calls or - * recording threads that haven't finished exiting yet. - */ - private fun updateForegroundState() { - if (recorders.isEmpty() && pendingExit == 0) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - if (recorders.any { it.value.isPaused }) { - startForeground(1, notifications.createPersistentNotification( - R.string.notification_recording_paused, - R.drawable.ic_launcher_quick_settings, - R.string.notification_action_resume, - createResumeIntent(), - )) - } else { - startForeground(1, notifications.createPersistentNotification( - R.string.notification_recording_in_progress, - R.drawable.ic_launcher_quick_settings, - R.string.notification_action_pause, - createPauseIntent(), - )) - } - notifications.vibrateIfEnabled(Notifications.CHANNEL_ID_PERSISTENT) - } - } - - private fun notifySuccess(file: OutputFile) { - notifications.notifySuccess( - R.string.notification_recording_succeeded, - R.drawable.ic_launcher_quick_settings, - file, - ) - } - - private fun notifyFailure(errorMsg: String?, file: OutputFile?) { - notifications.notifyFailure( - R.string.notification_recording_failed, - R.drawable.ic_launcher_quick_settings, - errorMsg, - file, - ) - } - - private fun onThreadExited() { - --pendingExit - updateForegroundState() - } - - override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) { - Log.i(TAG, "Recording completed: ${thread.id}: ${file?.redacted}") - handler.post { - onThreadExited() - - // If the recording was initially paused and the user never resumed it, there's no - // output file, so nothing needs to be shown. - if (file != null) { - notifySuccess(file) - } - } - } - - override fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) { - Log.w(TAG, "Recording failed: ${thread.id}: ${file?.redacted}") - handler.post { - onThreadExited() - - notifyFailure(errorMsg, file) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt b/app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt deleted file mode 100644 index 773de7526..000000000 --- a/app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt +++ /dev/null @@ -1,240 +0,0 @@ -package com.chiller3.bcr - -import android.content.Intent -import android.os.Handler -import android.os.Looper -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import android.util.Log -import kotlin.random.Random - -class RecorderMicTileService : TileService(), RecorderThread.OnRecordingCompletedListener { - companion object { - private val TAG = RecorderMicTileService::class.java.simpleName - - private val ACTION_PAUSE = "${RecorderMicTileService::class.java.canonicalName}.pause" - private val ACTION_RESUME = "${RecorderMicTileService::class.java.canonicalName}.resume" - private const val EXTRA_TOKEN = "token" - } - - private lateinit var notifications: Notifications - private val handler = Handler(Looper.getMainLooper()) - - private var recorder: RecorderThread? = null - - private var tileIsListening = false - - /** - * Token value for all intents received by this instance of the service. - * - * For the pause/resume functionality, we cannot use a bound service because [TileService] - * uses its own non-extensible [onBind] implementation. So instead, we rely on [onStartCommand]. - * However, because this service is required to be exported, the intents could potentially come - * from third party apps and we don't want those interfering with the recordings. - */ - private val token = Random.Default.nextBytes(128) - - private fun createBaseIntent(): Intent = - Intent(this, RecorderMicTileService::class.java).apply { - putExtra(EXTRA_TOKEN, token) - } - - private fun createPauseIntent(): Intent = - createBaseIntent().apply { - action = ACTION_PAUSE - } - - private fun createResumeIntent(): Intent = - createBaseIntent().apply { - action = ACTION_RESUME - } - - override fun onCreate() { - super.onCreate() - - notifications = Notifications(this) - } - - /** Handle intents triggered from notification actions for pausing and resuming. */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - try { - val receivedToken = intent?.getByteArrayExtra(EXTRA_TOKEN) - if (intent?.action != null && !receivedToken.contentEquals(token)) { - throw IllegalArgumentException("Invalid token") - } - - when (val action = intent?.action) { - ACTION_PAUSE, ACTION_RESUME -> { - recorder!!.isPaused = action == ACTION_PAUSE - updateForegroundState() - } - null -> { - // Ignore. Hack to keep service alive longer than the tile lifecycle. - } - else -> throw IllegalArgumentException("Invalid action: $action") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to handle intent: $intent", e) - } - - // Kill service if the only reason it is started is due to the intent - if (recorder == null) { - stopSelf(startId) - } - return START_NOT_STICKY - } - - override fun onStartListening() { - super.onStartListening() - - tileIsListening = true - - refreshTileState() - } - - override fun onStopListening() { - super.onStopListening() - - tileIsListening = false - } - - override fun onClick() { - super.onClick() - - if (!Permissions.haveRequired(this)) { - val intent = Intent(this, SettingsActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - startActivityAndCollapse(intent) - } else if (recorder == null) { - startRecording() - } else { - requestStopRecording() - } - - refreshTileState() - } - - private fun refreshTileState() { - val tile = qsTile - - // Tile.STATE_UNAVAILABLE is intentionally not used when permissions haven't been granted. - // Clicking the tile in that state does not invoke the click handler, so it wouldn't be - // possible to launch SettingsActivity to grant the permissions. - if (Permissions.haveRequired(this) && recorder != null) { - tile.state = Tile.STATE_ACTIVE - } else { - tile.state = Tile.STATE_INACTIVE - } - - tile.updateTile() - } - - /** - * Start the [RecorderThread]. - * - * If the required permissions aren't granted, then the service will stop. - * - * This function is idempotent. - */ - private fun startRecording() { - if (recorder == null) { - recorder = try { - RecorderThread(this, this, null) - } catch (e: Exception) { - notifyFailure(e.message, null) - throw e - } - - // Ensure the service lives past the tile lifecycle - startForegroundService(Intent(this, this::class.java)) - updateForegroundState() - recorder!!.start() - } - } - - /** - * Request the cancellation of the [RecorderThread]. - * - * The foreground notification stays alive until the [RecorderThread] exits and reports its - * status. The thread may exit before this function is called if an error occurs during - * recording. - * - * This function is idempotent. - */ - private fun requestStopRecording() { - recorder?.cancel() - } - - private fun updateForegroundState() { - if (recorder == null) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - if (recorder!!.isPaused) { - startForeground(1, notifications.createPersistentNotification( - R.string.notification_recording_mic_paused, - R.drawable.ic_launcher_quick_settings, - R.string.notification_action_resume, - createResumeIntent(), - )) - } else { - startForeground(1, notifications.createPersistentNotification( - R.string.notification_recording_mic_in_progress, - R.drawable.ic_launcher_quick_settings, - R.string.notification_action_pause, - createPauseIntent(), - )) - } - } - } - - private fun notifySuccess(file: OutputFile) { - notifications.notifySuccess( - R.string.notification_recording_mic_succeeded, - R.drawable.ic_launcher_quick_settings, - file, - ) - } - - private fun notifyFailure(errorMsg: String?, file: OutputFile?) { - notifications.notifyFailure( - R.string.notification_recording_mic_failed, - R.drawable.ic_launcher_quick_settings, - errorMsg, - file, - ) - } - - private fun onThreadExited() { - recorder = null - - if (tileIsListening) { - refreshTileState() - } - - // The service no longer needs to live past the tile lifecycle - updateForegroundState() - stopSelf() - } - - override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) { - Log.i(TAG, "Recording completed: ${thread.id}: ${file?.redacted}") - handler.post { - onThreadExited() - - // If the recording was initially paused and the user never resumed it, there's no - // output file, so nothing needs to be shown. - if (file != null) { - notifySuccess(file) - } - } - } - - override fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) { - Log.w(TAG, "Recording failed: ${thread.id}: ${file?.redacted}") - handler.post { - onThreadExited() - - notifyFailure(errorMsg, file) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/RecorderTileService.kt b/app/src/main/java/com/chiller3/bcr/RecorderTileService.kt deleted file mode 100644 index 99d2da223..000000000 --- a/app/src/main/java/com/chiller3/bcr/RecorderTileService.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.chiller3.bcr - -import android.content.Intent -import android.content.SharedPreferences -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import androidx.preference.PreferenceManager - -class RecorderTileService : TileService(), SharedPreferences.OnSharedPreferenceChangeListener { - private lateinit var prefs: Preferences - - override fun onCreate() { - super.onCreate() - - prefs = Preferences(this) - } - - override fun onStartListening() { - super.onStartListening() - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - prefs.registerOnSharedPreferenceChangeListener(this) - - refreshTileState() - } - - override fun onStopListening() { - super.onStopListening() - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - prefs.unregisterOnSharedPreferenceChangeListener(this) - } - - override fun onClick() { - super.onClick() - - if (!Permissions.haveRequired(this)) { - val intent = Intent(this, SettingsActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - startActivityAndCollapse(intent) - } else { - prefs.isCallRecordingEnabled = !prefs.isCallRecordingEnabled - } - - refreshTileState() - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - refreshTileState() - } - - private fun refreshTileState() { - val tile = qsTile - - // Tile.STATE_UNAVAILABLE is intentionally not used when permissions haven't been granted. - // Clicking the tile in that state does not invoke the click handler, so it wouldn't be - // possible to launch SettingsActivity to grant the permissions. - if (Permissions.haveRequired(this) && prefs.isCallRecordingEnabled) { - tile.state = Tile.STATE_ACTIVE - } else { - tile.state = Tile.STATE_INACTIVE - } - - tile.updateTile() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt b/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt deleted file mode 100644 index f42756e8e..000000000 --- a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.chiller3.bcr - -import android.content.Intent -import android.content.SharedPreferences -import android.net.Uri -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import com.chiller3.bcr.format.Format -import com.chiller3.bcr.format.NoParamInfo -import com.chiller3.bcr.format.RangedParamInfo -import com.chiller3.bcr.format.SampleRate - -class SettingsActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.settings_activity) - if (savedInstanceState == null) { - supportFragmentManager - .beginTransaction() - .replace(R.id.settings, SettingsFragment()) - .commit() - } - - setSupportActionBar(findViewById(R.id.toolbar)) - - setTitle(R.string.app_name_full) - } - - class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChangeListener, - Preference.OnPreferenceClickListener, LongClickablePreference.OnPreferenceLongClickListener, - SharedPreferences.OnSharedPreferenceChangeListener { - private lateinit var prefs: Preferences - private lateinit var prefCallRecording: SwitchPreferenceCompat - private lateinit var prefOutputDir: Preference - private lateinit var prefOutputFormat: Preference - private lateinit var prefInhibitBatteryOpt: SwitchPreferenceCompat - private lateinit var prefVersion: LongClickablePreference - - private val requestPermissionRequired = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> - // Call recording can still be enabled if optional permissions were not granted - if (granted.all { it.key !in Permissions.REQUIRED || it.value }) { - prefCallRecording.isChecked = true - } else { - startActivity(Permissions.getAppInfoIntent(requireContext())) - } - } - private val requestInhibitBatteryOpt = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - refreshInhibitBatteryOptState() - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.root_preferences, rootKey) - - val context = requireContext() - - prefs = Preferences(context) - - // If the desired state is enabled, set to disabled if runtime permissions have been - // denied. The user will have to grant permissions again to re-enable the features. - - prefCallRecording = findPreference(Preferences.PREF_CALL_RECORDING)!! - if (prefCallRecording.isChecked && !Permissions.haveRequired(context)) { - prefCallRecording.isChecked = false - } - prefCallRecording.onPreferenceChangeListener = this - - prefOutputDir = findPreference(Preferences.PREF_OUTPUT_DIR)!! - prefOutputDir.onPreferenceClickListener = this - refreshOutputDir() - - prefOutputFormat = findPreference(Preferences.PREF_OUTPUT_FORMAT)!! - prefOutputFormat.onPreferenceClickListener = this - refreshOutputFormat() - - prefInhibitBatteryOpt = findPreference(Preferences.PREF_INHIBIT_BATT_OPT)!! - prefInhibitBatteryOpt.onPreferenceChangeListener = this - - prefVersion = findPreference(Preferences.PREF_VERSION)!! - prefVersion.onPreferenceClickListener = this - prefVersion.onPreferenceLongClickListener = this - refreshVersion() - } - - override fun onStart() { - super.onStart() - - preferenceScreen.sharedPreferences!!.registerOnSharedPreferenceChangeListener(this) - - // Changing the battery state does not cause a reload of the activity - refreshInhibitBatteryOptState() - } - - override fun onStop() { - super.onStop() - - preferenceScreen.sharedPreferences!!.unregisterOnSharedPreferenceChangeListener(this) - } - - private fun refreshOutputDir() { - val context = requireContext() - val outputDirUri = prefs.outputDirOrDefault - val outputRetention = Retention.fromPreferences(prefs).toFormattedString(context) - - val summary = getString(R.string.pref_output_dir_desc) - prefOutputDir.summary = "${summary}\n\n${outputDirUri.formattedString} (${outputRetention})" - } - - private fun refreshOutputFormat() { - val (format, formatParamSaved) = Format.fromPreferences(prefs) - val formatParam = formatParamSaved ?: format.paramInfo.default - val summary = getString(R.string.pref_output_format_desc) - val prefix = when (val info = format.paramInfo) { - is RangedParamInfo -> "${info.format(formatParam)}, " - NoParamInfo -> "" - } - val sampleRate = SampleRate.fromPreferences(prefs) - - prefOutputFormat.summary = "${summary}\n\n${format.name} (${prefix}${sampleRate})" - } - - private fun refreshVersion() { - val suffix = if (prefs.isDebugMode) { - "+debugmode" - } else { - "" - } - prefVersion.summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.BUILD_TYPE}${suffix})" - } - - private fun refreshInhibitBatteryOptState() { - val inhibiting = Permissions.isInhibitingBatteryOpt(requireContext()) - prefInhibitBatteryOpt.isChecked = inhibiting - prefInhibitBatteryOpt.isEnabled = !inhibiting - } - - override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { - // No need to validate runtime permissions when disabling a feature - if (newValue == false) { - return true - } - - val context = requireContext() - - when (preference) { - prefCallRecording -> if (Permissions.haveRequired(context)) { - return true - } else { - // Ask for optional permissions the first time only - requestPermissionRequired.launch(Permissions.REQUIRED + Permissions.OPTIONAL) - } - // This is only reachable if battery optimization is not already inhibited - prefInhibitBatteryOpt -> requestInhibitBatteryOpt.launch( - Permissions.getInhibitBatteryOptIntent(requireContext())) - } - - return false - } - - override fun onPreferenceClick(preference: Preference): Boolean { - when (preference) { - prefOutputDir -> { - OutputDirectoryBottomSheetFragment().show( - childFragmentManager, OutputDirectoryBottomSheetFragment.TAG) - return true - } - prefOutputFormat -> { - OutputFormatBottomSheetFragment().show( - childFragmentManager, OutputFormatBottomSheetFragment.TAG) - return true - } - prefVersion -> { - val uri = Uri.parse(BuildConfig.PROJECT_URL_AT_COMMIT) - startActivity(Intent(Intent.ACTION_VIEW, uri)) - return true - } - } - - return false - } - - override fun onPreferenceLongClick(preference: Preference): Boolean { - when (preference) { - prefVersion -> { - prefs.isDebugMode = !prefs.isDebugMode - refreshVersion() - return true - } - } - - return false - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when { - // Update the switch state if it was toggled outside of the preference (eg. from the - // quick settings toggle) - key == prefCallRecording.key -> { - val current = prefCallRecording.isChecked - val expected = sharedPreferences.getBoolean(key, current) - if (current != expected) { - prefCallRecording.isChecked = expected - } - } - // Update the output directory state when it's changed by the bottom sheet - key == Preferences.PREF_OUTPUT_DIR || key == Preferences.PREF_OUTPUT_RETENTION -> { - refreshOutputDir() - } - // Update the output format state when it's changed by the bottom sheet - Preferences.isFormatKey(key) || key == Preferences.PREF_SAMPLE_RATE -> { - refreshOutputFormat() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt b/app/src/main/kotlin/com/patrykmis/bar/FilenameTemplate.kt similarity index 96% rename from app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt rename to app/src/main/kotlin/com/patrykmis/bar/FilenameTemplate.kt index 563e92985..573ab3ff8 100644 --- a/app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/FilenameTemplate.kt @@ -1,9 +1,10 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.content.Context import android.util.Log import androidx.documentfile.provider.DocumentFile -import java.util.* +import com.patrykmis.bar.extension.findFileFast +import java.util.Properties import java.util.regex.Matcher import java.util.regex.Pattern @@ -113,7 +114,7 @@ class FilenameTemplate private constructor(props: Properties, key: String) { Log.d(TAG, "Looking for custom filename template in: ${outputDir.uri}") - val templateFile = outputDir.findFileFast("bcr.properties") + val templateFile = outputDir.findFileFast("bar.properties") if (templateFile != null) { try { Log.d(TAG, "Loading custom filename template: ${templateFile.uri}") @@ -138,4 +139,4 @@ class FilenameTemplate private constructor(props: Properties, key: String) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/NotificationActionService.kt b/app/src/main/kotlin/com/patrykmis/bar/NotificationActionService.kt similarity index 94% rename from app/src/main/java/com/chiller3/bcr/NotificationActionService.kt rename to app/src/main/kotlin/com/patrykmis/bar/NotificationActionService.kt index d0d197a7b..600de104b 100644 --- a/app/src/main/java/com/chiller3/bcr/NotificationActionService.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/NotificationActionService.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.app.NotificationManager import android.app.Service @@ -9,12 +9,14 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.util.Log +import com.patrykmis.bar.output.OutputFile class NotificationActionService : Service() { companion object { private val TAG = NotificationActionService::class.java.simpleName - private val ACTION_DELETE_URI = "${NotificationActionService::class.java.canonicalName}.delete_uri" + private val ACTION_DELETE_URI = + "${NotificationActionService::class.java.canonicalName}.delete_uri" private const val EXTRA_REDACTED = "redacted" private const val EXTRA_NOTIFICATION_ID = "notification_id" @@ -75,6 +77,7 @@ class NotificationActionService : Service() { } }.start() } + else -> throw IllegalArgumentException("Invalid action: ${intent?.action}") } @@ -89,4 +92,4 @@ class NotificationActionService : Service() { START_NOT_STICKY } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/Notifications.kt b/app/src/main/kotlin/com/patrykmis/bar/Notifications.kt similarity index 84% rename from app/src/main/java/com/chiller3/bcr/Notifications.kt rename to app/src/main/kotlin/com/patrykmis/bar/Notifications.kt index c5b499981..dcd86b396 100644 --- a/app/src/main/java/com/chiller3/bcr/Notifications.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/Notifications.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.annotation.SuppressLint import android.app.Notification @@ -8,14 +8,16 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.res.Resources -import android.os.Build import android.os.VibrationEffect -import android.os.Vibrator import android.os.VibratorManager import android.util.Log import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import java.util.* +import com.patrykmis.bar.Notifications.Companion.CHANNEL_ID_FAILURE +import com.patrykmis.bar.Notifications.Companion.CHANNEL_ID_SUCCESS +import com.patrykmis.bar.extension.formattedString +import com.patrykmis.bar.output.OutputFile +import com.patrykmis.bar.settings.SettingsActivity class Notifications( private val context: Context, @@ -27,7 +29,7 @@ class Notifications( const val CHANNEL_ID_FAILURE = "failure" const val CHANNEL_ID_SUCCESS = "success" - private val LEGACY_CHANNEL_IDS = arrayOf("alerts") + private val LEGACY_CHANNEL_IDS = arrayOf() /** Incremented for each new alert (non-persistent) notification. */ private var notificationId = 2 @@ -62,8 +64,11 @@ class Notifications( */ @SuppressLint("DiscouragedApi") private val defaultPattern = try { - getLongArray(systemRes, systemRes.getIdentifier( - "config_defaultNotificationVibePattern", "array", "android")) + getLongArray( + systemRes, systemRes.getIdentifier( + "config_defaultNotificationVibePattern", "array", "android" + ) + ) } catch (e: Exception) { Log.w(TAG, "System vibration pattern not found; using hardcoded default", e) DEFAULT_VIBRATE_PATTERN @@ -111,11 +116,13 @@ class Notifications( * Legacy notification channels are deleted without migrating settings. */ fun updateChannels() { - notificationManager.createNotificationChannels(listOf( - createPersistentChannel(), - createFailureAlertsChannel(), - createSuccessAlertsChannel(), - )) + notificationManager.createNotificationChannels( + listOf( + createPersistentChannel(), + createFailureAlertsChannel(), + createSuccessAlertsChannel(), + ) + ) LEGACY_CHANNEL_IDS.forEach { notificationManager.deleteNotificationChannel(it) } } @@ -128,7 +135,7 @@ class Notifications( @StringRes title: Int, @DrawableRes icon: Int, @StringRes actionText: Int, - actionIntent: Intent, + actionIntent: PendingIntent, ): Notification { val notificationIntent = Intent(context, SettingsActivity::class.java) val pendingIntent = PendingIntent.getActivity( @@ -141,23 +148,16 @@ class Notifications( setContentIntent(pendingIntent) setOngoing(true) - val actionPendingIntent = PendingIntent.getService( - context, - 0, - actionIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + addAction( + Notification.Action.Builder( + null, + context.getString(actionText), + actionIntent, + ).build() ) - addAction(Notification.Action.Builder( - null, - context.getString(actionText), - actionPendingIntent, - ).build()) - // Inhibit 10-second delay when showing persistent notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) - } + setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) build() } } @@ -233,23 +233,29 @@ class Notifications( PendingIntent.FLAG_IMMUTABLE, ) - addAction(Notification.Action.Builder( - null, - context.getString(R.string.notification_action_open), - openIntent, - ).build()) + addAction( + Notification.Action.Builder( + null, + context.getString(R.string.notification_action_open), + openIntent, + ).build() + ) - addAction(Notification.Action.Builder( - null, - context.getString(R.string.notification_action_share), - shareIntent, - ).build()) + addAction( + Notification.Action.Builder( + null, + context.getString(R.string.notification_action_share), + shareIntent, + ).build() + ) - addAction(Notification.Action.Builder( - null, - context.getString(R.string.notification_action_delete), - deleteIntent, - ).build()) + addAction( + Notification.Action.Builder( + null, + context.getString(R.string.notification_action_delete), + deleteIntent, + ).build() + ) // Clicking on the notification behaves like the open action, except the // notification gets dismissed. The open and share actions do not dismiss the @@ -325,12 +331,9 @@ class Notifications( fun vibrateIfEnabled(channelId: String) { val channel = notificationManager.getNotificationChannel(channelId) if (channel.shouldVibrate()) { - val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = context.getSystemService(VibratorManager::class.java) - vibratorManager.defaultVibrator - } else { - context.getSystemService(Vibrator::class.java) - } + val vibrator = context + .getSystemService(VibratorManager::class.java) + .defaultVibrator if (vibrator.hasVibrator()) { val pattern = channel.vibrationPattern ?: defaultPattern @@ -340,4 +343,4 @@ class Notifications( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/OpenPersistentDocumentTree.kt b/app/src/main/kotlin/com/patrykmis/bar/OpenPersistentDocumentTree.kt similarity index 96% rename from app/src/main/java/com/chiller3/bcr/OpenPersistentDocumentTree.kt rename to app/src/main/kotlin/com/patrykmis/bar/OpenPersistentDocumentTree.kt index a1554fc2e..4bd8090ac 100644 --- a/app/src/main/java/com/chiller3/bcr/OpenPersistentDocumentTree.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/OpenPersistentDocumentTree.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.content.Context import android.content.Intent @@ -21,4 +21,4 @@ class OpenPersistentDocumentTree : ActivityResultContracts.OpenDocumentTree() { return intent } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/Permissions.kt b/app/src/main/kotlin/com/patrykmis/bar/Permissions.kt similarity index 73% rename from app/src/main/java/com/chiller3/bcr/Permissions.kt rename to app/src/main/kotlin/com/patrykmis/bar/Permissions.kt index 31b959845..9b44c7440 100644 --- a/app/src/main/java/com/chiller3/bcr/Permissions.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/Permissions.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.Manifest import android.annotation.SuppressLint @@ -6,30 +6,21 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.os.PowerManager import android.provider.Settings import androidx.core.content.ContextCompat object Permissions { - private val NOTIFICATION: Array = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf(Manifest.permission.POST_NOTIFICATIONS) - } else { - arrayOf() - } - - val REQUIRED: Array = arrayOf(Manifest.permission.RECORD_AUDIO) + NOTIFICATION - val OPTIONAL: Array = arrayOf( - Manifest.permission.READ_CONTACTS, - Manifest.permission.READ_PHONE_STATE, + val REQUIRED: Array = arrayOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.POST_NOTIFICATIONS ) private fun isGranted(context: Context, permission: String) = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED /** - * Check if all permissions required for call recording have been granted. + * Check if all permissions required for recording have been granted. */ fun haveRequired(context: Context): Boolean = REQUIRED.all { isGranted(context, it) } @@ -60,4 +51,4 @@ object Permissions { intent.data = Uri.fromParts("package", context.packageName, null) return intent } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/Preferences.kt b/app/src/main/kotlin/com/patrykmis/bar/Preferences.kt similarity index 96% rename from app/src/main/java/com/chiller3/bcr/Preferences.kt rename to app/src/main/kotlin/com/patrykmis/bar/Preferences.kt index 095ac1d5c..3c9845745 100644 --- a/app/src/main/java/com/chiller3/bcr/Preferences.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/Preferences.kt @@ -1,13 +1,15 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.content.Context import android.content.Intent import android.net.Uri import android.util.Log import androidx.core.content.edit +import androidx.core.net.toUri import androidx.preference.PreferenceManager -import com.chiller3.bcr.format.Format -import com.chiller3.bcr.format.SampleRate +import com.patrykmis.bar.format.Format +import com.patrykmis.bar.format.SampleRate +import com.patrykmis.bar.output.Retention import java.io.File class Preferences(private val context: Context) { @@ -91,7 +93,7 @@ class Preferences(private val context: Context) { * persisting permissions for the new URI fails, the saved output directory is not changed. */ var outputDir: Uri? - get() = prefs.getString(PREF_OUTPUT_DIR, null)?.let { Uri.parse(it) } + get() = prefs.getString(PREF_OUTPUT_DIR, null)?.let { it.toUri() } set(uri) { val oldUri = outputDir if (oldUri == uri) { @@ -215,4 +217,4 @@ class Preferences(private val context: Context) { var sampleRate: SampleRate? get() = getOptionalUint(PREF_SAMPLE_RATE)?.let { SampleRate(it) } set(sampleRate) = setOptionalUint(PREF_SAMPLE_RATE, sampleRate?.value) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/RecorderApplication.kt b/app/src/main/kotlin/com/patrykmis/bar/RecorderApplication.kt similarity index 83% rename from app/src/main/java/com/chiller3/bcr/RecorderApplication.kt rename to app/src/main/kotlin/com/patrykmis/bar/RecorderApplication.kt index edfbce5de..9d48c3abf 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderApplication.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/RecorderApplication.kt @@ -1,9 +1,10 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.app.Application import android.util.Log import androidx.core.net.toFile import com.google.android.material.color.DynamicColors +import com.patrykmis.bar.output.OutputDirUtils class RecorderApplication : Application() { override fun onCreate() { @@ -22,7 +23,11 @@ class RecorderApplication : Application() { val dirUtils = OutputDirUtils(this, redactor) val logcatFile = dirUtils.createFileInDefaultDir("crash.log", "text/plain") - Log.e(TAG, "Saving logcat to ${redactor.redact(logcatFile.uri)} due to uncaught exception in $t", e) + Log.e( + TAG, + "Saving logcat to ${redactor.redact(logcatFile.uri)} due to uncaught exception in $t", + e + ) try { ProcessBuilder("logcat", "-d", "*:V") @@ -42,4 +47,4 @@ class RecorderApplication : Application() { companion object { private val TAG = RecorderApplication::class.java.simpleName } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/patrykmis/bar/RecorderGateActivity.kt b/app/src/main/kotlin/com/patrykmis/bar/RecorderGateActivity.kt new file mode 100644 index 000000000..d35868ae5 --- /dev/null +++ b/app/src/main/kotlin/com/patrykmis/bar/RecorderGateActivity.kt @@ -0,0 +1,28 @@ +package com.patrykmis.bar + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.core.content.ContextCompat + +/** + * Invisible trampoline Activity used to enter a foreground-eligible state + * before starting the recording foreground service (microphone). + * + * Required for Android 14+ when starting mic FGS from Quick Settings tile. + */ +class RecorderGateActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Start the recorder service from a foreground-eligible context + ContextCompat.startForegroundService( + this, + Intent(this, RecorderService::class.java).setAction(RecorderService.ACTION_TOGGLE) + ) + + // Immediately finish - no UI needed + finish() + } +} diff --git a/app/src/main/kotlin/com/patrykmis/bar/RecorderMicTileService.kt b/app/src/main/kotlin/com/patrykmis/bar/RecorderMicTileService.kt new file mode 100644 index 000000000..3f3d0a0c8 --- /dev/null +++ b/app/src/main/kotlin/com/patrykmis/bar/RecorderMicTileService.kt @@ -0,0 +1,94 @@ +package com.patrykmis.bar + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.util.Log +import com.patrykmis.bar.settings.SettingsActivity + +class RecorderMicTileService : TileService() { + companion object { + private val TAG = RecorderMicTileService::class.java.simpleName + } + + private var tileIsListening = false + + override fun onStartListening() { + super.onStartListening() + + tileIsListening = true + + refreshTileState() + } + + override fun onStopListening() { + super.onStopListening() + + tileIsListening = false + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + override fun onClick() { + super.onClick() + + if (!Permissions.haveRequired(this)) { + val intent = Intent(this, SettingsActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse( + PendingIntent.getActivity( + this, 0, intent, PendingIntent.FLAG_IMMUTABLE + ) + ) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } else { + startRecorderGate() + } + + refreshTileState() + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + private fun startRecorderGate() { + val intent = Intent(this, RecorderGateActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse( + PendingIntent.getActivity( + this, 0, intent, PendingIntent.FLAG_IMMUTABLE + ) + ) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } + + private fun refreshTileState() { + val tile = qsTile + if (tile == null) { + Log.w(TAG, "Tile was null during refreshTileState") + return + } + + // Tile.STATE_UNAVAILABLE is intentionally not used when permissions haven't been granted. + // Clicking the tile in that state does not invoke the click handler, so it wouldn't be + // possible to launch SettingsActivity to grant the permissions. + if (Permissions.haveRequired(this) && RecorderService.isRecording) { + tile.state = Tile.STATE_ACTIVE + } else { + tile.state = Tile.STATE_INACTIVE + } + + tile.updateTile() + } +} diff --git a/app/src/main/java/com/chiller3/bcr/RecorderProvider.kt b/app/src/main/kotlin/com/patrykmis/bar/RecorderProvider.kt similarity index 93% rename from app/src/main/java/com/chiller3/bcr/RecorderProvider.kt rename to app/src/main/kotlin/com/patrykmis/bar/RecorderProvider.kt index debd58e62..fd43fbcea 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderProvider.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/RecorderProvider.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar import android.content.ContentProvider import android.content.ContentResolver @@ -7,9 +7,10 @@ import android.content.Intent import android.database.Cursor import android.net.Uri import android.os.ParcelFileDescriptor +import androidx.core.net.toUri /** - * This is an extremely minimal content provider so that BCR can provide an openable/shareable URI + * This is an extremely minimal content provider so that BAR can provide an openable/shareable URI * to other applications. SAF URIs cannot be shared directly because permission grants cannot be * propagated to the target app. * @@ -36,7 +37,7 @@ class RecorderProvider : ContentProvider() { } return try { - Uri.parse(param) + param.toUri() } catch (e: Exception) { null } @@ -64,7 +65,8 @@ class RecorderProvider : ContentProvider() { ): Cursor? = extractOrigUri(uri)?.let { context?.contentResolver?.query( - it, projection, selection, selectionArgs, sortOrder) + it, projection, selection, selectionArgs, sortOrder + ) } override fun insert(uri: Uri, values: ContentValues?): Uri? = null @@ -77,4 +79,4 @@ class RecorderProvider : ContentProvider() { selection: String?, selectionArgs: Array?, ): Int = 0 -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/patrykmis/bar/RecorderService.kt b/app/src/main/kotlin/com/patrykmis/bar/RecorderService.kt new file mode 100644 index 000000000..cd54c11ad --- /dev/null +++ b/app/src/main/kotlin/com/patrykmis/bar/RecorderService.kt @@ -0,0 +1,185 @@ +package com.patrykmis.bar + +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import com.patrykmis.bar.extension.threadIdCompat +import com.patrykmis.bar.output.OutputFile + +class RecorderService : Service(), RecorderThread.OnRecordingCompletedListener { + + companion object { + private val TAG = RecorderService::class.java.simpleName + + val ACTION_TOGGLE = "${RecorderService::class.java.canonicalName}.TOGGLE" + private val ACTION_PAUSE = "${RecorderService::class.java.canonicalName}.PAUSE" + private val ACTION_RESUME = "${RecorderService::class.java.canonicalName}.RESUME" + + @Volatile + var isRecording: Boolean = false + private set + } + + private lateinit var notifications: Notifications + private val handler = Handler(Looper.getMainLooper()) + + private var recorder: RecorderThread? = null + + override fun onCreate() { + super.onCreate() + + notifications = Notifications(this) + } + + override fun onBind(intent: Intent?): IBinder? = null + + /** Handle recorder control intents (toggle, pause, resume). */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_TOGGLE -> { + if (recorder == null) startRecording() else requestStopRecording() + } + + ACTION_PAUSE, ACTION_RESUME -> { + recorder?.isPaused = intent.action == ACTION_PAUSE + updateForegroundState() + } + + else -> { + Log.w(TAG, "Unknown action: ${intent?.action}") + } + } + return START_NOT_STICKY + } + + private fun createPauseIntent(): PendingIntent = + PendingIntent.getService( + this, + 1, + Intent(this, RecorderService::class.java).setAction(ACTION_PAUSE), + PendingIntent.FLAG_IMMUTABLE + ) + + private fun createResumeIntent(): PendingIntent = + PendingIntent.getService( + this, + 2, + Intent(this, RecorderService::class.java).setAction(ACTION_RESUME), + PendingIntent.FLAG_IMMUTABLE + ) + + /** + * Start the [RecorderThread]. + * + * If the required permissions aren't granted, then the service will stop. + * + * This function is idempotent. + */ + private fun startRecording() { + if (recorder != null) return + + recorder = try { + RecorderThread(this, this) + } catch (e: Exception) { + notifyFailure(e.message, null) + stopSelf() + return + } + + // Foreground MUST be started quickly after service start + updateForegroundState() + isRecording = true + recorder!!.start() + } + + /** + * Request the cancellation of the [RecorderThread]. + * + * The foreground notification stays alive until the [RecorderThread] exits and reports its + * status. The thread may exit before this function is called if an error occurs during + * recording. + * + * This function is idempotent. + */ + private fun requestStopRecording() { + recorder?.cancel() + } + + private fun updateForegroundState() { + if (recorder == null) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + if (recorder!!.isPaused) { + startForeground( + 1, notifications.createPersistentNotification( + R.string.notification_recording_mic_paused, + R.drawable.ic_launcher_quick_settings, + R.string.notification_action_resume, + createResumeIntent() + ) + ) + } else { + startForeground( + 1, notifications.createPersistentNotification( + R.string.notification_recording_mic_in_progress, + R.drawable.ic_launcher_quick_settings, + R.string.notification_action_pause, + createPauseIntent() + ) + ) + } + } + } + + private fun onThreadExited() { + recorder = null + isRecording = false + + // Recording finished - clean up foreground state and stop service + updateForegroundState() + stopSelf() + } + + override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) { + Log.i(TAG, "Recording completed: ${thread.threadIdCompat}: ${file?.redacted}") + handler.post { + onThreadExited() + + // If the recording was initially paused and the user never resumed it, there's no + // output file, so nothing needs to be shown. + if (file != null) { + notifySuccess(file) + } + } + } + + override fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) { + Log.w(TAG, "Recording failed: ${thread.threadIdCompat}: ${file?.redacted}") + handler.post { + onThreadExited() + + notifyFailure(errorMsg, file) + } + } + + private fun notifySuccess(file: OutputFile) { + notifications.notifySuccess( + R.string.notification_recording_mic_succeeded, + R.drawable.ic_launcher_quick_settings, + file, + ) + } + + private fun notifyFailure(errorMsg: String?, file: OutputFile?) { + notifications.notifyFailure( + R.string.notification_recording_mic_failed, + R.drawable.ic_launcher_quick_settings, + errorMsg, + file + ) + } +} diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/kotlin/com/patrykmis/bar/RecorderThread.kt similarity index 70% rename from app/src/main/java/com/chiller3/bcr/RecorderThread.kt rename to app/src/main/kotlin/com/patrykmis/bar/RecorderThread.kt index 98337a6d0..bcb7b3afe 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/RecorderThread.kt @@ -1,30 +1,33 @@ -package com.chiller3.bcr +package com.patrykmis.bar -import android.Manifest import android.annotation.SuppressLint import android.content.Context -import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.net.Uri -import android.os.Build import android.os.ParcelFileDescriptor import android.system.Os -import android.telecom.Call -import android.telecom.PhoneAccount -import android.telephony.SubscriptionManager -import android.telephony.TelephonyManager import android.util.Log import androidx.core.net.toFile import androidx.documentfile.provider.DocumentFile -import com.chiller3.bcr.format.Encoder -import com.chiller3.bcr.format.Format -import com.chiller3.bcr.format.SampleRate -import java.lang.Process +import com.patrykmis.bar.extension.listFilesWithNames +import com.patrykmis.bar.extension.renameToPreserveExt +import com.patrykmis.bar.extension.threadIdCompat +import com.patrykmis.bar.format.Encoder +import com.patrykmis.bar.format.Format +import com.patrykmis.bar.format.SampleRate +import com.patrykmis.bar.output.DaysRetention +import com.patrykmis.bar.output.NoRetention +import com.patrykmis.bar.output.OutputDirUtils +import com.patrykmis.bar.output.OutputFile +import com.patrykmis.bar.output.Retention import java.nio.ByteBuffer import java.text.ParsePosition -import java.time.* +import java.time.DateTimeException +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZonedDateTime import java.time.format.DateTimeFormatterBuilder import java.time.format.DateTimeParseException import java.time.format.SignStyle @@ -33,35 +36,31 @@ import java.time.temporal.Temporal import android.os.Process as AndroidProcess /** - * Captures call audio and encodes it into an output file in the user's selected directory or the + * Captures audio and encodes it into an output file in the user's selected directory or the * fallback/default directory. * - * @constructor Create a thread for recording a call or the mic. Note that the system only has a - * single [MediaRecorder.AudioSource.VOICE_CALL] stream. If multiple calls are being recorded, the - * recorded audio for each call may not be as expected. + * @constructor Create a thread for recording the mic. * @param context Used for querying shared preferences and accessing files via SAF. A reference is * kept in the object. * @param listener Used for sending completion notifications. The listener is called from this * thread, not the main thread. - * @param call Used only for determining the output filename and is not saved. If null, then this - * thread records from the mic, not from a call. */ class RecorderThread( private val context: Context, - private val listener: OnRecordingCompletedListener, - call: Call?, + private val listener: OnRecordingCompletedListener ) : Thread(RecorderThread::class.java.simpleName) { - private val tag = "${RecorderThread::class.java.simpleName}/${id}" + private val tag = "${RecorderThread::class.java.simpleName}/$threadIdCompat" private val prefs = Preferences(context) private val isDebug = prefs.isDebugMode - private val isMic = call == null // Thread state - @Volatile private var isCancelled = false + @Volatile + private var isCancelled = false private var captureFailed = false // Pause state - @Volatile var isPaused = prefs.initiallyPaused + @Volatile + var isPaused = prefs.initiallyPaused set(value) { field = value if (!value) { @@ -77,9 +76,7 @@ class RecorderThread( private var formatter = FORMATTER // Filename - private val filenameLock = Object() - private var pendingCallDetails = call?.details - private var lastCallDetails: Call.Details? = null + private val filenameLock = Any() private lateinit var filenameTemplate: FilenameTemplate private lateinit var filename: String private val redactions = HashMap() @@ -111,7 +108,6 @@ class RecorderThread( private lateinit var logcatProcess: Process init { - Log.i(tag, "Created thread for call: $call") Log.i(tag, "Initially paused: $isPaused") val savedFormat = Format.fromPreferences(prefs) @@ -163,6 +159,7 @@ class RecorderThread( return@evaluate handleDateFormat(it) } + else -> { Log.w(tag, "Unknown filename template variable: $it") } @@ -177,108 +174,12 @@ class RecorderThread( } } - /** - * Update [filename] with information from [details]. - * - * This function holds a lock on [filenameLock] until it returns. - */ - fun onCallDetailsChanged(details: Call.Details?) { - if (details == null) { - setFilenameForMic() - return - } - - synchronized(filenameLock) { - if (!this::filenameTemplate.isInitialized) { - // Thread hasn't started yet, so we haven't loaded the filename template - pendingCallDetails = details - return - } - - lastCallDetails = details - - filename = filenameTemplate.evaluate { - when { - it == "date" || it.startsWith("date:") -> { - val instant = Instant.ofEpochMilli(details.creationTimeMillis) - callTimestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()) - - return@evaluate handleDateFormat(it) - } - it == "direction" -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - when (details.callDirection) { - Call.Details.DIRECTION_INCOMING -> return@evaluate "in" - Call.Details.DIRECTION_OUTGOING -> return@evaluate "out" - Call.Details.DIRECTION_UNKNOWN -> {} - } - } - } - it == "sim_slot" -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - && context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) - == PackageManager.PERMISSION_GRANTED - && context.packageManager.hasSystemFeature( - PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)) { - val subscriptionManager = context.getSystemService(SubscriptionManager::class.java) - - // Only append SIM slot ID if the device has multiple active SIMs - if (subscriptionManager.activeSubscriptionInfoCount > 1) { - val telephonyManager = context.getSystemService(TelephonyManager::class.java) - val subscriptionId = telephonyManager.getSubscriptionId(details.accountHandle) - val subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(subscriptionId) - - return@evaluate "${subscriptionInfo.simSlotIndex + 1}" - } - } - } - it == "phone_number" -> { - if (details.handle?.scheme == PhoneAccount.SCHEME_TEL) { - redactions[details.handle.schemeSpecificPart] = "" - - return@evaluate details.handle.schemeSpecificPart - } - } - it == "caller_name" -> { - val callerName = details.callerDisplayName?.trim() - if (!callerName.isNullOrBlank()) { - redactions[callerName] = "" - - return@evaluate callerName - } - } - it == "contact_name" -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val contactName = details.contactDisplayName?.trim() - if (!contactName.isNullOrBlank()) { - redactions[contactName] = "" - - return@evaluate contactName - } - } - } - else -> { - Log.w(tag, "Unknown filename template variable: $it") - } - } - - null - } - // AOSP's SAF automatically replaces invalid characters with underscores, but just in - // case an OEM fork breaks that, do the replacement ourselves to prevent directory - // traversal attacks. - .replace('/', '_').trim() - - Log.i(tag, "Updated filename due to call details change: ${redactor.redact(filename)}") - } - } - override fun run() { var success = false var errorMsg: String? = null var resultUri: Uri? = null - val templateKey = if (pendingCallDetails == null) { "filename_mic" } else { "filename" } + val templateKey = "filename_mic" synchronized(filenameLock) { // We initially do not allow custom filename templates because SAF is extraordinarily @@ -286,8 +187,7 @@ class RecorderThread( // checking for the existence of the template may take >500ms. filenameTemplate = FilenameTemplate.load(context, templateKey, false) - onCallDetailsChanged(pendingCallDetails) - pendingCallDetails = null + setFilenameForMic() } startLogcat() @@ -299,7 +199,8 @@ class RecorderThread( Log.i(tag, "Recording cancelled before it began") } else { val initialFilename = synchronized(filenameLock) { filename } - val outputFile = dirUtils.createFileInDefaultDir(initialFilename, format.mimeTypeContainer) + val outputFile = + dirUtils.createFileInDefaultDir(initialFilename, format.mimeTypeContainer) resultUri = outputFile.uri try { @@ -311,16 +212,25 @@ class RecorderThread( val finalFilename = synchronized(filenameLock) { filenameTemplate = FilenameTemplate.load(context, templateKey, true) - onCallDetailsChanged(lastCallDetails) + setFilenameForMic() + filename } if (finalFilename != initialFilename) { - Log.i(tag, "Renaming ${redactor.redact(initialFilename)} to ${redactor.redact(finalFilename)}") + Log.i( + tag, + "Renaming ${redactor.redact(initialFilename)} to ${ + redactor.redact(finalFilename) + }" + ) if (outputFile.renameToPreserveExt(finalFilename)) { resultUri = outputFile.uri } else { - Log.w(tag, "Failed to rename to final filename: ${redactor.redact(finalFilename)}") + Log.w( + tag, + "Failed to rename to final filename: ${redactor.redact(finalFilename)}" + ) } } @@ -329,7 +239,12 @@ class RecorderThread( resultUri = it.uri } } else { - Log.i(tag, "Deleting because recording was never resumed: ${redactor.redact(finalFilename)}") + Log.i( + tag, + "Deleting because recording was never resumed: ${ + redactor.redact(finalFilename) + }" + ) outputFile.delete() resultUri = null } @@ -345,8 +260,12 @@ class RecorderThread( errorMsg = buildString { val elem = e.stackTrace.find { it.className.startsWith("android.media.") } if (elem != null) { - append(context.getString(R.string.notification_internal_android_error, - "${elem.className}.${elem.methodName}")) + append( + context.getString( + R.string.notification_internal_android_error, + "${elem.className}.${elem.methodName}" + ) + ) append("\n\n") } @@ -430,10 +349,18 @@ class RecorderThread( val finalLogcatFilename = synchronized(filenameLock) { "${filename}.log" } if (finalLogcatFilename != logcatFilename) { - Log.i(tag, "Renaming ${redactor.redact(logcatFilename)} to ${redactor.redact(finalLogcatFilename)}") + Log.i( + tag, + "Renaming ${redactor.redact(logcatFilename)} to ${ + redactor.redact(finalLogcatFilename) + }" + ) if (!logcatFile.renameToPreserveExt(finalLogcatFilename)) { - Log.w(tag, "Failed to rename to final filename: ${redactor.redact(finalLogcatFilename)}") + Log.w( + tag, + "Failed to rename to final filename: ${redactor.redact(finalLogcatFilename)}" + ) } } @@ -457,7 +384,10 @@ class RecorderThread( parsed.query(LocalDateTime::from) } - Log.d(tag, "Parsed $timestamp from $redacted; length=${name.length}; parsed=${pos.index}") + Log.d( + tag, + "Parsed $timestamp from $redacted; length=${name.length}; parsed=${pos.index}" + ) return timestamp } catch (e: DateTimeParseException) { @@ -485,6 +415,7 @@ class RecorderThread( Log.i(tag, "Keeping all existing files") return } + is DaysRetention -> r.toDuration() } Log.i(tag, "Retention period is $retention") @@ -522,18 +453,15 @@ class RecorderThread( AndroidProcess.setThreadPriority(AndroidProcess.THREAD_PRIORITY_URGENT_AUDIO) val minBufSize = AudioRecord.getMinBufferSize( - sampleRate.value.toInt(), CHANNEL_CONFIG, ENCODING) + sampleRate.value.toInt(), CHANNEL_CONFIG, ENCODING + ) if (minBufSize < 0) { throw Exception("Failure when querying minimum buffer size: $minBufSize") } Log.d(tag, "AudioRecord minimum buffer size: $minBufSize") val audioRecord = AudioRecord( - if (isMic) { - MediaRecorder.AudioSource.MIC - } else { - MediaRecorder.AudioSource.VOICE_CALL - }, + MediaRecorder.AudioSource.UNPROCESSED, sampleRate.value.toInt(), CHANNEL_CONFIG, ENCODING, @@ -542,7 +470,7 @@ class RecorderThread( minBufSize * 6, ) val initialBufSize = audioRecord.bufferSizeInFrames * - audioRecord.format.frameSizeInBytesCompat + audioRecord.format.frameSizeInBytes Log.d(tag, "AudioRecord initial buffer size: $initialBufSize") Log.d(tag, "AudioRecord format: ${audioRecord.format}") @@ -602,7 +530,7 @@ class RecorderThread( private fun encodeLoop(audioRecord: AudioRecord, encoder: Encoder, bufSize: Int) { var numFramesTotal = 0L var numFramesEncoded = 0L - val frameSize = audioRecord.format.frameSizeInBytesCompat + val frameSize = audioRecord.format.frameSizeInBytes // Use a slightly larger buffer to reduce the chance of problems under load val factor = 2 @@ -613,9 +541,8 @@ class RecorderThread( while (!isCancelled) { val begin = System.nanoTime() - // We do a non-blocking read because on Samsung devices, when the call ends, the audio - // device immediately stops producing data and blocks forever until the next call is - // active. + // Use non-blocking read to avoid AudioRecord deadlock on some OEMs (e.g. Samsung) + // when audio source stops producing data unexpectedly. val n = audioRecord.read(buffer, buffer.remaining(), AudioRecord.READ_NON_BLOCKING) val recordElapsed = System.nanoTime() - begin var encodeElapsed = 0L @@ -648,13 +575,15 @@ class RecorderThread( val totalElapsed = System.nanoTime() - begin if (encodeElapsed > bufferNs) { - Log.w(tag, "${encoder.javaClass.simpleName} took too long: " + - "timestampTotal=${numFramesTotal.toDouble() / audioRecord.sampleRate}s, " + - "timestampEncode=${numFramesEncoded.toDouble() / audioRecord.sampleRate}s, " + - "buffer=${bufferNs / 1_000_000.0}ms, " + - "total=${totalElapsed / 1_000_000.0}ms, " + - "record=${recordElapsed / 1_000_000.0}ms, " + - "encode=${encodeElapsed / 1_000_000.0}ms") + Log.w( + tag, "${encoder.javaClass.simpleName} took too long: " + + "timestampTotal=${numFramesTotal.toDouble() / audioRecord.sampleRate}s, " + + "timestampEncode=${numFramesEncoded.toDouble() / audioRecord.sampleRate}s, " + + "buffer=${bufferNs / 1_000_000.0}ms, " + + "total=${totalElapsed / 1_000_000.0}ms, " + + "record=${recordElapsed / 1_000_000.0}ms, " + + "encode=${encodeElapsed / 1_000_000.0}ms" + ) } } @@ -665,12 +594,14 @@ class RecorderThread( val durationSecsTotal = numFramesTotal.toDouble() / audioRecord.sampleRate val durationSecsEncoded = numFramesEncoded.toDouble() / audioRecord.sampleRate - Log.d(tag, "Input complete after ${"%.1f".format(durationSecsTotal)}s " + - "(${"%.1f".format(durationSecsEncoded)}s encoded)") + Log.d( + tag, "Input complete after ${"%.1f".format(durationSecsTotal)}s " + + "(${"%.1f".format(durationSecsEncoded)}s encoded)" + ) } companion object { - private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO + private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT // Eg. 20220429_180249.123-0400 @@ -714,4 +645,4 @@ class RecorderThread( */ fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/DocumentFileExtensions.kt b/app/src/main/kotlin/com/patrykmis/bar/extension/DocumentFileExtensions.kt similarity index 99% rename from app/src/main/java/com/chiller3/bcr/DocumentFileExtensions.kt rename to app/src/main/kotlin/com/patrykmis/bar/extension/DocumentFileExtensions.kt index 694a3f569..b7891cb1a 100644 --- a/app/src/main/java/com/chiller3/bcr/DocumentFileExtensions.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/extension/DocumentFileExtensions.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar.extension import android.content.ContentResolver import android.content.Context @@ -122,6 +122,7 @@ fun DocumentFile.renameToPreserveExt(displayName: String): Boolean { } } } + else -> displayName } diff --git a/app/src/main/kotlin/com/patrykmis/bar/extension/ThreadExtensions.kt b/app/src/main/kotlin/com/patrykmis/bar/extension/ThreadExtensions.kt new file mode 100644 index 000000000..3ae19a267 --- /dev/null +++ b/app/src/main/kotlin/com/patrykmis/bar/extension/ThreadExtensions.kt @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.patrykmis.bar.extension + +import android.os.Build + +val Thread.threadIdCompat: Long + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + this.threadId() + } else { + @Suppress("DEPRECATION") + this.id + } diff --git a/app/src/main/java/com/chiller3/bcr/UriExtensions.kt b/app/src/main/kotlin/com/patrykmis/bar/extension/UriExtensions.kt similarity index 96% rename from app/src/main/java/com/chiller3/bcr/UriExtensions.kt rename to app/src/main/kotlin/com/patrykmis/bar/extension/UriExtensions.kt index f75cda79a..9fa34bfa1 100644 --- a/app/src/main/java/com/chiller3/bcr/UriExtensions.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/extension/UriExtensions.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar.extension import android.content.ContentResolver import android.net.Uri @@ -25,5 +25,6 @@ val Uri.formattedString: String toString() } } + else -> toString() - } \ No newline at end of file + } diff --git a/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt b/app/src/main/kotlin/com/patrykmis/bar/format/AacFormat.kt similarity index 88% rename from app/src/main/java/com/chiller3/bcr/format/AacFormat.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/AacFormat.kt index dbd37f949..532d6499e 100644 --- a/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/AacFormat.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaCodecInfo import android.media.MediaFormat @@ -17,6 +17,7 @@ object AacFormat : Format() { 4_000u, 64_000u, ) + // https://datatracker.ietf.org/doc/html/rfc6381#section-3.1 override val mimeTypeContainer: String = "audio/mp4" override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_AAC @@ -30,13 +31,12 @@ object AacFormat : Format() { } else { MediaCodecInfo.CodecProfileLevel.AACObjectHE } - val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) setInteger(MediaFormat.KEY_AAC_PROFILE, profile) - setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) + setInteger(MediaFormat.KEY_BIT_RATE, param.toInt()) } } override fun getContainer(fd: FileDescriptor): Container = MediaMuxerContainer(fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/Container.kt b/app/src/main/kotlin/com/patrykmis/bar/format/Container.kt similarity index 97% rename from app/src/main/java/com/chiller3/bcr/format/Container.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/Container.kt index dd384b032..29c45cd12 100644 --- a/app/src/main/java/com/chiller3/bcr/format/Container.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/Container.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaCodec import android.media.MediaFormat diff --git a/app/src/main/java/com/chiller3/bcr/format/Encoder.kt b/app/src/main/kotlin/com/patrykmis/bar/format/Encoder.kt similarity index 97% rename from app/src/main/java/com/chiller3/bcr/format/Encoder.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/Encoder.kt index dde8b68c6..d0cbb639f 100644 --- a/app/src/main/java/com/chiller3/bcr/format/Encoder.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/Encoder.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaFormat import java.nio.ByteBuffer @@ -45,4 +45,4 @@ abstract class Encoder( * @param isEof No more data can be submitted after this method is called once with EOF == true. */ abstract fun encode(buffer: ByteBuffer, isEof: Boolean) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt b/app/src/main/kotlin/com/patrykmis/bar/format/FlacContainer.kt similarity index 95% rename from app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/FlacContainer.kt index 1202b40c2..12d7111e0 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/FlacContainer.kt @@ -1,7 +1,7 @@ @file:Suppress("OPT_IN_IS_NOT_ENABLED") @file:OptIn(ExperimentalUnsignedTypes::class) -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaCodec import android.media.MediaFormat @@ -67,8 +67,10 @@ class FlacContainer(private val fd: FileDescriptor) : Container { return track } - override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, - bufferInfo: MediaCodec.BufferInfo) { + override fun writeSamples( + trackIndex: Int, byteBuffer: ByteBuffer, + bufferInfo: MediaCodec.BufferInfo + ) { if (!isStarted) { throw IllegalStateException("Container not started") } else if (track < 0) { @@ -105,7 +107,8 @@ class FlacContainer(private val fd: FileDescriptor) : Container { // Validate the magic if (ByteBuffer.wrap(buf.asByteArray(), 0, 4) != - ByteBuffer.wrap(FLAC_MAGIC.asByteArray())) { + ByteBuffer.wrap(FLAC_MAGIC.asByteArray()) + ) { throw IOException("FLAC magic not found") } @@ -150,4 +153,4 @@ class FlacContainer(private val fd: FileDescriptor) : Container { private val TAG = FlacContainer::class.java.simpleName private val FLAC_MAGIC = ubyteArrayOf(0x66u, 0x4cu, 0x61u, 0x43u) // fLaC } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt b/app/src/main/kotlin/com/patrykmis/bar/format/FlacFormat.kt similarity index 96% rename from app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/FlacFormat.kt index d49179d24..dd3486b69 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/FlacFormat.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaFormat import java.io.FileDescriptor @@ -27,4 +27,4 @@ object FlacFormat : Format() { override fun getContainer(fd: FileDescriptor): Container = FlacContainer(fd) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/Format.kt b/app/src/main/kotlin/com/patrykmis/bar/format/Format.kt similarity index 93% rename from app/src/main/java/com/chiller3/bcr/format/Format.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/Format.kt index 154b56637..735339b87 100644 --- a/app/src/main/java/com/chiller3/bcr/format/Format.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/Format.kt @@ -1,9 +1,8 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.AudioFormat import android.media.MediaFormat -import com.chiller3.bcr.Preferences -import com.chiller3.bcr.frameSizeInBytesCompat +import com.patrykmis.bar.Preferences import java.io.FileDescriptor sealed class Format { @@ -48,7 +47,7 @@ sealed class Format { setString(MediaFormat.KEY_MIME, mimeTypeAudio) setInteger(MediaFormat.KEY_CHANNEL_COUNT, audioFormat.channelCount) setInteger(MediaFormat.KEY_SAMPLE_RATE, audioFormat.sampleRate) - setInteger(KEY_X_FRAME_SIZE_IN_BYTES, audioFormat.frameSizeInBytesCompat) + setInteger(KEY_X_FRAME_SIZE_IN_BYTES, audioFormat.frameSizeInBytes) } updateMediaFormat(format, param ?: paramInfo.default) @@ -104,7 +103,13 @@ sealed class Format { // Use the saved format if it is valid and supported on the current device. Otherwise, fall // back to the default. val format = prefs.format - ?.let { if (it.supported) { it } else { null } } + ?.let { + if (it.supported) { + it + } else { + null + } + } ?: default // Convert the saved value to the nearest valid value (eg. in case bitrate range or step @@ -116,4 +121,4 @@ sealed class Format { return Pair(format, param) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt b/app/src/main/kotlin/com/patrykmis/bar/format/FormatParamInfo.kt similarity index 89% rename from app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/FormatParamInfo.kt index 02f8d3672..45c1d854c 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/FormatParamInfo.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format sealed class FormatParamInfo(val default: UInt) { /** @@ -32,8 +32,10 @@ class RangedParamInfo( ) : FormatParamInfo(default) { override fun validate(param: UInt) { if (param !in range) { - throw IllegalArgumentException("Parameter ${format(param)} is not in the range: " + - "[${format(range.first)}, ${format(range.last)}]") + throw IllegalArgumentException( + "Parameter ${format(param)} is not in the range: " + + "[${format(range.first)}, ${format(range.last)}]" + ) } } @@ -74,4 +76,4 @@ object NoParamInfo : FormatParamInfo(0u) { override fun toNearest(param: UInt): UInt = param override fun format(param: UInt): String = "" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt b/app/src/main/kotlin/com/patrykmis/bar/format/MediaCodecEncoder.kt similarity index 89% rename from app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/MediaCodecEncoder.kt index 54ef0d35d..624ab3dd5 100644 --- a/app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/MediaCodecEncoder.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaCodec import android.media.MediaCodecList @@ -41,7 +41,8 @@ class MediaCodecEncoder( if (inputBufferId >= 0) { val inputBuffer = codec.getInputBuffer(inputBufferId)!! // Maximum non-overflowing buffer size that is a multiple of the frame size - val toCopy = min(buffer.remaining(), inputBuffer.remaining()) / frameSize * frameSize + val toCopy = + min(buffer.remaining(), inputBuffer.remaining()) / frameSize * frameSize // Temporarily change buffer limit to avoid overflow val oldLimit = buffer.limit() @@ -76,7 +77,11 @@ class MediaCodecEncoder( /** Flush [MediaCodec]'s pending encoded data to [container]. */ private fun flush(waitForever: Boolean) { while (true) { - val timeout = if (waitForever) { -1 } else { TIMEOUT } + val timeout = if (waitForever) { + -1 + } else { + TIMEOUT + } val outputBufferId = codec.dequeueOutputBuffer(bufferInfo, timeout) if (outputBufferId >= 0) { val buffer = codec.getOutputBuffer(outputBufferId)!! @@ -109,8 +114,9 @@ class MediaCodecEncoder( private const val TIMEOUT = 500L fun createCodec(mediaFormat: MediaFormat): MediaCodec { - val encoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(mediaFormat) - ?: throw Exception("No suitable encoder found for $mediaFormat") + val encoder = + MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(mediaFormat) + ?: throw Exception("No suitable encoder found for $mediaFormat") Log.d(TAG, "Audio encoder: $encoder") val codec = MediaCodec.createByCodecName(encoder) @@ -125,4 +131,4 @@ class MediaCodecEncoder( return codec } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/MediaMuxerContainer.kt b/app/src/main/kotlin/com/patrykmis/bar/format/MediaMuxerContainer.kt similarity index 83% rename from app/src/main/java/com/chiller3/bcr/format/MediaMuxerContainer.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/MediaMuxerContainer.kt index 87fc313c0..c8a6d4b7c 100644 --- a/app/src/main/java/com/chiller3/bcr/format/MediaMuxerContainer.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/MediaMuxerContainer.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaCodec import android.media.MediaFormat @@ -30,7 +30,9 @@ class MediaMuxerContainer( override fun addTrack(mediaFormat: MediaFormat): Int = muxer.addTrack(mediaFormat) - override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, - bufferInfo: MediaCodec.BufferInfo) = + override fun writeSamples( + trackIndex: Int, byteBuffer: ByteBuffer, + bufferInfo: MediaCodec.BufferInfo + ) = muxer.writeSampleData(trackIndex, byteBuffer, bufferInfo) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt b/app/src/main/kotlin/com/patrykmis/bar/format/OpusFormat.kt similarity index 72% rename from app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/OpusFormat.kt index 2dbfc97f1..7fea26a75 100644 --- a/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/OpusFormat.kt @@ -1,9 +1,7 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaFormat import android.media.MediaMuxer -import android.os.Build -import androidx.annotation.RequiresApi import java.io.FileDescriptor object OpusFormat : Format() { @@ -16,20 +14,19 @@ object OpusFormat : Format() { // https://wiki.hydrogenaud.io/index.php?title=Opus 48_000u, ) + // https://datatracker.ietf.org/doc/html/rfc7845#section-9 override val mimeTypeContainer: String = "audio/ogg" override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_OPUS override val passthrough: Boolean = false - override val supported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + override val supported: Boolean = true override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { mediaFormat.apply { - val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) - setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) + setInteger(MediaFormat.KEY_BIT_RATE, param.toInt()) } } - @RequiresApi(Build.VERSION_CODES.Q) override fun getContainer(fd: FileDescriptor): Container = MediaMuxerContainer(fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/PassthroughEncoder.kt b/app/src/main/kotlin/com/patrykmis/bar/format/PassthroughEncoder.kt similarity index 95% rename from app/src/main/java/com/chiller3/bcr/format/PassthroughEncoder.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/PassthroughEncoder.kt index 7d800a666..46bc1832e 100644 --- a/app/src/main/java/com/chiller3/bcr/format/PassthroughEncoder.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/PassthroughEncoder.kt @@ -1,8 +1,7 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaCodec import android.media.MediaFormat -import java.lang.IllegalStateException import java.nio.ByteBuffer /** @@ -63,4 +62,4 @@ class PassthroughEncoder( numFrames += frames } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/SampleRate.kt b/app/src/main/kotlin/com/patrykmis/bar/format/SampleRate.kt similarity index 68% rename from app/src/main/java/com/chiller3/bcr/format/SampleRate.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/SampleRate.kt index 54377e01f..4295375e2 100644 --- a/app/src/main/java/com/chiller3/bcr/format/SampleRate.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/SampleRate.kt @@ -1,6 +1,7 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format -import com.chiller3.bcr.Preferences +import com.patrykmis.bar.Preferences +import com.patrykmis.bar.format.SampleRate.Companion.default @JvmInline value class SampleRate(val value: UInt) { @@ -19,24 +20,29 @@ value class SampleRate(val value: UInt) { SampleRate(12_000u), SampleRate(16_000u), SampleRate(24_000u), - SampleRate(48_000u), + SampleRate(44_100u), + SampleRate(48_000u) ) val default = all.last() /** * Get the saved sample rate from the preferences. * - * If the saved sample rate is no longer valid or no sample rate is selected, then [default] - * is returned. + * If the saved sample rate is no longer valid or no sample rate is selected; or + * saved format is "OGG/Opus" and saved sample rate is 44.1 kHz + * then [default] is returned. */ fun fromPreferences(prefs: Preferences): SampleRate { val savedSampleRate = prefs.sampleRate + val savedFormat = prefs.format - if (savedSampleRate != null && all.contains(savedSampleRate)) { + if (savedSampleRate != null && all.contains(savedSampleRate) && + !(savedFormat?.name == "OGG/Opus" && savedSampleRate == SampleRate(44_100u)) + ) { return savedSampleRate } return default } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt b/app/src/main/kotlin/com/patrykmis/bar/format/WaveContainer.kt similarity index 95% rename from app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/WaveContainer.kt index 70fa1922a..1c857c2a5 100644 --- a/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/WaveContainer.kt @@ -1,7 +1,7 @@ @file:Suppress("OPT_IN_IS_NOT_ENABLED") @file:OptIn(ExperimentalUnsignedTypes::class) -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaCodec import android.media.MediaFormat @@ -67,8 +67,10 @@ class WaveContainer(private val fd: FileDescriptor) : Container { return track } - override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, - bufferInfo: MediaCodec.BufferInfo) { + override fun writeSamples( + trackIndex: Int, byteBuffer: ByteBuffer, + bufferInfo: MediaCodec.BufferInfo + ) { if (!isStarted) { throw IllegalStateException("Container not started") } else if (track < 0) { @@ -129,4 +131,4 @@ class WaveContainer(private val fd: FileDescriptor) : Container { private val FMT_MAGIC = ubyteArrayOf(0x66u, 0x6du, 0x74u, 0x20u) // "fmt " private val DATA_MAGIC = ubyteArrayOf(0x64u, 0x61u, 0x74u, 0x61u) // data } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt b/app/src/main/kotlin/com/patrykmis/bar/format/WaveFormat.kt similarity index 96% rename from app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt rename to app/src/main/kotlin/com/patrykmis/bar/format/WaveFormat.kt index 4bb2cca1e..467f908d4 100644 --- a/app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/format/WaveFormat.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.format +package com.patrykmis.bar.format import android.media.MediaFormat import java.io.FileDescriptor @@ -6,6 +6,7 @@ import java.io.FileDescriptor object WaveFormat : Format() { override val name: String = "WAV/PCM" override val paramInfo: FormatParamInfo = NoParamInfo + // Should be "audio/vnd.wave" [1], but Android only recognizes "audio/x-wav" [2] for the // purpose of picking an appropriate file extension when creating a file via SAF. // [1] https://datatracker.ietf.org/doc/html/rfc2361 @@ -21,4 +22,4 @@ object WaveFormat : Format() { override fun getContainer(fd: FileDescriptor): Container = WaveContainer(fd) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/OutputDirUtils.kt b/app/src/main/kotlin/com/patrykmis/bar/output/OutputDirUtils.kt similarity index 95% rename from app/src/main/java/com/chiller3/bcr/OutputDirUtils.kt rename to app/src/main/kotlin/com/patrykmis/bar/output/OutputDirUtils.kt index b7a58a68a..73493ddc0 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputDirUtils.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/output/OutputDirUtils.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar.output import android.content.Context import android.net.Uri @@ -8,6 +8,7 @@ import android.system.Os import android.system.OsConstants import android.util.Log import androidx.documentfile.provider.DocumentFile +import com.patrykmis.bar.Preferences import java.io.IOException class OutputDirUtils(private val context: Context, private val redactor: Redactor) { @@ -56,7 +57,8 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto while (remain > 0) { val ret = Os.sendfile( - targetPfd.fileDescriptor, sourcePfd.fileDescriptor, offset, remain) + targetPfd.fileDescriptor, sourcePfd.fileDescriptor, offset, remain + ) if (ret == 0L) { throw IOException("Unexpected EOF in sendfile()") } @@ -110,7 +112,11 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto * @throws IOException if [file] cannot be opened */ fun openFile(file: DocumentFile, truncate: Boolean): ParcelFileDescriptor { - val truncParam = if (truncate) { "t" } else { "" } + val truncParam = if (truncate) { + "t" + } else { + "" + } return context.contentResolver.openFileDescriptor(file.uri, "rw$truncParam") ?: throw IOException("Failed to open file at ${file.uri}") } @@ -130,4 +136,4 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto fun redact(uri: Uri): String } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/OutputFile.kt b/app/src/main/kotlin/com/patrykmis/bar/output/OutputFile.kt similarity index 96% rename from app/src/main/java/com/chiller3/bcr/OutputFile.kt rename to app/src/main/kotlin/com/patrykmis/bar/output/OutputFile.kt index 7bd134e03..390c2f369 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputFile.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/output/OutputFile.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar.output import android.content.ContentResolver import android.content.Context diff --git a/app/src/main/java/com/chiller3/bcr/Retention.kt b/app/src/main/kotlin/com/patrykmis/bar/output/Retention.kt similarity index 91% rename from app/src/main/java/com/chiller3/bcr/Retention.kt rename to app/src/main/kotlin/com/patrykmis/bar/output/Retention.kt index c2a3afde3..12a0de228 100644 --- a/app/src/main/java/com/chiller3/bcr/Retention.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/output/Retention.kt @@ -1,6 +1,9 @@ -package com.chiller3.bcr +package com.patrykmis.bar.output import android.content.Context +import com.patrykmis.bar.Preferences +import com.patrykmis.bar.R +import com.patrykmis.bar.output.Retention.Companion.default import java.time.Duration sealed interface Retention { diff --git a/app/src/main/java/com/chiller3/bcr/OutputDirectoryBottomSheetFragment.kt b/app/src/main/kotlin/com/patrykmis/bar/settings/OutputDirectoryBottomSheetFragment.kt similarity index 89% rename from app/src/main/java/com/chiller3/bcr/OutputDirectoryBottomSheetFragment.kt rename to app/src/main/kotlin/com/patrykmis/bar/settings/OutputDirectoryBottomSheetFragment.kt index 53077d564..6d6a47705 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputDirectoryBottomSheetFragment.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/settings/OutputDirectoryBottomSheetFragment.kt @@ -1,12 +1,16 @@ -package com.chiller3.bcr +package com.patrykmis.bar.settings import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.chiller3.bcr.databinding.OutputDirectoryBottomSheetBinding import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.slider.Slider +import com.patrykmis.bar.OpenPersistentDocumentTree +import com.patrykmis.bar.Preferences +import com.patrykmis.bar.databinding.OutputDirectoryBottomSheetBinding +import com.patrykmis.bar.extension.formattedString +import com.patrykmis.bar.output.Retention class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment(), Slider.OnChangeListener { private var _binding: OutputDirectoryBottomSheetBinding? = null @@ -78,4 +82,4 @@ class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment(), Slider.O companion object { val TAG: String = OutputDirectoryBottomSheetFragment::class.java.simpleName } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt b/app/src/main/kotlin/com/patrykmis/bar/settings/OutputFormatBottomSheetFragment.kt similarity index 75% rename from app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt rename to app/src/main/kotlin/com/patrykmis/bar/settings/OutputFormatBottomSheetFragment.kt index 4dfc15aa6..523af76fe 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/settings/OutputFormatBottomSheetFragment.kt @@ -1,16 +1,24 @@ -package com.chiller3.bcr +package com.patrykmis.bar.settings import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible -import com.chiller3.bcr.databinding.BottomSheetChipBinding -import com.chiller3.bcr.databinding.OutputFormatBottomSheetBinding -import com.chiller3.bcr.format.* import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.chip.ChipGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.slider.Slider +import com.patrykmis.bar.Preferences +import com.patrykmis.bar.R +import com.patrykmis.bar.databinding.BottomSheetChipBinding +import com.patrykmis.bar.databinding.OutputFormatBottomSheetBinding +import com.patrykmis.bar.format.Format +import com.patrykmis.bar.format.FormatParamInfo +import com.patrykmis.bar.format.NoParamInfo +import com.patrykmis.bar.format.RangedParamInfo +import com.patrykmis.bar.format.RangedParamType +import com.patrykmis.bar.format.SampleRate class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), ChipGroup.OnCheckedStateChangeListener, Slider.OnChangeListener, View.OnClickListener { @@ -72,7 +80,8 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), private fun addFormatChip(inflater: LayoutInflater, format: Format) { val chipBinding = BottomSheetChipBinding.inflate( - inflater, binding.nameGroup, false) + inflater, binding.nameGroup, false + ) val id = View.generateViewId() chipBinding.root.id = id chipBinding.root.text = format.name @@ -84,7 +93,8 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), private fun addSampleRateChip(inflater: LayoutInflater, sampleRate: SampleRate) { val chipBinding = BottomSheetChipBinding.inflate( - inflater, binding.sampleRateGroup, false) + inflater, binding.sampleRateGroup, false + ) val id = View.generateViewId() chipBinding.root.id = id chipBinding.root.text = sampleRate.toString() @@ -120,30 +130,54 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), is RangedParamInfo -> { binding.paramGroup.isVisible = true - binding.paramTitle.setText(when (info.type) { - RangedParamType.CompressionLevel -> R.string.output_format_bottom_sheet_compression_level - RangedParamType.Bitrate -> R.string.output_format_bottom_sheet_bitrate - }) + binding.paramTitle.setText( + when (info.type) { + RangedParamType.CompressionLevel -> R.string.output_format_bottom_sheet_compression_level + RangedParamType.Bitrate -> R.string.output_format_bottom_sheet_bitrate + } + ) binding.paramSlider.valueFrom = info.range.first.toFloat() binding.paramSlider.valueTo = info.range.last.toFloat() binding.paramSlider.stepSize = info.stepSize.toFloat() binding.paramSlider.value = (param ?: info.default).toFloat() } + NoParamInfo -> { binding.paramGroup.isVisible = false } } } + private fun checkSampleRateAndShowDialogIfNeeded(sampleRate: SampleRate?) { + if (prefs.format?.name == "OGG/Opus" && sampleRate?.value == 44_100u) { + prefs.sampleRate = SampleRate(48_000u) + showUnsupportedSampleRateDialog() + } else { + prefs.sampleRate = sampleRate + } + } + + private fun showUnsupportedSampleRateDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(getString(R.string.unsupported_sample_rate)) + .setNeutralButton(getString(android.R.string.ok)) { _, _ -> + refreshSampleRate() + } + .show() + } + override fun onCheckedChanged(group: ChipGroup, checkedIds: MutableList) { when (group) { binding.nameGroup -> { prefs.format = chipIdToFormat[checkedIds.first()]!! refreshParam() + checkSampleRateAndShowDialogIfNeeded(prefs.sampleRate) } + binding.sampleRateGroup -> { prefs.sampleRate = chipIdToSampleRate[checkedIds.first()] + checkSampleRateAndShowDialogIfNeeded(prefs.sampleRate) } } } @@ -173,4 +207,4 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), companion object { val TAG: String = OutputFormatBottomSheetFragment::class.java.simpleName } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/patrykmis/bar/settings/SettingsActivity.kt b/app/src/main/kotlin/com/patrykmis/bar/settings/SettingsActivity.kt new file mode 100644 index 000000000..28c9a9447 --- /dev/null +++ b/app/src/main/kotlin/com/patrykmis/bar/settings/SettingsActivity.kt @@ -0,0 +1,22 @@ +package com.patrykmis.bar.settings + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.patrykmis.bar.R + +class SettingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.settings_activity) + if (savedInstanceState == null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, SettingsFragment()) + .commit() + } + + setSupportActionBar(findViewById(R.id.toolbar)) + + setTitle(R.string.app_name_full) + } +} diff --git a/app/src/main/kotlin/com/patrykmis/bar/settings/SettingsFragment.kt b/app/src/main/kotlin/com/patrykmis/bar/settings/SettingsFragment.kt new file mode 100644 index 000000000..1f84f92d0 --- /dev/null +++ b/app/src/main/kotlin/com/patrykmis/bar/settings/SettingsFragment.kt @@ -0,0 +1,215 @@ +package com.patrykmis.bar.settings + +import android.content.SharedPreferences +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import com.patrykmis.bar.BuildConfig +import com.patrykmis.bar.Permissions +import com.patrykmis.bar.Preferences +import com.patrykmis.bar.R +import com.patrykmis.bar.extension.formattedString +import com.patrykmis.bar.format.Format +import com.patrykmis.bar.format.NoParamInfo +import com.patrykmis.bar.format.RangedParamInfo +import com.patrykmis.bar.format.SampleRate +import com.patrykmis.bar.output.Retention +import com.patrykmis.bar.view.LongClickablePreference + +class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChangeListener, + Preference.OnPreferenceClickListener, LongClickablePreference.OnPreferenceLongClickListener, + SharedPreferences.OnSharedPreferenceChangeListener { + private lateinit var prefs: Preferences + private lateinit var prefCallRecording: SwitchPreferenceCompat + private lateinit var prefOutputDir: Preference + private lateinit var prefOutputFormat: Preference + private lateinit var prefInhibitBatteryOpt: SwitchPreferenceCompat + private lateinit var prefVersion: LongClickablePreference + + private val requestPermissionRequired = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> + // Call recording can still be enabled if optional permissions were not granted + if (granted.all { it.key !in Permissions.REQUIRED || it.value }) { + prefCallRecording.isChecked = true + } else { + startActivity(Permissions.getAppInfoIntent(requireContext())) + } + } + private val requestInhibitBatteryOpt = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + refreshInhibitBatteryOptState() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.root_preferences, rootKey) + + val context = requireContext() + + prefs = Preferences(context) + + // If the desired state is enabled, set to disabled if runtime permissions have been + // denied. The user will have to grant permissions again to re-enable the features. + + prefCallRecording = findPreference(Preferences.PREF_CALL_RECORDING)!! + if (prefCallRecording.isChecked && !Permissions.haveRequired(context)) { + prefCallRecording.isChecked = false + } + prefCallRecording.onPreferenceChangeListener = this + + prefOutputDir = findPreference(Preferences.PREF_OUTPUT_DIR)!! + prefOutputDir.onPreferenceClickListener = this + refreshOutputDir() + + prefOutputFormat = findPreference(Preferences.PREF_OUTPUT_FORMAT)!! + prefOutputFormat.onPreferenceClickListener = this + refreshOutputFormat() + + prefInhibitBatteryOpt = findPreference(Preferences.PREF_INHIBIT_BATT_OPT)!! + prefInhibitBatteryOpt.onPreferenceChangeListener = this + + prefVersion = findPreference(Preferences.PREF_VERSION)!! + prefVersion.onPreferenceClickListener = this + prefVersion.onPreferenceLongClickListener = this + refreshVersion() + } + + override fun onStart() { + super.onStart() + + preferenceScreen.sharedPreferences!!.registerOnSharedPreferenceChangeListener(this) + + // Changing the battery state does not cause a reload of the activity + refreshInhibitBatteryOptState() + } + + override fun onStop() { + super.onStop() + + preferenceScreen.sharedPreferences!!.unregisterOnSharedPreferenceChangeListener(this) + } + + private fun refreshOutputDir() { + val context = requireContext() + val outputDirUri = prefs.outputDirOrDefault + val outputRetention = Retention.fromPreferences(prefs).toFormattedString(context) + + val summary = getString(R.string.pref_output_dir_desc) + prefOutputDir.summary = + "${summary}\n\n${outputDirUri.formattedString} (${outputRetention})" + } + + private fun refreshOutputFormat() { + val (format, formatParamSaved) = Format.fromPreferences(prefs) + val formatParam = formatParamSaved ?: format.paramInfo.default + val summary = getString(R.string.pref_output_format_desc) + val prefix = when (val info = format.paramInfo) { + is RangedParamInfo -> "${info.format(formatParam)}, " + NoParamInfo -> "" + } + val sampleRate = SampleRate.fromPreferences(prefs) + + prefOutputFormat.summary = "${summary}\n\n${format.name} (${prefix}${sampleRate})" + } + + private fun refreshVersion() { + val suffix = if (prefs.isDebugMode) { + "+debugmode" + } else { + "" + } + prefVersion.summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.BUILD_TYPE}${suffix})" + } + + private fun refreshInhibitBatteryOptState() { + val inhibiting = Permissions.isInhibitingBatteryOpt(requireContext()) + prefInhibitBatteryOpt.isChecked = inhibiting + prefInhibitBatteryOpt.isEnabled = !inhibiting + } + + override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { + // No need to validate runtime permissions when disabling a feature + if (newValue == false) { + return true + } + + val context = requireContext() + + when (preference) { + prefCallRecording -> if (Permissions.haveRequired(context)) { + return true + } else { + // Ask for optional permissions the first time only + requestPermissionRequired.launch(Permissions.REQUIRED) + } + // This is only reachable if battery optimization is not already inhibited + prefInhibitBatteryOpt -> requestInhibitBatteryOpt.launch( + Permissions.getInhibitBatteryOptIntent(requireContext()) + ) + } + + return false + } + + override fun onPreferenceClick(preference: Preference): Boolean { + when (preference) { + prefOutputDir -> { + OutputDirectoryBottomSheetFragment().show( + childFragmentManager, OutputDirectoryBottomSheetFragment.TAG + ) + return true + } + + prefOutputFormat -> { + OutputFormatBottomSheetFragment().show( + childFragmentManager, OutputFormatBottomSheetFragment.TAG + ) + return true + } + + prefVersion -> { + // val uri = Uri.parse(BuildConfig.PROJECT_URL_AT_COMMIT) + // startActivity(Intent(Intent.ACTION_VIEW, uri)) + return true + } + } + + return false + } + + override fun onPreferenceLongClick(preference: Preference): Boolean { + when (preference) { + prefVersion -> { + prefs.isDebugMode = !prefs.isDebugMode + refreshVersion() + return true + } + } + + return false + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { + when { + key == null -> return + // Update the switch state if it was toggled outside of the preference (eg. from the + // quick settings toggle) + key == prefCallRecording.key -> { + val current = prefCallRecording.isChecked + val expected = sharedPreferences.getBoolean(key, current) + if (current != expected) { + prefCallRecording.isChecked = expected + } + } + // Update the output directory state when it's changed by the bottom sheet + key == Preferences.PREF_OUTPUT_DIR || key == Preferences.PREF_OUTPUT_RETENTION -> { + refreshOutputDir() + } + // Update the output format state when it's changed by the bottom sheet + Preferences.isFormatKey(key) || key == Preferences.PREF_SAMPLE_RATE -> { + refreshOutputFormat() + } + } + } +} diff --git a/app/src/main/java/com/chiller3/bcr/LongClickablePreference.kt b/app/src/main/kotlin/com/patrykmis/bar/view/LongClickablePreference.kt similarity index 95% rename from app/src/main/java/com/chiller3/bcr/LongClickablePreference.kt rename to app/src/main/kotlin/com/patrykmis/bar/view/LongClickablePreference.kt index 7deda4b09..714f8d44e 100644 --- a/app/src/main/java/com/chiller3/bcr/LongClickablePreference.kt +++ b/app/src/main/kotlin/com/patrykmis/bar/view/LongClickablePreference.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr +package com.patrykmis.bar.view import android.content.Context import android.util.AttributeSet @@ -22,4 +22,4 @@ class LongClickablePreference(context: Context, attrs: AttributeSet?) : Preferen interface OnPreferenceLongClickListener { fun onPreferenceLongClick(preference: Preference): Boolean } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/bottom_sheet_chip.xml b/app/src/main/res/layout/bottom_sheet_chip.xml index 4de5838e7..737446f41 100644 --- a/app/src/main/res/layout/bottom_sheet_chip.xml +++ b/app/src/main/res/layout/bottom_sheet_chip.xml @@ -2,4 +2,4 @@ \ No newline at end of file + android:layout_height="wrap_content" /> diff --git a/app/src/main/res/layout/output_directory_bottom_sheet.xml b/app/src/main/res/layout/output_directory_bottom_sheet.xml index c05e0045a..30bd1bb7d 100644 --- a/app/src/main/res/layout/output_directory_bottom_sheet.xml +++ b/app/src/main/res/layout/output_directory_bottom_sheet.xml @@ -7,8 +7,8 @@ + android:text="@string/output_dir_bottom_sheet_change_dir" /> + android:text="@string/bottom_sheet_reset" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/output_format_bottom_sheet.xml b/app/src/main/res/layout/output_format_bottom_sheet.xml index 40a74fb87..f2105274b 100644 --- a/app/src/main/res/layout/output_format_bottom_sheet.xml +++ b/app/src/main/res/layout/output_format_bottom_sheet.xml @@ -7,8 +7,8 @@ - + android:gravity="center_horizontal" + android:orientation="vertical"> + - + android:text="@string/bottom_sheet_reset" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml index 24ed6e468..ca2eb91f3 100644 --- a/app/src/main/res/layout/settings_activity.xml +++ b/app/src/main/res/layout/settings_activity.xml @@ -12,4 +12,4 @@ android:id="@+id/settings" android:layout_width="match_parent" android:layout_height="match_parent" /> - \ No newline at end of file + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml index 6f3b755bf..b3e26b4c6 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml index 6f3b755bf..b3e26b4c6 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/raw/filename_template.properties b/app/src/main/res/raw/filename_template.properties index dd4deb3e9..56a26e94b 100644 --- a/app/src/main/res/raw/filename_template.properties +++ b/app/src/main/res/raw/filename_template.properties @@ -1,5 +1,5 @@ -# This file specifies the filename template for BCR's output files. To change -# the default filename template, copy this file to `bcr.properties` in the +# This file specifies the filename template for BAR's output files. To change +# the default filename template, copy this file to `bar.properties` in the # output directory and edit it to your liking. # # Syntax/rules: @@ -23,9 +23,9 @@ # value is added to the end. # # Troubleshooting: -# If there is a syntax error, BCR will ignore the custom template and fall +# If there is a syntax error, BAR will ignore the custom template and fall # back to the default. To find out more details, enable debug mode by long -# pressing BCR's version number. After the next phone call, BCR will create a +# pressing BAR's version number. BAR will create a # log file in the output directory. Search for `FilenameTemplate` in the log # file. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4b7955bb4..fc8f298fd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,6 +1,7 @@ + - BCR - Basic Call Recorder + BAR + Basic Audio Recorder General @@ -10,10 +11,6 @@ Grabacion de llamada Grabar llamadas telefónicas entrantes y salientes. Se requieren permisos de micrófono y notificación para grabar en segundo plano. - Iniciar pausado - La grabación se pausará después de comenzar. Si no lo reanuda con el botón en la notificación, el archivo de salida vacío no se guardará. - La llamada se grabará inmediatamente después de conectarse. Para pausar y reanudar, toque el botón en la notificación que se muestra durante una llamada. - Directorio de salida Elija un directorio para almacenar grabaciones. @@ -50,10 +47,6 @@ Alertas de errores durante la grabación de llamadas Alertas de éxito Alertas para grabaciones de llamadas exitosas - Grabación de llamadas en curso - Grabación de llamadas en pausa - No se pudo grabar la llamada - Llamada grabada con éxito 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. Abrir Compartir @@ -62,5 +55,4 @@ Reanudar - Grabación de llamada diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index bcc95ecab..4cf6c136c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,6 +1,7 @@ + - BCR - Basic Call Recorder + BAR + Basic Audio Recorder Général @@ -43,9 +44,6 @@ Service d\'arrière-plan Notification persistante pour l\'enregistrement en arrière-plan Alertes d\'erreurs pendant l\'enregistrement - Enregistrement d\'appel en cours - Enregistrement d\'appel échoué - Enregsitrement d\'appel diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index d57bba61f..e43aba701 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,6 +1,7 @@ - - BCR - מקליט שיחות בסיסי + + + BAR + מקליט שמע בסיסי כללי @@ -10,10 +11,6 @@ הקלטת שיחה הקלט שיחות יוצאות ונכנסות. חובה לאפשר הרשאות למיקרופון ולהתראות, מומלץ לאפשר גישה לטלפון ולאנשי הקשר על מנת שקבצי ההקלטות יישמרו עם נתוני השיחה - עצור הקלטה - ההקלטה לא תפעל בתחילתה. אם לא תפעיל אותה באמצעות הכפתור בהודעה, קובץ ההקלטה הריק לא ישמר - השיחה תוקלט מיד בתחילת שיחה. כדי להשהות ולהמשיך, לחץ על הכפתור בהתראה המוצגת במהלך שיחה - מיקום האחסון בחר תיקיה לאחסון הקלטות @@ -50,10 +47,6 @@ התראות על שגיאות במהלך הקלטת שיחה התראות סיום הקלטה בהצלחה התראות הקלטות שיחות שהסתיימו בהצלחה - הקלטת שיחה מתבצעת - הקלטת שיחה מושהית - הקלטת שיחה נכשלה - השיחה הוקלטה בהצלחה ההקלטה נכשלה ברכיב Android פנימי (%s). ייתכן שהמכשיר או הקושחה אינם תומכים בהקלטת שיחות פתיחה שיתוף @@ -62,5 +55,4 @@ המשך - הקלטת שיחה - \ No newline at end of file + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 474ad4bd2..33e7adba4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,6 +1,7 @@ + - BCR - Podstawowy rejestrator rozmów + BAR + Podstawowy rejestrator dźwięku Ogólne @@ -10,10 +11,6 @@ Nagrywanie rozmów Nagrywaj przychodzące i wychodzące rozmowy telefoniczne. Do nagrywania w tle wymagane są uprawnienia do mikrofonu i powiadomień. - Domyślnie wstrzymane - Nagrywanie zostanie wstrzymane po uruchomieniu. Jeżeli nie wznowisz go przyciskiem w powiadomieniu wyświetlanym w trakcie rozmowy, pusty plik wyjściowy nie zostanie zapisany. - Rozmowa będzie nagrywana natychmiast po rozpoczęciu. Możesz wstrzymać i wznowić nagrywanie przyciskiem w powiadomieniu wyświetlanym w trakcie rozmowy. - Katalog wyjściowy Wybierz katalog do przechowywania nagrań. @@ -42,7 +39,7 @@ Poziom kompresji Szybkość transmisji Częstotliwość próbkowania - + Przywróć domyślne ustawienia @@ -52,12 +49,8 @@ Informacje o błędach podczas nagrywania rozmów Zakończone nagrywanie Informacje o pomyślnym nagraniu rozmowy - Trwa nagrywanie rozmowy Trwa nagrywanie dźwięku - Nagrywanie rozmowy wstrzymane - Nie udało się nagrać rozmowy Nie udało się nagrać dźwięku - Pomyślnie nagrano rozmowę Pomyślnie nagrano dźwięk Podczas nagrywania wystąpił błąd w wewnętrznym komponencie Androida (%s). To urządzenie lub system może nie obsługiwać nagrywania rozmów. Otwórz @@ -67,6 +60,7 @@ Wznów - Nagrywanie rozmów Nagrywanie dźwięku + + Ta częstotliwość próbkowania nie jest obsługiwana przez wybrany format. Ustawiono na domyślną 48000 Hz. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cf79fab4e..dc6d6f7e6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,6 +1,7 @@ + - BCR - Basic Call Recorder + BAR + Basic Audio Recorder Основное @@ -10,10 +11,6 @@ Запись звонков Запись входящих и исходящих телефонных звонков. Для записи в фоновом режиме требуются разрешения на использование микрофона и уведомлений. - Остановить запись - Запись вызова будет приостановлена после запуска. Если вы не возобновите его с помощью кнопки в шторке уведомлений, отображаемой во время разговора, пустой файл сохранен не будет. - Запись вызова начнется автоматически, сразу после подключения. Чтобы сделать паузу и возобновить, нажмите на кнопку в шторке уведомлений, отображаемой во время разговора. - Папка для сохранения Выберите папку, в которой будут храниться записи. @@ -47,21 +44,16 @@ Фоновые сервисы Постоянное уведомление для записи вызовов в фоновом режиме - Происходит запись вызова Оповещение об ошибках во время записи разговора - Не удалось записать звонок Открыть Поделиться Удалить Предупреждение о сбое Оповещение об успешном выполнении Оповещение об успешной записи звонков - Запись звонков приостановлена - Успешно записанный звонок Сбой записи во внутреннем компоненте Android (%s). Это устройство или прошивка может не поддерживает запись вызовов. Пауза Возобновить - Запись звонков diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 731cbd49c..8ecfd137d 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -1,6 +1,7 @@ - - BCR - Základný nahrávač hovorov + + + BAR + Základný zvukový záznamník Všeobecné @@ -10,10 +11,6 @@ Nahrávanie hovorov Nahráva prichádzajúce a odchádzajúce hovory. Aby nahrávanie fungovalo na pozadí, je potrebné povoliť prístup k mikrofónu a upozorneniam. - Pôvodne pozastavené - Nahrávanie hovorov bude po spojení hovoru pozastavené. Ak nahrávanie nikdy nespustíte, prázdny súbor nebude uložený. - Nahrávanie sa automaticky spustí hneď po spojení hovoru. Pozastaviť a pokračovať v nahrávaní môžete klepnutím na tlačidlo v oznamovacej oblasti počas hovoru. - Priečinok s nahrávkami Vyberte priečinok, kde sa budú ukladať nahrávky. @@ -51,10 +48,6 @@ Upozornenia na chyby počas nahrávania Úspešné upozornenia Upozornenia na úspešne dokončené nahrávky hovorov - Nahrávanie hovoru - Nahrávanie hovoru pozastavené - Nahrávanie hovoru zlyhalo - Hovor úspešne nahratý Nahrávanie zlyhalo: interný komponent systému Android (%s). Toto zariadenie pravdepodobne nepodporuje nahrávanie hovorov. Otvoriť Zdieľať @@ -63,5 +56,4 @@ Pokračovať - Nahrávanie hovorov diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 40196f1f8..e1d2b6446 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,6 +1,7 @@ + - BCR - Basic Call Recorder + BAR + Basic Audio Recorder Genel @@ -42,12 +43,9 @@ Arka plan servisleri Arka plan çağrı kaydı için kalıcı bildirim Kayıt esnasındaki hatalar için uyarılar - Çağrı kaydediliyor - Çağrı kaydı başarısız - - Paylaş - Sil + + Paylaş + Sil - Çağrı kaydı diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index baed4710d..5a3e0d69e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -10,10 +10,7 @@ 通话录音 对来电和去电进行通话录音,录音时候需要麦克风和通知权限。 - 初始暂停 - 电话接通后不会启用录音功能,需要手动点击通知中心录音按钮才能开启录音。 - 电话接通后自动开启录音,如果需要暂停或者恢复录音,需要点击通知中心对应按钮。 保存目录 选择一个目录用来保存录音文件。 @@ -50,17 +47,12 @@ 录音发生错误通知 成功警告 通话录音完成通知 - 通话录音中 - 录音已暂停 - 通话录音失败 - 通话录音完成 通话录音在 Android 内部组件(%s)中失败。此设备或固件可能不支持通话录音。 打开 分享 删除 暂停 + 继续 - 继续 - 通话录音 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index eecd44133..e39848ad5 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -3,4 +3,4 @@ 28dp 16dp 28dp - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e2481543..2617886fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ - - BCR - Basic Call Recorder + + + BAR + Basic Audio Recorder General @@ -10,10 +11,6 @@ Call recording Record incoming and outgoing phone calls. Microphone and notification permissions are required for recording in the background. - Initially paused - Recording will be paused after starting. If you don\'t resume it using the button in the notification, the empty output file won\'t be saved. - Call will be recorded immediately after connecting. To pause and resume, tap on the button in the notification displayed during a call. - Output directory Pick a directory to store recordings. @@ -50,13 +47,9 @@ Alerts for errors during call recording Success alerts Alerts for successful call recordings - Call recording in progress Mic recording in progress - Call recording paused Mic recording paused - Failed to record call Failed to record mic - Successfully recorded call Successfully recorded mic The recording failed in an internal Android component (%s). This device or firmware might not support call recording. @@ -67,6 +60,7 @@ Resume - Call recording Mic recording + + This sample rate is unsupported by the chosen format. Defaulting to 48000 Hz. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2b08836c0..8b6a2fb24 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,8 +1,16 @@ - \ No newline at end of file + + + diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index d1463f9ba..b235bd268 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -1,50 +1,43 @@ + app:iconSpaceReserved="false" + app:title="@string/pref_header_general"> - - + app:title="@string/pref_call_recording_name" /> + app:title="@string/pref_output_dir_name" /> + app:title="@string/pref_output_format_name" /> + app:title="@string/pref_inhibit_batt_opt_name" /> + app:iconSpaceReserved="false" + app:title="@string/pref_header_about"> - + app:title="@string/pref_version_name" /> - \ No newline at end of file + diff --git a/app/src/release/AndroidManifest.xml b/app/src/release/AndroidManifest.xml new file mode 100644 index 000000000..1bc48b04e --- /dev/null +++ b/app/src/release/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/chiller3/bcr/ExampleUnitTest.kt b/app/src/test/java/com/chiller3/bcr/ExampleUnitTest.kt deleted file mode 100644 index 6bee23659..000000000 --- a/app/src/test/java/com/chiller3/bcr/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.chiller3.bcr - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8359086f6..762912648 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,4 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.0.2" apply false - id("org.jetbrains.kotlin.android") version "1.8.21" apply false -} - -task("clean") { - delete(rootProject.buildDir) + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..7b2831571 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,23 @@ +[versions] +androidx-activity = "1.12.4" +androidx-appcompat = "1.7.1" +androidx-core = "1.17.0" +androidx-documentfile = "1.1.0" +androidx-fragment = "1.8.9" +androidx-preference = "1.2.1" +androidGradlePlugin = "9.0.0" +kotlin = "2.3.10" +material = "1.13.0" + +[libraries] +androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidx-fragment" } +androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidx-preference" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c1962a79e..61285a659 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2c3425d49..414656493 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index aeb74cbb4..adff685a0 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -83,7 +85,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -111,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -130,10 +132,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. @@ -141,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -149,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -166,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -198,16 +202,15 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 6689b85be..e509b2dd8 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/settings.gradle.kts b/settings.gradle.kts index 0edfd6ea3..e5c68a099 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,5 +12,5 @@ dependencyResolutionManagement { mavenCentral() } } -rootProject.name = "BCR" +rootProject.name = "BAR" include(":app")