Compare commits

..

No commits in common. "master" and "v0.3.0" have entirely different histories.

179 changed files with 2635 additions and 14347 deletions

View File

@ -19,13 +19,16 @@ runs:
using: composite
steps:
- name: Write Keystore file 🗄️
shell: bash
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
id: android_keystore
uses: timheuer/base64-to-file@v1.0.3
with:
fileName: key.jks
encodedString: ${{ inputs.keyStoreBase64 }}
- name: Write Keystore properties 🗝️
shell: bash
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 "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties

View File

@ -7,15 +7,15 @@ jobs:
debug-builds:
runs-on: ubuntu-latest
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
uses: actions/setup-java@v4
uses: actions/setup-java@v3
with:
distribution: "adopt"
java-version: 21
java-version: 19
cache: "gradle"
- name: Compile
@ -23,7 +23,6 @@ jobs:
./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: alibi-app-debug-apks
path: app/build/outputs/apk/debug/app-*-debug.apk

View File

@ -10,9 +10,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
uses: actions/checkout@v3
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
@ -23,10 +21,10 @@ jobs:
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: 21
java-version: "17.x"
cache: 'gradle'
- name: Build APKs 📱
@ -39,14 +37,3 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
with:
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

View File

@ -10,9 +10,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
uses: actions/checkout@v3
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
@ -23,10 +21,10 @@ jobs:
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: 21
java-version: "17.x"
cache: 'gradle'
- name: Build APKs 📱
@ -41,4 +39,4 @@ jobs:
track: production
status: inProgress
inAppUpdatePriority: 2
userFraction: 0.2
userFraction: 0.33

1
.gitignore vendored
View File

@ -1,7 +1,6 @@
*.iml
.gradle
/local.properties
/.idea
/.idea/caches
/.idea/libraries
/.idea/modules.xml

View File

@ -3,21 +3,18 @@
# Alibi
<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/02.webp" width="30%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.webp" width="30%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.webp" width="30%" />
<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%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width="24%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width="24%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width="24%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width="24%" />
</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.
# 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://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="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="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
# Supporting Alibi
@ -35,8 +32,30 @@ people can use it more easily.
## Donate
It might sound crazy, but if you would just donate $ 1, it would totally mean the 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 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 can totally focus on Alibi and my other open
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`
* Bitcoin Cash: `qr9s64vfqedvurfef9ykf7szchmt0xyvnga452fc8l`
* Ethereum: `0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7`
* Tether USD: `0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7`
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`
* Zcash: `t1ZfvNpzfdaW6csT9Kc7iJA7LUU3hmNj2sx`
* Litecoin: `LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN`
* Dash: `XcTkni8CVAXBcuc5VwvHmsYftVK4CPLetU`
* Avalanche: `0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7`
* XRP: `rNpfDm8UwDTumCebchBadjVW2FEPteFgNg`
* Solana: `2h6CB3hz5Vb2nYS1RQiXZ4aWTzc5frBPR7Sp1b4muFqb`
* ADA: `addr1q8vy2vcp6lacaw8lkc29gufuzajaytc5qc0c2mxlmw5lndxcg5esr4lm36u0lds523cnc9m96gh3gpsls4kdlkaflx6qf6qpvc`
* Dogecoin: `DUA4j7mVoc7Rvezu8YgeRKwxNuMzKeDoxD`
* Tron: `THWVLGhne5wDsGjd1CNenHDKQGzvGzrzLb`
* Polkadot: `1642iaR6AoKyM6qnnMHkfCRfRqRKJ2wC6Cm3UEWEFEz6EtZR`
* Cosmos: `cosmos1vt5z6rfj5sgnkdlddkuu8srw3xupyqxscva9hz`
* Algorand: `QBOQ6VSLMD77QEF33P5J3HKGOM5RZLNO6P5P3FTWCMQM3ORF6QY2W34KUI`
* Tezos: `tz1QUWNYuFqDibGCrwmkdaHSpTx3d6ZdxLMi`
* Litecoin: `LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN`
* Filecoin: `f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq`

View File

@ -35,8 +35,8 @@ android {
applicationId "app.myzel394.alibi"
minSdk 24
targetSdk 34
versionCode 16
versionName "0.5.3"
versionCode 7
versionName "0.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -79,10 +79,9 @@ android {
buildFeatures {
compose true
buildConfig = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.10'
kotlinCompilerExtensionVersion '1.5.1'
}
packagingOptions {
resources {
@ -92,60 +91,41 @@ android {
}
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 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.activity:activity-compose:1.9.1'
implementation 'androidx.activity:activity-ktx:1.9.1'
implementation platform('androidx.compose:compose-bom:2024.09.00')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation platform('androidx.compose:compose-bom:2022.10.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3:1.2.1'
implementation "androidx.compose.material:material-icons-extended:1.6.8"
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.material:material-icons-extended:1.5.1"
implementation 'androidx.appcompat:appcompat:1.6.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00')
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
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'
annotationProcessor 'com.google.dagger:hilt-compiler:2.49'
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
implementation 'com.google.dagger:hilt-android:2.46.1'
annotationProcessor 'com.google.dagger:hilt-compiler:2.46.1'
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.1.1"
implementation "androidx.datastore:datastore-preferences:1.0.0"
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:duration:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:list: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"
}

View File

@ -2,40 +2,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
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.RECORD_AUDIO" />
<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.CAPTURE_AUDIO_OUTPUT" />
<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
android:name=".UpdateSettingsApp"
android:allowBackup="true"
@ -46,7 +23,6 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Alibi"
android:hardwareAccelerated="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
@ -67,13 +43,9 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<service
android:name=".services.AudioRecorderService"
android:foregroundServiceType="microphone" />
<service
android:name=".services.VideoRecorderService"
android:foregroundServiceType="camera|microphone" />
<!-- Change locale for Android <= 12 -->
<service

View File

@ -2,19 +2,19 @@ package app.myzel394.alibi
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.WindowCompat
import androidx.datastore.dataStore
import app.myzel394.alibi.db.AppSettings
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.SUPPORTS_DARK_MODE_NATIVELY
import app.myzel394.alibi.ui.theme.AlibiTheme
const val SETTINGS_FILE = "settings.json"
@ -30,21 +30,27 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
AlibiTheme {
LockedAppHandlers()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
Box(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.background
)
) {
AsLockedApp {
Navigation()
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)
}
}
}
AlibiTheme {
Navigation()
}
}
}
}

View File

@ -1,45 +1,24 @@
package app.myzel394.alibi.db
import android.Manifest
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import app.myzel394.alibi.R
import app.myzel394.alibi.helpers.AudioBatchesFolder
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 com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
@Serializable
data class AppSettings(
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings.getDefaultInstance(),
val videoRecorderSettings: VideoRecorderSettings = VideoRecorderSettings.getDefaultInstance(),
val appLockSettings: AppLockSettings? = null,
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings(),
val notificationSettings: NotificationSettings? = null,
val hasSeenOnboarding: 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 {
return copy(showAdvancedSettings = showAdvancedSettings)
@ -49,10 +28,6 @@ data class AppSettings(
return copy(audioRecorderSettings = audioRecorderSettings)
}
fun setVideoRecorderSettings(videoRecorderSettings: VideoRecorderSettings): AppSettings {
return copy(videoRecorderSettings = videoRecorderSettings)
}
fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings {
return copy(notificationSettings = notificationSettings)
}
@ -69,81 +44,14 @@ data class 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,
fun exportToString(): String {
return Json.encodeToString(serializer(), this)
}
companion object {
@ -163,52 +71,29 @@ data class RecordingInformation(
val folderPath: String,
@Serializable(with = LocalDateTimeSerializer::class)
val recordingStart: LocalDateTime,
val batchesAmount: Int,
val maxDuration: Long,
val intervalDuration: Long,
val fileExtension: String,
val type: Type,
val forceExactMaxDuration: Boolean,
) {
fun hasRecordingsAvailable(context: Context): Boolean =
when (type) {
Type.AUDIO -> AudioBatchesFolder.importFromFolder(folderPath, context)
.hasRecordingsAvailable()
Type.VIDEO -> VideoBatchesFolder.importFromFolder(folderPath, context)
.hasRecordingsAvailable()
}
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
return when (filenameFormat) {
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
getFullDuration() / 1000
)
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
}
}
fun getFullDuration(): Long {
// This is not accurate, since the last batch may be shorter than the others
// but it's good enough
return intervalDuration * batchesAmount - (intervalDuration * 0.5).toLong()
}
enum class Type {
AUDIO,
VIDEO,
}
val hasRecordingsAvailable
get() = File(folderPath).listFiles()?.isNotEmpty() ?: false
}
@Serializable
data class AudioRecorderSettings(
// 30 minutes
val maxDuration: Long = 30 * 60 * 1000L,
// 60 seconds
val intervalDuration: Long = 60 * 1000L,
val forceExactMaxDuration: Boolean = true,
// 320 Kbps
val bitRate: Int = 320000,
val samplingRate: Int? = null,
val outputFormat: Int? = null,
val encoder: Int? = null,
val showAllMicrophones: Boolean = false,
val deleteRecordingsImmediately: Boolean = false,
) {
fun getOutputFormat(): Int {
if (outputFormat != null) {
@ -276,6 +161,18 @@ data class AudioRecorderSettings(
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 {
if (bitRate !in 1000..320000) {
throw Exception("Bit rate must be between 1000 and 320000")
@ -308,10 +205,30 @@ data class AudioRecorderSettings(
return copy(encoder = encoder)
}
fun setMaxDuration(duration: Long): AudioRecorderSettings {
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 setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
return copy(forceExactMaxDuration = forceExactMaxDuration)
}
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
return copy(showAllMicrophones = showAllMicrophones)
}
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AudioRecorderSettings {
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
}
fun isEncoderCompatible(encoder: Int): Boolean {
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
return true
@ -322,31 +239,17 @@ data class AudioRecorderSettings(
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 {
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
val EXAMPLE_MAX_DURATIONS = listOf(
1 * 60 * 1000L,
5 * 60 * 1000L,
15 * 60 * 1000L,
30 * 60 * 1000L,
60 * 60 * 1000L,
2 * 60 * 60 * 1000L,
3 * 60 * 60 * 1000L,
)
val EXAMPLE_DURATION_TIMES = listOf(
60 * 1000L,
60 * 2 * 1000L,
60 * 5 * 1000L,
60 * 10 * 1000L,
60 * 15 * 1000L,
@ -445,93 +348,6 @@ data class AudioRecorderSettings(
}
}
@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,
@ -550,39 +366,39 @@ data class NotificationSettings(
@Serializable
data object Default : Preset(
R.string.ui_audioRecorder_state_recording_title,
R.string.ui_recorder_state_recording_description,
R.string.ui_audioRecorder_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,
R.string.ui_audioRecorder_state_recording_fake_weather_title,
R.string.ui_audioRecorder_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,
R.string.ui_audioRecorder_state_recording_fake_player_title,
R.string.ui_audioRecorder_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,
R.string.ui_audioRecorder_state_recording_fake_browser_title,
R.string.ui_audioRecorder_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,
R.string.ui_audioRecorder_state_recording_fake_vpn_title,
R.string.ui_audioRecorder_state_recording_fake_vpn_description,
false,
R.drawable.ic_vpn,
)
@ -608,10 +424,3 @@ data class NotificationSettings(
)
}
}
@Serializable
class AppLockSettings {
companion object {
fun getDefaultInstance() = AppLockSettings()
}
}

View File

@ -1,10 +1,7 @@
package app.myzel394.alibi.enums
enum class RecorderState {
STOPPED,
IDLE,
RECORDING,
PAUSED,
// Only used by the model to indicate that the service is not running
IDLE
}

View File

@ -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)
}
}
}

View File

@ -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",
)
}
}

View File

@ -0,0 +1,118 @@
package app.myzel394.alibi.helpers
import android.content.Context
import android.util.Log
import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import java.io.File
import java.time.format.DateTimeFormatter
data class AudioRecorderExporter(
val recording: RecordingInformation,
) {
val filePaths: List<File>
get() =
File(recording.folderPath).listFiles()?.filter {
val name = it.nameWithoutExtension
name.toIntOrNull() != null
}?.toList() ?: emptyList()
val hasRecordingAvailable: Boolean
get() = filePaths.isNotEmpty()
private fun stripConcatenatedFileToExactDuration(
outputFile: File
) {
// Move the concatenated file to a temporary file
val rawFile =
File("${recording.folderPath}/${outputFile.nameWithoutExtension}-raw.${recording.fileExtension}")
outputFile.renameTo(rawFile)
val command = "-sseof ${recording.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.state,
session.returnCode,
session.failStackTrace,
)
)
throw Exception("Failed to strip concatenated audio")
}
}
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
val paths = filePaths.joinToString("|")
val fileName = recording.recordingStart
.format(DateTimeFormatter.ISO_DATE_TIME)
.toString()
.replace(":", "-")
.replace(".", "_")
val outputFile = File("${recording.folderPath}/$fileName.${recording.fileExtension}")
if (outputFile.exists() && !forceConcatenation) {
return outputFile
}
val command = "-i 'concat:$paths' -y" +
" -acodec copy" +
" -metadata title='$fileName' " +
" -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" +
" -metadata batch_count='${filePaths.size}'" +
" -metadata batch_duration='${recording.intervalDuration}'" +
" -metadata max_duration='${recording.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.state,
session.returnCode,
session.failStackTrace,
)
)
throw Exception("Failed to concatenate audios")
}
val minRequiredForPossibleInExactMaxDuration =
recording.maxDuration / recording.intervalDuration
if (recording.forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
stripConcatenatedFileToExactDuration(outputFile)
}
return outputFile
}
suspend fun cleanupFiles() {
filePaths.forEach {
runCatching {
it.delete()
}
}
}
companion object {
fun getFolder(context: Context) = File(context.filesDir, RECORDER_SUBFOLDER_NAME)
fun clearAllRecordings(context: Context) {
getFolder(context).deleteRecursively()
}
fun hasRecordingsAvailable(context: Context) =
getFolder(context).listFiles()?.isNotEmpty() ?: false
}
}

View File

@ -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
}
*/
}
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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",
)
}
}

View File

@ -1,7 +1,7 @@
package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ServiceInfo
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
@ -10,129 +10,25 @@ import android.media.MediaRecorder.OnErrorListener
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.app.ServiceCompat
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.db.RecordingInformation
import androidx.core.content.ContextCompat.getSystemService
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
import java.lang.IllegalStateException
import java.util.concurrent.Executor
class AudioRecorderService :
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
override var batchesFolder = AudioBatchesFolder.viaInternalFolder(this)
private val handler = Handler(Looper.getMainLooper())
var amplitudes = mutableListOf<Int>()
private set
class AudioRecorderService : IntervalRecorderService() {
var amplitudesAmount = 1000
var selectedMicrophone: MicrophoneInfo? = null
var recorder: MediaRecorder? = null
private set
// Callbacks
var onError: () -> Unit = {}
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
var onMicrophoneDisconnected: () -> Unit = {}
var onMicrophoneReconnected: () -> Unit = {}
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
override fun startNewCycle() {
super.startNewCycle()
val newRecorder = createRecorder().also {
it.prepare()
}
resetRecorder()
startAudioDevice()
try {
recorder = newRecorder
newRecorder.start()
} catch (error: RuntimeException) {
onError()
}
}
override fun start() {
super.start()
createAmplitudesTimer()
registerMicrophoneListener()
}
override fun pause() {
super.pause()
resetRecorder()
}
override suspend fun stop() {
resetRecorder()
unregisterMicrophoneListener()
super.stop()
}
override fun resume() {
super.resume()
createAmplitudesTimer()
}
override fun startForegroundService() {
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 {
recorder!!.maxAmplitude
} catch (error: IllegalStateException) {
0
} catch (error: RuntimeException) {
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 ====
val filePath: String
get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
/// Tell Android to use the correct bluetooth microphone, if any selected
private fun startAudioDevice() {
@ -159,18 +55,12 @@ class AudioRecorderService :
}
}
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)
@ -178,64 +68,74 @@ class AudioRecorderService :
// - 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())
setOutputFile(filePath)
setOutputFormat(settings!!.outputFormat)
setAudioEncoder(settings!!.encoder)
setAudioEncodingBitRate(settings!!.bitRate)
setAudioSamplingRate(settings!!.samplingRate)
setOnErrorListener(OnErrorListener { _, _, _ ->
onError()
})
}
}
// ==== Microphone related ====
private fun resetRecorder() {
runCatching {
recorder?.apply {
stop()
reset()
release()
recorder?.let {
it.stop()
it.release()
}
clearAudioDevice()
batchesFolder.cleanup()
}
}
override fun startNewCycle() {
super.startNewCycle()
val newRecorder = createRecorder().also {
it.prepare()
}
resetRecorder()
startAudioDevice()
try {
recorder = newRecorder
newRecorder.start()
} catch (error: RuntimeException) {
onError()
}
}
override fun start() {
super.start()
registerMicrophoneListener()
}
override fun pause() {
super.pause()
resetRecorder()
}
override fun stop() {
super.stop()
resetRecorder()
selectedMicrophone = null
unregisterMicrophoneListener()
}
override fun getAmplitudeAmount(): Int = amplitudesAmount
override fun getAmplitude(): Int {
return try {
recorder!!.maxAmplitude
} catch (error: IllegalStateException) {
0
} catch (error: RuntimeException) {
0
}
}
@ -253,7 +153,7 @@ class AudioRecorderService :
super.onAudioDevicesAdded(addedDevices)
if (selectedMicrophone == null) {
return
return;
}
// We can't compare the ID, as it seems to be changing on each reconnect
@ -277,7 +177,7 @@ class AudioRecorderService :
super.onAudioDevicesRemoved(removedDevices)
if (selectedMicrophone == null) {
return
return;
}
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
@ -300,16 +200,4 @@ class AudioRecorderService :
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,
)
}

View File

@ -0,0 +1,55 @@
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()) {
// 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)
}
override fun start() {
createAmplitudesTimer()
}
override fun resume() {
createAmplitudesTimer()
}
}

View File

@ -1,45 +1,43 @@
package app.myzel394.alibi.services
import app.myzel394.alibi.db.AppSettings
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.RecordingInformation
import app.myzel394.alibi.helpers.AudioRecorderExporter
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.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class IntervalRecorderService<I, B : BatchesFolder> :
RecorderService() {
protected var counter = 0L
abstract class IntervalRecorderService : ExtraRecorderInformationService() {
private var job = SupervisorJob()
private var scope = CoroutineScope(Dispatchers.IO + job)
protected var counter = 0
private set
// Tracks the index of the currently locked file
private var lockedIndex: Long? = null
lateinit var settings: AppSettings
var settings: Settings? = null
protected set
private lateinit var cycleTimer: ScheduledExecutorService
abstract var batchesFolder: B
protected val outputFolder: File
get() = AudioRecorderExporter.getFolder(this)
var onBatchesFolderNotAccessible: () -> Unit = {}
abstract fun getRecordingInformation(): I
// When saving the recording, the files should be locked.
// 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
}
fun getRecordingInformation(): RecordingInformation = RecordingInformation(
folderPath = outputFolder.absolutePath,
recordingStart = recordingStart,
maxDuration = settings!!.maxDuration,
fileExtension = settings!!.fileExtension,
intervalDuration = settings!!.intervalDuration,
forceExactMaxDuration = settings!!.forceExactMaxDuration,
)
// Make overrideable
open fun startNewCycle() {
@ -50,9 +48,11 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :
private fun createTimer() {
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
it.scheduleAtFixedRate(
::startNewCycle,
{
startNewCycle()
},
0,
settings.intervalDuration,
settings!!.intervalDuration,
TimeUnit.MILLISECONDS
)
}
@ -61,45 +61,82 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :
override fun start() {
super.start()
batchesFolder.initFolders()
outputFolder.mkdirs()
if (!batchesFolder.checkIfFolderIsAccessible()) {
onBatchesFolderNotAccessible()
scope.launch {
dataStore.data.collectLatest { preferenceSettings ->
if (settings == null) {
settings = Settings.from(preferenceSettings.audioRecorderSettings)
throw AvoidErrorDialogError()
createTimer()
}
}
}
createTimer()
}
override fun pause() {
super.pause()
cycleTimer.shutdown()
}
override fun resume() {
super.resume()
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()
batchesFolder.cleanup()
super.stop()
}
fun clearAllRecordings() {
batchesFolder.deleteRecordings()
}
private fun deleteOldRecordings() {
val timeMultiplier = settings.maxDuration / settings.intervalDuration
val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
val earliestCounter = counter - timeMultiplier
if (earliestCounter <= 0) {
return
outputFolder.listFiles()?.forEach { file ->
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,
)
}
}
}
}

View File

@ -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.
![Diagram](model.svg)

View File

@ -58,7 +58,7 @@ data class RecorderNotificationHelper(
return PendingIntent.getService(
context,
requestCode,
Intent(context, context::class.java).apply {
Intent(context, AudioRecorderService::class.java).apply {
action = "changeState"
putExtra("newState", newState.name)
},
@ -89,24 +89,10 @@ data class RecorderNotificationHelper(
.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))
.setContentTitle(context.getString(R.string.ui_audioRecorder_state_recording_title))
.setContentText(context.getString(R.string.ui_audioRecorder_state_recording_description))
.build()
}
@ -119,39 +105,41 @@ data class RecorderNotificationHelper(
Date.from(
Calendar
.getInstance()
.also { it.add(Calendar.SECOND, -recordingTime.toInt()) }
.also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
.toInstant()
).time,
)
.addAction(
R.drawable.ic_cancel,
context.getString(R.string.ui_audioRecorder_action_delete_label),
getNotificationChangeStateIntent(RecorderState.IDLE, 1),
)
.addAction(
R.drawable.ic_pause,
context.getString(R.string.ui_recorder_action_pause_label),
context.getString(R.string.ui_audioRecorder_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,
)
?: context.getString(R.string.ui_audioRecorder_state_recording_title)
)
.setContentText(
details?.description
?: context.getString(R.string.ui_recorder_state_recording_description)
?: context.getString(R.string.ui_audioRecorder_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))
.setContentTitle(context.getString(R.string.ui_audioRecorder_state_paused_title))
.setContentText(context.getString(R.string.ui_audioRecorder_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),
context.getString(R.string.ui_audioRecorder_action_resume_label),
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
)
.build()

View File

@ -2,11 +2,14 @@ package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.app.Notification
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LifecycleService
import androidx.core.app.ServiceCompat
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.ui.utils.PermissionHelper
@ -17,86 +20,31 @@ import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class RecorderService : LifecycleService() {
abstract class RecorderService : Service() {
private val binder = RecorderBinder()
private var isPaused: Boolean = false
lateinit var recordingStart: LocalDateTime
private set
private lateinit var recordingTimeTimer: ScheduledExecutorService
private var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
var state = RecorderState.IDLE
private set
var onStateChange: ((RecorderState) -> Unit)? = null
var onError: () -> Unit = {}
var onRecordingTimeChange: ((Long) -> Unit)? = null
var recordingTime = 0L
private set
private lateinit var recordingTimeTimer: ScheduledExecutorService
var onRecordingTimeChange: ((Long) -> Unit)? = null
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
protected open fun start() {
createRecordingTimeTimer()
}
protected abstract fun start()
protected abstract fun pause()
protected abstract fun resume()
protected abstract fun stop()
protected open fun pause() {
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 onBind(p0: Intent?): IBinder? = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
@ -112,7 +60,7 @@ abstract class RecorderService : LifecycleService() {
"changeState" -> {
val newState = intent.getStringExtra("newState")?.let {
RecorderState.valueOf(it)
} ?: RecorderState.STOPPED
} ?: RecorderState.IDLE
changeState(newState)
}
}
@ -128,19 +76,16 @@ abstract class RecorderService : LifecycleService() {
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
it.scheduleAtFixedRate(
{
recordingTime += 1
recordingTime += 1000
onRecordingTimeChange?.invoke(recordingTime)
},
0,
1,
TimeUnit.SECONDS
1000,
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")
fun changeState(newState: RecorderState) {
if (state == newState) {
@ -153,16 +98,30 @@ abstract class RecorderService : LifecycleService() {
if (isPaused) {
resume()
isPaused = false
} else {
start()
}
// `start` is handled by `startRecording`
}
RecorderState.PAUSED -> pause()
RecorderState.PAUSED -> {
pause()
isPaused = true
}
else -> {}
}
// Update notification
when (newState) {
RecorderState.RECORDING -> {
createRecordingTimeTimer()
}
RecorderState.PAUSED, RecorderState.IDLE -> {
recordingTimeTimer.shutdown()
}
}
if (
arrayOf(
RecorderState.RECORDING,
@ -176,14 +135,45 @@ abstract class RecorderService : LifecycleService() {
notification
)
}
onStateChange?.invoke(newState)
}
protected fun getNotificationHelper(): RecorderNotificationHelper {
// Must be immediately called after creating the service!
fun startRecording() {
recordingStart = LocalDateTime.now()
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
},
)
// Start
changeState(RecorderState.RECORDING)
}
override fun onDestroy() {
super.onDestroy()
stop()
changeState(RecorderState.IDLE)
stopForeground(STOP_FOREGROUND_REMOVE)
NotificationManagerCompat.from(this)
.cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
stopSelf()
}
private fun getNotificationHelper(): RecorderNotificationHelper {
return RecorderNotificationHelper(this, notificationDetails)
}
private fun buildNotification(): Notification {
val notificationHelper = getNotificationHelper()
@ -201,9 +191,4 @@ abstract class RecorderService : LifecycleService() {
}
}
}
// Throw this error if you show a dialog yourself.
// This will prevent the service from showing their generic error dialog.
class AvoidErrorDialogError : RuntimeException()
}

View File

@ -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

View File

@ -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,
)
}
}
}
}

View File

@ -2,25 +2,12 @@ package app.myzel394.alibi.ui
import android.os.Build
import androidx.compose.ui.unit.dp
import java.util.Base64
import java.io.File
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 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"
val RECORDER_SUBFOLDER_NAME = ".recordings"
// You are not allowed to change the constants below.
// If you do so, you will be blocked on GitHub.
@ -64,17 +51,3 @@ val CRYPTO_DONATIONS = mapOf(
"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"
)

View File

@ -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))
}
}
)
}
}

View File

@ -14,7 +14,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -22,20 +21,17 @@ import androidx.navigation.compose.rememberNavController
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.models.VideoRecorderModel
import app.myzel394.alibi.ui.screens.AboutScreen
import app.myzel394.alibi.ui.screens.AudioRecorderScreen
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.WelcomeScreen
const val SCALE_IN = 1.25f
const val DEBUG_SKIP_WELCOME = false;
@Composable
fun Navigation(
audioRecorder: AudioRecorderModel = viewModel(),
videoRecorder: VideoRecorderModel = viewModel(),
audioRecorder: AudioRecorderModel = viewModel()
) {
val navController = rememberNavController()
val context = LocalContext.current
@ -47,11 +43,9 @@ fun Navigation(
DisposableEffect(Unit) {
audioRecorder.bindToService(context)
videoRecorder.bindToService(context)
onDispose {
audioRecorder.unbindFromService(context)
videoRecorder.unbindFromService(context)
}
}
@ -59,18 +53,10 @@ fun Navigation(
modifier = Modifier
.background(MaterialTheme.colorScheme.background),
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) {
WelcomeScreen(
onNavigateToAudioRecorderScreen = {
val mainHandler = ContextCompat.getMainExecutor(context)
mainHandler.execute {
navController.navigate(Screen.AudioRecorder.route)
}
},
)
WelcomeScreen(navController = navController)
}
composable(
Screen.AudioRecorder.route,
@ -84,13 +70,9 @@ fun Navigation(
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
}
) {
RecorderScreen(
onNavigateToSettingsScreen = {
navController.navigate(Screen.Settings.route)
},
AudioRecorderScreen(
navController = navController,
audioRecorder = audioRecorder,
videoRecorder = videoRecorder,
settings = settings,
)
}
composable(
@ -103,13 +85,8 @@ fun Navigation(
}
) {
SettingsScreen(
onBackNavigate = navController::popBackStack,
onNavigateToCustomRecordingNotifications = {
navController.navigate(Screen.CustomRecordingNotifications.route)
},
onNavigateToAboutScreen = { navController.navigate(Screen.About.route) },
navController = navController,
audioRecorder = audioRecorder,
videoRecorder = videoRecorder,
)
}
composable(
@ -126,7 +103,7 @@ fun Navigation(
}
) {
CustomRecordingNotificationsScreen(
onBackNavigate = navController::popBackStack
navController = navController,
)
}
composable(
@ -139,7 +116,7 @@ fun Navigation(
}
) {
AboutScreen(
onBackNavigate = navController::popBackStack,
navController = navController,
)
}
}

View File

@ -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.

View File

@ -33,10 +33,8 @@ 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
@ -132,11 +130,11 @@ fun DonationsTile() {
Column {
val uriHandler = LocalUriHandler.current
TextButton(
Button(
onClick = {
uriHandler.openUri(GITHUB_SPONSORS_URL)
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
colors = ButtonDefaults.textButtonColors(),
modifier = Modifier.fillMaxWidth(),
) {
Image(

View File

@ -16,7 +16,6 @@ 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
@ -63,11 +62,12 @@ fun GPGKeyOverview() {
)
.padding(8.dp),
)
TextButton(
Button(
onClick = {
val clip = ClipData.newPlainText("text", PUBLIC_KEY)
clipboardManager.setPrimaryClip(clip)
},
colors = ButtonDefaults.textButtonColors(),
modifier = Modifier
.fillMaxWidth()
) {

View File

@ -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.layout.fillMaxWidth
@ -29,7 +29,7 @@ fun AudioVisualizer(
val width = this.size.width
val boxWidth = width / amplitudes.size
amplitudes.forEachIndexed { index, amplitude ->
amplitudes.forEachIndexed {index, amplitude ->
val x = boxWidth * index
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
val boxHeight = height * amplitudePercentage

View File

@ -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.size
@ -11,7 +11,6 @@ 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.Modifier
import androidx.compose.ui.res.stringResource
@ -29,10 +28,10 @@ fun ConfirmDeletionDialog(
onDismiss()
},
title = {
Text(stringResource(R.string.ui_recorder_action_delete_confirm_title))
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_title))
},
text = {
Text(stringResource(R.string.ui_recorder_action_delete_confirm_message))
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_message))
},
icon = {
Icon(
@ -41,13 +40,12 @@ fun ConfirmDeletionDialog(
)
},
confirmButton = {
val label = stringResource(R.string.ui_recorder_action_delete_label)
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
onClick = {
onConfirm()
},
@ -63,15 +61,15 @@ fun ConfirmDeletionDialog(
},
dismissButton = {
val label = stringResource(R.string.dialog_close_cancel_label)
TextButton(
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
onClick = {
onDismiss()
},
colors = ButtonDefaults.textButtonColors(),
) {
Icon(
Icons.Default.Cancel,

View File

@ -1,10 +1,15 @@
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -34,8 +39,8 @@ fun DeleteButton(
},
)
}
val label = stringResource(R.string.ui_recorder_action_delete_label)
TextButton(
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
@ -44,6 +49,7 @@ fun DeleteButton(
onClick = {
showDeleteDialog = true
},
colors = ButtonDefaults.textButtonColors(),
) {
Text(
label,

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MicOff

View File

@ -1,6 +1,7 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button

View File

@ -1,14 +1,20 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.material.icons.Icons
import androidx.compose.material.icons.filled.MicExternalOn
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
@ -50,7 +56,6 @@ fun MicrophoneSelectionButton(
.fillMaxWidth()
.height(64.dp),
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
contentPadding = if (selected) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.TextButtonContentPadding,
) {
Row(
verticalAlignment = Alignment.CenterVertically,

View File

@ -1,10 +1,14 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
import android.R
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BluetoothAudio
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicExternalOn
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View File

@ -1,10 +1,12 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
package app.myzel394.alibi.ui.components.AudioRecorder.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.material3.LargeFloatingActionButton
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -18,8 +20,8 @@ fun PauseResumeButton(
isPaused: Boolean,
onChange: () -> Unit,
) {
val pauseLabel = stringResource(R.string.ui_recorder_action_pause_label)
val resumeLabel = stringResource(R.string.ui_recorder_action_resume_label)
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
FloatingActionButton(
modifier = Modifier

View File

@ -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.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
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.LocalDensity
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.MAX_AMPLITUDE
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.clamp
@ -37,7 +35,6 @@ private const val GROW_END = BOX_DIFF * 4
@Composable
fun RealtimeAudioVisualizer(
modifier: Modifier = Modifier,
audioRecorder: AudioRecorderModel,
) {
val scope = rememberCoroutineScope()
@ -66,28 +63,17 @@ fun RealtimeAudioVisualizer(
}
val configuration = LocalConfiguration.current
// Use greater value of width and height to make sure the amplitudes are shown
// when the user rotates the device
val availableSpace = with(LocalDensity.current) {
Math.max(
configuration.screenWidthDp.dp.toPx(),
configuration.screenHeightDp.dp.toPx()
)
}
val screenWidth = with(LocalDensity.current) { configuration.screenWidthDp.dp.toPx() }
LaunchedEffect(availableSpace) {
LaunchedEffect(screenWidth) {
// 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(
modifier = modifier.transformable(transformState),
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
) {
val height = this.size.height / 2f
val width = this.size.width
@ -101,8 +87,7 @@ fun RealtimeAudioVisualizer(
val horizontalProgress = (
clamp(horizontalValue, GROW_START, GROW_END)
- GROW_START) / (GROW_END - GROW_START)
val amplitudePercentage =
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
val boxHeight =
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)

View File

@ -0,0 +1,45 @@
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.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.unit.dp
import app.myzel394.alibi.ui.components.atoms.Pulsating
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.formatDuration
@Composable
fun RecordingTime(
time: Long,
) {
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(time),
style = MaterialTheme.typography.headlineLarge,
)
}
}

View File

@ -0,0 +1,53 @@
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
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.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.ui.Modifier
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.ui.BIG_PRIMARY_BUTTON_SIZE
@Composable
fun SaveButton(
modifier: Modifier = Modifier,
onSave: () -> Unit,
) {
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
}
.then(modifier),
onClick = onSave,
colors = ButtonDefaults.textButtonColors(),
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text(
label,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
)
}
}

View File

@ -1,30 +1,32 @@
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
package app.myzel394.alibi.ui.components.AudioRecorder.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.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.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
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
@ -36,14 +38,12 @@ 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.AudioRecorder.atoms.MicrophoneSelectionButton
import app.myzel394.alibi.ui.components.AudioRecorder.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
@ -76,23 +76,17 @@ fun MicrophoneSelection(
visibleMicrophones
}
val scope = rememberCoroutineScope()
fun hideSheet() {
scope.launch {
sheetState.hide()
showSelection = false
}
}
if (showSelection) {
ModalBottomSheet(
onDismissRequest = ::hideSheet,
onDismissRequest = {
showSelection = false
},
sheetState = sheetState,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = SHEET_BOTTOM_OFFSET),
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(48.dp),
) {
@ -123,7 +117,7 @@ fun MicrophoneSelection(
selectedAsFallback = isTryingToReconnect,
onSelect = {
audioRecorder.changeMicrophone(null)
hideSheet()
showSelection = false
}
)
}
@ -137,7 +131,7 @@ fun MicrophoneSelection(
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
onSelect = {
audioRecorder.changeMicrophone(microphone)
hideSheet()
showSelection = false
}
)
}
@ -149,7 +143,7 @@ fun MicrophoneSelection(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 32.dp),
) {
HorizontalDivider(
Divider(
modifier = Modifier
.weight(1f)
)
@ -159,7 +153,7 @@ fun MicrophoneSelection(
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center,
)
HorizontalDivider(
Divider(
modifier = Modifier
.weight(1f),
)
@ -174,7 +168,7 @@ fun MicrophoneSelection(
selected = audioRecorder.selectedMicrophone == microphone,
onSelect = {
audioRecorder.changeMicrophone(microphone)
hideSheet()
showSelection = false
}
)
}
@ -188,14 +182,11 @@ fun MicrophoneSelection(
}
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
TextButton(
Button(
onClick = {
scope.launch {
showSelection = true
sheetState.show()
}
showSelection = true
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
colors = ButtonDefaults.textButtonColors(),
) {
MicrophoneTypeInfo(
type = audioRecorder.selectedMicrophone?.type

View File

@ -1,15 +1,21 @@
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
import androidx.compose.foundation.layout.Box
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 app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneDisconnectedDialog
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneReconnectedDialog
import androidx.compose.ui.platform.LocalContext
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
import app.myzel394.alibi.ui.effects.rememberPrevious
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.MicrophoneInfo
@Composable
fun MicrophoneStatus(

View File

@ -0,0 +1,186 @@
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.material3.rememberStandardBottomSheetState
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.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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.helpers.AudioRecorderExporter
import app.myzel394.alibi.services.RecorderNotificationHelper
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 kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun StartRecording(
audioRecorder: AudioRecorderModel,
// 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,
) {
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.notificationDetails = appSettings.notificationSettings.let {
if (it == null)
null
else
RecorderNotificationHelper.NotificationDetails.fromNotificationSettings(
context,
it
)
}
AudioRecorderExporter.clearAllRecordings(context)
audioRecorder.startRecording(context)
}
}
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 = {
startRecording = true
},
) { 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 (appSettings.lastRecording?.hasRecordingsAvailable == true) {
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(appSettings.lastRecording.recordingStart),
)
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.semantics {
contentDescription = label
},
colors = ButtonDefaults.textButtonColors(),
onClick = onSaveLastRecording,
) {
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))
}
}

View File

@ -0,0 +1,150 @@
package app.myzel394.alibi.ui.components.AudioRecorder.organisms
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
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.width
import androidx.compose.material3.LinearProgressIndicator
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.helpers.AudioRecorderExporter
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.DeleteButton
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.PauseResumeButton
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RecordingTime
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveButton
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneSelection
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneStatus
import app.myzel394.alibi.ui.effects.rememberPrevious
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.KeepScreenOn
import app.myzel394.alibi.ui.utils.MicrophoneInfo
import kotlinx.coroutines.delay
import java.time.LocalDateTime
@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,
) {
RecordingTime(audioRecorder.recordingTime!!)
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))
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
DeleteButton(
onDelete = {
audioRecorder.stopRecording(context)
AudioRecorderExporter.clearAllRecordings(context)
}
)
}
Box(
contentAlignment = Alignment.Center,
) {
PauseResumeButton(
isPaused = audioRecorder.isPaused,
onChange = {
if (audioRecorder.isPaused) {
audioRecorder.resumeRecording()
} else {
audioRecorder.pauseRecording()
}
},
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
SaveButton(
onSave = {
runCatching {
audioRecorder.stopRecording(context)
}
audioRecorder.onRecordingSave()
}
)
}
}
MicrophoneStatus(audioRecorder)
}
}

View File

@ -19,11 +19,9 @@ 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
@ -84,7 +82,10 @@ fun LandingElement(
stringResource(R.string.ui_settings_customNotifications_landing_description),
style = MaterialTheme.typography.bodySmall,
)
FilledTonalButton(onClick = onOpenEditor) {
Button(
onClick = onOpenEditor,
colors = ButtonDefaults.filledTonalButtonColors(),
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
@ -99,8 +100,9 @@ fun LandingElement(
}
}
}
TextButton(
Button(
onClick = context::openNotificationsSettings,
colors = ButtonDefaults.textButtonColors(),
) {
Text(
stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications),

View File

@ -1,12 +1,18 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
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.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@ -14,6 +20,7 @@ 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.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -24,8 +31,12 @@ 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.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@ -34,8 +45,10 @@ 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 com.maxkeppeler.sheets.input.models.InputText
import java.time.Duration
import java.time.LocalDateTime
import java.time.Period
@Composable
fun EditNotificationInput(
@ -154,13 +167,13 @@ fun EditNotificationInput(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.ui_recorder_action_delete_label),
stringResource(R.string.ui_audioRecorder_action_delete_label),
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
)
Text(
stringResource(R.string.ui_recorder_action_pause_label),
stringResource(R.string.ui_audioRecorder_action_pause_label),
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,

View File

@ -90,7 +90,7 @@ fun NotificationEditor(
} else {
val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title)
val defaultDescription =
stringResource(R.string.ui_recorder_state_recording_description)
stringResource(R.string.ui_audioRecorder_state_recording_description)
LaunchedEffect(Unit) {
notificationModel.initialize(
@ -178,7 +178,6 @@ fun NotificationEditor(
notificationModel.asNotificationSettings()
)
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = HORIZONTAL_PADDING)

View File

@ -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))
}
}
)
}

View File

@ -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,
)
}
}
}
}

View File

@ -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
},
)
}
}

View File

@ -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,
)

View File

@ -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
)
}
}
}

View File

@ -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))
}
}
)
}

View File

@ -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 = {}
)
}

View File

@ -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,
)
}
}

View File

@ -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))
}
}
}
}

View File

@ -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),
)
}
}

View File

@ -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,
)
}
}
}

View File

@ -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]!!,
)
}
}
}
}

View File

@ -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))
}
}
}
}
}
}

View File

@ -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,
)
}
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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,
)
}
}
}
}
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -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
},
)
}

View File

@ -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
},
)
}

View File

@ -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
)
}
}
}

View File

@ -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()
}
},
)
}
}
}

View File

@ -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
)
}
}
}
)
}
)
}

View File

@ -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,
)
}
}
}

View File

@ -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)
}
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}

View File

@ -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)
)
}
}
)
}

View File

@ -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))
}
},
)
}

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -31,7 +31,7 @@ import app.myzel394.alibi.ui.enums.Screen
@Composable
fun AboutTile(
onNavigateToAboutScreen: () -> Unit,
navController: NavController,
) {
val label = stringResource(R.string.ui_about_title)
@ -44,7 +44,7 @@ fun AboutTile(
contentDescription = label
}
.clickable {
onNavigateToAboutScreen()
navController.navigate(Screen.About.route)
}
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(16.dp),

View File

@ -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.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -10,6 +11,7 @@ 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.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -34,7 +36,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderBitrateTile(
fun BitrateTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
@ -77,11 +79,11 @@ fun AudioRecorderBitrateTile(
val bitRate = text?.toIntOrNull()
if (bitRate == null) {
return@InputTextField ValidationResult.Invalid(notNumberLabel)
ValidationResult.Invalid(notNumberLabel)
}
if (bitRate !in 1..320) {
return@InputTextField ValidationResult.Invalid(notInRangeLabel)
ValidationResult.Invalid(notInRangeLabel)
}
ValidationResult.Valid
@ -90,9 +92,7 @@ fun AudioRecorderBitrateTile(
)
),
) { result ->
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException(
"Bitrate is null"
)
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException("Bitrate is null")
updateValue(bitRate * 1000)
}
@ -126,7 +126,7 @@ fun AudioRecorderBitrateTile(
ExampleListRoulette(
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
onItemSelected = ::updateValue,
) { bitRate ->
) {bitRate ->
Text(
stringResource(
R.string.format_kbps,

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
@ -21,7 +21,7 @@ import app.myzel394.alibi.ui.enums.Screen
@Composable
fun CustomNotificationTile(
onNavigateToCustomRecordingNotifications: () -> Unit,
navController: NavController,
settings: AppSettings,
) {
val dataStore = LocalContext.current.dataStore
@ -35,8 +35,7 @@ fun CustomNotificationTile(
SettingsTile(
firstModifier = Modifier
.clickable {
onNavigateToCustomRecordingNotifications()
navController.navigate(Screen.CustomRecordingNotifications.route)
}
.semantics { contentDescription = label },
title = stringResource(R.string.ui_settings_option_customNotification_title),

View File

@ -1,4 +1,4 @@
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.filled.DeleteSweep
@ -34,11 +34,13 @@ fun DeleteRecordingsImmediatelyTile(
},
trailing = {
Switch(
checked = settings.deleteRecordingsImmediately,
checked = settings.audioRecorderSettings.deleteRecordingsImmediately,
onCheckedChange = {
scope.launch {
dataStore.updateData {
it.setDeleteRecordingsImmediately(it.deleteRecordingsImmediately.not())
it.setAudioRecorderSettings(
it.audioRecorderSettings.setDeleteRecordingsImmediately(it.audioRecorderSettings.deleteRecordingsImmediately.not())
)
}
}
}

View File

@ -1,4 +1,4 @@
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
@ -12,6 +12,7 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
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
@ -32,7 +33,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderEncoderTile(
fun EncoderTile(
snackbarHostState: SnackbarHostState,
settings: AppSettings,
) {

View File

@ -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,
)
}
}
}

View File

@ -0,0 +1,54 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GraphicEq
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 com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
import kotlinx.coroutines.launch
@Composable
fun ForceExactMaxDurationTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
fun updateValue(forceExactMaxDuration: Boolean) {
scope.launch {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
)
}
}
}
SettingsTile(
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
leading = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
)
},
trailing = {
Switch(
checked = settings.audioRecorderSettings.forceExactMaxDuration,
onCheckedChange = ::updateValue,
)
},
)
}

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@ -6,18 +6,18 @@ 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.Check
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.SnackbarVisuals
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
@ -98,8 +98,8 @@ fun ImportExport(
duration = SnackbarDuration.Short,
)
}
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.CheckCircle,
@ -111,10 +111,11 @@ fun ImportExport(
}
},
dismissButton = {
TextButton(
Button(
onClick = {
settingsToBeImported = null
},
colors = ButtonDefaults.textButtonColors(),
) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
@ -127,11 +128,11 @@ fun ImportExport(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
FilledTonalButton(
Button(
onClick = {
openFile("application/json")
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
colors = ButtonDefaults.filledTonalButtonColors(),
) {
Icon(
Icons.Default.Download,
@ -141,7 +142,7 @@ fun ImportExport(
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_import_label))
}
FilledTonalButton(
Button(
onClick = {
val rawContent = settings.exportToString()
@ -150,7 +151,7 @@ fun ImportExport(
saveFile(tempFile, "alibi_settings.json")
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
colors = ButtonDefaults.filledTonalButtonColors(),
) {
Icon(
Icons.Default.Upload,

View File

@ -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.filled.Mic
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -9,6 +10,7 @@ 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.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -41,14 +43,10 @@ fun IntervalDurationTile(
fun updateValue(intervalDuration: Long) {
scope.launch {
if (intervalDuration > settings.maxDuration) {
dataStore.updateData {
it.setMaxDuration(intervalDuration)
}
}
dataStore.updateData {
it.setIntervalDuration(intervalDuration)
it.setAudioRecorderSettings(
it.audioRecorderSettings.setIntervalDuration(intervalDuration)
)
}
}
}
@ -67,7 +65,7 @@ fun IntervalDurationTile(
},
config = DurationConfig(
timeFormat = DurationFormat.MM_SS,
currentTime = settings.intervalDuration / 1000,
currentTime = settings.audioRecorderSettings.intervalDuration / 1000,
minTime = 10,
maxTime = 60 * 60,
)
@ -90,7 +88,7 @@ fun IntervalDurationTile(
shape = MaterialTheme.shapes.medium,
) {
Text(
text = formatDuration(settings.intervalDuration),
text = formatDuration(settings.audioRecorderSettings.intervalDuration),
)
}
},
@ -98,7 +96,7 @@ fun IntervalDurationTile(
ExampleListRoulette(
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
onItemSelected = ::updateValue,
) { duration ->
) {duration ->
Text(
text = formatDuration(duration),
)

View File

@ -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.filled.Memory
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -9,6 +10,7 @@ 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.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -40,14 +42,10 @@ fun MaxDurationTile(
fun updateValue(maxDuration: Long) {
scope.launch {
if (maxDuration < settings.intervalDuration) {
dataStore.updateData {
it.setIntervalDuration(maxDuration)
}
}
dataStore.updateData {
it.setMaxDuration(maxDuration)
it.setAudioRecorderSettings(
it.audioRecorderSettings.setMaxDuration(maxDuration)
)
}
}
}
@ -66,9 +64,9 @@ fun MaxDurationTile(
},
config = DurationConfig(
timeFormat = DurationFormat.HH_MM,
currentTime = settings.maxDuration / 1000,
currentTime = settings.audioRecorderSettings.maxDuration / 1000,
minTime = 60,
maxTime = 23 * 60 * 60 + 59 * 60,
maxTime = 10 * 24 * 60 * 60,
)
)
SettingsTile(
@ -88,7 +86,7 @@ fun MaxDurationTile(
),
shape = MaterialTheme.shapes.medium,
) {
Text(formatDuration(settings.maxDuration))
Text(formatDuration(settings.audioRecorderSettings.maxDuration))
}
},
extra = {

View File

@ -1,4 +1,4 @@
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.filled.AudioFile
@ -29,7 +29,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderOutputFormatTile(
fun OutputFormatTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()

View File

@ -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.material.icons.Icons
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -10,6 +11,7 @@ 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.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -34,7 +36,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderSamplingRateTile(
fun SamplingRateTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
@ -58,8 +60,7 @@ fun AudioRecorderSamplingRateTile(
header = Header.Default(
title = stringResource(R.string.ui_settings_option_samplingRate_title),
icon = IconSource(
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked)
.asPainterResource(),
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked).asPainterResource(),
contentDescription = null,
)
),
@ -78,11 +79,11 @@ fun AudioRecorderSamplingRateTile(
val samplingRate = text?.toIntOrNull()
if (samplingRate == null) {
return@InputTextField ValidationResult.Invalid(notNumberLabel)
ValidationResult.Invalid(notNumberLabel)
}
if (samplingRate <= 1000) {
return@InputTextField ValidationResult.Invalid(mustBeGreaterThanLabel)
if (samplingRate!! <= 1000) {
ValidationResult.Invalid(mustBeGreaterThanLabel)
}
ValidationResult.Valid
@ -91,8 +92,7 @@ fun AudioRecorderSamplingRateTile(
)
),
) { result ->
val samplingRate = result.getString("samplingRate")?.toIntOrNull()
?: throw IllegalStateException("SamplingRate is null")
val samplingRate = result.getString("samplingRate")?.toIntOrNull() ?: throw IllegalStateException("SamplingRate is null")
updateValue(samplingRate)
}
@ -115,8 +115,7 @@ fun AudioRecorderSamplingRateTile(
shape = MaterialTheme.shapes.medium,
) {
Text(
(settings.audioRecorderSettings.samplingRate
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
(settings.audioRecorderSettings.samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
)
}
},
@ -124,10 +123,9 @@ fun AudioRecorderSamplingRateTile(
ExampleListRoulette(
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
onItemSelected = ::updateValue,
) { samplingRate ->
) {samplingRate ->
Text(
(samplingRate
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
(samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
)
}
}

View File

@ -1,10 +1,12 @@
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.filled.GraphicEq
import androidx.compose.material.icons.filled.MicExternalOn
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
@ -12,11 +14,12 @@ 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 com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
import kotlinx.coroutines.launch
@Composable
fun AudioRecorderShowAllMicrophonesTile(
fun ShowAllMicrophonesTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()

View File

@ -28,6 +28,7 @@ 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 androidx.navigation.navOptions
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import kotlinx.coroutines.launch

View File

@ -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.Column
@ -64,7 +64,6 @@ fun ExplanationPage(
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.ChevronRight,

View File

@ -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),
)
}
}
}
}
}

View File

@ -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.Column
@ -10,7 +10,7 @@ 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.ChevronRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -59,20 +59,19 @@ fun ResponsibilityPage(
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onContinue,
onClick = { onContinue() },
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.ChevronRight,
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.continue_label))
Text(stringResource(R.string.ui_welcome_start_label))
}
}
}

View File

@ -1,206 +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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PermMedia
import androidx.compose.material3.ButtonDefaults
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.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 androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.components.atoms.VisualDensity
const val CUSTOM_FOLDER = "custom"
@Composable
fun SaveFolderSelection(
modifier: Modifier = Modifier,
saveFolder: String?,
isLowOnStorage: Boolean,
onSaveFolderChange: (String?) -> Unit,
) {
@Composable
fun createModifier(a11yLabel: String, onClick: () -> Unit) =
Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = a11yLabel
}
.clickable(onClick = onClick)
.padding(16.dp)
.padding(end = 8.dp)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainer)
.then(modifier),
verticalArrangement = Arrangement.Center,
) {
let {
val label = stringResource(R.string.ui_welcome_saveFolder_values_internal)
val a11yLabel = stringResource(
R.string.a11y_selectValue,
label
)
val folder = null
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = createModifier(a11yLabel) {
onSaveFolderChange(folder)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(
selected = saveFolder == folder,
onClick = { onSaveFolderChange(folder) },
)
Text(label)
}
Icon(
Icons.Default.Lock,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
}
}
let {
val label = stringResource(R.string.ui_welcome_saveFolder_values_media)
val a11yLabel = stringResource(
R.string.a11y_selectValue,
label
)
val folder = RECORDER_MEDIA_SELECTED_VALUE
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = createModifier(a11yLabel) {
onSaveFolderChange(folder)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(
selected = saveFolder == folder,
onClick = { onSaveFolderChange(folder) },
)
Text(label)
}
Icon(
Icons.Default.PermMedia,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
}
}
let {
val label = stringResource(R.string.ui_welcome_saveFolder_values_custom)
val a11yLabel = stringResource(
R.string.a11y_selectValue,
label
)
val folder = CUSTOM_FOLDER
Column(
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = createModifier(a11yLabel) {
onSaveFolderChange(folder)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(
selected = saveFolder == folder,
onClick = { onSaveFolderChange(folder) },
)
Text(label)
}
Icon(
Icons.Default.Folder,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
}
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),
) {
Text(
stringResource(R.string.ui_settings_option_saveFolder_videoUnsupported),
fontSize = MaterialTheme.typography.titleSmall.fontSize,
)
Text(
stringResource(R.string.ui_minApiRequired, 8, 26),
fontSize = MaterialTheme.typography.bodySmall.fontSize,
)
}
}
}
}
}
if (isLowOnStorage && saveFolder == null)
MessageBox(
type = MessageType.ERROR,
message = stringResource(R.string.ui_welcome_saveFolder_externalRequired)
)
else
Box(
modifier = Modifier.widthIn(max = 400.dp)
) {
MessageBox(
type = MessageType.INFO,
message = stringResource(R.string.ui_welcome_timeSettings_changeableHint),
density = VisualDensity.DENSE,
)
}
}
}

View File

@ -1,111 +0,0 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
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.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.ChevronRight
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.MaxDurationSelector
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 MaxDurationSettingsPage(
onContinue: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(40.dp))
Column(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.AccessTime,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(128.dp),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
stringResource(R.string.ui_welcome_timeSettings_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.ui_welcome_timeSettings_message),
fontStyle = MaterialTheme.typography.bodySmall.fontStyle,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.typography.bodySmall.color,
)
}
Spacer(modifier = Modifier.height(40.dp))
Box(
modifier = Modifier
.widthIn(max = 400.dp)
.padding(horizontal = 16.dp)
) {
MaxDurationSelector()
}
Spacer(modifier = Modifier.height(20.dp))
Box(
modifier = Modifier
.widthIn(max = 400.dp)
.padding(horizontal = 16.dp)
) {
MessageBox(
type = MessageType.INFO,
message = stringResource(R.string.ui_welcome_timeSettings_changeableHint),
density = VisualDensity.DENSE,
)
}
Spacer(modifier = Modifier.height(40.dp))
Button(
onClick = { onContinue() },
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.continue_label))
}
}
}

View File

@ -1,78 +0,0 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
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.material.icons.Icons
import androidx.compose.material.icons.filled.Celebration
import androidx.compose.material.icons.filled.Check
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
@Composable
fun ReadyPage(
onContinue: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.Celebration,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(128.dp),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
stringResource(R.string.ui_welcome_ready_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.ui_welcome_ready_message),
)
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onContinue,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_welcome_ready_start))
}
}
}

View File

@ -1,323 +0,0 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import android.Manifest
import android.content.Intent
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.layout.widthIn
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.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.AlertDialog
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.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.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.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.SaveFolderSelection
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog
import kotlin.concurrent.thread
@Composable
fun SaveFolderPage(
onBack: () -> Unit,
onContinue: (saveFolder: String?) -> Unit,
appSettings: AppSettings,
) {
var saveFolder by rememberSaveable { mutableStateOf<String?>(null) }
val context = LocalContext.current
var isLowOnStorage by rememberSaveable {
mutableStateOf(false)
}
// Fetching this synchronously results in the UI being blocked.
// Instead, we fetch this in a different thread and update the state when we have the result.
LaunchedEffect(appSettings, context) {
thread {
val availableBytes = VideoBatchesFolder.viaInternalFolder(context).getAvailableBytes()
if (availableBytes == null) {
isLowOnStorage = false
return@thread
}
val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings)
val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute
// Allow for a 10% margin of error
isLowOnStorage = availableBytes < requiredBytes
}
}
LaunchedEffect(isLowOnStorage, appSettings.maxDuration) {
if (isLowOnStorage) {
if (saveFolder == null) {
saveFolder = RECORDER_MEDIA_SELECTED_VALUE
}
} else {
saveFolder = null
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(40.dp))
Column(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(128.dp),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
stringResource(R.string.ui_welcome_saveFolder_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.ui_welcome_saveFolder_message),
fontStyle = MaterialTheme.typography.bodySmall.fontStyle,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.typography.bodySmall.color,
)
}
Spacer(modifier = Modifier.height(40.dp))
Box(
modifier = Modifier
.widthIn(max = 400.dp)
.padding(horizontal = 16.dp)
) {
SaveFolderSelection(
saveFolder = saveFolder,
isLowOnStorage = isLowOnStorage,
onSaveFolderChange = { saveFolder = it },
)
}
Spacer(modifier = Modifier.height(40.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
IconButton(
onClick = onBack,
modifier = Modifier
.size(BIG_PRIMARY_BUTTON_SIZE),
) {
Icon(
Icons.Default.ChevronLeft,
contentDescription = null,
)
}
var showError by rememberSaveable { mutableStateOf(false) }
if (showError) {
_FolderInaccessibleDialog(
onClose = {
showError = false
}
)
}
PermissionRequester(
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
onPermissionAvailable = { onContinue(saveFolder) },
) { requestWritePermission ->
val selectFolder = rememberFolderSelectorDialog { folder ->
if (folder == null) {
return@rememberFolderSelectorDialog
}
context.contentResolver.takePersistableUriPermission(
folder,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
if (BatchesFolder.canAccessFolder(context, folder)) {
onContinue(folder.toString())
} else {
showError = true
runCatching {
context.contentResolver.releasePersistableUriPermission(
folder,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
}
}
var showCustomFolderHint by rememberSaveable { mutableStateOf(false) }
if (showCustomFolderHint) {
_CustomFolderDialog(
onAbort = { showCustomFolderHint = false },
onOk = {
showCustomFolderHint = false
selectFolder()
},
)
}
Button(
onClick = {
when (saveFolder) {
null -> onContinue(saveFolder)
RECORDER_MEDIA_SELECTED_VALUE -> {
if (SUPPORTS_SCOPED_STORAGE) {
onContinue(saveFolder)
} else {
requestWritePermission()
}
}
else -> {
showCustomFolderHint = true
}
}
},
enabled = if (saveFolder == null) !isLowOnStorage else true,
modifier = Modifier
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.continue_label))
}
}
}
}
}
@Composable
fun _FolderInaccessibleDialog(
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
icon = {
Icon(
Icons.Default.Error,
contentDescription = null,
)
},
title = {
Text(stringResource(R.string.ui_error_occurred_title))
},
confirmButton = {
Button(onClick = onClose) {
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,
)
}
}
)
}
@Composable
fun _CustomFolderDialog(
onAbort: () -> Unit,
onOk: () -> Unit,
) {
AlertDialog(
onDismissRequest = onAbort,
icon = {
Icon(
Icons.Default.Folder,
contentDescription = null,
)
},
title = {
Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_title))
},
text = {
Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_message))
},
dismissButton = {
TextButton(
onClick = onAbort,
contentPadding = ButtonDefaults.TextButtonContentPadding,
colors = ButtonDefaults.textButtonColors(),
) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
},
confirmButton = {
Button(
onClick = onOk,
) {
Text(stringResource(R.string.dialog_close_neutral_label))
}
}
)
}

Some files were not shown because too many files have changed in this diff Show More