mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-19 07:15:25 +02:00
Compare commits
No commits in common. "master" and "v0.2.2" have entirely different histories.
9
.github/actions/prepare-keystore/action.yml
vendored
9
.github/actions/prepare-keystore/action.yml
vendored
@ -19,13 +19,16 @@ runs:
|
|||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Write Keystore file 🗄️
|
- name: Write Keystore file 🗄️
|
||||||
shell: bash
|
id: android_keystore
|
||||||
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
|
uses: timheuer/base64-to-file@v1.0.3
|
||||||
|
with:
|
||||||
|
fileName: key.jks
|
||||||
|
encodedString: ${{ inputs.keyStoreBase64 }}
|
||||||
|
|
||||||
- name: Write Keystore properties 🗝️
|
- name: Write Keystore properties 🗝️
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "storeFile=/home/runner/key.jks" > key.properties
|
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties
|
||||||
echo "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
|
echo "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
|
||||||
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
||||||
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties
|
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties
|
||||||
|
13
.github/workflows/build-testing.yaml
vendored
13
.github/workflows/build-testing.yaml
vendored
@ -7,15 +7,15 @@ jobs:
|
|||||||
debug-builds:
|
debug-builds:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: gradle/wrapper-validation-action@v2
|
- uses: gradle/wrapper-validation-action@v1
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
distribution: "adopt"
|
distribution: "adopt"
|
||||||
java-version: 21
|
java-version: 19
|
||||||
cache: "gradle"
|
cache: "gradle"
|
||||||
|
|
||||||
- name: Compile
|
- name: Compile
|
||||||
@ -23,7 +23,6 @@ jobs:
|
|||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: alibi-app-debug-apks
|
path: app/build/outputs/apk/debug/app-debug.apk
|
||||||
path: app/build/outputs/apk/debug/app-*-debug.apk
|
|
||||||
|
19
.github/workflows/release-app-github.yaml
vendored
19
.github/workflows/release-app-github.yaml
vendored
@ -10,9 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: gradle/wrapper-validation-action@v2
|
|
||||||
|
|
||||||
- name: Write KeyStore 🗝️
|
- name: Write KeyStore 🗝️
|
||||||
uses: ./.github/actions/prepare-keystore
|
uses: ./.github/actions/prepare-keystore
|
||||||
@ -23,10 +21,10 @@ jobs:
|
|||||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
java-version: 21
|
java-version: "17.x"
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build APKs 📱
|
- name: Build APKs 📱
|
||||||
@ -39,14 +37,3 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: app/build/outputs/apk/release/*.apk
|
files: app/build/outputs/apk/release/*.apk
|
||||||
|
|
||||||
- name: Build AABs 📱
|
|
||||||
run: ./gradlew bundleRelease
|
|
||||||
|
|
||||||
- name: Upload APKs bundles 🚀
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
with:
|
|
||||||
files: app/build/outputs/bundle/release/*.aab
|
|
||||||
|
12
.github/workflows/release-app-google-play.yaml
vendored
12
.github/workflows/release-app-google-play.yaml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Build and publish app to Google Play
|
name: Build and publish app
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@ -10,9 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: gradle/wrapper-validation-action@v2
|
|
||||||
|
|
||||||
- name: Write KeyStore 🗝️
|
- name: Write KeyStore 🗝️
|
||||||
uses: ./.github/actions/prepare-keystore
|
uses: ./.github/actions/prepare-keystore
|
||||||
@ -23,10 +21,10 @@ jobs:
|
|||||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
java-version: 21
|
java-version: "17.x"
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build APKs 📱
|
- name: Build APKs 📱
|
||||||
@ -41,4 +39,4 @@ jobs:
|
|||||||
track: production
|
track: production
|
||||||
status: inProgress
|
status: inProgress
|
||||||
inAppUpdatePriority: 2
|
inAppUpdatePriority: 2
|
||||||
userFraction: 0.2
|
userFraction: 0.33
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,7 +1,6 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea
|
|
||||||
/.idea/caches
|
/.idea/caches
|
||||||
/.idea/libraries
|
/.idea/libraries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
|
28
README.md
28
README.md
@ -3,21 +3,18 @@
|
|||||||
# Alibi
|
# Alibi
|
||||||
|
|
||||||
<p float="left" align="center">
|
<p float="left" align="center">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.webp" width="30%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width="24%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.webp" width="30%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width="24%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.webp" width="30%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width="24%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.webp" width="30%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width="24%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.webp" width="30%" />
|
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.webp" width="30%" />
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Alibi keeps recording audio/video in the background and saves the last 30 minutes at your request.
|
Alibi keeps recording in the background and saves the last 30 minutes at your request.
|
||||||
Everything is completely configurable. No internet connection required.
|
Everything is completely configurable. No internet connection required.
|
||||||
|
|
||||||
# Download
|
# Download
|
||||||
|
|
||||||
[<img src="readme_content/google-play-badge.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=app.myzel394.alibi)
|
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/app.myzel394.alibi)
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/app.myzel394.alibi)
|
|
||||||
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
|
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
|
||||||
|
|
||||||
# Supporting Alibi
|
# Supporting Alibi
|
||||||
@ -30,13 +27,16 @@ Add a new feature or fix bugs.
|
|||||||
|
|
||||||
## Add translations
|
## Add translations
|
||||||
|
|
||||||
[Translate Alibi into your language using Crowdin](https://crowdin.com/project/alibi), so that other
|
Translate Alibi into your language so that other people can use it more easily.
|
||||||
people can use it more easily.
|
|
||||||
|
|
||||||
## Donate
|
## Donate
|
||||||
|
|
||||||
It might sound crazy, but if you would just donate $ 1, it would totally mean the world to me, since
|
It might sound crazy, but if you would just donate 1$, it would totally mean to world to me, since
|
||||||
it's a really small amount and if everyone did that, I could focus on Alibi and my other open
|
it's a really small amount and if everyone did that, I can totally focus on Alibi and my other open
|
||||||
source projects. :)
|
source projects. :)
|
||||||
|
|
||||||
You can donate via [GitHub Sponsors](https://github.com/sponsors/Myzel394) or via [crypto currencies](https://github.com/Myzel394/contact-me?tab=readme-ov-file#donations).
|
You can donate via:
|
||||||
|
|
||||||
|
* [GitHub Sponsors](https://github.com/sponsors/Myzel394)
|
||||||
|
* Bitcoin: `bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6`
|
||||||
|
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`
|
||||||
|
@ -35,8 +35,8 @@ android {
|
|||||||
applicationId "app.myzel394.alibi"
|
applicationId "app.myzel394.alibi"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 16
|
versionCode 5
|
||||||
versionName "0.5.3"
|
versionName "0.2.2"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -78,11 +78,9 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose true
|
compose true
|
||||||
buildConfig = true
|
|
||||||
viewBinding = true
|
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion '1.5.10'
|
kotlinCompilerExtensionVersion '1.5.1'
|
||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
resources {
|
resources {
|
||||||
@ -92,60 +90,41 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
||||||
implementation 'androidx.activity:activity-compose:1.9.1'
|
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||||
implementation 'androidx.activity:activity-ktx:1.9.1'
|
implementation platform('androidx.compose:compose-bom:2022.10.00')
|
||||||
implementation platform('androidx.compose:compose-bom:2024.09.00')
|
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation 'androidx.compose.ui:ui'
|
||||||
implementation 'androidx.compose.ui:ui-graphics'
|
implementation 'androidx.compose.ui:ui-graphics'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
implementation 'androidx.compose.material3:material3:1.2.1'
|
implementation 'androidx.compose.material3:material3'
|
||||||
implementation "androidx.compose.material:material-icons-extended:1.6.8"
|
implementation "androidx.compose.material:material-icons-extended:1.5.1"
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00')
|
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||||
|
|
||||||
implementation "androidx.navigation:navigation-compose:2.7.7"
|
implementation "androidx.navigation:navigation-compose:2.7.2"
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.49'
|
implementation 'com.google.dagger:hilt-android:2.46.1'
|
||||||
annotationProcessor 'com.google.dagger:hilt-compiler:2.49'
|
annotationProcessor 'com.google.dagger:hilt-compiler:2.46.1'
|
||||||
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
|
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
|
implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1'
|
||||||
|
|
||||||
implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1'
|
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||||
|
|
||||||
implementation "androidx.datastore:datastore-preferences:1.1.1"
|
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
|
||||||
|
|
||||||
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||||
|
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:core:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:core:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
|
||||||
|
|
||||||
def camerax_version = "1.3.4"
|
|
||||||
implementation "androidx.camera:camera-core:${camerax_version}"
|
|
||||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
|
||||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
|
||||||
implementation "androidx.camera:camera-video:${camerax_version}"
|
|
||||||
|
|
||||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
|
||||||
implementation "androidx.camera:camera-extensions:${camerax_version}"
|
|
||||||
|
|
||||||
|
|
||||||
implementation "com.valentinilk.shimmer:compose-shimmer:1.2.0"
|
|
||||||
|
|
||||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
|
||||||
}
|
}
|
@ -2,39 +2,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-feature
|
|
||||||
android:name="android.hardware.microphone"
|
|
||||||
android:required="false" />
|
|
||||||
|
|
||||||
<uses-feature
|
|
||||||
android:name="android.hardware.camera"
|
|
||||||
android:required="false" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<!-- Required for Bluetooth microphones -->
|
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
|
||||||
|
|
||||||
<!-- Starting with Android 29, apps don't need to request the READ_EXTERNAL_STORAGE permission
|
|
||||||
for files in their own MediaStore -->
|
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="28" />
|
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="28" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".UpdateSettingsApp"
|
android:name=".UpdateSettingsApp"
|
||||||
@ -46,7 +17,6 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Alibi"
|
android:theme="@style/Theme.Alibi"
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@ -67,13 +37,7 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" />
|
||||||
<service
|
|
||||||
android:name=".services.AudioRecorderService"
|
|
||||||
android:foregroundServiceType="microphone" />
|
|
||||||
<service
|
|
||||||
android:name=".services.VideoRecorderService"
|
|
||||||
android:foregroundServiceType="camera|microphone" />
|
|
||||||
|
|
||||||
<!-- Change locale for Android <= 12 -->
|
<!-- Change locale for Android <= 12 -->
|
||||||
<service
|
<service
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
package app.myzel394.alibi
|
package app.myzel394.alibi
|
||||||
|
|
||||||
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE", "tr-TR")
|
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE")
|
@ -2,18 +2,13 @@ package app.myzel394.alibi
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.background
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.datastore.dataStore
|
import androidx.datastore.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettingsSerializer
|
import app.myzel394.alibi.db.AppSettingsSerializer
|
||||||
import app.myzel394.alibi.ui.AsLockedApp
|
|
||||||
import app.myzel394.alibi.ui.LockedAppHandlers
|
|
||||||
import app.myzel394.alibi.ui.Navigation
|
import app.myzel394.alibi.ui.Navigation
|
||||||
import app.myzel394.alibi.ui.theme.AlibiTheme
|
import app.myzel394.alibi.ui.theme.AlibiTheme
|
||||||
|
|
||||||
@ -31,19 +26,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
AlibiTheme {
|
AlibiTheme {
|
||||||
LockedAppHandlers()
|
Navigation()
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.background
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
AsLockedApp {
|
|
||||||
Navigation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,45 +1,21 @@
|
|||||||
package app.myzel394.alibi.db
|
package app.myzel394.alibi.db
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.camera.video.Quality
|
import android.util.Log
|
||||||
import androidx.camera.video.QualitySelector
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
import app.myzel394.alibi.R
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
import kotlinx.coroutines.delay
|
||||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import java.io.File
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppSettings(
|
data class AppSettings(
|
||||||
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings.getDefaultInstance(),
|
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings(),
|
||||||
val videoRecorderSettings: VideoRecorderSettings = VideoRecorderSettings.getDefaultInstance(),
|
|
||||||
|
|
||||||
val appLockSettings: AppLockSettings? = null,
|
|
||||||
|
|
||||||
val hasSeenOnboarding: Boolean = false,
|
val hasSeenOnboarding: Boolean = false,
|
||||||
val showAdvancedSettings: Boolean = false,
|
val showAdvancedSettings: Boolean = false,
|
||||||
val theme: Theme = Theme.SYSTEM,
|
|
||||||
val lastRecording: RecordingInformation? = null,
|
|
||||||
|
|
||||||
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
|
|
||||||
|
|
||||||
/// Recording information
|
|
||||||
// 30 minutes
|
|
||||||
val maxDuration: Long = 15 * 60 * 1000L,
|
|
||||||
// 60 seconds
|
|
||||||
val intervalDuration: Long = 60 * 1000L,
|
|
||||||
|
|
||||||
val notificationSettings: NotificationSettings? = null,
|
|
||||||
val deleteRecordingsImmediately: Boolean = false,
|
|
||||||
val saveFolder: String? = null,
|
|
||||||
) {
|
) {
|
||||||
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
|
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
|
||||||
return copy(showAdvancedSettings = showAdvancedSettings)
|
return copy(showAdvancedSettings = showAdvancedSettings)
|
||||||
@ -49,166 +25,123 @@ data class AppSettings(
|
|||||||
return copy(audioRecorderSettings = audioRecorderSettings)
|
return copy(audioRecorderSettings = audioRecorderSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVideoRecorderSettings(videoRecorderSettings: VideoRecorderSettings): AppSettings {
|
|
||||||
return copy(videoRecorderSettings = videoRecorderSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings {
|
|
||||||
return copy(notificationSettings = notificationSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings {
|
fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings {
|
||||||
return copy(hasSeenOnboarding = hasSeenOnboarding)
|
return copy(hasSeenOnboarding = hasSeenOnboarding)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTheme(theme: Theme): AppSettings {
|
|
||||||
return copy(theme = theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLastRecording(lastRecording: RecordingInformation?): AppSettings {
|
|
||||||
return copy(lastRecording = lastRecording)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
|
|
||||||
return copy(filenameFormat = filenameFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMaxDuration(duration: Long): AppSettings {
|
|
||||||
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
|
|
||||||
throw Exception("Max duration must be between 1 minute and 10 days")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration < intervalDuration) {
|
|
||||||
throw Exception("Max duration must be greater than interval duration")
|
|
||||||
}
|
|
||||||
|
|
||||||
return copy(maxDuration = duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setIntervalDuration(duration: Long): AppSettings {
|
|
||||||
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
|
|
||||||
throw Exception("Interval duration must be between 10 seconds and 1 hour")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration > maxDuration) {
|
|
||||||
throw Exception("Interval duration must be less than max duration")
|
|
||||||
}
|
|
||||||
|
|
||||||
return copy(intervalDuration = duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AppSettings {
|
|
||||||
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSaveFolder(saveFolder: String?): AppSettings {
|
|
||||||
return copy(saveFolder = saveFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAppLockSettings(appLockSettings: AppLockSettings?): AppSettings {
|
|
||||||
return copy(appLockSettings = appLockSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveLastRecording(recorder: RecorderModel): AppSettings {
|
|
||||||
return if (deleteRecordingsImmediately) {
|
|
||||||
this
|
|
||||||
} else {
|
|
||||||
setLastRecording(
|
|
||||||
recorder.recorderService!!.getRecordingInformation()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the object is present, biometric authentication is enabled.
|
|
||||||
// To disable biometric authentication, set the instance to null.
|
|
||||||
fun isAppLockEnabled() = appLockSettings != null
|
|
||||||
|
|
||||||
fun requiresExternalStoragePermission(context: Context): Boolean {
|
|
||||||
return !SUPPORTS_SCOPED_STORAGE && (saveFolder == RECORDER_MEDIA_SELECTED_VALUE && !PermissionHelper.hasGranted(
|
|
||||||
context,
|
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exportToString(): String {
|
|
||||||
return Json.encodeToString(serializer(), this)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class Theme {
|
|
||||||
SYSTEM,
|
|
||||||
LIGHT,
|
|
||||||
DARK,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class FilenameFormat {
|
|
||||||
DATETIME_ABSOLUTE_START,
|
|
||||||
DATETIME_RELATIVE_START,
|
|
||||||
DATETIME_NOW,
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getDefaultInstance(): AppSettings = AppSettings()
|
fun getDefaultInstance(): AppSettings = AppSettings()
|
||||||
|
|
||||||
fun fromExportedString(data: String): AppSettings {
|
|
||||||
return Json.decodeFromString(
|
|
||||||
serializer(),
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RecordingInformation(
|
data class LastRecording(
|
||||||
val folderPath: String,
|
val folderPath: String,
|
||||||
@Serializable(with = LocalDateTimeSerializer::class)
|
@Serializable(with = LocalDateTimeSerializer::class)
|
||||||
val recordingStart: LocalDateTime,
|
val recordingStart: LocalDateTime,
|
||||||
val batchesAmount: Int,
|
|
||||||
val maxDuration: Long,
|
val maxDuration: Long,
|
||||||
val intervalDuration: Long,
|
val intervalDuration: Long,
|
||||||
val fileExtension: String,
|
val fileExtension: String,
|
||||||
val type: Type,
|
val forceExactMaxDuration: Boolean,
|
||||||
) {
|
) {
|
||||||
fun hasRecordingsAvailable(context: Context): Boolean =
|
val fileFolder: File
|
||||||
when (type) {
|
get() = File(folderPath)
|
||||||
Type.AUDIO -> AudioBatchesFolder.importFromFolder(folderPath, context)
|
|
||||||
.hasRecordingsAvailable()
|
|
||||||
|
|
||||||
Type.VIDEO -> VideoBatchesFolder.importFromFolder(folderPath, context)
|
val filePaths: List<File>
|
||||||
.hasRecordingsAvailable()
|
get() =
|
||||||
}
|
File(folderPath).listFiles()?.filter {
|
||||||
|
val name = it.nameWithoutExtension
|
||||||
|
|
||||||
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
|
name.toIntOrNull() != null
|
||||||
return when (filenameFormat) {
|
}?.toList() ?: emptyList()
|
||||||
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
|
|
||||||
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
|
val hasRecordingAvailable: Boolean
|
||||||
getFullDuration() / 1000
|
get() = filePaths.isNotEmpty()
|
||||||
|
|
||||||
|
private fun stripConcatenatedFileToExactDuration(
|
||||||
|
outputFile: File
|
||||||
|
) {
|
||||||
|
// Move the concatenated file to a temporary file
|
||||||
|
val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
|
||||||
|
outputFile.renameTo(rawFile)
|
||||||
|
|
||||||
|
val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
|
||||||
|
|
||||||
|
val session = FFmpegKit.execute(command)
|
||||||
|
|
||||||
|
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||||
|
Log.d(
|
||||||
|
"Audio Concatenation",
|
||||||
|
String.format(
|
||||||
|
"Command failed with state %s and rc %s.%s",
|
||||||
|
session.getState(),
|
||||||
|
session.getReturnCode(),
|
||||||
|
session.getFailStackTrace()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
|
throw Exception("Failed to strip concatenated audio")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getFullDuration(): Long {
|
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
||||||
// This is not accurate, since the last batch may be shorter than the others
|
val paths = filePaths.joinToString("|")
|
||||||
// but it's good enough
|
val fileName = recordingStart
|
||||||
return intervalDuration * batchesAmount - (intervalDuration * 0.5).toLong()
|
.format(ISO_DATE_TIME)
|
||||||
}
|
.toString()
|
||||||
|
.replace(":", "-")
|
||||||
|
.replace(".", "_")
|
||||||
|
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
|
||||||
|
|
||||||
enum class Type {
|
if (outputFile.exists() && !forceConcatenation) {
|
||||||
AUDIO,
|
return outputFile
|
||||||
VIDEO,
|
}
|
||||||
|
|
||||||
|
val command = "-i 'concat:$paths' -y" +
|
||||||
|
" -acodec copy" +
|
||||||
|
" -metadata title='$fileName' " +
|
||||||
|
" -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
|
||||||
|
" -metadata batch_count='${filePaths.size}'" +
|
||||||
|
" -metadata batch_duration='${intervalDuration}'" +
|
||||||
|
" -metadata max_duration='${maxDuration}'" +
|
||||||
|
" $outputFile"
|
||||||
|
|
||||||
|
val session = FFmpegKit.execute(command)
|
||||||
|
|
||||||
|
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||||
|
Log.d(
|
||||||
|
"Audio Concatenation",
|
||||||
|
String.format(
|
||||||
|
"Command failed with state %s and rc %s.%s",
|
||||||
|
session.getState(),
|
||||||
|
session.getReturnCode(),
|
||||||
|
session.getFailStackTrace()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
throw Exception("Failed to concatenate audios")
|
||||||
|
}
|
||||||
|
|
||||||
|
val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
|
||||||
|
if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
|
||||||
|
stripConcatenatedFileToExactDuration(outputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputFile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AudioRecorderSettings(
|
data class AudioRecorderSettings(
|
||||||
|
val maxDuration: Long = 30 * 60 * 1000L,
|
||||||
|
// 60 seconds
|
||||||
|
val intervalDuration: Long = 60 * 1000L,
|
||||||
|
val forceExactMaxDuration: Boolean = true,
|
||||||
// 320 Kbps
|
// 320 Kbps
|
||||||
val bitRate: Int = 320000,
|
val bitRate: Int = 320000,
|
||||||
val samplingRate: Int? = null,
|
val samplingRate: Int? = null,
|
||||||
val outputFormat: Int? = null,
|
val outputFormat: Int? = null,
|
||||||
val encoder: Int? = null,
|
val encoder: Int? = null,
|
||||||
val showAllMicrophones: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
fun getOutputFormat(): Int {
|
fun getOutputFormat(): Int {
|
||||||
if (outputFormat != null) {
|
if (outputFormat != null) {
|
||||||
@ -221,7 +154,7 @@ data class AudioRecorderSettings(
|
|||||||
else MediaRecorder.OutputFormat.THREE_GPP
|
else MediaRecorder.OutputFormat.THREE_GPP
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (encoder) {
|
return when(encoder) {
|
||||||
MediaRecorder.AudioEncoder.AAC -> MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.AudioEncoder.AAC -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
MediaRecorder.AudioEncoder.AAC_ELD -> MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.AudioEncoder.AAC_ELD -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
MediaRecorder.AudioEncoder.AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
|
MediaRecorder.AudioEncoder.AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
|
||||||
@ -234,7 +167,6 @@ data class AudioRecorderSettings(
|
|||||||
MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaRecorder.AudioEncoder.OPUS -> {
|
MediaRecorder.AudioEncoder.OPUS -> {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
MediaRecorder.OutputFormat.OGG
|
MediaRecorder.OutputFormat.OGG
|
||||||
@ -242,12 +174,11 @@ data class AudioRecorderSettings(
|
|||||||
MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> MediaRecorder.OutputFormat.DEFAULT
|
else -> MediaRecorder.OutputFormat.DEFAULT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMimeType(): String = when (getOutputFormat()) {
|
fun getMimeType(): String = when(getOutputFormat()) {
|
||||||
MediaRecorder.OutputFormat.AAC_ADTS -> "audio/aac"
|
MediaRecorder.OutputFormat.AAC_ADTS -> "audio/aac"
|
||||||
MediaRecorder.OutputFormat.THREE_GPP -> "audio/3gpp"
|
MediaRecorder.OutputFormat.THREE_GPP -> "audio/3gpp"
|
||||||
MediaRecorder.OutputFormat.MPEG_4 -> "audio/mp4"
|
MediaRecorder.OutputFormat.MPEG_4 -> "audio/mp4"
|
||||||
@ -259,7 +190,7 @@ data class AudioRecorderSettings(
|
|||||||
else -> "audio/3gpp"
|
else -> "audio/3gpp"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSamplingRate(): Int = samplingRate ?: when (getOutputFormat()) {
|
fun getSamplingRate(): Int = samplingRate ?: when(getOutputFormat()) {
|
||||||
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
||||||
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
||||||
MediaRecorder.OutputFormat.MPEG_4 -> 44100
|
MediaRecorder.OutputFormat.MPEG_4 -> 44100
|
||||||
@ -271,12 +202,26 @@ data class AudioRecorderSettings(
|
|||||||
else -> 48000
|
else -> 48000
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getEncoder(): Int = encoder ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
fun getEncoder(): Int = encoder ?:
|
||||||
MediaRecorder.AudioEncoder.AAC
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
else
|
MediaRecorder.AudioEncoder.AAC
|
||||||
MediaRecorder.AudioEncoder.AMR_NB
|
else
|
||||||
|
MediaRecorder.AudioEncoder.AMR_NB
|
||||||
|
|
||||||
|
fun setIntervalDuration(duration: Long): AudioRecorderSettings {
|
||||||
|
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
|
||||||
|
throw Exception("Interval duration must be between 10 seconds and 1 hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration > maxDuration) {
|
||||||
|
throw Exception("Interval duration must be less than max duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy(intervalDuration = duration)
|
||||||
|
}
|
||||||
|
|
||||||
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
||||||
|
println("bitRate: $bitRate")
|
||||||
if (bitRate !in 1000..320000) {
|
if (bitRate !in 1000..320000) {
|
||||||
throw Exception("Bit rate must be between 1000 and 320000")
|
throw Exception("Bit rate must be between 1000 and 320000")
|
||||||
}
|
}
|
||||||
@ -308,8 +253,20 @@ data class AudioRecorderSettings(
|
|||||||
return copy(encoder = encoder)
|
return copy(encoder = encoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
fun setMaxDuration(duration: Long): AudioRecorderSettings {
|
||||||
return copy(showAllMicrophones = showAllMicrophones)
|
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
|
||||||
|
throw Exception("Max duration must be between 1 minute and 1 hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration < intervalDuration) {
|
||||||
|
throw Exception("Max duration must be greater than interval duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy(maxDuration = duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
|
||||||
|
return copy(forceExactMaxDuration = forceExactMaxDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isEncoderCompatible(encoder: Int): Boolean {
|
fun isEncoderCompatible(encoder: Int): Boolean {
|
||||||
@ -322,31 +279,17 @@ data class AudioRecorderSettings(
|
|||||||
return supportedFormats.contains(outputFormat)
|
return supportedFormats.contains(outputFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileExtension: String
|
|
||||||
get() = when (getOutputFormat()) {
|
|
||||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
|
||||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
|
||||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
|
||||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
|
||||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
|
||||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
|
||||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
|
||||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
|
||||||
else -> "raw"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
||||||
val EXAMPLE_MAX_DURATIONS = listOf(
|
val EXAMPLE_MAX_DURATIONS = listOf(
|
||||||
1 * 60 * 1000L,
|
|
||||||
5 * 60 * 1000L,
|
|
||||||
15 * 60 * 1000L,
|
15 * 60 * 1000L,
|
||||||
30 * 60 * 1000L,
|
30 * 60 * 1000L,
|
||||||
60 * 60 * 1000L,
|
60 * 60 * 1000L,
|
||||||
|
2 * 60 * 60 * 1000L,
|
||||||
|
3 * 60 * 60 * 1000L,
|
||||||
)
|
)
|
||||||
val EXAMPLE_DURATION_TIMES = listOf(
|
val EXAMPLE_DURATION_TIMES = listOf(
|
||||||
60 * 1000L,
|
60 * 1000L,
|
||||||
60 * 2 * 1000L,
|
|
||||||
60 * 5 * 1000L,
|
60 * 5 * 1000L,
|
||||||
60 * 10 * 1000L,
|
60 * 10 * 1000L,
|
||||||
60 * 15 * 1000L,
|
60 * 15 * 1000L,
|
||||||
@ -444,174 +387,3 @@ data class AudioRecorderSettings(
|
|||||||
}).toMap()
|
}).toMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class VideoRecorderSettings(
|
|
||||||
val targetedVideoBitRate: Int? = null,
|
|
||||||
val quality: String? = null,
|
|
||||||
val targetFrameRate: Int? = null,
|
|
||||||
) {
|
|
||||||
fun setTargetedVideoBitRate(bitRate: Int?): VideoRecorderSettings {
|
|
||||||
return copy(targetedVideoBitRate = bitRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setQuality(quality: Quality?): VideoRecorderSettings {
|
|
||||||
val invertedMap = QUALITY_NAME_QUALITY_MAP.entries.associateBy({ it.value }, { it.key })
|
|
||||||
|
|
||||||
return copy(quality = quality?.let { invertedMap[it] })
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTargetFrameRate(frameRate: Int?): VideoRecorderSettings {
|
|
||||||
return copy(targetFrameRate = frameRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getQuality(): Quality? =
|
|
||||||
quality?.let {
|
|
||||||
QUALITY_NAME_QUALITY_MAP[it]!!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getQualitySelector(): QualitySelector? =
|
|
||||||
quality?.let {
|
|
||||||
QualitySelector.from(
|
|
||||||
QUALITY_NAME_QUALITY_MAP[it]!!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMimeType() = "video/$fileExtension"
|
|
||||||
|
|
||||||
val fileExtension
|
|
||||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "mp4" else "3gp"
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun getDefaultInstance() = VideoRecorderSettings()
|
|
||||||
|
|
||||||
val QUALITY_NAME_QUALITY_MAP: Map<String, Quality> = mapOf(
|
|
||||||
"LOWEST" to Quality.LOWEST,
|
|
||||||
"HIGHEST" to Quality.HIGHEST,
|
|
||||||
"SD" to Quality.SD,
|
|
||||||
"HD" to Quality.HD,
|
|
||||||
"FHD" to Quality.FHD,
|
|
||||||
"UHD" to Quality.UHD,
|
|
||||||
)
|
|
||||||
|
|
||||||
val EXAMPLE_BITRATE_VALUES = listOf(
|
|
||||||
null,
|
|
||||||
500 * 1000,
|
|
||||||
// 1 Mbps
|
|
||||||
1 * 1000 * 1000,
|
|
||||||
2 * 1000 * 1000,
|
|
||||||
4 * 1000 * 1000,
|
|
||||||
8 * 1000 * 1000,
|
|
||||||
16 * 1000 * 1000,
|
|
||||||
32 * 1000 * 1000,
|
|
||||||
50 * 1000 * 1000,
|
|
||||||
100 * 1000 * 1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
val EXAMPLE_FRAME_RATE_VALUES = listOf(
|
|
||||||
null,
|
|
||||||
24,
|
|
||||||
30,
|
|
||||||
60,
|
|
||||||
120,
|
|
||||||
240,
|
|
||||||
)
|
|
||||||
|
|
||||||
val AVAILABLE_QUALITIES = listOf(
|
|
||||||
Quality.HIGHEST,
|
|
||||||
Quality.UHD,
|
|
||||||
Quality.FHD,
|
|
||||||
Quality.HD,
|
|
||||||
Quality.SD,
|
|
||||||
Quality.LOWEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
val EXAMPLE_QUALITY_VALUES = listOf(
|
|
||||||
null,
|
|
||||||
) + AVAILABLE_QUALITIES
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NotificationSettings(
|
|
||||||
val title: String,
|
|
||||||
val message: String,
|
|
||||||
val iconID: Int,
|
|
||||||
val showOngoing: Boolean,
|
|
||||||
val preset: Preset? = null,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
sealed class Preset(
|
|
||||||
val titleID: Int,
|
|
||||||
val messageID: Int,
|
|
||||||
val showOngoing: Boolean,
|
|
||||||
val iconID: Int,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data object Default : Preset(
|
|
||||||
R.string.ui_audioRecorder_state_recording_title,
|
|
||||||
R.string.ui_recorder_state_recording_description,
|
|
||||||
true,
|
|
||||||
R.drawable.launcher_monochrome_noopacity,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Weather : Preset(
|
|
||||||
R.string.ui_recorder_state_recording_fake_weather_title,
|
|
||||||
R.string.ui_recorder_state_recording_fake_weather_description,
|
|
||||||
false,
|
|
||||||
R.drawable.ic_cloud
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Player : Preset(
|
|
||||||
R.string.ui_recorder_state_recording_fake_player_title,
|
|
||||||
R.string.ui_recorder_state_recording_fake_player_description,
|
|
||||||
true,
|
|
||||||
R.drawable.ic_note,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Browser : Preset(
|
|
||||||
R.string.ui_recorder_state_recording_fake_browser_title,
|
|
||||||
R.string.ui_recorder_state_recording_fake_browser_description,
|
|
||||||
true,
|
|
||||||
R.drawable.ic_download,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object VPN : Preset(
|
|
||||||
R.string.ui_recorder_state_recording_fake_vpn_title,
|
|
||||||
R.string.ui_recorder_state_recording_fake_vpn_description,
|
|
||||||
false,
|
|
||||||
R.drawable.ic_vpn,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromPreset(preset: Preset): NotificationSettings {
|
|
||||||
return NotificationSettings(
|
|
||||||
title = "",
|
|
||||||
message = "",
|
|
||||||
showOngoing = preset.showOngoing,
|
|
||||||
iconID = preset.iconID,
|
|
||||||
preset = preset,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val PRESETS = listOf(
|
|
||||||
Preset.Default,
|
|
||||||
Preset.Weather,
|
|
||||||
Preset.Player,
|
|
||||||
Preset.Browser,
|
|
||||||
Preset.VPN,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class AppLockSettings {
|
|
||||||
companion object {
|
|
||||||
fun getDefaultInstance() = AppLockSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -13,7 +13,7 @@ import java.io.InputStream
|
|||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class AppSettingsSerializer : Serializer<AppSettings> {
|
class AppSettingsSerializer: Serializer<AppSettings> {
|
||||||
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
||||||
|
|
||||||
override suspend fun readFrom(input: InputStream): AppSettings {
|
override suspend fun readFrom(input: InputStream): AppSettings {
|
||||||
@ -39,9 +39,8 @@ class AppSettingsSerializer : Serializer<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
|
||||||
override val descriptor: SerialDescriptor =
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||||
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): LocalDateTime {
|
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||||
return LocalDateTime.parse(decoder.decodeString())
|
return LocalDateTime.parse(decoder.decodeString())
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
package app.myzel394.alibi.enums
|
package app.myzel394.alibi.enums
|
||||||
|
|
||||||
enum class RecorderState {
|
enum class RecorderState {
|
||||||
STOPPED,
|
IDLE,
|
||||||
RECORDING,
|
RECORDING,
|
||||||
PAUSED,
|
PAUSED,
|
||||||
|
|
||||||
// Only used by the model to indicate that the service is not running
|
|
||||||
IDLE
|
|
||||||
}
|
}
|
@ -1,76 +0,0 @@
|
|||||||
package app.myzel394.alibi.helpers
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.biometric.BiometricManager
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.biometric.BiometricPrompt
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlin.system.exitProcess
|
|
||||||
|
|
||||||
class AppLockHelper {
|
|
||||||
enum class SupportType {
|
|
||||||
AVAILABLE,
|
|
||||||
UNAVAILABLE,
|
|
||||||
NONE_ENROLLED,
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun getSupportType(context: Context): SupportType {
|
|
||||||
val biometricManager = BiometricManager.from(context)
|
|
||||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) {
|
|
||||||
BiometricManager.BIOMETRIC_SUCCESS -> SupportType.AVAILABLE
|
|
||||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> SupportType.NONE_ENROLLED
|
|
||||||
|
|
||||||
else -> SupportType.UNAVAILABLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun authenticate(
|
|
||||||
context: Context,
|
|
||||||
title: String,
|
|
||||||
subtitle: String
|
|
||||||
): CompletableDeferred<Boolean> {
|
|
||||||
val deferred = CompletableDeferred<Boolean>()
|
|
||||||
|
|
||||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
|
||||||
val biometricPrompt = BiometricPrompt(
|
|
||||||
context as FragmentActivity,
|
|
||||||
object : BiometricPrompt.AuthenticationCallback() {
|
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
||||||
deferred.complete(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
||||||
deferred.complete(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAuthenticationFailed() {
|
|
||||||
deferred.complete(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
|
||||||
.setTitle(title)
|
|
||||||
.setSubtitle(subtitle)
|
|
||||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
biometricPrompt.authenticate(promptInfo)
|
|
||||||
|
|
||||||
return deferred
|
|
||||||
}
|
|
||||||
|
|
||||||
fun closeApp(context: Context) {
|
|
||||||
(context as? Activity)?.let {
|
|
||||||
it.finishAndRemoveTask()
|
|
||||||
it.finishAffinity()
|
|
||||||
it.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
exitProcess(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,176 +0,0 @@
|
|||||||
package app.myzel394.alibi.helpers
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateAudioFiles
|
|
||||||
import app.myzel394.alibi.ui.AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME
|
|
||||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
|
||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileDescriptor
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
class AudioBatchesFolder(
|
|
||||||
override val context: Context,
|
|
||||||
override val type: BatchType,
|
|
||||||
override val customFolder: DocumentFile? = null,
|
|
||||||
override val subfolderName: String = AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
|
||||||
) : BatchesFolder(
|
|
||||||
context,
|
|
||||||
type,
|
|
||||||
customFolder,
|
|
||||||
subfolderName,
|
|
||||||
) {
|
|
||||||
override val concatenationFunction = ::concatenateAudioFiles
|
|
||||||
override val ffmpegParameters = FFMPEG_PARAMETERS
|
|
||||||
override val scopedMediaContentUri: Uri = SCOPED_MEDIA_CONTENT_URI
|
|
||||||
override val legacyMediaFolder = LEGACY_MEDIA_FOLDER
|
|
||||||
|
|
||||||
private var customFileFileDescriptor: ParcelFileDescriptor? = null
|
|
||||||
private var mediaFileFileDescriptor: ParcelFileDescriptor? = null
|
|
||||||
|
|
||||||
override fun getOutputFileForFFmpeg(
|
|
||||||
date: LocalDateTime,
|
|
||||||
extension: String,
|
|
||||||
fileName: String,
|
|
||||||
): String {
|
|
||||||
return when (type) {
|
|
||||||
BatchType.INTERNAL -> asInternalGetOutputFile(fileName).absolutePath
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> {
|
|
||||||
FFmpegKitConfig.getSafParameterForWrite(
|
|
||||||
context,
|
|
||||||
(customFolder!!.findFile(fileName) ?: customFolder.createFile(
|
|
||||||
"audio/${extension}",
|
|
||||||
fileName,
|
|
||||||
)!!).uri
|
|
||||||
)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
val mediaUri = getOrCreateMediaFile(
|
|
||||||
name = fileName,
|
|
||||||
mimeType = "audio/$extension",
|
|
||||||
relativePath = BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_SUBFOLDER_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
return FFmpegKitConfig.getSafParameterForWrite(
|
|
||||||
context,
|
|
||||||
mediaUri
|
|
||||||
)!!
|
|
||||||
} else {
|
|
||||||
val path = arrayOf(
|
|
||||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
|
||||||
MEDIA_SUBFOLDER_NAME,
|
|
||||||
fileName,
|
|
||||||
).joinToString("/")
|
|
||||||
return File(path)
|
|
||||||
.apply {
|
|
||||||
createNewFile()
|
|
||||||
}.absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cleanup() {
|
|
||||||
runCatching {
|
|
||||||
customFileFileDescriptor?.close()
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
mediaFileFileDescriptor?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun asCustomGetFileDescriptor(
|
|
||||||
counter: Long,
|
|
||||||
fileExtension: String,
|
|
||||||
): FileDescriptor {
|
|
||||||
runCatching {
|
|
||||||
customFileFileDescriptor?.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
val file =
|
|
||||||
getCustomDefinedFolder().createFile("audio/$fileExtension", "$counter.$fileExtension")!!
|
|
||||||
|
|
||||||
customFileFileDescriptor = context.contentResolver.openFileDescriptor(file.uri, "w")!!
|
|
||||||
|
|
||||||
return customFileFileDescriptor!!.fileDescriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
fun asMediaGetScopedStorageFileDescriptor(
|
|
||||||
name: String,
|
|
||||||
mimeType: String
|
|
||||||
): FileDescriptor {
|
|
||||||
runCatching {
|
|
||||||
mediaFileFileDescriptor?.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaUri = getOrCreateMediaFile(
|
|
||||||
name = name,
|
|
||||||
mimeType = mimeType,
|
|
||||||
relativePath = SCOPED_STORAGE_RELATIVE_PATH,
|
|
||||||
)
|
|
||||||
|
|
||||||
mediaFileFileDescriptor = context.contentResolver.openFileDescriptor(mediaUri, "w")!!
|
|
||||||
|
|
||||||
return mediaFileFileDescriptor!!.fileDescriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun viaInternalFolder(context: Context) = AudioBatchesFolder(context, BatchType.INTERNAL)
|
|
||||||
|
|
||||||
fun viaCustomFolder(context: Context, folder: DocumentFile) =
|
|
||||||
AudioBatchesFolder(context, BatchType.CUSTOM, folder)
|
|
||||||
|
|
||||||
fun viaMediaFolder(context: Context) = AudioBatchesFolder(context, BatchType.MEDIA)
|
|
||||||
|
|
||||||
fun importFromFolder(folder: String, context: Context) = when (folder) {
|
|
||||||
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
|
|
||||||
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
|
|
||||||
else -> viaCustomFolder(context, DocumentFile.fromTreeUri(context, Uri.parse(folder))!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
val BASE_LEGACY_STORAGE_FOLDER = Environment.DIRECTORY_PODCASTS
|
|
||||||
val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/.audio_recordings"
|
|
||||||
val BASE_SCOPED_STORAGE_RELATIVE_PATH =
|
|
||||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
|
||||||
Environment.DIRECTORY_RECORDINGS
|
|
||||||
else
|
|
||||||
Environment.DIRECTORY_PODCASTS)
|
|
||||||
val SCOPED_STORAGE_RELATIVE_PATH =
|
|
||||||
BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_RECORDINGS_SUBFOLDER
|
|
||||||
|
|
||||||
// Don't use those values directly, use the constants from the instance.
|
|
||||||
// Those values are only used inside the `SaveFolderTile`
|
|
||||||
val SCOPED_MEDIA_CONTENT_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
|
||||||
val LEGACY_MEDIA_FOLDER = File(
|
|
||||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
|
||||||
MEDIA_RECORDINGS_SUBFOLDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
// Parameters to be passed in descending order
|
|
||||||
// Those parameters first try to concatenate without re-encoding
|
|
||||||
// if that fails, it'll try several fallback methods
|
|
||||||
// this is audio only
|
|
||||||
val FFMPEG_PARAMETERS = arrayOf(
|
|
||||||
" -c copy",
|
|
||||||
" -acodec copy",
|
|
||||||
" -c:a aac",
|
|
||||||
" -c:a libmp3lame",
|
|
||||||
" -c:a libopus",
|
|
||||||
" -c:a libvorbis",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,602 +0,0 @@
|
|||||||
package app.myzel394.alibi.helpers
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import android.os.storage.StorageManager
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.provider.MediaStore.Video.Media
|
|
||||||
import android.system.Os
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.RecordingInformation
|
|
||||||
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
|
||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlin.reflect.KFunction4
|
|
||||||
|
|
||||||
|
|
||||||
abstract class BatchesFolder(
|
|
||||||
open val context: Context,
|
|
||||||
open val type: BatchType,
|
|
||||||
open val customFolder: DocumentFile? = null,
|
|
||||||
open val subfolderName: String = ".recordings",
|
|
||||||
) {
|
|
||||||
abstract val concatenationFunction: KFunction4<Iterable<String>, String, String, (Int) -> Unit, CompletableDeferred<Unit>>
|
|
||||||
abstract val ffmpegParameters: Array<String>
|
|
||||||
abstract val scopedMediaContentUri: Uri
|
|
||||||
abstract val legacyMediaFolder: File
|
|
||||||
|
|
||||||
val mediaPrefix
|
|
||||||
get() = MEDIA_RECORDINGS_PREFIX + subfolderName.substring(1) + "-"
|
|
||||||
|
|
||||||
fun initFolders() {
|
|
||||||
when (type) {
|
|
||||||
BatchType.INTERNAL -> getInternalFolder().mkdirs()
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> {
|
|
||||||
if (customFolder!!.findFile(subfolderName) == null) {
|
|
||||||
customFolder!!.createDirectory(subfolderName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
// Scoped storage works fine on new Android versions,
|
|
||||||
// we need to manually manage the folder on older versions
|
|
||||||
if (!SUPPORTS_SCOPED_STORAGE) {
|
|
||||||
legacyMediaFolder.mkdirs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getInternalFolder(): File {
|
|
||||||
return File(context.filesDir, subfolderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCustomDefinedFolder(): DocumentFile {
|
|
||||||
return customFolder!!.findFile(subfolderName)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
protected fun queryMediaContent(
|
|
||||||
callback: (rawName: String, counter: Int, uri: Uri, cursor: Cursor) -> Any?,
|
|
||||||
) {
|
|
||||||
context.contentResolver.query(
|
|
||||||
scopedMediaContentUri,
|
|
||||||
null,
|
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
)!!.use { cursor ->
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
val rawName = cursor.getColumnIndex(Media.DISPLAY_NAME).let { id ->
|
|
||||||
if (id == -1) null else cursor.getString(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawName.isNullOrBlank() || !rawName.startsWith(mediaPrefix)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val counter =
|
|
||||||
rawName.substringAfter(mediaPrefix).substringBeforeLast(".").toIntOrNull()
|
|
||||||
?: continue
|
|
||||||
|
|
||||||
val id = cursor.getColumnIndex(Media._ID).let { id ->
|
|
||||||
if (id == -1) null else cursor.getString(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.isNullOrBlank()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = Uri.withAppendedPath(scopedMediaContentUri, id)
|
|
||||||
|
|
||||||
val result = callback(rawName, counter, uri, cursor)
|
|
||||||
|
|
||||||
if (result == false) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBatchesForFFmpeg(): List<String> {
|
|
||||||
return when (type) {
|
|
||||||
BatchType.INTERNAL ->
|
|
||||||
((getInternalFolder()
|
|
||||||
.listFiles()
|
|
||||||
?.filter {
|
|
||||||
it.nameWithoutExtension.toIntOrNull() != null
|
|
||||||
}
|
|
||||||
?.toList()
|
|
||||||
?: emptyList()) as List<File>)
|
|
||||||
.sortedBy {
|
|
||||||
it.nameWithoutExtension.toInt()
|
|
||||||
}
|
|
||||||
.map { it.absolutePath }
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> getCustomDefinedFolder()
|
|
||||||
.listFiles()
|
|
||||||
.filter {
|
|
||||||
it.name?.substringBeforeLast(".")?.toIntOrNull() != null
|
|
||||||
}
|
|
||||||
.sortedBy {
|
|
||||||
it.name!!.substringBeforeLast(".").toInt()
|
|
||||||
}
|
|
||||||
.map {
|
|
||||||
FFmpegKitConfig.getSafParameterForRead(
|
|
||||||
context,
|
|
||||||
it.uri,
|
|
||||||
)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
val fileUris = mutableListOf<Pair<String, Uri>>()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
queryMediaContent { rawName, _, uri, _ ->
|
|
||||||
fileUris.add(Pair(rawName, uri))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
legacyMediaFolder.listFiles()?.forEach {
|
|
||||||
fileUris.add(Pair(it.name, it.toUri()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileUris
|
|
||||||
.sortedBy {
|
|
||||||
val name = it.first
|
|
||||||
|
|
||||||
return@sortedBy name
|
|
||||||
.substring(mediaPrefix.length)
|
|
||||||
.substringBeforeLast(".")
|
|
||||||
.toInt()
|
|
||||||
}
|
|
||||||
.map { pair ->
|
|
||||||
val uri = pair.second
|
|
||||||
|
|
||||||
FFmpegKitConfig.getSafParameterForRead(
|
|
||||||
context,
|
|
||||||
uri,
|
|
||||||
)!!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getName(date: LocalDateTime, extension: String): String {
|
|
||||||
val name = date
|
|
||||||
.format(DateTimeFormatter.ISO_DATE_TIME)
|
|
||||||
.toString()
|
|
||||||
.replace(":", "-")
|
|
||||||
.replace(".", "_")
|
|
||||||
|
|
||||||
return "$name.$extension"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun asInternalGetOutputFile(fileName: String): File {
|
|
||||||
return File(getInternalFolder(), fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun asMediaGetLegacyFile(name: String): File = File(
|
|
||||||
legacyMediaFolder,
|
|
||||||
name
|
|
||||||
).apply {
|
|
||||||
createNewFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkIfOutputAlreadyExists(
|
|
||||||
fileName: String,
|
|
||||||
): Boolean {
|
|
||||||
return when (type) {
|
|
||||||
BatchType.INTERNAL -> File(getInternalFolder(), fileName).exists()
|
|
||||||
|
|
||||||
BatchType.CUSTOM ->
|
|
||||||
getCustomDefinedFolder().findFile(fileName)?.exists() ?: false
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
var exists = false
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
queryMediaContent { rawName, _, _, _ ->
|
|
||||||
if (rawName == fileName) {
|
|
||||||
exists = true
|
|
||||||
return@queryMediaContent true
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return exists
|
|
||||||
} else {
|
|
||||||
return File(
|
|
||||||
legacyMediaFolder,
|
|
||||||
fileName,
|
|
||||||
).exists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun getOutputFileForFFmpeg(
|
|
||||||
date: LocalDateTime,
|
|
||||||
extension: String,
|
|
||||||
fileName: String,
|
|
||||||
): String
|
|
||||||
|
|
||||||
abstract fun cleanup()
|
|
||||||
|
|
||||||
suspend fun concatenate(
|
|
||||||
recording: RecordingInformation,
|
|
||||||
filenameFormat: AppSettings.FilenameFormat,
|
|
||||||
disableCache: Boolean? = null,
|
|
||||||
onNextParameterTry: (String) -> Unit = {},
|
|
||||||
onProgress: (Float?) -> Unit = {},
|
|
||||||
fileName: String,
|
|
||||||
): String {
|
|
||||||
val disableCache = disableCache ?: (type != BatchType.INTERNAL)
|
|
||||||
val date = recording.getStartDateForFilename(filenameFormat)
|
|
||||||
|
|
||||||
if (!disableCache && checkIfOutputAlreadyExists(fileName)
|
|
||||||
) {
|
|
||||||
return getOutputFileForFFmpeg(
|
|
||||||
date = recording.recordingStart,
|
|
||||||
extension = recording.fileExtension,
|
|
||||||
fileName = fileName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (parameter in ffmpegParameters) {
|
|
||||||
Log.i("Concatenation", "Trying parameter $parameter")
|
|
||||||
onNextParameterTry(parameter)
|
|
||||||
onProgress(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val fullTime = recording.getFullDuration().toFloat();
|
|
||||||
val filePaths = getBatchesForFFmpeg()
|
|
||||||
|
|
||||||
val outputFile = getOutputFileForFFmpeg(
|
|
||||||
date = date,
|
|
||||||
extension = recording.fileExtension,
|
|
||||||
fileName = fileName,
|
|
||||||
)
|
|
||||||
|
|
||||||
concatenationFunction(
|
|
||||||
filePaths,
|
|
||||||
outputFile,
|
|
||||||
parameter
|
|
||||||
) { time ->
|
|
||||||
// The progressbar for the conversion is calculated based on the
|
|
||||||
// current time of the conversion and the total time of the batches.
|
|
||||||
onProgress(time / fullTime)
|
|
||||||
}.await()
|
|
||||||
return outputFile
|
|
||||||
} catch (e: MediaConverter.FFmpegException) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw MediaConverter.FFmpegException("Failed to concatenate")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exportFolderForSettings(): String {
|
|
||||||
return when (type) {
|
|
||||||
BatchType.INTERNAL -> RECORDER_INTERNAL_SELECTED_VALUE
|
|
||||||
BatchType.MEDIA -> RECORDER_MEDIA_SELECTED_VALUE
|
|
||||||
BatchType.CUSTOM -> customFolder!!.uri.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteRecordings() {
|
|
||||||
// Currently deletes all recordings.
|
|
||||||
// This is fine, because we are saving the recordings
|
|
||||||
// in a dedicated subfolder
|
|
||||||
when (type) {
|
|
||||||
BatchType.INTERNAL -> getInternalFolder().deleteRecursively()
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete()
|
|
||||||
?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
// TODO: Also delete pending recordings
|
|
||||||
// --> Doesn't seem to be possible :/
|
|
||||||
context.contentResolver.delete(
|
|
||||||
scopedMediaContentUri,
|
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
|
|
||||||
} else {
|
|
||||||
legacyMediaFolder.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasRecordingsAvailable(): Boolean {
|
|
||||||
return when (type) {
|
|
||||||
BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty()
|
|
||||||
?: false
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
var hasRecordings = false
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
context.contentResolver.query(
|
|
||||||
scopedMediaContentUri,
|
|
||||||
arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
|
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
)!!.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
hasRecordings = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasRecordings
|
|
||||||
} else {
|
|
||||||
return legacyMediaFolder.listFiles()?.isNotEmpty() ?: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteRecordings(range: LongRange) {
|
|
||||||
when (type) {
|
|
||||||
BatchType.INTERNAL -> getInternalFolder().listFiles()?.forEach {
|
|
||||||
val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return@forEach
|
|
||||||
|
|
||||||
if (fileCounter in range) {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> getCustomDefinedFolder().listFiles().forEach {
|
|
||||||
val fileCounter = it.name?.substringBeforeLast(".")?.toIntOrNull() ?: return@forEach
|
|
||||||
|
|
||||||
if (fileCounter in range) {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
val deletableNames = mutableListOf<String>()
|
|
||||||
|
|
||||||
queryMediaContent { rawName, counter, _, _ ->
|
|
||||||
if (counter in range) {
|
|
||||||
deletableNames.add(rawName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
context.contentResolver.delete(
|
|
||||||
scopedMediaContentUri,
|
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME} IN (${
|
|
||||||
deletableNames.joinToString(
|
|
||||||
","
|
|
||||||
) { "'$it'" }
|
|
||||||
})",
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
// This is unfortunate if the files can't be deleted, but let's just
|
|
||||||
// ignore it since we can't do anything about it
|
|
||||||
} catch (e: RuntimeException) {
|
|
||||||
// Probably file not found
|
|
||||||
e.printStackTrace()
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
// Strange filename, should not happen
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO: Fix "would you like to try saving" -> Save button
|
|
||||||
legacyMediaFolder.listFiles()?.forEach {
|
|
||||||
val fileCounter =
|
|
||||||
it.nameWithoutExtension.substring(mediaPrefix.length).toIntOrNull()
|
|
||||||
?: return@forEach
|
|
||||||
|
|
||||||
if (fileCounter in range) {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkIfFolderIsAccessible(): Boolean {
|
|
||||||
try {
|
|
||||||
return when (type) {
|
|
||||||
BatchType.INTERNAL -> true
|
|
||||||
BatchType.CUSTOM -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead()
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
if (SUPPORTS_SCOPED_STORAGE) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return PermissionHelper.hasGranted(
|
|
||||||
context,
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
) &&
|
|
||||||
PermissionHelper.hasGranted(
|
|
||||||
context,
|
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: NullPointerException) {
|
|
||||||
error.printStackTrace()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun asInternalGetFile(counter: Long, fileExtension: String): File {
|
|
||||||
return File(getInternalFolder(), "$counter.$fileExtension")
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
fun getOrCreateMediaFile(
|
|
||||||
name: String,
|
|
||||||
mimeType: String,
|
|
||||||
relativePath: String,
|
|
||||||
): Uri {
|
|
||||||
// Check if already exists
|
|
||||||
var uri: Uri? = null
|
|
||||||
|
|
||||||
context.contentResolver.query(
|
|
||||||
scopedMediaContentUri,
|
|
||||||
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME),
|
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME} = '$name'",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
)!!.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
// No need to check for the name since the query already did that
|
|
||||||
val id = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
|
||||||
|
|
||||||
if (id == -1) {
|
|
||||||
return@use
|
|
||||||
}
|
|
||||||
|
|
||||||
uri = ContentUris.withAppendedId(
|
|
||||||
scopedMediaContentUri,
|
|
||||||
cursor.getLong(id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uri == null) {
|
|
||||||
try {
|
|
||||||
// Create empty output file to be able to write to it
|
|
||||||
uri = context.contentResolver.insert(
|
|
||||||
scopedMediaContentUri,
|
|
||||||
ContentValues().apply {
|
|
||||||
put(
|
|
||||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
put(
|
|
||||||
MediaStore.MediaColumns.MIME_TYPE,
|
|
||||||
mimeType
|
|
||||||
)
|
|
||||||
|
|
||||||
put(
|
|
||||||
Media.RELATIVE_PATH,
|
|
||||||
relativePath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)!!
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Media", "Failed to create file", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uri!!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAvailableBytes(): Long? {
|
|
||||||
if (type == BatchType.CUSTOM) {
|
|
||||||
var fileDescriptor: ParcelFileDescriptor? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
fileDescriptor =
|
|
||||||
context.contentResolver.openFileDescriptor(customFolder!!.uri, "r")!!
|
|
||||||
val stats = Os.fstatvfs(fileDescriptor.fileDescriptor)
|
|
||||||
|
|
||||||
val available = stats.f_bavail * stats.f_bsize
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
fileDescriptor.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return available
|
|
||||||
} catch (e: Exception) {
|
|
||||||
runCatching {
|
|
||||||
fileDescriptor?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val storageManager = context.getSystemService(StorageManager::class.java) ?: return null
|
|
||||||
val file = when (type) {
|
|
||||||
BatchType.INTERNAL -> context.filesDir
|
|
||||||
BatchType.MEDIA ->
|
|
||||||
if (SUPPORTS_SCOPED_STORAGE)
|
|
||||||
File(
|
|
||||||
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH),
|
|
||||||
Media.EXTERNAL_CONTENT_URI.toString(),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
File(
|
|
||||||
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_LEGACY_STORAGE_FOLDER),
|
|
||||||
VideoBatchesFolder.MEDIA_RECORDINGS_SUBFOLDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> throw IllegalArgumentException("This code should not be reachable")
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
storageManager.getAllocatableBytes(storageManager.getUuidForPath(file))
|
|
||||||
} else {
|
|
||||||
file.usableSpace;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class BatchType {
|
|
||||||
INTERNAL,
|
|
||||||
CUSTOM,
|
|
||||||
MEDIA,
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun requiredBytesForOneMinuteOfRecording(appSettings: AppSettings): Long {
|
|
||||||
// 350 MiB sounds like a good default
|
|
||||||
return 350 * 1024 * 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canAccessFolder(context: Context, uri: Uri): Boolean {
|
|
||||||
// This always returns false for some reason, let's just assume it's true
|
|
||||||
return true
|
|
||||||
/*
|
|
||||||
return try {
|
|
||||||
// Create temp file
|
|
||||||
val docFile = DocumentFile.fromSingleUri(context, uri)!!
|
|
||||||
|
|
||||||
return docFile.canWrite().also {
|
|
||||||
println("Can write? ${it}")
|
|
||||||
} && docFile.canRead().also {
|
|
||||||
println("Can read? ${it}")
|
|
||||||
}
|
|
||||||
} catch (error: RuntimeException) {
|
|
||||||
error.printStackTrace()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
|||||||
package app.myzel394.alibi.helpers
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
|
|
||||||
data class Doctor(
|
|
||||||
val context: Context
|
|
||||||
) {
|
|
||||||
fun checkIfFileSaverDialogIsAvailable(): Boolean {
|
|
||||||
// Since API 30, we can't query other packages so easily anymore
|
|
||||||
// (see https://developer.android.com/training/package-visibility).
|
|
||||||
// For now, we assume the user has a file saver app installed.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
|
||||||
if (intent.resolveActivity(context.packageManager) != null) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val results =
|
|
||||||
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
|
|
||||||
|
|
||||||
if (results.isNotEmpty()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,183 +0,0 @@
|
|||||||
package app.myzel394.alibi.helpers
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import java.io.File
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
// Abstract class for concatenating audio and video files
|
|
||||||
// The concatenator runs in its own thread to avoid unresponsiveness.
|
|
||||||
// You may be wondering why we simply not iterate over the FFMPEG_PARAMETERS
|
|
||||||
// in this thread and then call each FFmpeg initiation just right after it?
|
|
||||||
// The answer: It's easier; We don't have to deal with the `getBatchesForFFmpeg` function, because
|
|
||||||
// the batches are only usable once and we if iterate in this thread over the FFMPEG_PARAMETERS
|
|
||||||
// we would need to refetch the batches here, which is more messy.
|
|
||||||
// This is okay, because in 99% of the time the first or second parameter will work,
|
|
||||||
// and so there is no real performance loss.
|
|
||||||
abstract class Concatenator(
|
|
||||||
private val inputFiles: Iterable<String>,
|
|
||||||
private val outputFile: String,
|
|
||||||
private val extraCommand: String
|
|
||||||
) : Thread() {
|
|
||||||
abstract fun concatenate(): CompletableDeferred<Unit>
|
|
||||||
|
|
||||||
class FFmpegException(message: String) : Exception(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AudioConcatenator(
|
|
||||||
private val inputFiles: Iterable<String>,
|
|
||||||
private val outputFile: String,
|
|
||||||
private val extraCommand: String
|
|
||||||
) : Concatenator(
|
|
||||||
inputFiles,
|
|
||||||
outputFile,
|
|
||||||
extraCommand
|
|
||||||
) {
|
|
||||||
override fun concatenate(): CompletableDeferred<Unit> {
|
|
||||||
val completer = CompletableDeferred<Unit>()
|
|
||||||
|
|
||||||
val filePathsConcatenated = inputFiles.joinToString("|")
|
|
||||||
val command =
|
|
||||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
|
||||||
" -i 'concat:$filePathsConcatenated'" +
|
|
||||||
" -y" +
|
|
||||||
extraCommand +
|
|
||||||
" $outputFile"
|
|
||||||
|
|
||||||
FFmpegKit.executeAsync(
|
|
||||||
command
|
|
||||||
) { session ->
|
|
||||||
if (!ReturnCode.isSuccess(session!!.returnCode)) {
|
|
||||||
Log.i(
|
|
||||||
"Audio Concatenation",
|
|
||||||
String.format(
|
|
||||||
"Command failed with state %s and rc %s.%s",
|
|
||||||
session.state,
|
|
||||||
session.returnCode,
|
|
||||||
session.failStackTrace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
completer.completeExceptionally(Exception("Failed to concatenate audios"))
|
|
||||||
} else {
|
|
||||||
completer.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return completer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MediaConverter {
|
|
||||||
companion object {
|
|
||||||
fun concatenateAudioFiles(
|
|
||||||
inputFiles: Iterable<String>,
|
|
||||||
outputFile: String,
|
|
||||||
extraCommand: String = "",
|
|
||||||
onProgress: (Int) -> Unit = { },
|
|
||||||
): CompletableDeferred<Unit> {
|
|
||||||
val completer = CompletableDeferred<Unit>()
|
|
||||||
|
|
||||||
val filePathsConcatenated = inputFiles.joinToString("|")
|
|
||||||
val command =
|
|
||||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
|
||||||
" -strict normal" +
|
|
||||||
" -i 'concat:$filePathsConcatenated'" +
|
|
||||||
extraCommand +
|
|
||||||
" -y" +
|
|
||||||
" $outputFile"
|
|
||||||
|
|
||||||
FFmpegKit.executeAsync(
|
|
||||||
command,
|
|
||||||
{ session ->
|
|
||||||
if (!ReturnCode.isSuccess(session!!.returnCode)) {
|
|
||||||
Log.i(
|
|
||||||
"Audio Concatenation",
|
|
||||||
String.format(
|
|
||||||
"Command failed with state %s and rc %s.%s",
|
|
||||||
session.state,
|
|
||||||
session.returnCode,
|
|
||||||
session.failStackTrace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
completer.completeExceptionally(Exception("Failed to concatenate audios"))
|
|
||||||
} else {
|
|
||||||
completer.complete(Unit)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
{ statistics ->
|
|
||||||
onProgress(statistics.time)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return completer
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createTempFile(content: String): File {
|
|
||||||
val id = UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
return File.createTempFile(".temp-ffmpeg-files-$id", ".txt").apply {
|
|
||||||
writeText(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun concatenateVideoFiles(
|
|
||||||
inputFiles: Iterable<String>,
|
|
||||||
outputFile: String,
|
|
||||||
extraCommand: String = "",
|
|
||||||
onProgress: (Int) -> Unit = { },
|
|
||||||
): CompletableDeferred<Unit> {
|
|
||||||
val completer = CompletableDeferred<Unit>()
|
|
||||||
|
|
||||||
val listFile = createTempFile(inputFiles.joinToString("\n") { "file '$it'" })
|
|
||||||
|
|
||||||
val command =
|
|
||||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
|
||||||
" -safe 0" +
|
|
||||||
" -strict normal" +
|
|
||||||
" -f concat" +
|
|
||||||
" -i ${listFile.absolutePath}" +
|
|
||||||
extraCommand +
|
|
||||||
" -y" +
|
|
||||||
" $outputFile"
|
|
||||||
|
|
||||||
FFmpegKit.executeAsync(
|
|
||||||
command,
|
|
||||||
{ session ->
|
|
||||||
runCatching {
|
|
||||||
listFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(session!!.returnCode)) {
|
|
||||||
completer.complete(Unit)
|
|
||||||
} else {
|
|
||||||
Log.i(
|
|
||||||
"Video Concatenation",
|
|
||||||
String.format(
|
|
||||||
"Command failed with state %s and rc %s.%s",
|
|
||||||
session.state,
|
|
||||||
session.returnCode,
|
|
||||||
session.failStackTrace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
completer.completeExceptionally(FFmpegException("Failed to concatenate videos"))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
{ statistics ->
|
|
||||||
onProgress(statistics.time)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return completer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FFmpegException(message: String) : Exception(message)
|
|
||||||
}
|
|
@ -1,182 +0,0 @@
|
|||||||
package app.myzel394.alibi.helpers
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles
|
|
||||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
|
||||||
import app.myzel394.alibi.ui.VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME
|
|
||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
class VideoBatchesFolder(
|
|
||||||
override val context: Context,
|
|
||||||
override val type: BatchType,
|
|
||||||
override val customFolder: DocumentFile? = null,
|
|
||||||
override val subfolderName: String = VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
|
||||||
) : BatchesFolder(
|
|
||||||
context,
|
|
||||||
type,
|
|
||||||
customFolder,
|
|
||||||
subfolderName,
|
|
||||||
) {
|
|
||||||
override val concatenationFunction = ::concatenateVideoFiles
|
|
||||||
override val ffmpegParameters = FFMPEG_PARAMETERS
|
|
||||||
override val scopedMediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
|
||||||
override val legacyMediaFolder = File(
|
|
||||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
|
||||||
MEDIA_RECORDINGS_SUBFOLDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var customParcelFileDescriptor: ParcelFileDescriptor? = null
|
|
||||||
|
|
||||||
override fun getOutputFileForFFmpeg(
|
|
||||||
date: LocalDateTime,
|
|
||||||
extension: String,
|
|
||||||
fileName: String,
|
|
||||||
): String {
|
|
||||||
return when (type) {
|
|
||||||
BatchType.INTERNAL -> asInternalGetOutputFile(fileName).absolutePath
|
|
||||||
|
|
||||||
BatchType.CUSTOM -> {
|
|
||||||
FFmpegKitConfig.getSafParameterForWrite(
|
|
||||||
context,
|
|
||||||
(customFolder!!.findFile(fileName) ?: customFolder.createFile(
|
|
||||||
"video/${extension}",
|
|
||||||
fileName,
|
|
||||||
)!!).uri
|
|
||||||
)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchType.MEDIA -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
val mediaUri = getOrCreateMediaFile(
|
|
||||||
name = fileName,
|
|
||||||
mimeType = "video/$extension",
|
|
||||||
relativePath = BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_SUBFOLDER_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
return FFmpegKitConfig.getSafParameterForWrite(
|
|
||||||
context,
|
|
||||||
mediaUri
|
|
||||||
)!!
|
|
||||||
} else {
|
|
||||||
val path = arrayOf(
|
|
||||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
|
||||||
MEDIA_SUBFOLDER_NAME,
|
|
||||||
fileName,
|
|
||||||
).joinToString("/")
|
|
||||||
return File(path)
|
|
||||||
.apply {
|
|
||||||
createNewFile()
|
|
||||||
}.absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cleanup() {
|
|
||||||
runCatching {
|
|
||||||
customParcelFileDescriptor?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun asCustomGetParcelFileDescriptor(
|
|
||||||
counter: Long,
|
|
||||||
fileExtension: String,
|
|
||||||
): ParcelFileDescriptor {
|
|
||||||
runCatching {
|
|
||||||
customParcelFileDescriptor?.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
val file =
|
|
||||||
getCustomDefinedFolder().createFile(
|
|
||||||
"video/$fileExtension",
|
|
||||||
"$counter.$fileExtension"
|
|
||||||
)!!
|
|
||||||
val resolver = context.contentResolver.acquireContentProviderClient(file.uri)!!
|
|
||||||
|
|
||||||
resolver.use {
|
|
||||||
customParcelFileDescriptor = it.openFile(file.uri, "w")!!
|
|
||||||
|
|
||||||
return customParcelFileDescriptor!!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
fun asMediaGetScopedStorageContentValues(name: String) = ContentValues().apply {
|
|
||||||
put(
|
|
||||||
MediaStore.Video.Media.IS_PENDING,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
put(
|
|
||||||
MediaStore.Video.Media.RELATIVE_PATH,
|
|
||||||
SCOPED_STORAGE_RELATIVE_PATH,
|
|
||||||
)
|
|
||||||
|
|
||||||
put(
|
|
||||||
MediaStore.Video.Media.DISPLAY_NAME,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun viaInternalFolder(context: Context) = VideoBatchesFolder(context, BatchType.INTERNAL)
|
|
||||||
|
|
||||||
fun viaCustomFolder(context: Context, folder: DocumentFile) =
|
|
||||||
VideoBatchesFolder(context, BatchType.CUSTOM, folder)
|
|
||||||
|
|
||||||
fun viaMediaFolder(context: Context) = VideoBatchesFolder(context, BatchType.MEDIA)
|
|
||||||
|
|
||||||
fun importFromFolder(folder: String?, context: Context) = when (folder) {
|
|
||||||
null -> viaInternalFolder(context)
|
|
||||||
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
|
|
||||||
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
|
|
||||||
else -> viaCustomFolder(
|
|
||||||
context,
|
|
||||||
DocumentFile.fromTreeUri(context, Uri.parse(folder))!!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val BASE_LEGACY_STORAGE_FOLDER = Environment.DIRECTORY_DCIM
|
|
||||||
val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/.video_recordings"
|
|
||||||
val BASE_SCOPED_STORAGE_RELATIVE_PATH = Environment.DIRECTORY_DCIM
|
|
||||||
val SCOPED_STORAGE_RELATIVE_PATH =
|
|
||||||
BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_RECORDINGS_SUBFOLDER
|
|
||||||
|
|
||||||
// Parameters to be passed in descending order
|
|
||||||
// Those parameters first try to concatenate without re-encoding
|
|
||||||
// if that fails, it'll try several fallback methods
|
|
||||||
val FFMPEG_PARAMETERS = arrayOf(
|
|
||||||
" -c copy",
|
|
||||||
" -c:v copy",
|
|
||||||
" -c:v copy -c:a aac",
|
|
||||||
" -c:v copy -c:a libmp3lame",
|
|
||||||
" -c:v copy -c:a libopus",
|
|
||||||
" -c:v copy -c:a libvorbis",
|
|
||||||
" -c:a copy",
|
|
||||||
// There's nothing else we can do to avoid re-encoding,
|
|
||||||
// so we'll just have to re-encode the whole thing
|
|
||||||
" -c:v libx264 -c:a copy",
|
|
||||||
" -c:v libx264 -c:a aac",
|
|
||||||
" -c:v libx265 -c:a aac",
|
|
||||||
" -c:v libx264 -c:a libmp3lame",
|
|
||||||
" -c:v libx264 -c:a libopus",
|
|
||||||
" -c:v libx264 -c:a libvorbis",
|
|
||||||
" -c:v libx265 -c:a copy",
|
|
||||||
" -c:v libx265 -c:a aac",
|
|
||||||
" -c:v libx265 -c:a libmp3lame",
|
|
||||||
" -c:v libx265 -c:a libopus",
|
|
||||||
" -c:v libx265 -c:a libvorbis",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,43 +1,46 @@
|
|||||||
package app.myzel394.alibi.services
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.media.AudioDeviceCallback
|
|
||||||
import android.media.AudioDeviceInfo
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.media.MediaRecorder.OnErrorListener
|
import android.media.MediaRecorder.OnErrorListener
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import java.lang.IllegalStateException
|
||||||
import android.os.Looper
|
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import app.myzel394.alibi.NotificationHelper
|
|
||||||
import app.myzel394.alibi.db.RecordingInformation
|
|
||||||
import app.myzel394.alibi.enums.RecorderState
|
|
||||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
|
||||||
import app.myzel394.alibi.helpers.BatchesFolder
|
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
|
||||||
|
|
||||||
class AudioRecorderService :
|
class AudioRecorderService: IntervalRecorderService() {
|
||||||
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
|
|
||||||
override var batchesFolder = AudioBatchesFolder.viaInternalFolder(this)
|
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
var amplitudes = mutableListOf<Int>()
|
|
||||||
private set
|
|
||||||
var amplitudesAmount = 1000
|
var amplitudesAmount = 1000
|
||||||
|
|
||||||
var selectedMicrophone: MicrophoneInfo? = null
|
|
||||||
|
|
||||||
var recorder: MediaRecorder? = null
|
var recorder: MediaRecorder? = null
|
||||||
private set
|
private set
|
||||||
|
var onError: () -> Unit = {}
|
||||||
|
|
||||||
// Callbacks
|
val filePath: String
|
||||||
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
get() = "$folder/$counter.${settings!!.fileExtension}"
|
||||||
var onMicrophoneDisconnected: () -> Unit = {}
|
|
||||||
var onMicrophoneReconnected: () -> Unit = {}
|
private fun createRecorder(): MediaRecorder {
|
||||||
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaRecorder(this)
|
||||||
|
} else {
|
||||||
|
MediaRecorder()
|
||||||
|
}.apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
setOutputFile(filePath)
|
||||||
|
setOutputFormat(settings!!.outputFormat)
|
||||||
|
setAudioEncoder(settings!!.encoder)
|
||||||
|
setAudioEncodingBitRate(settings!!.bitRate)
|
||||||
|
setAudioSamplingRate(settings!!.samplingRate)
|
||||||
|
setOnErrorListener(OnErrorListener { _, _, _ ->
|
||||||
|
onError()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetRecorder() {
|
||||||
|
runCatching {
|
||||||
|
recorder?.let {
|
||||||
|
it.stop()
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun startNewCycle() {
|
override fun startNewCycle() {
|
||||||
super.startNewCycle()
|
super.startNewCycle()
|
||||||
@ -47,7 +50,6 @@ class AudioRecorderService :
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetRecorder()
|
resetRecorder()
|
||||||
startAudioDevice()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
recorder = newRecorder
|
recorder = newRecorder
|
||||||
@ -57,48 +59,21 @@ class AudioRecorderService :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
super.start()
|
|
||||||
|
|
||||||
createAmplitudesTimer()
|
|
||||||
registerMicrophoneListener()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
super.pause()
|
super.pause()
|
||||||
|
|
||||||
resetRecorder()
|
resetRecorder()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun stop() {
|
override fun stop() {
|
||||||
resetRecorder()
|
|
||||||
unregisterMicrophoneListener()
|
|
||||||
|
|
||||||
super.stop()
|
super.stop()
|
||||||
|
|
||||||
|
resetRecorder()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resume() {
|
override fun getAmplitudeAmount(): Int = amplitudesAmount
|
||||||
super.resume()
|
|
||||||
createAmplitudesTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startForegroundService() {
|
override fun getAmplitude(): Int {
|
||||||
ServiceCompat.startForeground(
|
|
||||||
this,
|
|
||||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
|
||||||
getNotificationHelper().buildStartingNotification(),
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==== Amplitude related ====
|
|
||||||
private fun getAmplitudeAmount(): Int = amplitudesAmount
|
|
||||||
|
|
||||||
private fun getAmplitude(): Int {
|
|
||||||
return try {
|
return try {
|
||||||
recorder!!.maxAmplitude
|
recorder!!.maxAmplitude
|
||||||
} catch (error: IllegalStateException) {
|
} catch (error: IllegalStateException) {
|
||||||
@ -107,209 +82,4 @@ class AudioRecorderService :
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateAmplitude() {
|
|
||||||
if (state !== RecorderState.RECORDING) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
amplitudes.add(getAmplitude())
|
|
||||||
onAmplitudeChange?.invoke(amplitudes)
|
|
||||||
|
|
||||||
// Delete old amplitudes
|
|
||||||
if (amplitudes.size > getAmplitudeAmount()) {
|
|
||||||
// Should be more efficient than dropping the elements, getting a new list
|
|
||||||
// clearing old list and adding new elements to it
|
|
||||||
repeat(amplitudes.size - getAmplitudeAmount()) {
|
|
||||||
amplitudes.removeAt(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.postDelayed(::updateAmplitude, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createAmplitudesTimer() {
|
|
||||||
handler.postDelayed(::updateAmplitude, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==== Audio device related ====
|
|
||||||
|
|
||||||
/// Tell Android to use the correct bluetooth microphone, if any selected
|
|
||||||
private fun startAudioDevice() {
|
|
||||||
if (selectedMicrophone == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
audioManger.setCommunicationDevice(selectedMicrophone!!.deviceInfo)
|
|
||||||
} else {
|
|
||||||
audioManger.startBluetoothSco()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearAudioDevice() {
|
|
||||||
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
audioManger.clearCommunicationDevice()
|
|
||||||
} else {
|
|
||||||
audioManger.stopBluetoothSco()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getNameForMediaFile() =
|
|
||||||
"${batchesFolder.mediaPrefix}$counter.${settings.audioRecorderSettings.fileExtension}"
|
|
||||||
|
|
||||||
// ==== Actual recording related ====
|
|
||||||
private fun createRecorder(): MediaRecorder {
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
MediaRecorder(this)
|
|
||||||
} else {
|
|
||||||
MediaRecorder()
|
|
||||||
}.apply {
|
|
||||||
val audioSettings = settings.audioRecorderSettings
|
|
||||||
|
|
||||||
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
|
|
||||||
// and Redmi Buds 3 Pro:
|
|
||||||
// - MIC: Uses the bottom microphone of the phone (17)
|
|
||||||
// - CAMCORDER: Uses the top microphone of the phone (2)
|
|
||||||
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
|
|
||||||
// - DEFAULT: Uses the bottom microphone of the phone (17)
|
|
||||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
||||||
|
|
||||||
when (batchesFolder.type) {
|
|
||||||
BatchesFolder.BatchType.INTERNAL -> {
|
|
||||||
setOutputFile(
|
|
||||||
batchesFolder.asInternalGetFile(
|
|
||||||
counter,
|
|
||||||
audioSettings.fileExtension
|
|
||||||
).absolutePath
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchesFolder.BatchType.CUSTOM -> {
|
|
||||||
setOutputFile(
|
|
||||||
batchesFolder.asCustomGetFileDescriptor(
|
|
||||||
counter,
|
|
||||||
audioSettings.fileExtension
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchesFolder.BatchType.MEDIA -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
setOutputFile(
|
|
||||||
batchesFolder.asMediaGetScopedStorageFileDescriptor(
|
|
||||||
getNameForMediaFile(),
|
|
||||||
"audio/${audioSettings.fileExtension}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val name = getNameForMediaFile()
|
|
||||||
val file = batchesFolder.asMediaGetLegacyFile(name)
|
|
||||||
|
|
||||||
setOutputFile(file.absolutePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOutputFormat(audioSettings.getOutputFormat())
|
|
||||||
|
|
||||||
setAudioEncoder(audioSettings.getEncoder())
|
|
||||||
setAudioEncodingBitRate(audioSettings.bitRate)
|
|
||||||
setAudioSamplingRate(audioSettings.getSamplingRate())
|
|
||||||
setOnErrorListener(OnErrorListener { _, _, _ ->
|
|
||||||
onError()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==== Microphone related ====
|
|
||||||
private fun resetRecorder() {
|
|
||||||
runCatching {
|
|
||||||
recorder?.apply {
|
|
||||||
stop()
|
|
||||||
reset()
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
clearAudioDevice()
|
|
||||||
batchesFolder.cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun changeMicrophone(microphone: MicrophoneInfo?) {
|
|
||||||
selectedMicrophone = microphone
|
|
||||||
onSelectedMicrophoneChange(microphone)
|
|
||||||
|
|
||||||
if (state == RecorderState.RECORDING) {
|
|
||||||
startNewCycle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
|
||||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
|
||||||
super.onAudioDevicesAdded(addedDevices)
|
|
||||||
|
|
||||||
if (selectedMicrophone == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't compare the ID, as it seems to be changing on each reconnect
|
|
||||||
val newDevice = addedDevices?.find {
|
|
||||||
it.productName == selectedMicrophone!!.deviceInfo.productName &&
|
|
||||||
it.isSink == selectedMicrophone!!.deviceInfo.isSink &&
|
|
||||||
it.type == selectedMicrophone!!.deviceInfo.type && (
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
it.address == selectedMicrophone!!.deviceInfo.address
|
|
||||||
} else true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (newDevice != null) {
|
|
||||||
changeMicrophone(MicrophoneInfo.fromDeviceInfo(newDevice))
|
|
||||||
|
|
||||||
onMicrophoneReconnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
|
||||||
super.onAudioDevicesRemoved(removedDevices)
|
|
||||||
|
|
||||||
if (selectedMicrophone == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
|
|
||||||
onMicrophoneDisconnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerMicrophoneListener() {
|
|
||||||
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
|
||||||
|
|
||||||
audioManager.registerAudioDeviceCallback(
|
|
||||||
audioDeviceCallback,
|
|
||||||
Handler(Looper.getMainLooper())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterMicrophoneListener() {
|
|
||||||
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
|
||||||
|
|
||||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==== Settings ====
|
|
||||||
override fun getRecordingInformation() =
|
|
||||||
RecordingInformation(
|
|
||||||
folderPath = batchesFolder.exportFolderForSettings(),
|
|
||||||
recordingStart = recordingStart,
|
|
||||||
maxDuration = settings.maxDuration,
|
|
||||||
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
|
|
||||||
fileExtension = settings.audioRecorderSettings.fileExtension,
|
|
||||||
intervalDuration = settings.intervalDuration,
|
|
||||||
type = RecordingInformation.Type.AUDIO,
|
|
||||||
)
|
|
||||||
}
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
abstract class ExtraRecorderInformationService: RecorderService() {
|
||||||
|
abstract fun getAmplitudeAmount(): Int
|
||||||
|
abstract fun getAmplitude(): Int
|
||||||
|
|
||||||
|
var amplitudes = mutableListOf<Int>()
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun updateAmplitude() {
|
||||||
|
if (state !== RecorderState.RECORDING) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amplitudes.add(getAmplitude())
|
||||||
|
onAmplitudeChange?.invoke(amplitudes)
|
||||||
|
|
||||||
|
// Delete old amplitudes
|
||||||
|
if (amplitudes.size > getAmplitudeAmount()) {
|
||||||
|
amplitudes.drop(amplitudes.size - getAmplitudeAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.postDelayed(::updateAmplitude, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAmplitudesTimer() {
|
||||||
|
handler.postDelayed(::updateAmplitude, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
createAmplitudesTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resume() {
|
||||||
|
createAmplitudesTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,45 +1,45 @@
|
|||||||
package app.myzel394.alibi.services
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
import android.content.Context
|
||||||
import app.myzel394.alibi.helpers.BatchesFolder
|
import android.media.MediaRecorder
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||||
|
import app.myzel394.alibi.db.LastRecording
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Executor
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
abstract class IntervalRecorderService<I, B : BatchesFolder> :
|
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
||||||
RecorderService() {
|
private var job = SupervisorJob()
|
||||||
protected var counter = 0L
|
private var scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
protected var counter = 0
|
||||||
private set
|
private set
|
||||||
|
protected lateinit var folder: File
|
||||||
// Tracks the index of the currently locked file
|
var settings: Settings? = null
|
||||||
private var lockedIndex: Long? = null
|
protected set
|
||||||
|
|
||||||
lateinit var settings: AppSettings
|
|
||||||
|
|
||||||
private lateinit var cycleTimer: ScheduledExecutorService
|
private lateinit var cycleTimer: ScheduledExecutorService
|
||||||
|
|
||||||
abstract var batchesFolder: B
|
fun createLastRecording(): LastRecording = LastRecording(
|
||||||
|
folderPath = folder.absolutePath,
|
||||||
var onBatchesFolderNotAccessible: () -> Unit = {}
|
recordingStart = recordingStart,
|
||||||
|
maxDuration = settings!!.maxDuration,
|
||||||
abstract fun getRecordingInformation(): I
|
fileExtension = settings!!.fileExtension,
|
||||||
|
intervalDuration = settings!!.intervalDuration,
|
||||||
// When saving the recording, the files should be locked.
|
forceExactMaxDuration = settings!!.forceExactMaxDuration,
|
||||||
// This prevents the service from deleting the currently available files, so that
|
)
|
||||||
// they can be safely used to save the recording.
|
|
||||||
// Once finished, make sure to unlock the files using `unlockFiles`.
|
|
||||||
fun lockFiles() {
|
|
||||||
lockedIndex = counter
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlocks and deletes the files that were locked using `lockFiles`.
|
|
||||||
fun unlockFiles(cleanupFiles: Boolean = false) {
|
|
||||||
if (cleanupFiles) {
|
|
||||||
batchesFolder.deleteRecordings(0..<lockedIndex!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
lockedIndex = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make overrideable
|
// Make overrideable
|
||||||
open fun startNewCycle() {
|
open fun startNewCycle() {
|
||||||
@ -50,56 +50,103 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :
|
|||||||
private fun createTimer() {
|
private fun createTimer() {
|
||||||
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
it.scheduleAtFixedRate(
|
it.scheduleAtFixedRate(
|
||||||
::startNewCycle,
|
{
|
||||||
|
startNewCycle()
|
||||||
|
},
|
||||||
0,
|
0,
|
||||||
settings.intervalDuration,
|
settings!!.intervalDuration,
|
||||||
TimeUnit.MILLISECONDS
|
TimeUnit.MILLISECONDS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getRandomFileFolder(): String {
|
||||||
|
// uuid
|
||||||
|
val folder = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
return "${externalCacheDir!!.absolutePath}/$folder"
|
||||||
|
}
|
||||||
|
|
||||||
override fun start() {
|
override fun start() {
|
||||||
super.start()
|
super.start()
|
||||||
|
|
||||||
batchesFolder.initFolders()
|
folder = File(getRandomFileFolder())
|
||||||
|
folder.mkdirs()
|
||||||
|
|
||||||
if (!batchesFolder.checkIfFolderIsAccessible()) {
|
scope.launch {
|
||||||
onBatchesFolderNotAccessible()
|
dataStore.data.collectLatest { preferenceSettings ->
|
||||||
|
if (settings == null) {
|
||||||
|
settings = Settings.from(preferenceSettings.audioRecorderSettings)
|
||||||
|
|
||||||
throw AvoidErrorDialogError()
|
createTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
super.pause()
|
|
||||||
cycleTimer.shutdown()
|
cycleTimer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resume() {
|
override fun resume() {
|
||||||
super.resume()
|
|
||||||
createTimer()
|
createTimer()
|
||||||
|
|
||||||
|
// We first want to start our timers, so the `ExtraRecorderInformationService` can fetch
|
||||||
|
// amplitudes
|
||||||
|
super.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun stop() {
|
override fun stop() {
|
||||||
cycleTimer.shutdown()
|
cycleTimer.shutdown()
|
||||||
batchesFolder.cleanup()
|
|
||||||
super.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearAllRecordings() {
|
|
||||||
batchesFolder.deleteRecordings()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteOldRecordings() {
|
private fun deleteOldRecordings() {
|
||||||
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
|
||||||
val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
|
val earliestCounter = counter - timeMultiplier
|
||||||
|
|
||||||
if (earliestCounter <= 0) {
|
folder.listFiles()?.forEach { file ->
|
||||||
return
|
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
||||||
|
|
||||||
|
if (fileCounter < earliestCounter) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
batchesFolder.deleteRecordings(0..earliestCounter)
|
data class Settings(
|
||||||
|
val maxDuration: Long,
|
||||||
|
val intervalDuration: Long,
|
||||||
|
val forceExactMaxDuration: Boolean,
|
||||||
|
val bitRate: Int,
|
||||||
|
val samplingRate: Int,
|
||||||
|
val outputFormat: Int,
|
||||||
|
val encoder: Int,
|
||||||
|
) {
|
||||||
|
val fileExtension: String
|
||||||
|
get() = when(outputFormat) {
|
||||||
|
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||||
|
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||||
|
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||||
|
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||||
|
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||||
|
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||||
|
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||||
|
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||||
|
else -> "raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
||||||
|
return Settings(
|
||||||
|
intervalDuration = audioRecorderSettings.intervalDuration,
|
||||||
|
bitRate = audioRecorderSettings.bitRate,
|
||||||
|
samplingRate = audioRecorderSettings.getSamplingRate(),
|
||||||
|
outputFormat = audioRecorderSettings.getOutputFormat(),
|
||||||
|
encoder = audioRecorderSettings.getEncoder(),
|
||||||
|
maxDuration = audioRecorderSettings.maxDuration,
|
||||||
|
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,10 +0,0 @@
|
|||||||
# services
|
|
||||||
|
|
||||||
This folder contains all available services.
|
|
||||||
|
|
||||||
## VideoRecorderService
|
|
||||||
|
|
||||||
I found it a bit confusing on how to properly handle the services, so I made this diagram
|
|
||||||
to help me understand it better. I hope it helps you too.
|
|
||||||
|
|
||||||

|
|
@ -1,159 +0,0 @@
|
|||||||
package app.myzel394.alibi.services
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import app.myzel394.alibi.MainActivity
|
|
||||||
import app.myzel394.alibi.NotificationHelper
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.db.NotificationSettings
|
|
||||||
import app.myzel394.alibi.enums.RecorderState
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
data class RecorderNotificationHelper(
|
|
||||||
val context: Context,
|
|
||||||
val details: NotificationDetails? = null,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class NotificationDetails(
|
|
||||||
val title: String,
|
|
||||||
val description: String,
|
|
||||||
val icon: Int,
|
|
||||||
val isOngoing: Boolean,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun fromNotificationSettings(
|
|
||||||
context: Context,
|
|
||||||
settings: NotificationSettings,
|
|
||||||
): NotificationDetails {
|
|
||||||
return if (settings.preset == null) {
|
|
||||||
NotificationDetails(
|
|
||||||
settings.title,
|
|
||||||
settings.message,
|
|
||||||
settings.iconID,
|
|
||||||
settings.showOngoing,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
NotificationDetails(
|
|
||||||
context.getString(settings.preset.titleID),
|
|
||||||
context.getString(settings.preset.messageID),
|
|
||||||
settings.preset.iconID,
|
|
||||||
settings.preset.showOngoing,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getNotificationChangeStateIntent(
|
|
||||||
newState: RecorderState,
|
|
||||||
requestCode: Int
|
|
||||||
): PendingIntent {
|
|
||||||
return PendingIntent.getService(
|
|
||||||
context,
|
|
||||||
requestCode,
|
|
||||||
Intent(context, context::class.java).apply {
|
|
||||||
action = "changeState"
|
|
||||||
putExtra("newState", newState.name)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getIconID(): Int = details?.icon ?: R.drawable.launcher_monochrome_noopacity
|
|
||||||
|
|
||||||
private fun createBaseNotification(): NotificationCompat.Builder {
|
|
||||||
return NotificationCompat.Builder(
|
|
||||||
context,
|
|
||||||
NotificationHelper.RECORDER_CHANNEL_ID
|
|
||||||
)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
||||||
.setSmallIcon(getIconID())
|
|
||||||
.setContentIntent(
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
Intent(context, MainActivity::class.java),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setSilent(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setChronometerCountDown(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStringForRecorder(audioRes: Int, videoRes: Int): String =
|
|
||||||
when (context::class.java) {
|
|
||||||
AudioRecorderService::class.java -> context.getString(audioRes)
|
|
||||||
|
|
||||||
VideoRecorderService::class.java -> context.getString(videoRes)
|
|
||||||
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildStartingNotification(): Notification {
|
|
||||||
return createBaseNotification()
|
|
||||||
.setContentTitle(
|
|
||||||
getStringForRecorder(
|
|
||||||
R.string.ui_audioRecorder_state_recording_title,
|
|
||||||
R.string.ui_videoRecorder_state_recording_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setContentText(context.getString(R.string.ui_recorder_state_recording_description))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildRecordingNotification(recordingTime: Long): Notification {
|
|
||||||
return createBaseNotification()
|
|
||||||
.setUsesChronometer(details?.isOngoing ?: true)
|
|
||||||
.setOngoing(details?.isOngoing ?: true)
|
|
||||||
.setShowWhen(details?.isOngoing ?: true)
|
|
||||||
.setWhen(
|
|
||||||
Date.from(
|
|
||||||
Calendar
|
|
||||||
.getInstance()
|
|
||||||
.also { it.add(Calendar.SECOND, -recordingTime.toInt()) }
|
|
||||||
.toInstant()
|
|
||||||
).time,
|
|
||||||
)
|
|
||||||
.addAction(
|
|
||||||
R.drawable.ic_pause,
|
|
||||||
context.getString(R.string.ui_recorder_action_pause_label),
|
|
||||||
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
|
|
||||||
)
|
|
||||||
.setContentTitle(
|
|
||||||
details?.title
|
|
||||||
?: getStringForRecorder(
|
|
||||||
R.string.ui_audioRecorder_state_recording_title,
|
|
||||||
R.string.ui_videoRecorder_state_recording_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setContentText(
|
|
||||||
details?.description
|
|
||||||
?: context.getString(R.string.ui_recorder_state_recording_description)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildPausedNotification(start: LocalDateTime): Notification {
|
|
||||||
return createBaseNotification()
|
|
||||||
.setContentTitle(context.getString(R.string.ui_recorder_state_paused_title))
|
|
||||||
.setContentText(context.getString(R.string.ui_recorder_state_paused_description))
|
|
||||||
.setOngoing(false)
|
|
||||||
.setUsesChronometer(false)
|
|
||||||
.setWhen(Date.from(start.atZone(ZoneId.systemDefault()).toInstant()).time)
|
|
||||||
.addAction(
|
|
||||||
R.drawable.ic_play,
|
|
||||||
context.getString(R.string.ui_recorder_action_resume_label),
|
|
||||||
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,117 +2,58 @@ package app.myzel394.alibi.services
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import app.myzel394.alibi.MainActivity
|
||||||
import app.myzel394.alibi.NotificationHelper
|
import app.myzel394.alibi.NotificationHelper
|
||||||
|
import app.myzel394.alibi.R
|
||||||
import app.myzel394.alibi.enums.RecorderState
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
||||||
abstract class RecorderService : LifecycleService() {
|
abstract class RecorderService: Service() {
|
||||||
private val binder = RecorderBinder()
|
private val binder = RecorderBinder()
|
||||||
|
|
||||||
private var isPaused: Boolean = false
|
private var isPaused: Boolean = false
|
||||||
|
|
||||||
lateinit var recordingStart: LocalDateTime
|
lateinit var recordingStart: LocalDateTime
|
||||||
private set
|
private set
|
||||||
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
|
||||||
private var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
|
||||||
|
|
||||||
var state = RecorderState.IDLE
|
var state = RecorderState.IDLE
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var onStateChange: ((RecorderState) -> Unit)? = null
|
var onStateChange: ((RecorderState) -> Unit)? = null
|
||||||
var onError: () -> Unit = {}
|
|
||||||
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
|
||||||
|
|
||||||
var recordingTime = 0L
|
var recordingTime = 0L
|
||||||
private set
|
private set
|
||||||
|
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
||||||
|
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
||||||
|
|
||||||
protected open fun start() {
|
protected abstract fun start()
|
||||||
createRecordingTimeTimer()
|
protected abstract fun pause()
|
||||||
}
|
protected abstract fun resume()
|
||||||
|
protected abstract fun stop()
|
||||||
|
|
||||||
protected open fun pause() {
|
override fun onBind(p0: Intent?): IBinder? = binder
|
||||||
isPaused = true
|
|
||||||
|
|
||||||
recordingTimeTimer.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected open fun resume() {
|
|
||||||
createRecordingTimeTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected open suspend fun stop() {
|
|
||||||
recordingTimeTimer.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract fun startForegroundService()
|
|
||||||
|
|
||||||
fun startRecording() {
|
|
||||||
recordingStart = LocalDateTime.now()
|
|
||||||
|
|
||||||
startForegroundService()
|
|
||||||
changeState(RecorderState.RECORDING)
|
|
||||||
|
|
||||||
try {
|
|
||||||
start()
|
|
||||||
} catch (error: RuntimeException) {
|
|
||||||
error.printStackTrace()
|
|
||||||
|
|
||||||
if (error !is AvoidErrorDialogError) {
|
|
||||||
onError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun stopRecording() {
|
|
||||||
changeState(RecorderState.STOPPED)
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pauseRecording() {
|
|
||||||
changeState(RecorderState.PAUSED)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resumeRecording() {
|
|
||||||
changeState(RecorderState.RECORDING)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() {
|
|
||||||
NotificationManagerCompat.from(this)
|
|
||||||
.cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
super.onBind(intent)
|
|
||||||
return binder
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
"init" -> {
|
|
||||||
notificationDetails = intent.getStringExtra("notificationDetails")?.let {
|
|
||||||
Json.decodeFromString(
|
|
||||||
RecorderNotificationHelper.NotificationDetails.serializer(),
|
|
||||||
it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"changeState" -> {
|
"changeState" -> {
|
||||||
val newState = intent.getStringExtra("newState")?.let {
|
val newState = intent.getStringExtra("newState")?.let {
|
||||||
RecorderState.valueOf(it)
|
RecorderState.valueOf(it)
|
||||||
} ?: RecorderState.STOPPED
|
} ?: RecorderState.IDLE
|
||||||
changeState(newState)
|
changeState(newState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,7 +61,7 @@ abstract class RecorderService : LifecycleService() {
|
|||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class RecorderBinder : Binder() {
|
inner class RecorderBinder: Binder() {
|
||||||
fun getService(): RecorderService = this@RecorderService
|
fun getService(): RecorderService = this@RecorderService
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,19 +69,16 @@ abstract class RecorderService : LifecycleService() {
|
|||||||
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
it.scheduleAtFixedRate(
|
it.scheduleAtFixedRate(
|
||||||
{
|
{
|
||||||
recordingTime += 1
|
recordingTime += 1000
|
||||||
onRecordingTimeChange?.invoke(recordingTime)
|
onRecordingTimeChange?.invoke(recordingTime)
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
1,
|
1000,
|
||||||
TimeUnit.SECONDS
|
TimeUnit.MILLISECONDS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used to change the state of the service
|
|
||||||
// will internally call start() / pause() / resume() / stop()
|
|
||||||
// Immediately after creating the service make sure to call `changeState(RecorderState.RECORDING)`
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun changeState(newState: RecorderState) {
|
fun changeState(newState: RecorderState) {
|
||||||
if (state == newState) {
|
if (state == newState) {
|
||||||
@ -153,57 +91,151 @@ abstract class RecorderService : LifecycleService() {
|
|||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
resume()
|
resume()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
|
} else {
|
||||||
|
start()
|
||||||
}
|
}
|
||||||
// `start` is handled by `startRecording`
|
|
||||||
}
|
}
|
||||||
|
RecorderState.PAUSED -> {
|
||||||
RecorderState.PAUSED -> pause()
|
pause()
|
||||||
|
isPaused = true
|
||||||
else -> {}
|
}
|
||||||
|
RecorderState.IDLE -> {
|
||||||
|
stop()
|
||||||
|
onDestroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update notification
|
when (newState) {
|
||||||
|
RecorderState.RECORDING -> {
|
||||||
|
createRecordingTimeTimer()
|
||||||
|
}
|
||||||
|
RecorderState.PAUSED, RecorderState.IDLE -> {
|
||||||
|
recordingTimeTimer.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
arrayOf(
|
arrayOf(
|
||||||
RecorderState.RECORDING,
|
RecorderState.RECORDING,
|
||||||
RecorderState.PAUSED
|
RecorderState.PAUSED
|
||||||
).contains(newState) &&
|
).contains(newState) &&
|
||||||
PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)
|
PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
) {
|
){
|
||||||
val notification = buildNotification()
|
val notification = buildNotification()
|
||||||
NotificationManagerCompat.from(this).notify(
|
NotificationManagerCompat.from(this).notify(
|
||||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
||||||
notification
|
notification
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onStateChange?.invoke(newState)
|
onStateChange?.invoke(newState)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun getNotificationHelper(): RecorderNotificationHelper {
|
// Must be immediately called after creating the service!
|
||||||
return RecorderNotificationHelper(this, notificationDetails)
|
fun startRecording() {
|
||||||
|
recordingStart = LocalDateTime.now()
|
||||||
|
|
||||||
|
val notification = buildStartNotification()
|
||||||
|
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
// Start
|
||||||
|
changeState(RecorderState.RECORDING)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(): Notification {
|
override fun onDestroy() {
|
||||||
val notificationHelper = getNotificationHelper()
|
super.onDestroy()
|
||||||
|
|
||||||
return when (state) {
|
changeState(RecorderState.IDLE)
|
||||||
RecorderState.RECORDING -> {
|
|
||||||
notificationHelper.buildRecordingNotification(recordingTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecorderState.PAUSED -> {
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
notificationHelper.buildPausedNotification(recordingStart)
|
NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
|
||||||
}
|
stopSelf()
|
||||||
|
|
||||||
else -> {
|
|
||||||
throw IllegalStateException("Notification can't be built in state $state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildStartNotification(): Notification = NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
|
||||||
|
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
|
||||||
|
.setSmallIcon(R.drawable.launcher_foreground)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.build()
|
||||||
|
|
||||||
// Throw this error if you show a dialog yourself.
|
private fun getNotificationChangeStateIntent(newState: RecorderState, requestCode: Int): PendingIntent {
|
||||||
// This will prevent the service from showing their generic error dialog.
|
return PendingIntent.getService(
|
||||||
class AvoidErrorDialogError : RuntimeException()
|
this,
|
||||||
|
requestCode,
|
||||||
|
Intent(this, AudioRecorderService::class.java).apply {
|
||||||
|
action = "changeState"
|
||||||
|
putExtra("newState", newState.name)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification = when(state) {
|
||||||
|
RecorderState.RECORDING -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
|
||||||
|
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
|
||||||
|
.setSmallIcon(R.drawable.launcher_foreground)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setWhen(
|
||||||
|
Date.from(
|
||||||
|
Calendar
|
||||||
|
.getInstance()
|
||||||
|
.also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
|
||||||
|
.toInstant()
|
||||||
|
).time,
|
||||||
|
)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setUsesChronometer(true)
|
||||||
|
.setChronometerCountDown(false)
|
||||||
|
.setContentIntent(
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_cancel,
|
||||||
|
getString(R.string.ui_audioRecorder_action_delete_label),
|
||||||
|
getNotificationChangeStateIntent(RecorderState.IDLE, 1),
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_pause,
|
||||||
|
getString(R.string.ui_audioRecorder_action_pause_label),
|
||||||
|
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
RecorderState.PAUSED -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.ui_audioRecorder_state_paused_title))
|
||||||
|
.setContentText(getString(R.string.ui_audioRecorder_state_paused_description))
|
||||||
|
.setSmallIcon(R.drawable.launcher_foreground)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setUsesChronometer(false)
|
||||||
|
.setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setContentIntent(
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_play,
|
||||||
|
getString(R.string.ui_audioRecorder_action_resume_label),
|
||||||
|
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
else -> throw IllegalStateException("Invalid state passed to `buildNotification()`")
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,347 +0,0 @@
|
|||||||
package app.myzel394.alibi.services
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Range
|
|
||||||
import androidx.camera.core.Camera
|
|
||||||
import androidx.camera.core.CameraSelector
|
|
||||||
import androidx.camera.core.TorchState
|
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
||||||
import androidx.camera.video.FileDescriptorOutputOptions
|
|
||||||
import androidx.camera.video.FileOutputOptions
|
|
||||||
import androidx.camera.video.MediaStoreOutputOptions
|
|
||||||
import androidx.camera.video.Quality
|
|
||||||
import androidx.camera.video.QualitySelector
|
|
||||||
import androidx.camera.video.Recorder
|
|
||||||
import androidx.camera.video.Recording
|
|
||||||
import androidx.camera.video.VideoCapture
|
|
||||||
import androidx.camera.video.VideoRecordEvent
|
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import app.myzel394.alibi.NotificationHelper
|
|
||||||
import app.myzel394.alibi.db.RecordingInformation
|
|
||||||
import app.myzel394.alibi.enums.RecorderState
|
|
||||||
import app.myzel394.alibi.helpers.BatchesFolder
|
|
||||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
|
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlin.properties.Delegates
|
|
||||||
|
|
||||||
class VideoRecorderService :
|
|
||||||
IntervalRecorderService<RecordingInformation, VideoBatchesFolder>() {
|
|
||||||
override var batchesFolder = VideoBatchesFolder.viaInternalFolder(this)
|
|
||||||
|
|
||||||
private val job = SupervisorJob()
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + job)
|
|
||||||
|
|
||||||
private var camera: Camera? = null
|
|
||||||
private var cameraProvider: ProcessCameraProvider? = null
|
|
||||||
private var videoCapture: VideoCapture<Recorder>? = null
|
|
||||||
private var activeRecording: Recording? = null
|
|
||||||
|
|
||||||
// Used to listen and check if the camera is available
|
|
||||||
private var _cameraAvailableListener = CompletableDeferred<Unit>()
|
|
||||||
private lateinit var _videoFinalizerListener: CompletableDeferred<Unit>;
|
|
||||||
|
|
||||||
// Absolute last completer that can be awaited to ensure that the camera is closed
|
|
||||||
private var _cameraCloserListener = CompletableDeferred<Unit>()
|
|
||||||
|
|
||||||
private lateinit var selectedCamera: CameraSelector
|
|
||||||
private var enableAudio by Delegates.notNull<Boolean>()
|
|
||||||
|
|
||||||
var onCameraControlAvailable = {}
|
|
||||||
|
|
||||||
var cameraControl: CameraControl? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
if (intent?.action == "init") {
|
|
||||||
selectedCamera = CameraSelector.Builder().requireLensFacing(
|
|
||||||
intent.getIntExtra("cameraID", CameraSelector.LENS_FACING_BACK)
|
|
||||||
).build()
|
|
||||||
enableAudio = intent.getBooleanExtra("enableAudio", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
super.start()
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
openCamera()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun stop() {
|
|
||||||
super.stop()
|
|
||||||
|
|
||||||
stopActiveRecording()
|
|
||||||
|
|
||||||
// Camera can only be closed after the recording has been finalized
|
|
||||||
withTimeoutOrNull(CAMERA_CLOSE_TIMEOUT) {
|
|
||||||
_videoFinalizerListener.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
closeCamera()
|
|
||||||
|
|
||||||
withTimeoutOrNull(CAMERA_CLOSE_TIMEOUT) {
|
|
||||||
_cameraCloserListener.await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pause() {
|
|
||||||
super.pause()
|
|
||||||
|
|
||||||
stopActiveRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startForegroundService() {
|
|
||||||
ServiceCompat.startForeground(
|
|
||||||
this,
|
|
||||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
|
||||||
getNotificationHelper().buildStartingNotification(),
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
if (enableAudio)
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
||||||
else
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override fun startNewCycle() {
|
|
||||||
super.startNewCycle()
|
|
||||||
|
|
||||||
fun action() {
|
|
||||||
stopActiveRecording()
|
|
||||||
val newRecording = prepareVideoRecording()
|
|
||||||
|
|
||||||
_videoFinalizerListener = CompletableDeferred()
|
|
||||||
|
|
||||||
activeRecording = newRecording.start(ContextCompat.getMainExecutor(this)) { event ->
|
|
||||||
if (event is VideoRecordEvent.Finalize && (this@VideoRecorderService.state == RecorderState.STOPPED || this@VideoRecorderService.state == RecorderState.PAUSED)) {
|
|
||||||
_videoFinalizerListener.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_cameraAvailableListener.isCompleted) {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
// Race condition of `startNewCycle` being called before `invokeOnCompletion`
|
|
||||||
// has been called can be ignored, as the camera usually opens within 5 seconds
|
|
||||||
// and the interval can't be set shorter than 10 seconds.
|
|
||||||
_cameraAvailableListener.invokeOnCompletion {
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Runs a function in the main thread
|
|
||||||
private fun runOnMain(callback: () -> Unit) {
|
|
||||||
val mainHandler = ContextCompat.getMainExecutor(this)
|
|
||||||
|
|
||||||
mainHandler.execute(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildRecorder() = Recorder.Builder()
|
|
||||||
.setQualitySelector(
|
|
||||||
settings.videoRecorderSettings.getQualitySelector()
|
|
||||||
?: QualitySelector.from(Quality.HIGHEST)
|
|
||||||
)
|
|
||||||
.apply {
|
|
||||||
if (settings.videoRecorderSettings.targetedVideoBitRate != null) {
|
|
||||||
setTargetVideoEncodingBitRate(settings.videoRecorderSettings.targetedVideoBitRate!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun buildVideoCapture(recorder: Recorder) = VideoCapture.Builder(recorder)
|
|
||||||
.apply {
|
|
||||||
val frameRate = settings.videoRecorderSettings.targetFrameRate
|
|
||||||
if (frameRate != null) {
|
|
||||||
setTargetFrameRate(Range(frameRate, frameRate))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// Open the camera.
|
|
||||||
// Used to open it for a longer time, shouldn't be called when pausing / resuming.
|
|
||||||
// This should only be called when starting a recording.
|
|
||||||
private suspend fun openCamera() {
|
|
||||||
cameraProvider = withContext(Dispatchers.IO) {
|
|
||||||
ProcessCameraProvider.getInstance(this@VideoRecorderService).get()
|
|
||||||
}
|
|
||||||
|
|
||||||
val recorder = buildRecorder()
|
|
||||||
videoCapture = buildVideoCapture(recorder)
|
|
||||||
|
|
||||||
runOnMain {
|
|
||||||
try {
|
|
||||||
camera = cameraProvider!!.bindToLifecycle(
|
|
||||||
this,
|
|
||||||
selectedCamera,
|
|
||||||
videoCapture
|
|
||||||
)
|
|
||||||
|
|
||||||
cameraControl = CameraControl(camera!!).also {
|
|
||||||
it.init()
|
|
||||||
}
|
|
||||||
onCameraControlAvailable()
|
|
||||||
|
|
||||||
_cameraAvailableListener.complete(Unit)
|
|
||||||
} catch (error: IllegalArgumentException) {
|
|
||||||
onError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the camera
|
|
||||||
// Used to close it finally, shouldn't be called when pausing / resuming.
|
|
||||||
// This should only be called after recording has finished.
|
|
||||||
private fun closeCamera() {
|
|
||||||
runOnMain {
|
|
||||||
runCatching {
|
|
||||||
cameraProvider?.unbindAll()
|
|
||||||
}
|
|
||||||
_cameraCloserListener.complete(Unit)
|
|
||||||
|
|
||||||
// Doesn't need to run on main thread, but
|
|
||||||
// if it runs outside `runOnMain`, `cameraProvider` is already null
|
|
||||||
// before it's unbound
|
|
||||||
cameraProvider = null
|
|
||||||
videoCapture = null
|
|
||||||
camera = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `resume` override not needed as `startNewCycle` is called by `IntervalRecorderService`
|
|
||||||
|
|
||||||
private fun stopActiveRecording() {
|
|
||||||
runCatching {
|
|
||||||
activeRecording?.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getNameForMediaFile() =
|
|
||||||
"${batchesFolder.mediaPrefix}$counter.${settings.videoRecorderSettings.fileExtension}"
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission", "NewApi")
|
|
||||||
private fun prepareVideoRecording() =
|
|
||||||
videoCapture!!.output
|
|
||||||
.let {
|
|
||||||
if (batchesFolder.type == BatchesFolder.BatchType.CUSTOM && SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
|
|
||||||
it.prepareRecording(
|
|
||||||
this,
|
|
||||||
FileDescriptorOutputOptions.Builder(
|
|
||||||
batchesFolder.asCustomGetParcelFileDescriptor(
|
|
||||||
counter,
|
|
||||||
settings.videoRecorderSettings.fileExtension
|
|
||||||
)
|
|
||||||
).build()
|
|
||||||
)
|
|
||||||
} else if (batchesFolder.type == BatchesFolder.BatchType.MEDIA) {
|
|
||||||
if (SUPPORTS_SCOPED_STORAGE) {
|
|
||||||
val name = getNameForMediaFile()
|
|
||||||
|
|
||||||
it.prepareRecording(
|
|
||||||
this,
|
|
||||||
MediaStoreOutputOptions
|
|
||||||
.Builder(
|
|
||||||
contentResolver,
|
|
||||||
batchesFolder.scopedMediaContentUri,
|
|
||||||
)
|
|
||||||
.setContentValues(
|
|
||||||
batchesFolder.asMediaGetScopedStorageContentValues(
|
|
||||||
name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val name = getNameForMediaFile()
|
|
||||||
|
|
||||||
it.prepareRecording(
|
|
||||||
this,
|
|
||||||
FileOutputOptions
|
|
||||||
.Builder(batchesFolder.asMediaGetLegacyFile(name))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
it.prepareRecording(
|
|
||||||
this,
|
|
||||||
FileOutputOptions.Builder(
|
|
||||||
batchesFolder.asInternalGetFile(
|
|
||||||
counter,
|
|
||||||
settings.videoRecorderSettings.fileExtension
|
|
||||||
).apply {
|
|
||||||
createNewFile()
|
|
||||||
}
|
|
||||||
).build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.run {
|
|
||||||
if (enableAudio) {
|
|
||||||
return@run withAudioEnabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRecordingInformation() =
|
|
||||||
RecordingInformation(
|
|
||||||
folderPath = batchesFolder.exportFolderForSettings(),
|
|
||||||
recordingStart = recordingStart,
|
|
||||||
maxDuration = settings.maxDuration,
|
|
||||||
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
|
|
||||||
fileExtension = settings.videoRecorderSettings.fileExtension,
|
|
||||||
intervalDuration = settings.intervalDuration,
|
|
||||||
type = RecordingInformation.Type.VIDEO,
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CAMERA_CLOSE_TIMEOUT = 20000L
|
|
||||||
}
|
|
||||||
|
|
||||||
class CameraControl(
|
|
||||||
val camera: Camera,
|
|
||||||
// Save state for optimistic updates
|
|
||||||
var torchEnabled: Boolean = false,
|
|
||||||
) {
|
|
||||||
fun init() {
|
|
||||||
torchEnabled = camera.cameraInfo.torchState.value == TorchState.ON
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enableTorch() {
|
|
||||||
torchEnabled = true
|
|
||||||
camera.cameraControl.enableTorch(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disableTorch() {
|
|
||||||
torchEnabled = false
|
|
||||||
camera.cameraControl.enableTorch(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isHardwareTorchReallyEnabled(): Boolean {
|
|
||||||
return camera.cameraInfo.torchState.value == TorchState.ON
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasTorchAvailable() = camera.cameraInfo.hasFlashUnit()
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 13 KiB |
@ -1,151 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Fingerprint
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ElevatedButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.helpers.AppLockHelper
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
// After this amount, close the app
|
|
||||||
const val MAX_TRIES = 5
|
|
||||||
|
|
||||||
// Makes sure the app needs to be unlocked first, if app lock is enabled
|
|
||||||
@Composable
|
|
||||||
fun AsLockedApp(
|
|
||||||
content: (@Composable () -> Unit),
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val settings = context
|
|
||||||
.dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = null)
|
|
||||||
.value ?: return
|
|
||||||
|
|
||||||
// -1 = Unlocked, any other value = locked
|
|
||||||
var tries by remember {
|
|
||||||
mutableIntStateOf(
|
|
||||||
if (settings.isAppLockEnabled()) 0 else -1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tries == -1) {
|
|
||||||
return content()
|
|
||||||
}
|
|
||||||
|
|
||||||
val title = stringResource(R.string.identityVerificationRequired_title)
|
|
||||||
val subtitle = stringResource(R.string.identityVerificationRequired_subtitle)
|
|
||||||
|
|
||||||
fun openAuthentication() {
|
|
||||||
if (tries >= MAX_TRIES) {
|
|
||||||
AppLockHelper.closeApp(context)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
val successful = AppLockHelper.authenticate(
|
|
||||||
context,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
).await()
|
|
||||||
|
|
||||||
if (successful) {
|
|
||||||
tries = -1
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
tries++
|
|
||||||
|
|
||||||
if (tries >= MAX_TRIES) {
|
|
||||||
AppLockHelper.closeApp(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(settings.isAppLockEnabled()) {
|
|
||||||
if (settings.isAppLockEnabled()) {
|
|
||||||
openAuthentication()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold { paddingValues ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Box {}
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Fingerprint,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.ui_locked_title),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ElevatedButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
|
||||||
onClick = ::openAuthentication,
|
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.ui_locked_unlocked),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,80 +1,6 @@
|
|||||||
package app.myzel394.alibi.ui
|
package app.myzel394.alibi.ui
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
|
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
|
||||||
val BIG_PRIMARY_BUTTON_MAX_WIDTH = 450.dp
|
|
||||||
|
|
||||||
val SHEET_BOTTOM_OFFSET = 24.dp
|
|
||||||
val MAX_AMPLITUDE = 20000
|
val MAX_AMPLITUDE = 20000
|
||||||
val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
|
||||||
|
|
||||||
val MEDIA_SUBFOLDER_NAME = "alibi"
|
|
||||||
|
|
||||||
val SUPPORTS_SCOPED_STORAGE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
|
||||||
val SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
|
||||||
val MEDIA_RECORDINGS_PREFIX = "alibi-recording-"
|
|
||||||
val RECORDER_MEDIA_SELECTED_VALUE = "_'media"
|
|
||||||
val RECORDER_INTERNAL_SELECTED_VALUE = "_'internal"
|
|
||||||
|
|
||||||
val VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME = ".video_recordings"
|
|
||||||
val AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME = ".audio_recordings"
|
|
||||||
|
|
||||||
// You are not allowed to change the constants below.
|
|
||||||
// If you do so, you will be blocked on GitHub.
|
|
||||||
const val REPO_URL = "https://github.com/Myzel394/Alibi"
|
|
||||||
const val TRANSLATION_HELP_URL = "https://crowdin.com/project/alibi"
|
|
||||||
const val GITHUB_SPONSORS_URL = "https://github.com/sponsors/Myzel394"
|
|
||||||
const val PUBLIC_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK-----
|
|
||||||
|
|
||||||
mDMEZTfvnhYJKwYBBAHaRw8BAQdAi2AiLsTaBoLhnQtY5vi3xBU/H428wbNfBSe+
|
|
||||||
2dhz3r60Jk15emVsMzk0IDxnaXRodWIuN2Eyb3BAc2ltcGxlbG9naW4uY28+iJkE
|
|
||||||
ExYKAEEWIQR9BS8nNHwqrNgV0B3NE0dCwel5WQUCZTfvngIbAwUJEswDAAULCQgH
|
|
||||||
AgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRDNE0dCwel5WcS8AQCf9g6eEaut1suW
|
|
||||||
l6jCLIg3b1nWLckmLJaonM6PruUtigEAmVnFOxMpOZEIcILT8CD2Riy+IVN9gTNH
|
|
||||||
qOHnaFsu8AK4OARlN++eEgorBgEEAZdVAQUBAQdAe4ffDtRundKH9kam746i2TBu
|
|
||||||
P9sfb3QVi5QqfK+bek8DAQgHiH4EGBYKACYWIQR9BS8nNHwqrNgV0B3NE0dCwel5
|
|
||||||
WQUCZTfvngIbDAUJEswDAAAKCRDNE0dCwel5WWwSAQDj4ZAl6bSqwbcptEMYQaPM
|
|
||||||
MMhMafm446MjkhQioeXw+wEAzA8mS6RBx7IZvu1dirmFHXOEYJclwjyQhNs4uEjq
|
|
||||||
/Ak=
|
|
||||||
=ICHe
|
|
||||||
-----END PGP PUBLIC KEY BLOCK-----"""
|
|
||||||
const val PUBLIC_KEY_FINGERPRINT = "7D05 2F27 347C 2AAC D815 D01D CD13 4742 C1E9 7959"
|
|
||||||
val CRYPTO_DONATIONS = mapOf(
|
|
||||||
"Bitcoin" to "bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6",
|
|
||||||
"Bitcoin Cash" to "qr9s64vfqedvurfef9ykf7szchmt0xyvnga452fc8l",
|
|
||||||
"Ethereum" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
|
|
||||||
"Tether USD" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
|
|
||||||
"Monero" to "83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8",
|
|
||||||
"Zcash" to "t1ZfvNpzfdaW6csT9Kc7iJA7LUU3hmNj2sx",
|
|
||||||
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
|
|
||||||
"Dash" to "XcTkni8CVAXBcuc5VwvHmsYftVK4CPLetU",
|
|
||||||
"Avalanche" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
|
|
||||||
"XRP" to "rNpfDm8UwDTumCebchBadjVW2FEPteFgNg",
|
|
||||||
"Solana" to "2h6CB3hz5Vb2nYS1RQiXZ4aWTzc5frBPR7Sp1b4muFqb",
|
|
||||||
"ADA" to "addr1q8vy2vcp6lacaw8lkc29gufuzajaytc5qc0c2mxlmw5lndxcg5esr4lm36u0lds523cnc9m96gh3gpsls4kdlkaflx6qf6qpvc",
|
|
||||||
"Dogecoin" to "DUA4j7mVoc7Rvezu8YgeRKwxNuMzKeDoxD",
|
|
||||||
"Tron" to "THWVLGhne5wDsGjd1CNenHDKQGzvGzrzLb",
|
|
||||||
"Polkadot" to "1642iaR6AoKyM6qnnMHkfCRfRqRKJ2wC6Cm3UEWEFEz6EtZR",
|
|
||||||
"Cosmos" to "cosmos1vt5z6rfj5sgnkdlddkuu8srw3xupyqxscva9hz",
|
|
||||||
"Algorand" to "QBOQ6VSLMD77QEF33P5J3HKGOM5RZLNO6P5P3FTWCMQM3ORF6QY2W34KUI",
|
|
||||||
"Tezos" to "tz1QUWNYuFqDibGCrwmkdaHSpTx3d6ZdxLMi",
|
|
||||||
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
|
|
||||||
"Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Base64encoding these values so that bots can't easily scrape them.
|
|
||||||
val b64d = Base64.getDecoder()
|
|
||||||
val CONTACT_METHODS = mapOf<String, String>(
|
|
||||||
"E-Mail" to String(b64d.decode("Z2" + "9vZ2xlLXBsYX" + "k" + "uMjlrMWFAYWxlZWFzL" + "mNvbQo=")).trim(),
|
|
||||||
"GitHub" to String(
|
|
||||||
b64d.decode(
|
|
||||||
"aHR" +
|
|
||||||
"0cHM6Ly9n" + "a" + "XRodWIuY29t" + "L015emVsMzk0L2NvbnRhY3QtbWUK"
|
|
||||||
)
|
|
||||||
).trim(),
|
|
||||||
"Mastodon" to String(b64d.decode("T" + "X" + "l6Z" + "WwzOTRAbWFzdG9kb24uc29" + "jaWFsCg" + "==")).trim(),
|
|
||||||
"Reddit" to "https://reddit.com/u/Myzel394"
|
|
||||||
)
|
|
||||||
|
@ -1,84 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui
|
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Error
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.helpers.Doctor
|
|
||||||
|
|
||||||
// Handlers that can safely be run when the app is locked (biometric authentication required)
|
|
||||||
@Composable
|
|
||||||
fun LockedAppHandlers() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val settings = context
|
|
||||||
.dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = null)
|
|
||||||
.value ?: return
|
|
||||||
|
|
||||||
LaunchedEffect(settings.theme) {
|
|
||||||
if (!SUPPORTS_DARK_MODE_NATIVELY) {
|
|
||||||
val currentValue = AppCompatDelegate.getDefaultNightMode()
|
|
||||||
|
|
||||||
if (settings.theme == AppSettings.Theme.LIGHT && currentValue != AppCompatDelegate.MODE_NIGHT_NO) {
|
|
||||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
|
||||||
} else if (settings.theme == AppSettings.Theme.DARK && currentValue != AppCompatDelegate.MODE_NIGHT_YES) {
|
|
||||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showFileSaverUnavailableDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
val doctor = Doctor(context)
|
|
||||||
|
|
||||||
if (!doctor.checkIfFileSaverDialogIsAvailable()) {
|
|
||||||
showFileSaverUnavailableDialog = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showFileSaverUnavailableDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Error,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onDismissRequest = {
|
|
||||||
showFileSaverUnavailableDialog = false
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.ui_severeError_fileSaverUnavailable_title))
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.ui_severeError_fileSaverUnavailable_text))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showFileSaverUnavailableDialog = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.dialog_close_neutral_label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,37 +5,34 @@ import androidx.compose.animation.fadeIn
|
|||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.LastRecording
|
||||||
import app.myzel394.alibi.ui.enums.Screen
|
import app.myzel394.alibi.ui.enums.Screen
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
import app.myzel394.alibi.ui.screens.AudioRecorder
|
||||||
import app.myzel394.alibi.ui.screens.AboutScreen
|
|
||||||
import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen
|
|
||||||
import app.myzel394.alibi.ui.screens.RecorderScreen
|
|
||||||
import app.myzel394.alibi.ui.screens.SettingsScreen
|
import app.myzel394.alibi.ui.screens.SettingsScreen
|
||||||
import app.myzel394.alibi.ui.screens.WelcomeScreen
|
import app.myzel394.alibi.ui.screens.WelcomeScreen
|
||||||
|
|
||||||
const val SCALE_IN = 1.25f
|
const val SCALE_IN = 1.25f
|
||||||
const val DEBUG_SKIP_WELCOME = false;
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Navigation(
|
fun Navigation(
|
||||||
audioRecorder: AudioRecorderModel = viewModel(),
|
audioRecorder: AudioRecorderModel = viewModel()
|
||||||
videoRecorder: VideoRecorderModel = viewModel(),
|
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -47,11 +44,9 @@ fun Navigation(
|
|||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
audioRecorder.bindToService(context)
|
audioRecorder.bindToService(context)
|
||||||
videoRecorder.bindToService(context)
|
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
audioRecorder.unbindFromService(context)
|
audioRecorder.unbindFromService(context)
|
||||||
videoRecorder.unbindFromService(context)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,18 +54,10 @@ fun Navigation(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (settings.hasSeenOnboarding || DEBUG_SKIP_WELCOME) Screen.AudioRecorder.route else Screen.Welcome.route,
|
startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route,
|
||||||
) {
|
) {
|
||||||
composable(Screen.Welcome.route) {
|
composable(Screen.Welcome.route) {
|
||||||
WelcomeScreen(
|
WelcomeScreen(navController = navController)
|
||||||
onNavigateToAudioRecorderScreen = {
|
|
||||||
val mainHandler = ContextCompat.getMainExecutor(context)
|
|
||||||
|
|
||||||
mainHandler.execute {
|
|
||||||
navController.navigate(Screen.AudioRecorder.route)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
Screen.AudioRecorder.route,
|
Screen.AudioRecorder.route,
|
||||||
@ -84,13 +71,9 @@ fun Navigation(
|
|||||||
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
|
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
RecorderScreen(
|
AudioRecorder(
|
||||||
onNavigateToSettingsScreen = {
|
navController = navController,
|
||||||
navController.navigate(Screen.Settings.route)
|
|
||||||
},
|
|
||||||
audioRecorder = audioRecorder,
|
audioRecorder = audioRecorder,
|
||||||
videoRecorder = videoRecorder,
|
|
||||||
settings = settings,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@ -103,43 +86,8 @@ fun Navigation(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBackNavigate = navController::popBackStack,
|
navController = navController,
|
||||||
onNavigateToCustomRecordingNotifications = {
|
|
||||||
navController.navigate(Screen.CustomRecordingNotifications.route)
|
|
||||||
},
|
|
||||||
onNavigateToAboutScreen = { navController.navigate(Screen.About.route) },
|
|
||||||
audioRecorder = audioRecorder,
|
audioRecorder = audioRecorder,
|
||||||
videoRecorder = videoRecorder,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
Screen.CustomRecordingNotifications.route,
|
|
||||||
enterTransition = {
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { it -> it / 2 }
|
|
||||||
) + fadeIn()
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = { it -> it / 2 }
|
|
||||||
) + fadeOut(tween(150))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CustomRecordingNotificationsScreen(
|
|
||||||
onBackNavigate = navController::popBackStack
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
Screen.About.route,
|
|
||||||
enterTransition = {
|
|
||||||
scaleIn()
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
scaleOut() + fadeOut(tween(150))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
AboutScreen(
|
|
||||||
onBackNavigate = navController::popBackStack,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
# ui
|
|
||||||
|
|
||||||
This folder contains all user interfaces. The folder is structured as follows:
|
|
||||||
|
|
||||||
* `components`: contains all reusable components
|
|
||||||
* `atoms`, `molecules`, `organisms`, `pages`: contains components that are generic and can be
|
|
||||||
reused in different contexts
|
|
||||||
* `<name>Screen/{atoms,molecules,organisms,pages}`: contains components that are specific to a
|
|
||||||
screen
|
|
||||||
* `screens`: contains all screens. Screens are composed of components from the `components` folder
|
|
||||||
* `models`: contains view models used by the screens
|
|
||||||
* `utils`: contains general utility functions
|
|
||||||
|
|
||||||
The root Kotlin files are used for the general setup of the UI.
|
|
@ -1,184 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.AboutScreen.atoms
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyFranc
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyLira
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyPound
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyRuble
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyRupee
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyYen
|
|
||||||
import androidx.compose.material.icons.filled.CurrencyYuan
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.CRYPTO_DONATIONS
|
|
||||||
import app.myzel394.alibi.ui.GITHUB_SPONSORS_URL
|
|
||||||
import app.myzel394.alibi.ui.PUBLIC_KEY
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DonationsTile() {
|
|
||||||
var donationsOpened by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val label = stringResource(R.string.ui_about_contribute_donatation)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.clickable {
|
|
||||||
donationsOpened = !donationsOpened
|
|
||||||
}
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
listOf(
|
|
||||||
Icons.Default.CurrencyBitcoin,
|
|
||||||
Icons.Default.CurrencyFranc,
|
|
||||||
Icons.Default.CurrencyLira,
|
|
||||||
Icons.Default.CurrencyPound,
|
|
||||||
Icons.Default.CurrencyRuble,
|
|
||||||
Icons.Default.CurrencyRupee,
|
|
||||||
Icons.Default.CurrencyYen,
|
|
||||||
Icons.Default.CurrencyYuan,
|
|
||||||
).asSequence().shuffled().first(),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_about_contribute_donatation),
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val rotation by animateFloatAsState(
|
|
||||||
if (donationsOpened) -180f else 0f,
|
|
||||||
label = "iconRotation"
|
|
||||||
)
|
|
||||||
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize.times(1.2f))
|
|
||||||
.rotate(rotation)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val clipboardManager =
|
|
||||||
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = donationsOpened,
|
|
||||||
enter = expandVertically(),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
uriHandler.openUri(GITHUB_SPONSORS_URL)
|
|
||||||
},
|
|
||||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_github),
|
|
||||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_about_contribute_donation_githubSponsors)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for (crypto in CRYPTO_DONATIONS) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.clickable {
|
|
||||||
val clip = ClipData.newPlainText("text", crypto.value)
|
|
||||||
clipboardManager.setPrimaryClip(clip)
|
|
||||||
}
|
|
||||||
.padding(16.dp)
|
|
||||||
.horizontalScroll(rememberScrollState()),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ContentCopy,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
crypto.key,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
crypto.value,
|
|
||||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize.times(0.5),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,77 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.AboutScreen.atoms
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Key
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.PUBLIC_KEY_FINGERPRINT
|
|
||||||
import app.myzel394.alibi.ui.PUBLIC_KEY
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GPGKeyOverview() {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
|
||||||
)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Key,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(48.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_about_gpg_key_hint),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
val clipboardManager =
|
|
||||||
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
Text(
|
|
||||||
PUBLIC_KEY_FINGERPRINT,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
.padding(8.dp),
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
val clip = ClipData.newPlainText("text", PUBLIC_KEY)
|
|
||||||
clipboardManager.setPrimaryClip(clip)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ui_about_gpg_key_copy))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -29,7 +29,7 @@ fun AudioVisualizer(
|
|||||||
val width = this.size.width
|
val width = this.size.width
|
||||||
val boxWidth = width / amplitudes.size
|
val boxWidth = width / amplitudes.size
|
||||||
|
|
||||||
amplitudes.forEachIndexed { index, amplitude ->
|
amplitudes.forEachIndexed {index, amplitude ->
|
||||||
val x = boxWidth * index
|
val x = boxWidth * index
|
||||||
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
||||||
val boxHeight = height * amplitudePercentage
|
val boxHeight = height * amplitudePercentage
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -11,7 +11,6 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -29,10 +28,10 @@ fun ConfirmDeletionDialog(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(stringResource(R.string.ui_recorder_action_delete_confirm_title))
|
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_title))
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(stringResource(R.string.ui_recorder_action_delete_confirm_message))
|
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_message))
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
@ -41,13 +40,12 @@ fun ConfirmDeletionDialog(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
val label = stringResource(R.string.ui_recorder_action_delete_label)
|
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = label
|
contentDescription = label
|
||||||
},
|
},
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
onConfirm()
|
onConfirm()
|
||||||
},
|
},
|
||||||
@ -63,15 +61,15 @@ fun ConfirmDeletionDialog(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
val label = stringResource(R.string.dialog_close_cancel_label)
|
val label = stringResource(R.string.dialog_close_cancel_label)
|
||||||
TextButton(
|
Button(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = label
|
contentDescription = label
|
||||||
},
|
},
|
||||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Cancel,
|
Icons.Default.Cancel,
|
@ -1,19 +1,16 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.gestures.rememberTransformableState
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.gestures.transformable
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@ -22,6 +19,7 @@ import androidx.compose.ui.graphics.drawscope.translate
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.services.RecorderService
|
||||||
import app.myzel394.alibi.ui.MAX_AMPLITUDE
|
import app.myzel394.alibi.ui.MAX_AMPLITUDE
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
import app.myzel394.alibi.ui.utils.clamp
|
import app.myzel394.alibi.ui.utils.clamp
|
||||||
@ -37,11 +35,10 @@ private const val GROW_END = BOX_DIFF * 4
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RealtimeAudioVisualizer(
|
fun RealtimeAudioVisualizer(
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
audioRecorder: AudioRecorderModel,
|
audioRecorder: AudioRecorderModel,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val amplitudes = audioRecorder.amplitudes
|
val amplitudes = audioRecorder.amplitudes!!
|
||||||
val primary = MaterialTheme.colorScheme.primary
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
val primaryMuted = primary.copy(alpha = 0.3f)
|
val primaryMuted = primary.copy(alpha = 0.3f)
|
||||||
|
|
||||||
@ -66,28 +63,17 @@ fun RealtimeAudioVisualizer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
// Use greater value of width and height to make sure the amplitudes are shown
|
val screenWidth = with (LocalDensity.current) {configuration.screenWidthDp.dp.toPx()}
|
||||||
// when the user rotates the device
|
|
||||||
val availableSpace = with(LocalDensity.current) {
|
|
||||||
Math.max(
|
|
||||||
configuration.screenWidthDp.dp.toPx(),
|
|
||||||
configuration.screenHeightDp.dp.toPx()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(availableSpace) {
|
LaunchedEffect(screenWidth) {
|
||||||
// Add 1 to allow the visualizer to overflow the screen
|
// Add 1 to allow the visualizer to overflow the screen
|
||||||
audioRecorder.setMaxAmplitudesAmount(ceil(availableSpace.toInt() / BOX_DIFF).toInt() + 1)
|
audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
|
||||||
val transformState = rememberTransformableState { zoomChange, _, _ ->
|
|
||||||
scale *= zoomChange
|
|
||||||
}
|
|
||||||
val amplitudePercentageModifier = MAX_AMPLITUDE * (1 / scale)
|
|
||||||
|
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = modifier.transformable(transformState),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(300.dp),
|
||||||
) {
|
) {
|
||||||
val height = this.size.height / 2f
|
val height = this.size.height / 2f
|
||||||
val width = this.size.width
|
val width = this.size.width
|
||||||
@ -100,11 +86,9 @@ fun RealtimeAudioVisualizer(
|
|||||||
val isOverThreshold = offset >= GROW_START_INDEX
|
val isOverThreshold = offset >= GROW_START_INDEX
|
||||||
val horizontalProgress = (
|
val horizontalProgress = (
|
||||||
clamp(horizontalValue, GROW_START, GROW_END)
|
clamp(horizontalValue, GROW_START, GROW_END)
|
||||||
- GROW_START) / (GROW_END - GROW_START)
|
- GROW_START) / (GROW_END - GROW_START)
|
||||||
val amplitudePercentage =
|
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
||||||
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
|
val boxHeight = (height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
||||||
val boxHeight =
|
|
||||||
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
|
||||||
|
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = if (amplitudePercentage > 0.05f && isOverThreshold) primary else primaryMuted,
|
color = if (amplitudePercentage > 0.05f && isOverThreshold) primary else primaryMuted,
|
@ -0,0 +1,110 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Memory
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.services.RecorderService
|
||||||
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SaveRecordingButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
service: RecorderService,
|
||||||
|
onSaveFile: (File) -> Unit,
|
||||||
|
label: String = stringResource(R.string.ui_audioRecorder_action_save_label),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var isProcessingAudio by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (isProcessingAudio)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Memory,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
LinearProgressIndicator()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {}
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.then(modifier),
|
||||||
|
onClick = {
|
||||||
|
isProcessingAudio = true
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.getStackTraceString(error)
|
||||||
|
} finally {
|
||||||
|
isProcessingAudio = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Save,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,219 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LargeFloatingActionButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.services.RecorderService
|
||||||
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
||||||
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
|
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||||
|
import app.myzel394.alibi.ui.utils.formatDuration
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecordingStatus(
|
||||||
|
audioRecorder: AudioRecorderModel,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
now = LocalDateTime.now()
|
||||||
|
delay(900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show animation when the recording has just started
|
||||||
|
val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
|
||||||
|
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
progressVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
KeepScreenOn()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Box {}
|
||||||
|
RealtimeAudioVisualizer(audioRecorder = audioRecorder)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Pulsating {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Red)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = formatDuration(audioRecorder.recordingTime!!),
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = progressVisible,
|
||||||
|
enter = expandHorizontally(
|
||||||
|
tween(1000)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = audioRecorder.progress,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(300.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
ConfirmDeletionDialog(
|
||||||
|
onDismiss = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
},
|
||||||
|
onConfirm = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
audioRecorder.stopRecording(context, saveAsLastRecording = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showDeleteDialog = true
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
|
||||||
|
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
|
||||||
|
LargeFloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = if (audioRecorder.isPaused) resumeLabel else pauseLabel
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (audioRecorder.isPaused) {
|
||||||
|
audioRecorder.resumeRecording()
|
||||||
|
} else {
|
||||||
|
audioRecorder.pauseRecording()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (audioRecorder.isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
|
||||||
|
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||||
|
.alpha(alpha)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
audioRecorder.stopRecording(context)
|
||||||
|
}
|
||||||
|
audioRecorder.onRecordingSave()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Save,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.ui_audioRecorder_action_save_label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,147 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||||
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
|
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StartRecording(
|
||||||
|
audioRecorder: AudioRecorderModel,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
PermissionRequester(
|
||||||
|
permission = Manifest.permission.RECORD_AUDIO,
|
||||||
|
icon = Icons.Default.Mic,
|
||||||
|
onPermissionAvailable = {
|
||||||
|
audioRecorder.startRecording(context)
|
||||||
|
},
|
||||||
|
) { trigger ->
|
||||||
|
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
trigger()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.size(200.dp)
|
||||||
|
.clip(shape = CircleShape),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Mic,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val settings = LocalContext
|
||||||
|
.current
|
||||||
|
.dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_audioRecorder_action_start_description, settings.audioRecorderSettings.maxDuration / 1000 / 60),
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Bottom,
|
||||||
|
) {
|
||||||
|
val label = stringResource(
|
||||||
|
R.string.ui_audioRecorder_action_saveOldRecording_label,
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(),
|
||||||
|
onClick = {
|
||||||
|
audioRecorder.stopRecording(context)
|
||||||
|
audioRecorder.onRecordingSave()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Save,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
@ -1,110 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowRightAlt
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.utils.openNotificationsSettings
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LandingElement(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onOpenEditor: () -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 32.dp, vertical = 64.dp)
|
|
||||||
.then(modifier),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Box() {}
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.ic_custom_recording_notifications_blob),
|
|
||||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.tertiaryContainer),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(512.dp)
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Notifications,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_customNotifications_landing_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_customNotifications_landing_description),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
FilledTonalButton(onClick = onOpenEditor) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Edit,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_settings_customNotifications_landing_getStarted
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = context::openNotificationsSettings,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.db.NotificationSettings
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NotificationPresetSelect(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
preset: NotificationSettings.Preset
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(MaterialTheme.shapes.large)
|
|
||||||
.then(modifier)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
PreviewIcon(
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
painter = painterResource(id = preset.iconID),
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(preset.titleID),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(preset.messageID),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PreviewIcon(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
painter: Painter,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.then(modifier)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.secondary)
|
|
||||||
.padding(2.dp)
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painter,
|
|
||||||
contentDescription = null,
|
|
||||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models
|
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.db.NotificationSettings
|
|
||||||
|
|
||||||
class NotificationViewModel : ViewModel() {
|
|
||||||
// We want to show the actual translated strings of the preset
|
|
||||||
// in the preview but don't want to save them to the database
|
|
||||||
// because they should be retrieved in the notification itself.
|
|
||||||
// Thus we save whether the preset has been changed by the user
|
|
||||||
private var _presetChanged = false
|
|
||||||
|
|
||||||
private var _title = mutableStateOf("")
|
|
||||||
val title: String
|
|
||||||
get() = _title.value
|
|
||||||
private var _description = mutableStateOf("")
|
|
||||||
val description: String
|
|
||||||
get() = _description.value
|
|
||||||
|
|
||||||
var showOngoing: Boolean by mutableStateOf(true)
|
|
||||||
var icon: Int by mutableIntStateOf(R.drawable.launcher_monochrome_noopacity)
|
|
||||||
|
|
||||||
// `preset` can't be used as a variable name here because
|
|
||||||
// the compiler throws a strange error then
|
|
||||||
var notificationPreset: NotificationSettings.Preset? by mutableStateOf(null)
|
|
||||||
|
|
||||||
private var _hasBeenInitialized = false;
|
|
||||||
|
|
||||||
|
|
||||||
fun setPreset(title: String, description: String, preset: NotificationSettings.Preset) {
|
|
||||||
_presetChanged = false
|
|
||||||
|
|
||||||
_title.value = title
|
|
||||||
_description.value = description
|
|
||||||
showOngoing = preset.showOngoing
|
|
||||||
icon = preset.iconID
|
|
||||||
this.notificationPreset = preset
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(title: String) {
|
|
||||||
_presetChanged = true
|
|
||||||
_title.value = title
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDescription(description: String) {
|
|
||||||
_presetChanged = true
|
|
||||||
_description.value = description
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initialize(
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
showOngoing: Boolean = true,
|
|
||||||
icon: Int = R.drawable.launcher_monochrome_noopacity,
|
|
||||||
) {
|
|
||||||
_title.value = title
|
|
||||||
_description.value = description
|
|
||||||
this.showOngoing = showOngoing
|
|
||||||
this.icon = icon
|
|
||||||
_hasBeenInitialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun asNotificationSettings(): NotificationSettings {
|
|
||||||
return if (!_presetChanged && notificationPreset != null) {
|
|
||||||
NotificationSettings.fromPreset(notificationPreset!!)
|
|
||||||
} else {
|
|
||||||
NotificationSettings(
|
|
||||||
title = title,
|
|
||||||
message = description,
|
|
||||||
iconID = icon,
|
|
||||||
showOngoing = showOngoing,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,171 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Circle
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.PreviewIcon
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberForceUpdate
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EditNotificationInput(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
showOngoing: Boolean,
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
icon: Painter,
|
|
||||||
onShowOngoingChange: (Boolean) -> Unit,
|
|
||||||
onTitleChange: (String) -> Unit,
|
|
||||||
onDescriptionChange: (String) -> Unit,
|
|
||||||
onIconChange: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
var ongoingStartTime by remember { mutableStateOf(LocalDateTime.now()) }
|
|
||||||
|
|
||||||
val secondaryColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
|
|
||||||
LaunchedEffect(showOngoing) {
|
|
||||||
if (showOngoing) {
|
|
||||||
ongoingStartTime = LocalDateTime.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
|
|
||||||
.padding(16.dp)
|
|
||||||
.then(modifier),
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
val headlineSize = 22.dp
|
|
||||||
|
|
||||||
PreviewIcon(
|
|
||||||
modifier = Modifier.size(headlineSize),
|
|
||||||
painter = icon,
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier.height(headlineSize),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.app_name),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = secondaryColor,
|
|
||||||
)
|
|
||||||
if (showOngoing) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Circle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = secondaryColor,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
val fakeAlpha = rememberForceUpdate()
|
|
||||||
val formattedTime = {
|
|
||||||
val difference =
|
|
||||||
Duration.between(
|
|
||||||
ongoingStartTime,
|
|
||||||
LocalDateTime.now(),
|
|
||||||
)
|
|
||||||
val minutes = difference.toMinutes()
|
|
||||||
val seconds = difference.minusMinutes(minutes).seconds
|
|
||||||
|
|
||||||
"${if (minutes < 10) "0$minutes" else minutes}:${if (seconds < 10) "0$seconds" else seconds}"
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
formattedTime(),
|
|
||||||
modifier = Modifier.alpha(fakeAlpha),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = secondaryColor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = title,
|
|
||||||
onValueChange = onTitleChange,
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Text,
|
|
||||||
imeAction = ImeAction.Next,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = description,
|
|
||||||
onValueChange = onDescriptionChange,
|
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Text,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_recorder_action_delete_label),
|
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
|
||||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_recorder_action_pause_label),
|
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
|
||||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.db.NotificationSettings
|
|
||||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.NotificationPresetSelect
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun NotificationPresetsRoulette(
|
|
||||||
onClick: (String, String, NotificationSettings.Preset) -> Unit,
|
|
||||||
) {
|
|
||||||
val state = rememberLazyListState()
|
|
||||||
|
|
||||||
LazyRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
state = state,
|
|
||||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state)
|
|
||||||
) {
|
|
||||||
items(NotificationSettings.PRESETS.size) {
|
|
||||||
val preset = NotificationSettings.PRESETS[it]
|
|
||||||
|
|
||||||
val label = stringResource(
|
|
||||||
R.string.ui_settings_customNotifications_preset_apply_label,
|
|
||||||
stringResource(preset.titleID)
|
|
||||||
)
|
|
||||||
val presetTitle = stringResource(preset.titleID)
|
|
||||||
val presetDescription = stringResource(preset.messageID)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.width(
|
|
||||||
LocalConfiguration.current.screenWidthDp.dp,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
NotificationPresetSelect(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(.95f)
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.clickable {
|
|
||||||
onClick(
|
|
||||||
presetTitle,
|
|
||||||
presetDescription,
|
|
||||||
preset,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
preset = preset,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,203 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.organisms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.CheckboxDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.NotificationSettings
|
|
||||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models.NotificationViewModel
|
|
||||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.EditNotificationInput
|
|
||||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.NotificationPresetsRoulette
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
|
||||||
|
|
||||||
val HORIZONTAL_PADDING = 16.dp;
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NotificationEditor(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
notificationModel: NotificationViewModel = viewModel(),
|
|
||||||
onNotificationChange: (NotificationSettings) -> Unit,
|
|
||||||
) {
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
if (settings.notificationSettings != null) {
|
|
||||||
val title = settings.notificationSettings.let {
|
|
||||||
if (it.preset != null)
|
|
||||||
stringResource(it.preset.titleID)
|
|
||||||
else
|
|
||||||
it.title
|
|
||||||
}
|
|
||||||
val description = settings.notificationSettings.let {
|
|
||||||
if (it.preset != null)
|
|
||||||
stringResource(it.preset.messageID)
|
|
||||||
else
|
|
||||||
it.message
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
notificationModel.initialize(
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
settings.notificationSettings.showOngoing,
|
|
||||||
settings.notificationSettings.iconID,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (settings.notificationSettings.preset != null) {
|
|
||||||
notificationModel.setPreset(
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
settings.notificationSettings.preset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title)
|
|
||||||
val defaultDescription =
|
|
||||||
stringResource(R.string.ui_recorder_state_recording_description)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
notificationModel.initialize(
|
|
||||||
defaultTitle,
|
|
||||||
defaultDescription,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.then(modifier),
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = HORIZONTAL_PADDING),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
MessageBox(
|
|
||||||
type = MessageType.SURFACE,
|
|
||||||
message = stringResource(R.string.ui_settings_customNotifications_edit_help)
|
|
||||||
)
|
|
||||||
|
|
||||||
EditNotificationInput(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
showOngoing = notificationModel.showOngoing,
|
|
||||||
title = notificationModel.title,
|
|
||||||
description = notificationModel.description,
|
|
||||||
icon = painterResource(notificationModel.icon),
|
|
||||||
onShowOngoingChange = {
|
|
||||||
notificationModel.showOngoing = it
|
|
||||||
},
|
|
||||||
onTitleChange = notificationModel::setTitle,
|
|
||||||
onDescriptionChange = notificationModel::setDescription,
|
|
||||||
onIconChange = {
|
|
||||||
notificationModel.icon = it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.clickable {
|
|
||||||
notificationModel.showOngoing = notificationModel.showOngoing.not()
|
|
||||||
}
|
|
||||||
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
|
||||||
.padding(8.dp),
|
|
||||||
) {
|
|
||||||
Checkbox(
|
|
||||||
checked = notificationModel.showOngoing,
|
|
||||||
onCheckedChange = {
|
|
||||||
notificationModel.showOngoing = it
|
|
||||||
},
|
|
||||||
colors = CheckboxDefaults.colors(
|
|
||||||
checkedColor = MaterialTheme.colorScheme.tertiary,
|
|
||||||
checkmarkColor = MaterialTheme.colorScheme.onTertiary,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.ui_settings_customNotifications_showOngoing_label),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
) {
|
|
||||||
NotificationPresetsRoulette(
|
|
||||||
onClick = notificationModel::setPreset,
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onNotificationChange(
|
|
||||||
notificationModel.asNotificationSettings()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = HORIZONTAL_PADDING)
|
|
||||||
.height(48.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize)
|
|
||||||
)
|
|
||||||
Spacer(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(ButtonDefaults.IconSpacing)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_customNotifications_save_label)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Error
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BatchesInaccessibleDialog(
|
|
||||||
onClose: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Error,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.ui_recorder_error_recording_title))
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.ui_recorder_error_batchesInaccessible_description))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onClose) {
|
|
||||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,85 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun BigButton(
|
|
||||||
label: String,
|
|
||||||
icon: ImageVector,
|
|
||||||
description: String? = null,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit = {},
|
|
||||||
isBig: Boolean? = null,
|
|
||||||
) {
|
|
||||||
val orientation = LocalConfiguration.current.orientation
|
|
||||||
|
|
||||||
BoxWithConstraints {
|
|
||||||
val isLarge = isBig
|
|
||||||
?: (maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(if (isLarge) 250.dp else 190.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.combinedClickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = ripple(color = MaterialTheme.colorScheme.primary),
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(if (isLarge) 80.dp else 60.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.camera.core.CameraSelector
|
|
||||||
import androidx.camera.core.Preview
|
|
||||||
import androidx.camera.view.PreviewView
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import app.myzel394.alibi.ui.utils.getCameraProvider
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CameraPreview(
|
|
||||||
modifier: Modifier,
|
|
||||||
cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
|
||||||
) {
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
// Video preview
|
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
|
||||||
val previewView = PreviewView(context).apply {
|
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val previewUseCase = Preview.Builder()
|
|
||||||
.build()
|
|
||||||
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
|
||||||
|
|
||||||
coroutineScope.launch {
|
|
||||||
val cameraProvider = context.getCameraProvider()
|
|
||||||
try {
|
|
||||||
cameraProvider.unbindAll()
|
|
||||||
cameraProvider.bindToLifecycle(
|
|
||||||
lifecycleOwner,
|
|
||||||
cameraSelector,
|
|
||||||
previewUseCase
|
|
||||||
)
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Log.e("CameraPreview", "Use case binding failed", ex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previewView
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,103 +0,0 @@
|
|||||||
import androidx.camera.core.ExperimentalLensFacing
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Camera
|
|
||||||
import androidx.compose.material.icons.filled.Person
|
|
||||||
import androidx.compose.material.icons.filled.QuestionMark
|
|
||||||
import androidx.compose.material.icons.filled.Videocam
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CameraSelectionButton(
|
|
||||||
cameraID: CameraInfo.Lens,
|
|
||||||
selected: Boolean,
|
|
||||||
onSelected: () -> Unit,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
) {
|
|
||||||
val backgroundColor by animateColorAsState(
|
|
||||||
targetValue = if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(
|
|
||||||
alpha = 0.2f
|
|
||||||
) else Color.Transparent,
|
|
||||||
// Make animation about 0.5x faster than default
|
|
||||||
animationSpec = spring(
|
|
||||||
stiffness = Spring.StiffnessLow,
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
),
|
|
||||||
label = "backgroundColor"
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.clickable(onClick = onSelected)
|
|
||||||
.background(backgroundColor)
|
|
||||||
.padding(vertical = 8.dp, horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = selected,
|
|
||||||
onClick = onSelected,
|
|
||||||
)
|
|
||||||
if (description == null) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
CAMERA_LENS_ICON_MAP[cameraID]!!,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val CAMERA_LENS_ICON_MAP = mapOf(
|
|
||||||
CameraInfo.Lens.BACK to Icons.Default.Camera,
|
|
||||||
CameraInfo.Lens.FRONT to Icons.Default.Person,
|
|
||||||
CameraInfo.Lens.EXTERNAL to Icons.Default.Videocam,
|
|
||||||
CameraInfo.Lens.UNKNOWN to Icons.Default.QuestionMark,
|
|
||||||
)
|
|
@ -1,53 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DeleteButton(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showDeleteDialog) {
|
|
||||||
ConfirmDeletionDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showDeleteDialog = false
|
|
||||||
},
|
|
||||||
onConfirm = {
|
|
||||||
showDeleteDialog = false
|
|
||||||
onDelete()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val label = stringResource(R.string.ui_recorder_action_delete_label)
|
|
||||||
TextButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.then(modifier),
|
|
||||||
onClick = {
|
|
||||||
showDeleteDialog = true
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.helpers.BatchesFolder
|
|
||||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.VisualDensity
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LowStorageInfo(
|
|
||||||
modifier: Modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
appSettings: AppSettings,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val availableBytes =
|
|
||||||
VideoBatchesFolder.importFromFolder(appSettings.saveFolder, context).getAvailableBytes()
|
|
||||||
|
|
||||||
if (availableBytes == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings)
|
|
||||||
val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute
|
|
||||||
|
|
||||||
// Allow for a 10% margin of error
|
|
||||||
val isLowOnStorage = availableBytes < requiredBytes * 1.1
|
|
||||||
println("LowStorageInfo: availableBytes: $availableBytes, requiredBytes: $requiredBytes, isLowOnStorage: $isLowOnStorage")
|
|
||||||
|
|
||||||
if (isLowOnStorage)
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
BoxWithConstraints {
|
|
||||||
val isLarge = maxHeight > 600.dp;
|
|
||||||
|
|
||||||
MessageBox(
|
|
||||||
type = MessageType.WARNING,
|
|
||||||
message = if (appSettings.saveFolder == null)
|
|
||||||
stringResource(R.string.ui_recorder_lowOnStorage_hintANDswitchSaveFolder)
|
|
||||||
else stringResource(R.string.ui_recorder_lowOnStorage_hint),
|
|
||||||
density = if (isLarge) VisualDensity.COMFORTABLE else VisualDensity.COMPACT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.MicOff
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneDisconnectedDialog(
|
|
||||||
microphoneName: String,
|
|
||||||
onClose: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_audioRecorder_error_microphoneDisconnected_title,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
|
|
||||||
microphoneName,
|
|
||||||
microphoneName,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.MicOff,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val label = stringResource(R.string.dialog_close_neutral_label)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
modifier = Modifier
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
},
|
|
||||||
onClick = onClose,
|
|
||||||
) {
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,62 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Star
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneReconnectedDialog(
|
|
||||||
microphoneName: String,
|
|
||||||
onClose: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_audioRecorder_error_microphoneReconnected_title,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_audioRecorder_error_microphoneReconnected_message,
|
|
||||||
microphoneName,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Star,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val label = stringResource(R.string.dialog_close_neutral_label)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
modifier = Modifier
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
},
|
|
||||||
onClick = onClose,
|
|
||||||
) {
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,86 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.MicExternalOn
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneSelectionButton(
|
|
||||||
microphone: MicrophoneInfo? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
selectedAsFallback: Boolean = false,
|
|
||||||
disabled: Boolean = false,
|
|
||||||
onSelect: () -> Unit,
|
|
||||||
) {
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
// Copied from Android's [FilledButtonTokens]
|
|
||||||
val disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onSelect,
|
|
||||||
enabled = !disabled,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(64.dp),
|
|
||||||
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
|
|
||||||
contentPadding = if (selected) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.TextButtonContentPadding,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing),
|
|
||||||
) {
|
|
||||||
MicrophoneTypeInfo(
|
|
||||||
type = microphone?.type ?: MicrophoneInfo.MicrophoneType.PHONE,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = microphone?.name
|
|
||||||
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone),
|
|
||||||
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
|
|
||||||
)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && settings.audioRecorderSettings.showAllMicrophones && microphone?.deviceInfo?.address?.isNotBlank() == true)
|
|
||||||
Text(
|
|
||||||
microphone.deviceInfo.address.toString(),
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.toSpanStyle().fontSize,
|
|
||||||
color = if (disabled) disabledTextColor else if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.secondary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (selectedAsFallback)
|
|
||||||
Icon(
|
|
||||||
Icons.Default.MicExternalOn,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.BluetoothAudio
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.material.icons.filled.MicExternalOn
|
|
||||||
import androidx.compose.material.icons.filled.Smartphone
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneTypeInfo(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
type: MicrophoneInfo.MicrophoneType,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = when (type) {
|
|
||||||
MicrophoneInfo.MicrophoneType.BLUETOOTH -> Icons.Filled.BluetoothAudio
|
|
||||||
MicrophoneInfo.MicrophoneType.WIRED -> Icons.Filled.MicExternalOn
|
|
||||||
MicrophoneInfo.MicrophoneType.PHONE -> Icons.Filled.Smartphone
|
|
||||||
MicrophoneInfo.MicrophoneType.OTHER -> Icons.Filled.Mic
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Pause
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PauseResumeButton(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
isPaused: Boolean,
|
|
||||||
onChange: () -> Unit,
|
|
||||||
) {
|
|
||||||
val pauseLabel = stringResource(R.string.ui_recorder_action_pause_label)
|
|
||||||
val resumeLabel = stringResource(R.string.ui_recorder_action_resume_label)
|
|
||||||
|
|
||||||
FloatingActionButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.semantics {
|
|
||||||
contentDescription = if (isPaused) resumeLabel else pauseLabel
|
|
||||||
}
|
|
||||||
.then(modifier),
|
|
||||||
onClick = onChange,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RecorderErrorDialog(
|
|
||||||
onClose: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Warning,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.ui_recorder_error_recording_title))
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.ui_recorder_error_recording_description))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onClose) {
|
|
||||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Memory
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RecorderProcessingDialog(
|
|
||||||
progress: Float?,
|
|
||||||
) {
|
|
||||||
KeepScreenOn()
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { },
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Memory,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_recorder_action_save_processing_dialog_title),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_recorder_action_save_processing_dialog_description),
|
|
||||||
)
|
|
||||||
CircularProgressIndicator()
|
|
||||||
if (progress == null)
|
|
||||||
LinearProgressIndicator()
|
|
||||||
else
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { progress },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun SaveButton(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onSave: () -> Unit,
|
|
||||||
onLongClick: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val label = stringResource(R.string.ui_recorder_action_save_label)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(ButtonDefaults.textShape)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.combinedClickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = ripple(color = MaterialTheme.colorScheme.primary),
|
|
||||||
onClick = onSave,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
)
|
|
||||||
.padding(ButtonDefaults.TextButtonContentPadding)
|
|
||||||
.then(modifier)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SaveCurrentNowModal(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: () -> Unit,
|
|
||||||
) {
|
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
|
||||||
|
|
||||||
// Auto save on specific events
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = sheetState,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_recorder_action_saveCurrent),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_recorder_action_saveCurrent_explanation),
|
|
||||||
)
|
|
||||||
|
|
||||||
TextButton(onClick = onConfirm) {
|
|
||||||
Text(stringResource(R.string.ui_recorder_action_save_label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.FlashlightOff
|
|
||||||
import androidx.compose.material.icons.filled.FlashlightOn
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TorchStatus(
|
|
||||||
enabled: Boolean,
|
|
||||||
onChange: () -> Unit,
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onChange,
|
|
||||||
colors = if (enabled) ButtonDefaults.filledTonalButtonColors() else ButtonDefaults.outlinedButtonColors(),
|
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (enabled) Icons.Default.FlashlightOff else Icons.Default.FlashlightOn,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
if (enabled) stringResource(R.string.ui_videoRecorder_action_torch_off)
|
|
||||||
else stringResource(R.string.ui_videoRecorder_action_torch_on),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,67 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.BigButton
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AudioRecordingStart(
|
|
||||||
audioRecorder: AudioRecorderModel,
|
|
||||||
appSettings: AppSettings,
|
|
||||||
useLargeButtons: Boolean? = null,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// We can't get the current `notificationDetails` inside the
|
|
||||||
// `onPermissionAvailable` function. We'll instead use this hack
|
|
||||||
// with `LaunchedEffect` to get the current value.
|
|
||||||
var startRecording by rememberSaveable { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(startRecording) {
|
|
||||||
if (startRecording) {
|
|
||||||
startRecording = false
|
|
||||||
|
|
||||||
audioRecorder.startRecording(context, appSettings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PermissionRequester(
|
|
||||||
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
|
||||||
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
|
||||||
onPermissionAvailable = {
|
|
||||||
startRecording = true
|
|
||||||
}
|
|
||||||
) { triggerExternalStorage ->
|
|
||||||
PermissionRequester(
|
|
||||||
permission = Manifest.permission.RECORD_AUDIO,
|
|
||||||
icon = Icons.Default.Mic,
|
|
||||||
onPermissionAvailable = {
|
|
||||||
if (appSettings.requiresExternalStoragePermission(context)) {
|
|
||||||
triggerExternalStorage()
|
|
||||||
} else {
|
|
||||||
startRecording = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { triggerRecordAudio ->
|
|
||||||
BigButton(
|
|
||||||
label = stringResource(R.string.ui_audioRecorder_action_start_label),
|
|
||||||
icon = Icons.Default.Mic,
|
|
||||||
onClick = triggerRecordAudio,
|
|
||||||
isBig = useLargeButtons,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import CameraSelectionButton
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.camera.core.CameraSelector
|
|
||||||
import androidx.camera.core.ExperimentalLensFacing
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CamerasSelection(
|
|
||||||
cameras: Iterable<CameraInfo>,
|
|
||||||
videoSettings: VideoRecorderModel,
|
|
||||||
) {
|
|
||||||
val CAMERA_LENS_TEXT_MAP = mapOf(
|
|
||||||
CameraInfo.Lens.BACK to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label),
|
|
||||||
CameraInfo.Lens.FRONT to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label),
|
|
||||||
CameraInfo.Lens.EXTERNAL to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_external_label),
|
|
||||||
CameraInfo.Lens.UNKNOWN to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_unknown_label),
|
|
||||||
)
|
|
||||||
|
|
||||||
Column {
|
|
||||||
if (CameraInfo.checkHasNormalCameras(cameras)) {
|
|
||||||
CameraSelectionButton(
|
|
||||||
cameraID = CameraInfo.Lens.BACK,
|
|
||||||
label = stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label),
|
|
||||||
selected = videoSettings.cameraID == CameraInfo.Lens.BACK.androidValue,
|
|
||||||
onSelected = {
|
|
||||||
videoSettings.cameraID = CameraInfo.Lens.BACK.androidValue
|
|
||||||
},
|
|
||||||
)
|
|
||||||
CameraSelectionButton(
|
|
||||||
cameraID = CameraInfo.Lens.FRONT,
|
|
||||||
label = stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label),
|
|
||||||
selected = videoSettings.cameraID == CameraInfo.Lens.FRONT.androidValue,
|
|
||||||
onSelected = {
|
|
||||||
videoSettings.cameraID = CameraInfo.Lens.FRONT.androidValue
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
cameras.forEach { camera ->
|
|
||||||
CameraSelectionButton(
|
|
||||||
cameraID = camera.lens,
|
|
||||||
selected = videoSettings.cameraID == camera.id,
|
|
||||||
onSelected = {
|
|
||||||
videoSettings.cameraID = camera.id
|
|
||||||
},
|
|
||||||
label = stringResource(
|
|
||||||
R.string.ui_videoRecorder_action_start_settings_cameraLens_label,
|
|
||||||
camera.id
|
|
||||||
),
|
|
||||||
description = CAMERA_LENS_TEXT_MAP[camera.lens]!!,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,223 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneSelectionButton
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneTypeInfo
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneSelection(
|
|
||||||
audioRecorder: AudioRecorderModel
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var showSelection by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
val allMicrophones = MicrophoneInfo.fetchDeviceMicrophones(context)
|
|
||||||
val visibleMicrophones = MicrophoneInfo.filterMicrophones(allMicrophones)
|
|
||||||
val hiddenMicrophones = allMicrophones - visibleMicrophones.toSet()
|
|
||||||
|
|
||||||
val isTryingToReconnect =
|
|
||||||
audioRecorder.selectedMicrophone != null && audioRecorder.microphoneStatus == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED
|
|
||||||
|
|
||||||
val shownMicrophones = if (isTryingToReconnect && visibleMicrophones.isEmpty()) {
|
|
||||||
listOf(audioRecorder.selectedMicrophone!!)
|
|
||||||
} else {
|
|
||||||
visibleMicrophones
|
|
||||||
}
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
fun hideSheet() {
|
|
||||||
scope.launch {
|
|
||||||
sheetState.hide()
|
|
||||||
showSelection = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSelection) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = ::hideSheet,
|
|
||||||
sheetState = sheetState,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = SHEET_BOTTOM_OFFSET),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(48.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_audioRecorder_info_microphone_changeExplanation),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isTryingToReconnect)
|
|
||||||
MessageBox(
|
|
||||||
type = MessageType.INFO,
|
|
||||||
message = stringResource(
|
|
||||||
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
|
|
||||||
audioRecorder.selectedMicrophone?.name ?: "",
|
|
||||||
audioRecorder.selectedMicrophone?.name ?: "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 32.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
MicrophoneSelectionButton(
|
|
||||||
selected = audioRecorder.selectedMicrophone == null,
|
|
||||||
selectedAsFallback = isTryingToReconnect,
|
|
||||||
onSelect = {
|
|
||||||
audioRecorder.changeMicrophone(null)
|
|
||||||
hideSheet()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(shownMicrophones.size) {
|
|
||||||
val microphone = shownMicrophones[it]
|
|
||||||
|
|
||||||
MicrophoneSelectionButton(
|
|
||||||
microphone = microphone,
|
|
||||||
selected = audioRecorder.selectedMicrophone == microphone,
|
|
||||||
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
|
|
||||||
onSelect = {
|
|
||||||
audioRecorder.changeMicrophone(microphone)
|
|
||||||
hideSheet()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.audioRecorderSettings.showAllMicrophones) {
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.padding(vertical = 32.dp),
|
|
||||||
) {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_audioRecorder_info_microphone_hiddenMicrophones),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items(hiddenMicrophones.size) {
|
|
||||||
val microphone = hiddenMicrophones[it]
|
|
||||||
|
|
||||||
MicrophoneSelectionButton(
|
|
||||||
microphone = microphone,
|
|
||||||
selected = audioRecorder.selectedMicrophone == microphone,
|
|
||||||
onSelect = {
|
|
||||||
audioRecorder.changeMicrophone(microphone)
|
|
||||||
hideSheet()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// We need to show a placeholder box to keep the the rest aligned correctly
|
|
||||||
Box {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
showSelection = true
|
|
||||||
sheetState.show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
MicrophoneTypeInfo(
|
|
||||||
type = audioRecorder.selectedMicrophone?.type
|
|
||||||
?: MicrophoneInfo.MicrophoneType.PHONE,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
text = audioRecorder.selectedMicrophone.let {
|
|
||||||
it?.name
|
|
||||||
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (isTryingToReconnect) {
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Warning,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneDisconnectedDialog
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneReconnectedDialog
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberPrevious
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneStatus(
|
|
||||||
audioRecorder: AudioRecorderModel,
|
|
||||||
) {
|
|
||||||
val microphoneStatus = audioRecorder.microphoneStatus
|
|
||||||
val previousStatus = rememberPrevious(microphoneStatus)
|
|
||||||
|
|
||||||
var showMicrophoneStatusDialog by remember {
|
|
||||||
// null = no dialog
|
|
||||||
// `MicrophoneConnectivityStatus.CONNECTED` = Reconnected dialog
|
|
||||||
// `MicrophoneConnectivityStatus.DISCONNECTED` = Disconnected dialog
|
|
||||||
mutableStateOf<AudioRecorderModel.MicrophoneConnectivityStatus?>(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(microphoneStatus) {
|
|
||||||
if (microphoneStatus != previousStatus && showMicrophoneStatusDialog == null && previousStatus != null && audioRecorder.selectedMicrophone != null) {
|
|
||||||
showMicrophoneStatusDialog = microphoneStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED) {
|
|
||||||
MicrophoneDisconnectedDialog(
|
|
||||||
onClose = {
|
|
||||||
showMicrophoneStatusDialog = null
|
|
||||||
},
|
|
||||||
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.CONNECTED) {
|
|
||||||
MicrophoneReconnectedDialog(
|
|
||||||
onClose = {
|
|
||||||
showMicrophoneStatusDialog = null
|
|
||||||
},
|
|
||||||
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MicrophoneSelection(
|
|
||||||
audioRecorder = audioRecorder,
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,95 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.AudioRecorderSettings.Companion.EXAMPLE_MAX_DURATIONS
|
|
||||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
|
||||||
import app.myzel394.alibi.ui.utils.formatDuration
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun QuickMaxDurationSelector(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = sheetState,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 400.dp)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_recorder_action_changeMaxDuration_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
for (duration in EXAMPLE_MAX_DURATIONS) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
sheetState.hide()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setMaxDuration(duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(64.dp),
|
|
||||||
) {
|
|
||||||
Text(formatDuration(duration))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,183 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.DeleteButton
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.PauseResumeButton
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveButton
|
|
||||||
import app.myzel394.alibi.ui.utils.RandomStack
|
|
||||||
import app.myzel394.alibi.ui.utils.rememberInitialRecordingAnimation
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RecordingControl(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
orientation: Int = LocalConfiguration.current.orientation,
|
|
||||||
initialDelay: Long = 0L,
|
|
||||||
isPaused: Boolean,
|
|
||||||
recordingTime: Long,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onPauseResume: () -> Unit,
|
|
||||||
onSaveAndStop: () -> Unit,
|
|
||||||
onSaveCurrent: () -> Unit,
|
|
||||||
) {
|
|
||||||
val animateIn = rememberInitialRecordingAnimation(recordingTime)
|
|
||||||
|
|
||||||
var deleteButtonAlphaIsIn by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val deleteButtonAlpha by animateFloatAsState(
|
|
||||||
if (deleteButtonAlphaIsIn) 1f else 0f,
|
|
||||||
label = "deleteButtonAlpha",
|
|
||||||
animationSpec = tween(durationMillis = 500)
|
|
||||||
)
|
|
||||||
|
|
||||||
var pauseButtonAlphaIsIn by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val pauseButtonAlpha by animateFloatAsState(
|
|
||||||
if (pauseButtonAlphaIsIn) 1f else 0f,
|
|
||||||
label = "pauseButtonAlpha",
|
|
||||||
animationSpec = tween(durationMillis = 500)
|
|
||||||
)
|
|
||||||
|
|
||||||
var saveButtonAlphaIsIn by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val saveButtonAlpha by animateFloatAsState(
|
|
||||||
if (saveButtonAlphaIsIn) 1f else 0f,
|
|
||||||
label = "saveButtonAlpha",
|
|
||||||
animationSpec = tween(durationMillis = 500)
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(animateIn) {
|
|
||||||
if (animateIn) {
|
|
||||||
delay(initialDelay)
|
|
||||||
|
|
||||||
val stack = RandomStack.of(arrayOf(1, 2, 3).asIterable())
|
|
||||||
|
|
||||||
while (!stack.isEmpty()) {
|
|
||||||
when (stack.popRandom()) {
|
|
||||||
1 -> {
|
|
||||||
deleteButtonAlphaIsIn = true
|
|
||||||
}
|
|
||||||
|
|
||||||
2 -> {
|
|
||||||
pauseButtonAlphaIsIn = true
|
|
||||||
}
|
|
||||||
|
|
||||||
3 -> {
|
|
||||||
saveButtonAlphaIsIn = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(250)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (orientation) {
|
|
||||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.alpha(saveButtonAlpha),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
SaveButton(
|
|
||||||
onSave = onSaveAndStop,
|
|
||||||
onLongClick = onSaveCurrent,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.alpha(pauseButtonAlpha),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
PauseResumeButton(
|
|
||||||
isPaused = isPaused,
|
|
||||||
onChange = onPauseResume,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.alpha(deleteButtonAlpha),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
DeleteButton(
|
|
||||||
onDelete = onDelete,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.alpha(deleteButtonAlpha),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
DeleteButton(onDelete = onDelete)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.alpha(pauseButtonAlpha),
|
|
||||||
) {
|
|
||||||
PauseResumeButton(
|
|
||||||
isPaused = isPaused,
|
|
||||||
onChange = onPauseResume,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.alpha(saveButtonAlpha),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
SaveButton(
|
|
||||||
onSave = onSaveAndStop,
|
|
||||||
onLongClick = onSaveCurrent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,129 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.expandHorizontally
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
|
||||||
import app.myzel394.alibi.ui.utils.formatDuration
|
|
||||||
import app.myzel394.alibi.ui.utils.isSameDay
|
|
||||||
import app.myzel394.alibi.ui.utils.rememberInitialRecordingAnimation
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RecordingStatus(
|
|
||||||
recordingTime: Long,
|
|
||||||
progress: Float,
|
|
||||||
recordingStart: LocalDateTime,
|
|
||||||
maxDuration: Long,
|
|
||||||
progressModifier: Modifier = Modifier.width(300.dp),
|
|
||||||
) {
|
|
||||||
val animateIn = rememberInitialRecordingAnimation(recordingTime)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Pulsating {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(16.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Red)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = formatDuration(recordingTime * 1000),
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = animateIn,
|
|
||||||
enter = expandHorizontally(
|
|
||||||
tween(1000)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { progress },
|
|
||||||
modifier = progressModifier,
|
|
||||||
drawStopIndicator = { },
|
|
||||||
gapSize = 0.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = animateIn, enter = fadeIn()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
R.string.ui_recorder_info_saveNowTime,
|
|
||||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
|
||||||
.format(
|
|
||||||
LocalDateTime.now().minusSeconds(
|
|
||||||
min(
|
|
||||||
maxDuration / 1000,
|
|
||||||
recordingTime
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = animateIn, enter = fadeIn()) {
|
|
||||||
Text(
|
|
||||||
text = recordingStart.let {
|
|
||||||
if (isSameDay(it, LocalDateTime.now())) {
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_recorder_info_startTime_short,
|
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
|
||||||
.format(it)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_recorder_info_startTime_full,
|
|
||||||
DateTimeFormatter.ofLocalizedDateTime(
|
|
||||||
FormatStyle.MEDIUM,
|
|
||||||
FormatStyle.SHORT
|
|
||||||
)
|
|
||||||
.format(it)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,242 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import androidx.camera.core.CameraSelector
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.material3.BottomSheetDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.input.pointer.positionChange
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Popup
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
|
||||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.CameraPreview
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberPrevious
|
|
||||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
@OptIn(
|
|
||||||
ExperimentalMaterial3Api::class,
|
|
||||||
)
|
|
||||||
@Composable
|
|
||||||
fun VideoRecorderPreparationSheet(
|
|
||||||
showPreview: Boolean,
|
|
||||||
videoSettings: VideoRecorderModel,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onPreviewVisible: () -> Unit,
|
|
||||||
onPreviewHidden: () -> Unit,
|
|
||||||
onStartRecording: () -> Unit,
|
|
||||||
) {
|
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val cameras = CameraInfo.queryAvailableCameras(context)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
videoSettings.init(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = sheetState,
|
|
||||||
dragHandle = {
|
|
||||||
if (showPreview)
|
|
||||||
Unit
|
|
||||||
else
|
|
||||||
BottomSheetDefaults.DragHandle()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
awaitEachGesture {
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent()
|
|
||||||
if (!event.changes.elementAt(0).pressed) {
|
|
||||||
onPreviewHidden()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (showPreview) {
|
|
||||||
CameraPreview(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
cameraSelector = videoSettings.cameraSelector,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BoxWithConstraints {
|
|
||||||
val constraints = this
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = SHEET_BOTTOM_OFFSET, top = 24.dp)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(30.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
if (constraints.maxHeight > 600.dp) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CameraAlt,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(80.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_videoRecorder_action_start_settings_label),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PermissionRequester(
|
|
||||||
permission = Manifest.permission.RECORD_AUDIO,
|
|
||||||
icon = Icons.Default.Mic,
|
|
||||||
onPermissionAvailable = {
|
|
||||||
videoSettings.enableAudio = !videoSettings.enableAudio
|
|
||||||
},
|
|
||||||
) { trigger ->
|
|
||||||
GlobalSwitch(
|
|
||||||
label = stringResource(R.string.ui_videoRecorder_action_start_settings_enableAudio_label),
|
|
||||||
checked = videoSettings.enableAudio,
|
|
||||||
onCheckedChange = {
|
|
||||||
trigger()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_selection_label),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
CamerasSelection(
|
|
||||||
cameras = cameras,
|
|
||||||
videoSettings = videoSettings,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val label =
|
|
||||||
stringResource(R.string.ui_videoRecorder_action_start_settings_start_label)
|
|
||||||
val hasGrantedCameraPermission =
|
|
||||||
PermissionHelper.hasGranted(context, Manifest.permission.CAMERA)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
PermissionRequester(
|
|
||||||
permission = Manifest.permission.CAMERA,
|
|
||||||
icon = Icons.Default.CameraAlt,
|
|
||||||
onPermissionAvailable = {
|
|
||||||
onStartRecording()
|
|
||||||
}
|
|
||||||
) { trigger ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
|
||||||
.padding(16.dp)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onLongPress = {
|
|
||||||
if (hasGrantedCameraPermission) {
|
|
||||||
onPreviewVisible()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
trigger()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasGrantedCameraPermission) {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_videoRecorder_action_preview_label
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,94 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.BigButton
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
|
||||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun VideoRecordingStart(
|
|
||||||
videoRecorder: VideoRecorderModel,
|
|
||||||
appSettings: AppSettings,
|
|
||||||
onHideAudioRecording: () -> Unit,
|
|
||||||
onShowAudioRecording: () -> Unit,
|
|
||||||
showPreview: Boolean,
|
|
||||||
useLargeButtons: Boolean? = null,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var showSheet by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSheet) {
|
|
||||||
VideoRecorderPreparationSheet(
|
|
||||||
showPreview = showPreview,
|
|
||||||
videoSettings = videoRecorder,
|
|
||||||
onDismiss = {
|
|
||||||
showSheet = false
|
|
||||||
},
|
|
||||||
onPreviewVisible = onHideAudioRecording,
|
|
||||||
onPreviewHidden = onShowAudioRecording,
|
|
||||||
onStartRecording = {
|
|
||||||
videoRecorder.startRecording(context, appSettings)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PermissionRequester(
|
|
||||||
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
|
||||||
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
|
||||||
onPermissionAvailable = {
|
|
||||||
showSheet = true
|
|
||||||
}
|
|
||||||
) { triggerExternalStorage ->
|
|
||||||
BigButton(
|
|
||||||
label = stringResource(R.string.ui_videoRecorder_action_start_label),
|
|
||||||
description = stringResource(R.string.ui_videoRecorder_action_configure_label),
|
|
||||||
icon = Icons.Default.CameraAlt,
|
|
||||||
onLongClick = {
|
|
||||||
if (appSettings.requiresExternalStoragePermission(context)) {
|
|
||||||
triggerExternalStorage()
|
|
||||||
return@BigButton
|
|
||||||
}
|
|
||||||
|
|
||||||
showSheet = true
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (appSettings.requiresExternalStoragePermission(context)) {
|
|
||||||
triggerExternalStorage()
|
|
||||||
return@BigButton
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PermissionHelper.hasGranted(
|
|
||||||
context,
|
|
||||||
Manifest.permission.CAMERA
|
|
||||||
) && PermissionHelper.hasGranted(
|
|
||||||
context,
|
|
||||||
Manifest.permission.RECORD_AUDIO
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
videoRecorder.startRecording(context, appSettings)
|
|
||||||
} else {
|
|
||||||
showSheet = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isBig = useLargeButtons,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,189 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RealtimeAudioVisualizer
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveCurrentNowModal
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.MicrophoneStatus
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AudioRecordingStatus(
|
|
||||||
audioRecorder: AudioRecorderModel,
|
|
||||||
) {
|
|
||||||
val configuration = LocalConfiguration.current.orientation
|
|
||||||
|
|
||||||
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
now = LocalDateTime.now()
|
|
||||||
delay(900)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
KeepScreenOn()
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = 32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Box {}
|
|
||||||
RealtimeAudioVisualizer(
|
|
||||||
audioRecorder = audioRecorder,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.widthIn(max = 300.dp)
|
|
||||||
.weight(1f),
|
|
||||||
)
|
|
||||||
|
|
||||||
when (configuration) {
|
|
||||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement
|
|
||||||
.spacedBy(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.weight(3f),
|
|
||||||
) {
|
|
||||||
RecordingStatus(
|
|
||||||
recordingTime = audioRecorder.recordingTime,
|
|
||||||
progress = audioRecorder.progress,
|
|
||||||
recordingStart = audioRecorder.recordingStart,
|
|
||||||
maxDuration = audioRecorder.settings!!.maxDuration,
|
|
||||||
progressModifier = Modifier.fillMaxWidth(.9f),
|
|
||||||
)
|
|
||||||
|
|
||||||
MicrophoneStatus(audioRecorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
_PrimitiveControls(audioRecorder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
RecordingStatus(
|
|
||||||
recordingTime = audioRecorder.recordingTime,
|
|
||||||
progress = audioRecorder.progress,
|
|
||||||
recordingStart = audioRecorder.recordingStart,
|
|
||||||
maxDuration = audioRecorder.settings!!.maxDuration,
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement
|
|
||||||
.spacedBy(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
MicrophoneStatus(audioRecorder)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
_PrimitiveControls(audioRecorder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun _PrimitiveControls(audioRecorder: AudioRecorderModel) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStore = context.dataStore
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var showConfirmSaveNow by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showConfirmSaveNow) {
|
|
||||||
SaveCurrentNowModal(
|
|
||||||
onDismiss = {
|
|
||||||
showConfirmSaveNow = false
|
|
||||||
},
|
|
||||||
onConfirm = {
|
|
||||||
showConfirmSaveNow = false
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
audioRecorder.recorderService!!.startNewCycle()
|
|
||||||
|
|
||||||
audioRecorder.onRecordingSave(false).join()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordingControl(
|
|
||||||
isPaused = audioRecorder.isPaused,
|
|
||||||
recordingTime = audioRecorder.recordingTime,
|
|
||||||
onDelete = {
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
audioRecorder.batchesFolder!!.deleteRecordings()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPauseResume = {
|
|
||||||
if (audioRecorder.isPaused) {
|
|
||||||
audioRecorder.resumeRecording()
|
|
||||||
} else {
|
|
||||||
audioRecorder.pauseRecording()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSaveAndStop = {
|
|
||||||
scope.launch {
|
|
||||||
audioRecorder.stopRecording(context)
|
|
||||||
|
|
||||||
dataStore.updateData {
|
|
||||||
it.saveLastRecording(audioRecorder as RecorderModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
audioRecorder.onRecordingSave(false).join()
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSaveCurrent = {
|
|
||||||
showConfirmSaveNow = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,366 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.SnackbarResult
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.RecordingInformation
|
|
||||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
|
||||||
import app.myzel394.alibi.helpers.BatchesFolder
|
|
||||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
|
||||||
import app.myzel394.alibi.services.IntervalRecorderService
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.BatchesInaccessibleDialog
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RecorderErrorDialog
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RecorderProcessingDialog
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberOpenUri
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.models.BaseRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import java.util.Timer
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
typealias RecorderModel = BaseRecorderModel<
|
|
||||||
RecordingInformation,
|
|
||||||
BatchesFolder,
|
|
||||||
IntervalRecorderService<RecordingInformation, BatchesFolder>,
|
|
||||||
>
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RecorderEventsHandler(
|
|
||||||
settings: AppSettings,
|
|
||||||
snackbarHostState: SnackbarHostState,
|
|
||||||
audioRecorder: AudioRecorderModel,
|
|
||||||
videoRecorder: VideoRecorderModel,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStore = context.dataStore
|
|
||||||
|
|
||||||
var isProcessing by remember { mutableStateOf(false) }
|
|
||||||
var showRecorderError by remember { mutableStateOf(false) }
|
|
||||||
var showBatchesInaccessibleError by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
var processingProgress by remember { mutableStateOf<Float?>(null) }
|
|
||||||
|
|
||||||
val saveAudioFile = rememberFileSaverDialog(settings.audioRecorderSettings.getMimeType()) {
|
|
||||||
if (settings.deleteRecordingsImmediately) {
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.batchesFolder?.deleteRecordings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioRecorder.batchesFolder?.hasRecordingsAvailable() == false) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setLastRecording(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val saveVideoFile = rememberFileSaverDialog(settings.videoRecorderSettings.getMimeType()) {
|
|
||||||
if (settings.deleteRecordingsImmediately) {
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.batchesFolder?.deleteRecordings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoRecorder.batchesFolder?.hasRecordingsAvailable() == false) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setLastRecording(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveAsLastRecording(
|
|
||||||
recorder: RecorderModel
|
|
||||||
) {
|
|
||||||
if (!settings.deleteRecordingsImmediately) {
|
|
||||||
val information = recorder.recorderService?.getRecordingInformation()
|
|
||||||
|
|
||||||
if (information == null) {
|
|
||||||
Log.e("RecorderEventsHandler", "Recording information is null")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setLastRecording(
|
|
||||||
information
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val successMessage = stringResource(R.string.ui_recorder_action_save_success)
|
|
||||||
val openMessage = stringResource(R.string.ui_recorder_action_save_openFolder)
|
|
||||||
|
|
||||||
val openFolder = rememberOpenUri()
|
|
||||||
|
|
||||||
fun showSnackbar() {
|
|
||||||
scope.launch {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
message = successMessage,
|
|
||||||
duration = SnackbarDuration.Short,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showSnackbar(uri: Uri) {
|
|
||||||
scope.launch {
|
|
||||||
val result = snackbarHostState.showSnackbar(
|
|
||||||
message = successMessage,
|
|
||||||
actionLabel = openMessage,
|
|
||||||
duration = SnackbarDuration.Short,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
openFolder(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveRecording(
|
|
||||||
recorder: RecorderModel,
|
|
||||||
cleanupOldFiles: Boolean = false
|
|
||||||
): CompletableDeferred<Unit> {
|
|
||||||
val completer = CompletableDeferred<Unit>()
|
|
||||||
|
|
||||||
// If processing takes this short, don't show the processing dialog
|
|
||||||
val timer = Timer().schedule(250L) {
|
|
||||||
isProcessing = true
|
|
||||||
}
|
|
||||||
|
|
||||||
thread {
|
|
||||||
runBlocking {
|
|
||||||
try {
|
|
||||||
if (recorder.isCurrentlyActivelyRecording) {
|
|
||||||
recorder.recorderService?.lockFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
val recording =
|
|
||||||
// When new recording created
|
|
||||||
recorder.recorderService?.getRecordingInformation()
|
|
||||||
// When recording is loaded from lastRecording
|
|
||||||
?: settings.lastRecording
|
|
||||||
?: throw Exception("No recording information available")
|
|
||||||
|
|
||||||
val batchesFolder = when (recorder.javaClass) {
|
|
||||||
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
|
|
||||||
recording.folderPath,
|
|
||||||
context
|
|
||||||
)
|
|
||||||
|
|
||||||
VideoRecorderModel::class.java -> VideoBatchesFolder.importFromFolder(
|
|
||||||
recording.folderPath,
|
|
||||||
context
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> throw Exception("Unknown recorder type")
|
|
||||||
}
|
|
||||||
|
|
||||||
val fileName = batchesFolder.getName(
|
|
||||||
recording.recordingStart,
|
|
||||||
recording.fileExtension,
|
|
||||||
)
|
|
||||||
|
|
||||||
batchesFolder.concatenate(
|
|
||||||
recording,
|
|
||||||
filenameFormat = settings.filenameFormat,
|
|
||||||
fileName = fileName,
|
|
||||||
onProgress = { percentage ->
|
|
||||||
processingProgress = percentage
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save file
|
|
||||||
when (batchesFolder.type) {
|
|
||||||
BatchesFolder.BatchType.INTERNAL -> {
|
|
||||||
when (batchesFolder) {
|
|
||||||
is AudioBatchesFolder -> {
|
|
||||||
saveAudioFile(
|
|
||||||
batchesFolder.asInternalGetOutputFile(fileName), fileName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is VideoBatchesFolder -> {
|
|
||||||
saveVideoFile(
|
|
||||||
batchesFolder.asInternalGetOutputFile(fileName), fileName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchesFolder.BatchType.CUSTOM -> {
|
|
||||||
showSnackbar(batchesFolder.customFolder!!.uri)
|
|
||||||
|
|
||||||
if (settings.deleteRecordingsImmediately) {
|
|
||||||
batchesFolder.deleteRecordings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchesFolder.BatchType.MEDIA -> {
|
|
||||||
showSnackbar()
|
|
||||||
|
|
||||||
if (settings.deleteRecordingsImmediately) {
|
|
||||||
batchesFolder.deleteRecordings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: Exception) {
|
|
||||||
Log.getStackTraceString(error)
|
|
||||||
} finally {
|
|
||||||
if (recorder.isCurrentlyActivelyRecording) {
|
|
||||||
recorder.recorderService?.unlockFiles(cleanupOldFiles)
|
|
||||||
}
|
|
||||||
timer.cancel()
|
|
||||||
isProcessing = false
|
|
||||||
processingProgress = null
|
|
||||||
completer.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return completer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register audio recorder events
|
|
||||||
// Absolutely no idea, but somehow on some devices the `DisposableEffect`
|
|
||||||
// is registered twice, and THEN disposed once (AFTER being called twice),
|
|
||||||
// which then causes the `onRecordingSave` to be in a weird state.
|
|
||||||
// This variable is a workaround to prevent this from happening.
|
|
||||||
var previousAudioSettings: AppSettings? = null
|
|
||||||
DisposableEffect(settings) {
|
|
||||||
if (previousAudioSettings == settings) {
|
|
||||||
onDispose { }
|
|
||||||
} else {
|
|
||||||
previousAudioSettings = settings
|
|
||||||
audioRecorder.onRecordingSave = { cleanupOldFiles ->
|
|
||||||
saveRecording(audioRecorder as RecorderModel, cleanupOldFiles)
|
|
||||||
}
|
|
||||||
audioRecorder.onRecordingStart = {
|
|
||||||
snackbarHostState.currentSnackbarData?.dismiss()
|
|
||||||
}
|
|
||||||
audioRecorder.onError = {
|
|
||||||
scope.launch {
|
|
||||||
saveAsLastRecording(audioRecorder as RecorderModel)
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
showRecorderError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
audioRecorder.onBatchesFolderNotAccessible = {
|
|
||||||
scope.launch {
|
|
||||||
showBatchesInaccessibleError = true
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
audioRecorder.onRecordingSave = {
|
|
||||||
throw NotImplementedError("onRecordingSave should not be called now")
|
|
||||||
}
|
|
||||||
audioRecorder.onError = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register video recorder events
|
|
||||||
var previousVideoSettings: AppSettings? = null
|
|
||||||
DisposableEffect(settings) {
|
|
||||||
if (previousVideoSettings == settings) {
|
|
||||||
onDispose { }
|
|
||||||
} else {
|
|
||||||
previousVideoSettings = settings
|
|
||||||
Log.i("Alibi", "===== Registering videoRecorder events $videoRecorder")
|
|
||||||
videoRecorder.onRecordingSave = { cleanupOldFiles ->
|
|
||||||
saveRecording(videoRecorder as RecorderModel, cleanupOldFiles)
|
|
||||||
}
|
|
||||||
videoRecorder.onRecordingStart = {
|
|
||||||
snackbarHostState.currentSnackbarData?.dismiss()
|
|
||||||
}
|
|
||||||
videoRecorder.onError = {
|
|
||||||
scope.launch {
|
|
||||||
saveAsLastRecording(videoRecorder as RecorderModel)
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
showRecorderError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
videoRecorder.onBatchesFolderNotAccessible = {
|
|
||||||
scope.launch {
|
|
||||||
showBatchesInaccessibleError = true
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
Log.i("Alibi", "===== Disposing videoRecorder events")
|
|
||||||
videoRecorder.onRecordingSave = {
|
|
||||||
throw NotImplementedError("onRecordingSave should not be called now")
|
|
||||||
}
|
|
||||||
videoRecorder.onError = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isProcessing)
|
|
||||||
RecorderProcessingDialog(
|
|
||||||
progress = processingProgress,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (showBatchesInaccessibleError)
|
|
||||||
BatchesInaccessibleDialog(
|
|
||||||
onClose = {
|
|
||||||
showBatchesInaccessibleError = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else if (showRecorderError)
|
|
||||||
RecorderErrorDialog(
|
|
||||||
onClose = {
|
|
||||||
showRecorderError = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,234 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.requiredWidthIn
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.text.ClickableText
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_MAX_WIDTH
|
|
||||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.LowStorageInfo
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.AudioRecordingStart
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.QuickMaxDurationSelector
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.VideoRecordingStart
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberForceUpdateOnLifeCycleChange
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StartRecording(
|
|
||||||
audioRecorder: AudioRecorderModel,
|
|
||||||
videoRecorder: VideoRecorderModel,
|
|
||||||
// Loading this from parent, because if we load it ourselves
|
|
||||||
// and permissions have already been granted, initial
|
|
||||||
// settings will be used, instead of the actual settings.
|
|
||||||
appSettings: AppSettings,
|
|
||||||
onSaveLastRecording: () -> Unit,
|
|
||||||
onHideTopBar: () -> Unit,
|
|
||||||
onShowTopBar: () -> Unit,
|
|
||||||
showAudioRecorder: Boolean,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val orientation = LocalConfiguration.current.orientation
|
|
||||||
|
|
||||||
val label = stringResource(
|
|
||||||
R.string.ui_recorder_action_start_description_2,
|
|
||||||
appSettings.maxDuration / 1000 / 60
|
|
||||||
)
|
|
||||||
val annotatedDescription = buildAnnotatedString {
|
|
||||||
append(stringResource(R.string.ui_recorder_action_start_description_1))
|
|
||||||
|
|
||||||
withStyle(SpanStyle(background = MaterialTheme.colorScheme.surfaceVariant)) {
|
|
||||||
pushStringAnnotation(
|
|
||||||
tag = "minutes",
|
|
||||||
annotation = label,
|
|
||||||
)
|
|
||||||
append(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
append(stringResource(R.string.ui_recorder_action_start_description_3))
|
|
||||||
}
|
|
||||||
|
|
||||||
var showQuickMaxDurationSelector by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showQuickMaxDurationSelector) {
|
|
||||||
QuickMaxDurationSelector(
|
|
||||||
onDismiss = {
|
|
||||||
showQuickMaxDurationSelector = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BoxWithConstraints {
|
|
||||||
val isLargeDisplay =
|
|
||||||
maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = if (orientation == Configuration.ORIENTATION_PORTRAIT) 0.dp else 16.dp),
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
when (orientation) {
|
|
||||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
if (showAudioRecorder)
|
|
||||||
AudioRecordingStart(
|
|
||||||
audioRecorder = audioRecorder,
|
|
||||||
appSettings = appSettings,
|
|
||||||
)
|
|
||||||
VideoRecordingStart(
|
|
||||||
videoRecorder = videoRecorder,
|
|
||||||
appSettings = appSettings,
|
|
||||||
onHideAudioRecording = onHideTopBar,
|
|
||||||
onShowAudioRecording = onShowTopBar,
|
|
||||||
showPreview = !showAudioRecorder,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
if (showAudioRecorder)
|
|
||||||
AudioRecordingStart(
|
|
||||||
audioRecorder = audioRecorder,
|
|
||||||
appSettings = appSettings,
|
|
||||||
useLargeButtons = isLargeDisplay,
|
|
||||||
)
|
|
||||||
VideoRecordingStart(
|
|
||||||
videoRecorder = videoRecorder,
|
|
||||||
appSettings = appSettings,
|
|
||||||
onHideAudioRecording = onHideTopBar,
|
|
||||||
onShowAudioRecording = onShowTopBar,
|
|
||||||
showPreview = !showAudioRecorder,
|
|
||||||
useLargeButtons = isLargeDisplay,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
val forceUpdate = rememberForceUpdateOnLifeCycleChange()
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.then(forceUpdate),
|
|
||||||
verticalArrangement = Arrangement.Bottom,
|
|
||||||
) {
|
|
||||||
if (appSettings.lastRecording?.hasRecordingsAvailable(context) == true) {
|
|
||||||
val label = stringResource(
|
|
||||||
R.string.ui_recorder_action_saveOldRecording_label,
|
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
|
|
||||||
.format(appSettings.lastRecording.recordingStart),
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.requiredWidthIn(max = BIG_PRIMARY_BUTTON_MAX_WIDTH)
|
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
},
|
|
||||||
onClick = onSaveLastRecording,
|
|
||||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.launcher_monochrome_noopacity),
|
|
||||||
contentDescription = null,
|
|
||||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
|
|
||||||
ClickableText(
|
|
||||||
text = annotatedDescription,
|
|
||||||
onClick = { textIndex ->
|
|
||||||
if (annotatedDescription.getStringAnnotations(textIndex, textIndex)
|
|
||||||
.firstOrNull()?.tag == "minutes"
|
|
||||||
) {
|
|
||||||
showQuickMaxDurationSelector = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 300.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LowStorageInfo(
|
|
||||||
modifier = if (isLargeDisplay) Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.widthIn(max = 400.dp) else Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(4.dp),
|
|
||||||
appSettings = appSettings
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,295 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveCurrentNowModal
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.TorchStatus
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl
|
|
||||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus
|
|
||||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
|
||||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
|
||||||
import com.valentinilk.shimmer.shimmer
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun VideoRecordingStatus(
|
|
||||||
videoRecorder: VideoRecorderModel,
|
|
||||||
) {
|
|
||||||
val orientation = LocalConfiguration.current.orientation
|
|
||||||
|
|
||||||
KeepScreenOn()
|
|
||||||
|
|
||||||
when (orientation) {
|
|
||||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement
|
|
||||||
.spacedBy(32.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
.align(Alignment.CenterVertically),
|
|
||||||
) {
|
|
||||||
_VideoGeneralInfo(videoRecorder)
|
|
||||||
_VideoRecordingStatus(videoRecorder)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement
|
|
||||||
.spacedBy(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
_VideoControls(videoRecorder)
|
|
||||||
HorizontalDivider()
|
|
||||||
_PrimitiveControls(videoRecorder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = 32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Box {}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement
|
|
||||||
.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
_VideoGeneralInfo(videoRecorder)
|
|
||||||
_VideoRecordingStatus(videoRecorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement
|
|
||||||
.spacedBy(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
_VideoControls(videoRecorder)
|
|
||||||
HorizontalDivider()
|
|
||||||
_PrimitiveControls(videoRecorder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun _VideoGeneralInfo(videoRecorder: VideoRecorderModel) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val availableCameras = CameraInfo.queryAvailableCameras(context)
|
|
||||||
val orientation = LocalConfiguration.current.orientation
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement
|
|
||||||
.spacedBy(if (orientation == Configuration.ORIENTATION_LANDSCAPE) 12.dp else 24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CameraAlt,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(if (orientation == Configuration.ORIENTATION_LANDSCAPE) 48.dp else 64.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (videoRecorder.isStartingRecording) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(128.dp)
|
|
||||||
.height(
|
|
||||||
with(LocalDensity.current) {
|
|
||||||
MaterialTheme.typography.labelMedium.fontSize.toDp()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.shimmer()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
MaterialTheme.shapes.small
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.form_value_selected,
|
|
||||||
if (CameraInfo.checkHasNormalCameras(availableCameras)) {
|
|
||||||
videoRecorder.cameraID.let {
|
|
||||||
if (it == CameraInfo.Lens.BACK.androidValue)
|
|
||||||
stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label)
|
|
||||||
else
|
|
||||||
stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_videoRecorder_action_start_settings_cameraLens_label,
|
|
||||||
videoRecorder.cameraID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun _VideoRecordingStatus(videoRecorder: VideoRecorderModel) {
|
|
||||||
RecordingStatus(
|
|
||||||
recordingTime = videoRecorder.recordingTime,
|
|
||||||
progress = videoRecorder.progress,
|
|
||||||
recordingStart = videoRecorder.recordingStart,
|
|
||||||
maxDuration = videoRecorder.settings!!.maxDuration,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun _PrimitiveControls(videoRecorder: VideoRecorderModel) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStore = context.dataStore
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var showConfirmSaveNow by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showConfirmSaveNow) {
|
|
||||||
SaveCurrentNowModal(
|
|
||||||
onDismiss = {
|
|
||||||
showConfirmSaveNow = false
|
|
||||||
},
|
|
||||||
onConfirm = {
|
|
||||||
showConfirmSaveNow = false
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
videoRecorder.recorderService!!.startNewCycle()
|
|
||||||
|
|
||||||
videoRecorder.onRecordingSave(false).join()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordingControl(
|
|
||||||
orientation = Configuration.ORIENTATION_PORTRAIT,
|
|
||||||
// There may be some edge cases where the app may crash if the
|
|
||||||
// user stops or pauses the recording too soon, so we simply add a
|
|
||||||
// small delay to prevent that
|
|
||||||
initialDelay = 1000L,
|
|
||||||
isPaused = videoRecorder.isPaused,
|
|
||||||
recordingTime = videoRecorder.recordingTime,
|
|
||||||
onDelete = {
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
videoRecorder.batchesFolder!!.deleteRecordings()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPauseResume = {
|
|
||||||
if (videoRecorder.isPaused) {
|
|
||||||
videoRecorder.resumeRecording()
|
|
||||||
} else {
|
|
||||||
videoRecorder.pauseRecording()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSaveAndStop = {
|
|
||||||
println("User initiated video recording save and stop")
|
|
||||||
scope.launch {
|
|
||||||
Log.i("Alibi", "====== Asking to stop recording...")
|
|
||||||
videoRecorder.stopRecording(context)
|
|
||||||
Log.i("Alibi", "====== Asking to stop recording... done")
|
|
||||||
|
|
||||||
Log.i("Alibi", "====== Updating data store...")
|
|
||||||
dataStore.updateData {
|
|
||||||
it.saveLastRecording(videoRecorder as RecorderModel)
|
|
||||||
}
|
|
||||||
Log.i("Alibi", "====== Updating data store... done")
|
|
||||||
|
|
||||||
Log.i("Alibi", "===== Asking to save recording...")
|
|
||||||
videoRecorder.onRecordingSave(false).join()
|
|
||||||
Log.i("Alibi", "===== Asking to save recording... done")
|
|
||||||
|
|
||||||
Log.i("Alibi", "===== Destroying service...")
|
|
||||||
runCatching {
|
|
||||||
videoRecorder.destroyService(context)
|
|
||||||
}
|
|
||||||
Log.i("Alibi", "===== Destroying service... done")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSaveCurrent = {
|
|
||||||
showConfirmSaveNow = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun _VideoControls(videoRecorder: VideoRecorderModel) {
|
|
||||||
if (!videoRecorder.isStartingRecording) {
|
|
||||||
val cameraControl = videoRecorder.recorderService!!.cameraControl!!
|
|
||||||
if (cameraControl.hasTorchAvailable()) {
|
|
||||||
var torchEnabled by rememberSaveable { mutableStateOf(cameraControl.torchEnabled) }
|
|
||||||
|
|
||||||
TorchStatus(
|
|
||||||
enabled = torchEnabled,
|
|
||||||
onChange = {
|
|
||||||
if (torchEnabled) {
|
|
||||||
torchEnabled = false
|
|
||||||
cameraControl.disableTorch()
|
|
||||||
} else {
|
|
||||||
torchEnabled = true
|
|
||||||
cameraControl.enableTorch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
|
||||||
import androidx.compose.material.icons.filled.Info
|
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import app.myzel394.alibi.ui.enums.Screen
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AboutTile(
|
|
||||||
onNavigateToAboutScreen: () -> Unit,
|
|
||||||
) {
|
|
||||||
val label = stringResource(R.string.ui_about_title)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 32.dp, vertical = 48.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.clickable {
|
|
||||||
onNavigateToAboutScreen()
|
|
||||||
}
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Info,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ChevronRight,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import app.myzel394.alibi.ui.enums.Screen
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CustomNotificationTile(
|
|
||||||
onNavigateToCustomRecordingNotifications: () -> Unit,
|
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
val label = if (settings.notificationSettings == null)
|
|
||||||
stringResource(R.string.ui_settings_option_customNotification_description_setup)
|
|
||||||
else stringResource(
|
|
||||||
R.string.ui_settings_option_customNotification_description_edit
|
|
||||||
)
|
|
||||||
|
|
||||||
SettingsTile(
|
|
||||||
firstModifier = Modifier
|
|
||||||
.clickable {
|
|
||||||
|
|
||||||
onNavigateToCustomRecordingNotifications()
|
|
||||||
}
|
|
||||||
.semantics { contentDescription = label },
|
|
||||||
title = stringResource(R.string.ui_settings_option_customNotification_title),
|
|
||||||
description = label,
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Notifications,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ChevronRight,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.DeleteSweep
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DeleteRecordingsImmediatelyTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_title),
|
|
||||||
description = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_description),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.DeleteSweep,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = settings.deleteRecordingsImmediately,
|
|
||||||
onCheckedChange = {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setDeleteRecordingsImmediately(it.deleteRecordingsImmediately.not())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,100 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import android.os.Message
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.magnifier
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Fingerprint
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppLockSettings
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.helpers.AppLockHelper
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.VisualDensity
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EnableAppLockTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStore = context.dataStore
|
|
||||||
|
|
||||||
val appLockSupport = AppLockHelper.getSupportType(context)
|
|
||||||
|
|
||||||
if (appLockSupport === AppLockHelper.SupportType.UNAVAILABLE) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_enableAppLock_title),
|
|
||||||
description = stringResource(R.string.ui_settings_option_enableAppLock_description),
|
|
||||||
tertiaryLine = {
|
|
||||||
if (appLockSupport === AppLockHelper.SupportType.NONE_ENROLLED) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
|
||||||
) {
|
|
||||||
MessageBox(
|
|
||||||
type = MessageType.WARNING,
|
|
||||||
message = stringResource(R.string.ui_settings_option_enableAppLock_enrollmentRequired),
|
|
||||||
density = VisualDensity.COMPACT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Fingerprint,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
val title = stringResource(R.string.identityVerificationRequired_title)
|
|
||||||
val subtitle = stringResource(R.string.identityVerificationRequired_subtitle)
|
|
||||||
|
|
||||||
Switch(
|
|
||||||
checked = settings.isAppLockEnabled(),
|
|
||||||
enabled = appLockSupport === AppLockHelper.SupportType.AVAILABLE,
|
|
||||||
onCheckedChange = {
|
|
||||||
scope.launch {
|
|
||||||
val authenticationSuccessful = AppLockHelper.authenticate(
|
|
||||||
context,
|
|
||||||
title = title,
|
|
||||||
subtitle = subtitle,
|
|
||||||
).await()
|
|
||||||
|
|
||||||
if (!authenticationSuccessful) {
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setAppLockSettings(
|
|
||||||
if (it.appLockSettings == null)
|
|
||||||
AppLockSettings.getDefaultInstance()
|
|
||||||
else
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,235 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.TextSnippet
|
|
||||||
import androidx.compose.material.icons.filled.AccessTime
|
|
||||||
import androidx.compose.material.icons.filled.Circle
|
|
||||||
import androidx.compose.material.icons.filled.Timelapse
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
val FORMAT_RESOURCE_MAP: Map<AppSettings.FilenameFormat, Int> = mapOf(
|
|
||||||
AppSettings.FilenameFormat.DATETIME_RELATIVE_START to R.string.ui_settings_option_filenameFormat_action_relativeStart_label,
|
|
||||||
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START to R.string.ui_settings_option_filenameFormat_action_absoluteStart_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun FilenameFormatTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
snackbarHostState: SnackbarHostState,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStore = context.dataStore
|
|
||||||
|
|
||||||
val successMessage = stringResource(R.string.ui_settings_option_filenameFormat_success)
|
|
||||||
fun updateValue(format: AppSettings.FilenameFormat) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setFilenameFormat(format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectionVisible by remember { mutableStateOf(false) }
|
|
||||||
val selectionSheetState = rememberModalBottomSheetState(true)
|
|
||||||
|
|
||||||
fun hideSheet() {
|
|
||||||
scope.launch {
|
|
||||||
selectionSheetState.hide()
|
|
||||||
selectionVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionVisible) {
|
|
||||||
SelectionSheet(
|
|
||||||
sheetState = selectionSheetState,
|
|
||||||
updateValue = { format ->
|
|
||||||
hideSheet()
|
|
||||||
|
|
||||||
if (format != null) {
|
|
||||||
updateValue(format)
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
message = successMessage,
|
|
||||||
duration = SnackbarDuration.Short,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = ::hideSheet,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_filenameFormat_title),
|
|
||||||
description = stringResource(R.string.ui_settings_option_filenameFormat_explanation),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.TextSnippet,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
selectionVisible = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(FORMAT_RESOURCE_MAP[settings.filenameFormat]!!),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun SelectionSheet(
|
|
||||||
sheetState: SheetState,
|
|
||||||
updateValue: (AppSettings.FilenameFormat?) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
sheetState = sheetState,
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_filenameFormat_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
|
|
||||||
SelectionButton(
|
|
||||||
label = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_label),
|
|
||||||
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_explanation),
|
|
||||||
icon = Icons.Default.AccessTime,
|
|
||||||
onClick = {
|
|
||||||
updateValue(AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
SelectionButton(
|
|
||||||
label = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_label),
|
|
||||||
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_explanation),
|
|
||||||
icon = Icons.Default.Timelapse,
|
|
||||||
onClick = {
|
|
||||||
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
SelectionButton(
|
|
||||||
label = stringResource(R.string.ui_settings_option_filenameFormat_action_now_label),
|
|
||||||
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_now_explanation),
|
|
||||||
icon = Icons.Default.Circle,
|
|
||||||
onClick = {
|
|
||||||
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SelectionButton(
|
|
||||||
label: String,
|
|
||||||
explanation: String,
|
|
||||||
icon: ImageVector,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.clickable {
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize)
|
|
||||||
.fillMaxWidth(0.1f),
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(0.9f),
|
|
||||||
) {
|
|
||||||
Text(label)
|
|
||||||
Text(
|
|
||||||
explanation,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,164 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.Download
|
|
||||||
import androidx.compose.material.icons.filled.Upload
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
|
||||||
import app.myzel394.alibi.ui.utils.rememberFileSelectorDialog
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ImportExport(
|
|
||||||
snackbarHostState: SnackbarHostState,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
var settingsToBeImported by remember { mutableStateOf<AppSettings?>(null) }
|
|
||||||
|
|
||||||
val saveFile = rememberFileSaverDialog("application/json")
|
|
||||||
val openFile = rememberFileSelectorDialog { uri ->
|
|
||||||
val file = File.createTempFile("alibi_settings", ".json")
|
|
||||||
|
|
||||||
context.contentResolver.openInputStream(uri)!!.use {
|
|
||||||
it.copyTo(file.outputStream())
|
|
||||||
}
|
|
||||||
val rawContent = file.readText()
|
|
||||||
|
|
||||||
settingsToBeImported = AppSettings.fromExportedString(rawContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsToBeImported != null) {
|
|
||||||
val successMessage = stringResource(R.string.ui_settings_option_import_success)
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
settingsToBeImported = null
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.ui_settings_option_import_label))
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.ui_settings_option_import_dialog_text))
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Download,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
settingsToBeImported!!
|
|
||||||
}
|
|
||||||
settingsToBeImported = null
|
|
||||||
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
message = successMessage,
|
|
||||||
withDismissAction = true,
|
|
||||||
duration = SnackbarDuration.Short,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.ui_settings_option_import_dialog_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
settingsToBeImported = null
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.dialog_close_cancel_label))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = {
|
|
||||||
openFile("application/json")
|
|
||||||
},
|
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Download,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.ui_settings_option_import_label))
|
|
||||||
}
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = {
|
|
||||||
val rawContent = settings.exportToString()
|
|
||||||
|
|
||||||
val tempFile = File.createTempFile("alibi_settings", ".json")
|
|
||||||
tempFile.writeText(rawContent)
|
|
||||||
|
|
||||||
saveFile(tempFile, "alibi_settings.json")
|
|
||||||
},
|
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Upload,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.ui_settings_option_export_label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,694 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
|
||||||
import androidx.compose.material.icons.filled.Cancel
|
|
||||||
import androidx.compose.material.icons.filled.Error
|
|
||||||
import androidx.compose.material.icons.filled.Folder
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.material.icons.filled.PermMedia
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
|
||||||
import app.myzel394.alibi.helpers.BatchesFolder
|
|
||||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
|
||||||
import app.myzel394.alibi.ui.AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME
|
|
||||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
|
||||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
|
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
|
||||||
import app.myzel394.alibi.ui.VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME
|
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.FolderBreadcrumbs
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberOpenUri
|
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
|
||||||
import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.net.URLDecoder
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SaveFolderTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
snackbarHostState: SnackbarHostState,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val dataStore = context.dataStore
|
|
||||||
|
|
||||||
var showError by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val successMessage = stringResource(R.string.ui_settings_option_saveFolder_success)
|
|
||||||
fun updateValue(path: String?) {
|
|
||||||
if (path != null && path != RECORDER_MEDIA_SELECTED_VALUE) {
|
|
||||||
context.contentResolver.takePersistableUriPermission(
|
|
||||||
path.toUri(),
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!BatchesFolder.canAccessFolder(context, path.toUri())) {
|
|
||||||
showError = true
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
context.contentResolver.releasePersistableUriPermission(
|
|
||||||
path.toUri(),
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
// Clean up
|
|
||||||
val grantedURIs = context.contentResolver.persistedUriPermissions;
|
|
||||||
|
|
||||||
grantedURIs.forEach { permission ->
|
|
||||||
if (permission.uri == path?.toUri()) {
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
|
|
||||||
context.contentResolver.releasePersistableUriPermission(
|
|
||||||
permission.uri,
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setSaveFolder(path)
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
message = successMessage,
|
|
||||||
duration = SnackbarDuration.Short,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectionVisible by remember { mutableStateOf(false) }
|
|
||||||
val selectionSheetState = rememberModalBottomSheetState(true)
|
|
||||||
|
|
||||||
fun hideSheet() {
|
|
||||||
scope.launch {
|
|
||||||
selectionSheetState.hide()
|
|
||||||
selectionVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showError) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
showError = false
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Error,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.ui_error_occurred_title))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(onClick = {
|
|
||||||
showError = false
|
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_batchesFolderInaccessible_error),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionVisible) {
|
|
||||||
SelectionSheet(
|
|
||||||
sheetState = selectionSheetState,
|
|
||||||
updateValue = { path ->
|
|
||||||
updateValue(path)
|
|
||||||
hideSheet()
|
|
||||||
},
|
|
||||||
onDismiss = ::hideSheet,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var showDCIMFolderHelpSheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showDCIMFolderHelpSheet) {
|
|
||||||
DCIMFolderExplanationDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showDCIMFolderHelpSheet = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var showExplanationDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showExplanationDialog) {
|
|
||||||
InternalFolderExplanationDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showExplanationDialog = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_saveFolder_title),
|
|
||||||
description = stringResource(R.string.ui_settings_option_saveFolder_explanation),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.InsertDriveFile,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
selectionVisible = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.ui_settings_option_saveFolder_action_select_label),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extra = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
R.string.form_value_selected,
|
|
||||||
when (settings.saveFolder) {
|
|
||||||
RECORDER_MEDIA_SELECTED_VALUE -> stringResource(R.string.ui_settings_option_saveFolder_dcimValue)
|
|
||||||
null -> stringResource(R.string.ui_settings_option_saveFolder_defaultValue)
|
|
||||||
else -> splitPath(settings.saveFolder).joinToString(" > ")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
val openFolder = rememberOpenUri()
|
|
||||||
|
|
||||||
when (settings.saveFolder) {
|
|
||||||
null -> {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
showExplanationDialog = true
|
|
||||||
},
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label),
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RECORDER_MEDIA_SELECTED_VALUE -> {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
showDCIMFolderHelpSheet = true
|
|
||||||
},
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label),
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Custom folder
|
|
||||||
else ->
|
|
||||||
// Doesn't seem to reliably work on all devices, 30 & 33
|
|
||||||
// has been tested; so we just show the button for versions
|
|
||||||
// above 30
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
openFolder(
|
|
||||||
DocumentFile.fromTreeUri(
|
|
||||||
context,
|
|
||||||
settings.saveFolder.toUri(),
|
|
||||||
)!!.uri
|
|
||||||
)
|
|
||||||
},
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_openFolder_label),
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DCIMFolderExplanationDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.PermMedia,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_generalExplanation),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
// I tried adding support for opening the folder directly by tapping on the
|
|
||||||
// breadcrumbs, but couldn't get it to work.
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Mic,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
FolderBreadcrumbs(
|
|
||||||
folders = listOf(
|
|
||||||
if (SUPPORTS_SCOPED_STORAGE)
|
|
||||||
AudioBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH
|
|
||||||
else
|
|
||||||
AudioBatchesFolder.BASE_LEGACY_STORAGE_FOLDER,
|
|
||||||
MEDIA_SUBFOLDER_NAME
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Box {}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CameraAlt,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
FolderBreadcrumbs(
|
|
||||||
folders = listOf(
|
|
||||||
if (SUPPORTS_SCOPED_STORAGE)
|
|
||||||
VideoBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH
|
|
||||||
else
|
|
||||||
VideoBatchesFolder.BASE_LEGACY_STORAGE_FOLDER,
|
|
||||||
MEDIA_SUBFOLDER_NAME
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Box {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_settings_option_saveFolder_explainMediaFolder_subfoldersExplanation,
|
|
||||||
AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
|
||||||
VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InternalFolderExplanationDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_explainInternalFolder_explanation),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun SelectionSheet(
|
|
||||||
sheetState: SheetState,
|
|
||||||
updateValue: (String?) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val selectFolder = rememberFolderSelectorDialog { folder ->
|
|
||||||
if (folder == null) {
|
|
||||||
return@rememberFolderSelectorDialog
|
|
||||||
}
|
|
||||||
|
|
||||||
updateValue(folder.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
var showExternalPermissionRequired by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showExternalPermissionRequired) {
|
|
||||||
ExternalPermissionRequiredDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showExternalPermissionRequired = false
|
|
||||||
},
|
|
||||||
onGranted = {
|
|
||||||
showExternalPermissionRequired = false
|
|
||||||
updateValue(RECORDER_MEDIA_SELECTED_VALUE)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
sheetState = sheetState,
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_title),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
|
|
||||||
SelectionButton(
|
|
||||||
label = stringResource(R.string.ui_settings_option_saveFolder_action_default_label),
|
|
||||||
icon = Icons.Default.Lock,
|
|
||||||
onClick = {
|
|
||||||
updateValue(null)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
SelectionButton(
|
|
||||||
label = stringResource(R.string.ui_settings_option_saveFolder_action_dcim_label),
|
|
||||||
icon = Icons.Default.PermMedia,
|
|
||||||
onClick = {
|
|
||||||
if (
|
|
||||||
SUPPORTS_SCOPED_STORAGE ||
|
|
||||||
PermissionHelper.hasGranted(
|
|
||||||
context,
|
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
updateValue(RECORDER_MEDIA_SELECTED_VALUE)
|
|
||||||
} else {
|
|
||||||
showExternalPermissionRequired = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
Column {
|
|
||||||
SelectionButton(
|
|
||||||
label = stringResource(R.string.ui_settings_option_saveFolder_action_custom_label),
|
|
||||||
icon = Icons.Default.Folder,
|
|
||||||
onClick = selectFolder,
|
|
||||||
)
|
|
||||||
if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
MessageBox(
|
|
||||||
type = MessageType.INFO,
|
|
||||||
message = stringResource(R.string.ui_settings_option_saveFolder_videoUnsupported),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_minApiRequired, 8, 26),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SelectionButton(
|
|
||||||
label: String,
|
|
||||||
icon: ImageVector,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.clickable {
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Text(label)
|
|
||||||
Box {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ExternalPermissionRequiredDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onGranted: () -> Unit,
|
|
||||||
) {
|
|
||||||
PermissionRequester(
|
|
||||||
icon = Icons.Default.PermMedia,
|
|
||||||
permission = Manifest.permission.READ_EXTERNAL_STORAGE,
|
|
||||||
onPermissionAvailable = onGranted,
|
|
||||||
) { trigger ->
|
|
||||||
AlertDialog(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.PermMedia,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_externalPermissionRequired_title),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_externalPermissionRequired_text),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = trigger,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.ui_settings_option_saveFolder_externalPermissionRequired_action_confirm),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Cancel,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.dialog_close_cancel_label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun splitPath(path: String): List<String> {
|
|
||||||
return try {
|
|
||||||
URLDecoder
|
|
||||||
.decode(path, "UTF-8")
|
|
||||||
.split(":", limit = 3)[2]
|
|
||||||
.split("/")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
listOf(path)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,60 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DividerTitle(
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 32.dp),
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.align(Alignment.CenterVertically),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.align(Alignment.CenterVertically),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,145 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Tune
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
|
||||||
import app.myzel394.alibi.db.VideoRecorderSettings
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.ExampleListRoulette
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import app.myzel394.alibi.ui.utils.IconResource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.Header
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import com.maxkeppeler.sheets.input.InputDialog
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputSelection
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputTextField
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputTextFieldType
|
|
||||||
import com.maxkeppeler.sheets.input.models.ValidationResult
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun VideoRecorderBitrateTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val showDialog = rememberUseCaseState()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
fun updateValue(bitRate: Int?) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setVideoRecorderSettings(
|
|
||||||
it.videoRecorderSettings.setTargetedVideoBitRate(bitRate)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val notNumberLabel = stringResource(R.string.form_error_type_notNumber)
|
|
||||||
InputDialog(
|
|
||||||
state = showDialog,
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.ui_settings_option_videoTargetedBitrate_title),
|
|
||||||
icon = IconSource(
|
|
||||||
painter = IconResource.fromImageVector(Icons.Default.Tune).asPainterResource(),
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
selection = InputSelection(
|
|
||||||
input = listOf(
|
|
||||||
InputTextField(
|
|
||||||
header = InputHeader(
|
|
||||||
title = stringResource(id = R.string.ui_settings_option_videoTargetedBitrate_explanation),
|
|
||||||
),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
),
|
|
||||||
type = InputTextFieldType.OUTLINED,
|
|
||||||
text = if (settings.videoRecorderSettings.targetedVideoBitRate == null) "" else (settings.videoRecorderSettings.targetedVideoBitRate / 1000).toString(),
|
|
||||||
validationListener = { text ->
|
|
||||||
val bitRate = text?.toIntOrNull()
|
|
||||||
|
|
||||||
if (bitRate == null) {
|
|
||||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
ValidationResult.Valid
|
|
||||||
},
|
|
||||||
key = "bitrate",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
) { result ->
|
|
||||||
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: return@InputSelection
|
|
||||||
|
|
||||||
updateValue(bitRate * 1000)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_videoTargetedBitrate_title),
|
|
||||||
description = stringResource(R.string.ui_settings_option_bitrate_description),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Tune,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Button(
|
|
||||||
onClick = showDialog::show,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
Text(formatBitrate(settings.videoRecorderSettings.targetedVideoBitRate))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extra = {
|
|
||||||
ExampleListRoulette(
|
|
||||||
items = VideoRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
|
||||||
onItemSelected = ::updateValue,
|
|
||||||
) { bitRate ->
|
|
||||||
Text(formatBitrate(bitRate))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun formatBitrate(bitrate: Int?): String {
|
|
||||||
return if (bitrate == null)
|
|
||||||
stringResource(R.string.ui_settings_value_auto_label)
|
|
||||||
else if (bitrate >= 1000 * 1000 && bitrate % (1000 * 1000) == 0)
|
|
||||||
stringResource(
|
|
||||||
R.string.format_mbps,
|
|
||||||
bitrate / 1000 / 1000,
|
|
||||||
)
|
|
||||||
else if (bitrate >= 1000 && bitrate % 1000 == 0)
|
|
||||||
stringResource(
|
|
||||||
R.string.format_kbps,
|
|
||||||
bitrate / 1000,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
stringResource(
|
|
||||||
R.string.format_bps,
|
|
||||||
bitrate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.BrokenImage
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.VideoRecorderSettings
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.ExampleListRoulette
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import app.myzel394.alibi.ui.utils.IconResource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.Header
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import com.maxkeppeler.sheets.input.InputDialog
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputSelection
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputTextField
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputTextFieldType
|
|
||||||
import com.maxkeppeler.sheets.input.models.ValidationResult
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun VideoRecorderFrameRateTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val showDialog = rememberUseCaseState()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
fun updateValue(frameRate: Int?) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setVideoRecorderSettings(
|
|
||||||
it.videoRecorderSettings.setTargetFrameRate(frameRate)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val notNumberLabel = stringResource(R.string.form_error_type_notNumber)
|
|
||||||
InputDialog(
|
|
||||||
state = showDialog,
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.ui_settings_option_videoTargetedFrameRate_title),
|
|
||||||
icon = IconSource(
|
|
||||||
painter = IconResource.fromImageVector(Icons.Default.BrokenImage)
|
|
||||||
.asPainterResource(),
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
selection = InputSelection(
|
|
||||||
input = listOf(
|
|
||||||
InputTextField(
|
|
||||||
header = InputHeader(
|
|
||||||
title = stringResource(id = R.string.ui_settings_option_videoTargetedFrameRate_explanation),
|
|
||||||
),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
),
|
|
||||||
type = InputTextFieldType.OUTLINED,
|
|
||||||
text = if (settings.videoRecorderSettings.targetFrameRate == null) "" else settings.videoRecorderSettings.targetFrameRate.toString(),
|
|
||||||
validationListener = { text ->
|
|
||||||
val frameRate = text?.toIntOrNull()
|
|
||||||
|
|
||||||
if (frameRate == null) {
|
|
||||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
ValidationResult.Valid
|
|
||||||
},
|
|
||||||
key = "framerate",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
) { result ->
|
|
||||||
val frameRate = result.getString("framerate")?.toIntOrNull() ?: return@InputSelection
|
|
||||||
|
|
||||||
updateValue(frameRate)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_videoTargetedFrameRate_title),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.BrokenImage,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Button(
|
|
||||||
onClick = showDialog::show,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
if (settings.videoRecorderSettings.targetFrameRate == null)
|
|
||||||
Text(stringResource(R.string.ui_settings_value_auto_label))
|
|
||||||
else
|
|
||||||
Text(settings.videoRecorderSettings.targetFrameRate.toString())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extra = {
|
|
||||||
ExampleListRoulette(
|
|
||||||
items = VideoRecorderSettings.EXAMPLE_FRAME_RATE_VALUES,
|
|
||||||
onItemSelected = ::updateValue,
|
|
||||||
) { frameRate ->
|
|
||||||
Text(
|
|
||||||
frameRate?.toString() ?: stringResource(R.string.ui_settings_value_auto_label)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,119 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
|
||||||
|
|
||||||
import android.media.MediaRecorder
|
|
||||||
import androidx.camera.video.Quality
|
|
||||||
import androidx.camera.video.Recorder
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.HighQuality
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.VideoRecorderSettings
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.ExampleListRoulette
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
|
||||||
import app.myzel394.alibi.ui.utils.IconResource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.Header
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
|
||||||
import com.maxkeppeler.sheets.list.ListDialog
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListOption
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun VideoRecorderQualityTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val QUALITY_NAME_TEXT_MAP = mapOf<Quality, String>(
|
|
||||||
Quality.HIGHEST to stringResource(R.string.ui_settings_value_videoQuality_values_highest),
|
|
||||||
Quality.UHD to stringResource(R.string.ui_settings_value_videoQuality_values_uhd),
|
|
||||||
Quality.FHD to stringResource(R.string.ui_settings_value_videoQuality_values_fhd),
|
|
||||||
Quality.HD to stringResource(R.string.ui_settings_value_videoQuality_values_hd),
|
|
||||||
Quality.SD to stringResource(R.string.ui_settings_value_videoQuality_values_sd),
|
|
||||||
Quality.LOWEST to stringResource(R.string.ui_settings_value_videoQuality_values_lowest),
|
|
||||||
)
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val showDialog = rememberUseCaseState()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
fun updateValue(quality: Quality?) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setVideoRecorderSettings(
|
|
||||||
it.videoRecorderSettings.setQuality(quality)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ListDialog(
|
|
||||||
state = showDialog,
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.ui_settings_option_videoQualityTile_title),
|
|
||||||
icon = IconSource(
|
|
||||||
painter = IconResource.fromImageVector(Icons.Default.HighQuality)
|
|
||||||
.asPainterResource(),
|
|
||||||
contentDescription = null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
selection = ListSelection.Single(
|
|
||||||
showRadioButtons = true,
|
|
||||||
options = VideoRecorderSettings.AVAILABLE_QUALITIES.map { quality ->
|
|
||||||
ListOption(
|
|
||||||
titleText = QUALITY_NAME_TEXT_MAP[quality]!!,
|
|
||||||
selected = settings.videoRecorderSettings.quality == quality.toString(),
|
|
||||||
)
|
|
||||||
}.toList()
|
|
||||||
) { index, _ ->
|
|
||||||
val quality = VideoRecorderSettings.AVAILABLE_QUALITIES[index]
|
|
||||||
|
|
||||||
updateValue(quality)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_videoQualityTile_title),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.HighQuality,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Button(
|
|
||||||
onClick = showDialog::show,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
QUALITY_NAME_TEXT_MAP[settings.videoRecorderSettings.getQuality()]
|
|
||||||
?: stringResource(
|
|
||||||
R.string.ui_settings_value_auto_label
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extra = {
|
|
||||||
ExampleListRoulette(
|
|
||||||
items = listOf(null),
|
|
||||||
onItemSelected = ::updateValue,
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ui_settings_value_auto_label))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,7 +1,8 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -10,6 +11,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -34,12 +36,14 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioRecorderBitrateTile(
|
fun BitrateTile() {
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
fun updateValue(bitRate: Int) {
|
fun updateValue(bitRate: Int) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -77,11 +81,11 @@ fun AudioRecorderBitrateTile(
|
|||||||
val bitRate = text?.toIntOrNull()
|
val bitRate = text?.toIntOrNull()
|
||||||
|
|
||||||
if (bitRate == null) {
|
if (bitRate == null) {
|
||||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
ValidationResult.Invalid(notNumberLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bitRate !in 1..320) {
|
if (bitRate !in 1..320) {
|
||||||
return@InputTextField ValidationResult.Invalid(notInRangeLabel)
|
ValidationResult.Invalid(notInRangeLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidationResult.Valid
|
ValidationResult.Valid
|
||||||
@ -90,9 +94,7 @@ fun AudioRecorderBitrateTile(
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
) { result ->
|
) { result ->
|
||||||
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException(
|
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException("Bitrate is null")
|
||||||
"Bitrate is null"
|
|
||||||
)
|
|
||||||
|
|
||||||
updateValue(bitRate * 1000)
|
updateValue(bitRate * 1000)
|
||||||
}
|
}
|
||||||
@ -126,7 +128,7 @@ fun AudioRecorderBitrateTile(
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) { bitRate ->
|
) {bitRate ->
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.format_kbps,
|
R.string.format_kbps,
|
@ -1,7 +1,8 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AudioFile
|
||||||
import androidx.compose.material.icons.filled.Memory
|
import androidx.compose.material.icons.filled.Memory
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -12,6 +13,7 @@ import androidx.compose.material3.SnackbarDuration
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -32,16 +34,18 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioRecorderEncoderTile(
|
fun EncoderTile(
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
val updatedOutputFormatLabel =
|
val updatedOutputFormatLabel = stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
|
||||||
stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
|
|
||||||
|
|
||||||
fun updateValue(encoder: Int?) {
|
fun updateValue(encoder: Int?) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -88,7 +92,7 @@ fun AudioRecorderEncoderTile(
|
|||||||
selected = settings.audioRecorderSettings.encoder == index,
|
selected = settings.audioRecorderSettings.encoder == index,
|
||||||
)
|
)
|
||||||
}.toList()
|
}.toList()
|
||||||
) { index, _ ->
|
) {index, option ->
|
||||||
updateValue(index)
|
updateValue(index)
|
||||||
},
|
},
|
||||||
)
|
)
|
@ -1,48 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
|
||||||
import androidx.compose.material.icons.filled.Folder
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FolderBreadcrumbs(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
textStyle: TextStyle? = null,
|
|
||||||
folders: Iterable<String>,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
folders.forEachIndexed { index, folder ->
|
|
||||||
if (index != 0) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ChevronRight,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = folder,
|
|
||||||
modifier = Modifier
|
|
||||||
.then(modifier),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = textStyle ?: MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,10 +1,11 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MicExternalOn
|
import androidx.compose.material.icons.filled.GraphicEq
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -12,21 +13,25 @@ import app.myzel394.alibi.R
|
|||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettings
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||||
|
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioRecorderShowAllMicrophonesTile(
|
fun ForceExactMaxDurationTile() {
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
fun updateValue(showAllMicrophones: Boolean) {
|
fun updateValue(forceExactMaxDuration: Boolean) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setAudioRecorderSettings(
|
it.setAudioRecorderSettings(
|
||||||
it.audioRecorderSettings.setShowAllMicrophones(showAllMicrophones)
|
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -34,17 +39,17 @@ fun AudioRecorderShowAllMicrophonesTile(
|
|||||||
|
|
||||||
|
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title = stringResource(R.string.ui_settings_option_showAllMicrophones_title),
|
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
|
||||||
description = stringResource(R.string.ui_settings_option_showAllMicrophones_description),
|
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
|
||||||
leading = {
|
leading = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.MicExternalOn,
|
Icons.Default.GraphicEq,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = settings.audioRecorderSettings.showAllMicrophones,
|
checked = settings.audioRecorderSettings.forceExactMaxDuration,
|
||||||
onCheckedChange = ::updateValue,
|
onCheckedChange = ::updateValue,
|
||||||
)
|
)
|
||||||
},
|
},
|
@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
import app.myzel394.alibi.SUPPORTED_LOCALES
|
import app.myzel394.alibi.SUPPORTED_LOCALES
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||||
import app.myzel394.alibi.ui.utils.IconResource
|
import app.myzel394.alibi.ui.utils.IconResource
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.Timer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -9,6 +10,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -32,23 +34,21 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun IntervalDurationTile(
|
fun IntervalDurationTile() {
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
fun updateValue(intervalDuration: Long) {
|
fun updateValue(intervalDuration: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (intervalDuration > settings.maxDuration) {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setMaxDuration(intervalDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setIntervalDuration(intervalDuration)
|
it.setAudioRecorderSettings(
|
||||||
|
it.audioRecorderSettings.setIntervalDuration(intervalDuration)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,7 +67,7 @@ fun IntervalDurationTile(
|
|||||||
},
|
},
|
||||||
config = DurationConfig(
|
config = DurationConfig(
|
||||||
timeFormat = DurationFormat.MM_SS,
|
timeFormat = DurationFormat.MM_SS,
|
||||||
currentTime = settings.intervalDuration / 1000,
|
currentTime = settings.audioRecorderSettings.intervalDuration / 1000,
|
||||||
minTime = 10,
|
minTime = 10,
|
||||||
maxTime = 60 * 60,
|
maxTime = 60 * 60,
|
||||||
)
|
)
|
||||||
@ -90,7 +90,7 @@ fun IntervalDurationTile(
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(settings.intervalDuration),
|
text = formatDuration(settings.audioRecorderSettings.intervalDuration),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -98,7 +98,7 @@ fun IntervalDurationTile(
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
|
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) { duration ->
|
) {duration ->
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(duration),
|
text = formatDuration(duration),
|
||||||
)
|
)
|
@ -1,6 +1,7 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Memory
|
||||||
import androidx.compose.material.icons.filled.Timer
|
import androidx.compose.material.icons.filled.Timer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -9,6 +10,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -31,23 +33,21 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MaxDurationTile(
|
fun MaxDurationTile() {
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
fun updateValue(maxDuration: Long) {
|
fun updateValue(maxDuration: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (maxDuration < settings.intervalDuration) {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setIntervalDuration(maxDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setMaxDuration(maxDuration)
|
it.setAudioRecorderSettings(
|
||||||
|
it.audioRecorderSettings.setMaxDuration(maxDuration)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,10 +65,10 @@ fun MaxDurationTile(
|
|||||||
updateValue(newTimeInSeconds * 1000L)
|
updateValue(newTimeInSeconds * 1000L)
|
||||||
},
|
},
|
||||||
config = DurationConfig(
|
config = DurationConfig(
|
||||||
timeFormat = DurationFormat.HH_MM,
|
timeFormat = DurationFormat.MM_SS,
|
||||||
currentTime = settings.maxDuration / 1000,
|
currentTime = settings.audioRecorderSettings.maxDuration / 1000,
|
||||||
minTime = 60,
|
minTime = 60,
|
||||||
maxTime = 23 * 60 * 60 + 59 * 60,
|
maxTime = 24 * 60 * 60,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
@ -88,14 +88,14 @@ fun MaxDurationTile(
|
|||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(formatDuration(settings.maxDuration))
|
Text(formatDuration(settings.audioRecorderSettings.maxDuration))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extra = {
|
extra = {
|
||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_MAX_DURATIONS,
|
items = AudioRecorderSettings.EXAMPLE_MAX_DURATIONS,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) { maxDuration ->
|
) {maxDuration ->
|
||||||
Text(formatDuration(maxDuration))
|
Text(formatDuration(maxDuration))
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,5 +1,6 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
|
import android.media.MediaRecorder
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AudioFile
|
import androidx.compose.material.icons.filled.AudioFile
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@ -7,8 +8,13 @@ import androidx.compose.material3.ButtonDefaults
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarVisuals
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -29,12 +35,14 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioRecorderOutputFormatTile(
|
fun OutputFormatTile() {
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
val availableOptions = if (settings.audioRecorderSettings.encoder == null)
|
val availableOptions = if (settings.audioRecorderSettings.encoder == null)
|
||||||
AudioRecorderSettings.OUTPUT_FORMAT_INDEX_TEXT_MAP.keys.toTypedArray()
|
AudioRecorderSettings.OUTPUT_FORMAT_INDEX_TEXT_MAP.keys.toTypedArray()
|
||||||
else AudioRecorderSettings.ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[settings.audioRecorderSettings.encoder]!!
|
else AudioRecorderSettings.ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[settings.audioRecorderSettings.encoder]!!
|
||||||
@ -66,7 +74,7 @@ fun AudioRecorderOutputFormatTile(
|
|||||||
selected = settings.audioRecorderSettings.outputFormat == option,
|
selected = settings.audioRecorderSettings.outputFormat == option,
|
||||||
)
|
)
|
||||||
}.toList()
|
}.toList()
|
||||||
) { index, _ ->
|
) {index, option ->
|
||||||
updateValue(availableOptions[index])
|
updateValue(availableOptions[index])
|
||||||
},
|
},
|
||||||
)
|
)
|
@ -1,8 +1,9 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -10,6 +11,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -34,12 +36,14 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioRecorderSamplingRateTile(
|
fun SamplingRateTile() {
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
fun updateValue(samplingRate: Int?) {
|
fun updateValue(samplingRate: Int?) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -58,8 +62,7 @@ fun AudioRecorderSamplingRateTile(
|
|||||||
header = Header.Default(
|
header = Header.Default(
|
||||||
title = stringResource(R.string.ui_settings_option_samplingRate_title),
|
title = stringResource(R.string.ui_settings_option_samplingRate_title),
|
||||||
icon = IconSource(
|
icon = IconSource(
|
||||||
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked)
|
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked).asPainterResource(),
|
||||||
.asPainterResource(),
|
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -78,11 +81,11 @@ fun AudioRecorderSamplingRateTile(
|
|||||||
val samplingRate = text?.toIntOrNull()
|
val samplingRate = text?.toIntOrNull()
|
||||||
|
|
||||||
if (samplingRate == null) {
|
if (samplingRate == null) {
|
||||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
ValidationResult.Invalid(notNumberLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (samplingRate <= 1000) {
|
if (samplingRate!! <= 1000) {
|
||||||
return@InputTextField ValidationResult.Invalid(mustBeGreaterThanLabel)
|
ValidationResult.Invalid(mustBeGreaterThanLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidationResult.Valid
|
ValidationResult.Valid
|
||||||
@ -91,8 +94,7 @@ fun AudioRecorderSamplingRateTile(
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
) { result ->
|
) { result ->
|
||||||
val samplingRate = result.getString("samplingRate")?.toIntOrNull()
|
val samplingRate = result.getString("samplingRate")?.toIntOrNull() ?: throw IllegalStateException("SamplingRate is null")
|
||||||
?: throw IllegalStateException("SamplingRate is null")
|
|
||||||
|
|
||||||
updateValue(samplingRate)
|
updateValue(samplingRate)
|
||||||
}
|
}
|
||||||
@ -115,8 +117,7 @@ fun AudioRecorderSamplingRateTile(
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
(settings.audioRecorderSettings.samplingRate
|
(settings.audioRecorderSettings.samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||||
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -124,10 +125,9 @@ fun AudioRecorderSamplingRateTile(
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
|
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) { samplingRate ->
|
) {samplingRate ->
|
||||||
Text(
|
Text(
|
||||||
(samplingRate
|
(samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||||
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,173 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Preview(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
backgroundColor: Color,
|
|
||||||
primaryColor: Color,
|
|
||||||
textColor: Color,
|
|
||||||
onSelect: () -> Unit,
|
|
||||||
isSelected: Boolean = false,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier,
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(100.dp)
|
|
||||||
.height(200.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp))
|
|
||||||
.border(width = 1.dp, color = textColor, shape = RoundedCornerShape(10.dp))
|
|
||||||
.background(backgroundColor)
|
|
||||||
.clickable { onSelect() },
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(10.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(30.dp)
|
|
||||||
.height(10.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp))
|
|
||||||
.background(primaryColor)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp))
|
|
||||||
.background(primaryColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Mic,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = primaryColor,
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(40.dp)
|
|
||||||
.height(6.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp))
|
|
||||||
.background(primaryColor)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(75.dp)
|
|
||||||
.height(10.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp))
|
|
||||||
.background(textColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box {}
|
|
||||||
}
|
|
||||||
if (isSelected) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(30.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ThemeSelector() {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Preview(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
backgroundColor = Color(0xFFF0F0F0),
|
|
||||||
primaryColor = Color(0xFFAAAAAA),
|
|
||||||
textColor = Color(0xFFCCCCCC),
|
|
||||||
onSelect = {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setTheme(AppSettings.Theme.LIGHT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isSelected = settings.theme == AppSettings.Theme.LIGHT,
|
|
||||||
)
|
|
||||||
Preview(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
backgroundColor = Color(0xFF444444),
|
|
||||||
primaryColor = Color(0xFF888888),
|
|
||||||
textColor = Color(0xFF606060),
|
|
||||||
onSelect = {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setTheme(AppSettings.Theme.DARK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isSelected = settings.theme == AppSettings.Theme.DARK,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -64,7 +64,6 @@ fun ExplanationPage(
|
|||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ChevronRight,
|
Icons.Default.ChevronRight,
|
@ -1,196 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.Timer
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.contentColorFor
|
|
||||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.ui.utils.IconResource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.Header
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import com.maxkeppeler.sheets.duration.DurationDialog
|
|
||||||
import com.maxkeppeler.sheets.duration.models.DurationConfig
|
|
||||||
import com.maxkeppeler.sheets.duration.models.DurationFormat
|
|
||||||
import com.maxkeppeler.sheets.duration.models.DurationSelection
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
const val MINUTES_1 = 1000 * 60 * 1L
|
|
||||||
const val MINUTES_5 = 1000 * 60 * 5L
|
|
||||||
const val MINUTES_15 = 1000 * 60 * 15L
|
|
||||||
const val MINUTES_30 = 1000 * 60 * 30L
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MaxDurationSelector(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val OPTIONS = mapOf<Long, String>(
|
|
||||||
MINUTES_1 to stringResource(R.string.ui_welcome_timeSettings_values_1min),
|
|
||||||
MINUTES_5 to stringResource(R.string.ui_welcome_timeSettings_values_5min),
|
|
||||||
MINUTES_15 to stringResource(R.string.ui_welcome_timeSettings_values_15min),
|
|
||||||
MINUTES_30 to stringResource(R.string.ui_welcome_timeSettings_values_30min),
|
|
||||||
)
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
var selectedDuration by rememberSaveable { mutableLongStateOf(MINUTES_15) };
|
|
||||||
|
|
||||||
// Make sure appSettings is updated properly
|
|
||||||
LaunchedEffect(selectedDuration) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setMaxDuration(selectedDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
.then(modifier),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
for ((duration, label) in OPTIONS) {
|
|
||||||
val a11yLabel = stringResource(
|
|
||||||
R.string.a11y_selectValue,
|
|
||||||
label
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = a11yLabel
|
|
||||||
}
|
|
||||||
.clickable {
|
|
||||||
selectedDuration = duration
|
|
||||||
}
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = selectedDuration == duration,
|
|
||||||
onClick = { selectedDuration = duration },
|
|
||||||
)
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
val showDialog = rememberUseCaseState()
|
|
||||||
val label = stringResource(R.string.ui_welcome_timeSettings_values_custom)
|
|
||||||
val selected = selectedDuration !in OPTIONS.keys
|
|
||||||
|
|
||||||
DurationDialog(
|
|
||||||
state = showDialog,
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.ui_settings_option_maxDuration_title),
|
|
||||||
icon = IconSource(
|
|
||||||
painter = IconResource.fromImageVector(Icons.Default.Timer)
|
|
||||||
.asPainterResource(),
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
selection = DurationSelection { newTimeInSeconds ->
|
|
||||||
selectedDuration = newTimeInSeconds * 1000L
|
|
||||||
},
|
|
||||||
config = DurationConfig(
|
|
||||||
timeFormat = DurationFormat.HH_MM,
|
|
||||||
currentTime = selectedDuration / 1000,
|
|
||||||
minTime = 60,
|
|
||||||
maxTime = 23 * 60 * 60 + 60 * 59,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.clickable {
|
|
||||||
showDialog.show()
|
|
||||||
}
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Edit,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.minimumInteractiveComponentSize()
|
|
||||||
.padding(2.dp),
|
|
||||||
tint = if (selected) MaterialTheme.colorScheme.primary else contentColorFor(
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (selected) {
|
|
||||||
val totalMinutes = selectedDuration / 1000 / 60
|
|
||||||
val minutes = totalMinutes % 60
|
|
||||||
val hours = (totalMinutes / 60).toInt()
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = when (hours) {
|
|
||||||
0 -> stringResource(
|
|
||||||
R.string.ui_welcome_timeSettings_values_customFormat_mm,
|
|
||||||
minutes
|
|
||||||
)
|
|
||||||
|
|
||||||
1 -> stringResource(
|
|
||||||
R.string.ui_welcome_timeSettings_values_customFormat_h_mm,
|
|
||||||
minutes
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> stringResource(
|
|
||||||
R.string.ui_welcome_timeSettings_values_customFormat_hh_mm,
|
|
||||||
hours,
|
|
||||||
minutes
|
|
||||||
)
|
|
||||||
},
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.ui_welcome_timeSettings_values_custom),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -59,20 +59,19 @@ fun ResponsibilityPage(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Button(
|
Button(
|
||||||
onClick = onContinue,
|
onClick = { onContinue() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ChevronRight,
|
Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
Text(stringResource(R.string.continue_label))
|
Text(stringResource(R.string.ui_welcome_start_label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user