Compare commits

..

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

251 changed files with 1741 additions and 18655 deletions

View File

@ -1,31 +0,0 @@
name: Prepare KeyStore
description: Write the KeyStore file and properties to disk
inputs:
signingStorePassword:
description: 'The password for the KeyStore'
required: true
signingKeyPassword:
description: 'The password for the Key'
required: true
signingKeyAlias:
description: 'The alias for the Key'
required: true
keyStoreBase64:
description: 'The KeyStore file encoded as base64'
required: true
runs:
using: composite
steps:
- name: Write Keystore file 🗄️
shell: bash
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
- name: Write Keystore properties 🗝️
shell: bash
run: |
echo "storeFile=/home/runner/key.jks" > 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
path: app/build/outputs/apk/debug/app-debug.apk

View File

@ -1,52 +0,0 @@
name: Build and publish app
on:
release:
types: [ published ]
jobs:
release-app-github:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
with:
signingStorePassword: ${{ secrets.SIGNING_STORE_PASSWORD }}
signingKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
signingKeyAlias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: 21
cache: 'gradle'
- name: Build APKs 📱
run: ./gradlew assembleRelease
- name: Upload APKs 🚀
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
env:
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

@ -1,44 +0,0 @@
name: Build and publish app to Google Play
on:
release:
types: [ published ]
jobs:
release-app-google-play:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
with:
signingStorePassword: ${{ secrets.SIGNING_STORE_PASSWORD }}
signingKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
signingKeyAlias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: 21
cache: 'gradle'
- name: Build APKs 📱
run: ./gradlew bundleRelease
- name: Upload APKs 🚀
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_STORE_SERVICE_ACCOUNT }}
packageName: app.myzel394.alibi
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: production
status: inProgress
inAppUpdatePriority: 2
userFraction: 0.2

44
.github/workflows/release-app.yaml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Build and publish app
on:
release:
types: [ published ]
jobs:
build-app:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Write Keystore file 🗄️
id: android_keystore
uses: timheuer/base64-to-file@v1.0.3
with:
fileName: key.jks
encodedString: ${{ secrets.KEYSTORE }}
- name: Write Keystore properties 🗝️
run: |
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties
echo "storePassword=${{ secrets.SIGNING_STORE_PASSWORD }}" >> key.properties
echo "keyPassword=${{ secrets.SIGNING_KEY_PASSWORD }}" >> key.properties
echo "keyAlias=${{ secrets.SIGNING_KEY_ALIAS }}" >> key.properties
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: "17.x"
cache: 'gradle'
- name: Build APKs 📱
run: ./gradlew assembleRelease
- name: Upload APKs 🚀
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
with:
files: app/build/outputs/apk/release/*.apk

1
.gitignore vendored
View File

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

View File

@ -1,31 +0,0 @@
PRIVACY POLICY
1. INTRODUCTION
This Privacy Policy governs the use of the application ("Alibi"). Please read this Privacy Policy carefully. By using the App, you acknowledge that you have read, understood, and agree to be bound by the terms of this Privacy Policy.
2. DATA COLLECTION AND USE
Currently, the App does not collect any personal data because it does not have a network connection. However, this may change in the future. If we decide to collect data, we will update this Privacy Policy accordingly and it is your responsibility to review this Privacy Policy periodically.
3. LOG DATA
In the future, we may want to collect log data for the purpose of improving the App. This collection will be optional and you will have the choice to opt-in. The log data may include information such as your device's Internet Protocol ("IP") address, device name, operating system version, the configuration of the App, the time and date of your use of the App, and other statistics.
4. USER RESPONSIBILITIES
As a user, you are responsible for the maintenance and security of the App on your device. We are not responsible for any damages or losses related to your use or misuse of the App.
5. PRIVACY POLICY CHANGES
It is your responsibility to check this Privacy Policy periodically for changes. Your continued use of the App following the posting of any changes to this Privacy Policy constitutes acceptance of those changes.
6. COMMUNICATION
We do not have an obligation to inform users of any changes to this Privacy Policy. It is your responsibility to review this Privacy Policy periodically and stay informed about any changes to it.
7. LEGAL DISCLAIMER
We disclaim all warranties, express or implied, including any warranties of accuracy, non-infringement, merchantability, and fitness for a particular purpose. We are not liable for any damages, whether direct, indirect, special, consequential, or other damages, arising from your use of the App.
By using the App, you agree to the terms of this Privacy Policy. If you do not agree with these terms, please do not use the App.

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
@ -30,13 +27,16 @@ Add a new feature or fix bugs.
## Add translations
[Translate Alibi into your language using Crowdin](https://crowdin.com/project/alibi), so that other
people can use it more easily.
Translate Alibi into your language so that other 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`
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`

View File

@ -35,8 +35,8 @@ android {
applicationId "app.myzel394.alibi"
minSdk 24
targetSdk 34
versionCode 16
versionName "0.5.3"
versionCode 4
versionName "0.2.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -78,11 +78,9 @@ android {
}
buildFeatures {
compose true
buildConfig = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.10'
kotlinCompilerExtensionVersion '1.5.1'
}
packagingOptions {
resources {
@ -92,60 +90,41 @@ android {
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.core:core-ktx:1.10.1'
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.1'
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.4.3"
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.0-rc01"
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,39 +2,10 @@
<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.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" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".UpdateSettingsApp"
@ -46,7 +17,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 +37,7 @@
<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" />
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" />
<!-- Change locale for Android <= 12 -->
<service

View File

@ -1,3 +1,3 @@
package app.myzel394.alibi
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE", "tr-TR")
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN")

View File

@ -2,18 +2,11 @@ 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.core.view.WindowCompat
import androidx.datastore.dataStore
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.theme.AlibiTheme
@ -23,7 +16,7 @@ val Context.dataStore by dataStore(
serializer = AppSettingsSerializer()
)
class MainActivity : AppCompatActivity() {
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -31,20 +24,8 @@ class MainActivity : AppCompatActivity() {
setContent {
AlibiTheme {
LockedAppHandlers()
Box(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.background
)
) {
AsLockedApp {
Navigation()
}
}
}
}
}
}

View File

@ -1,45 +1,21 @@
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 android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.delay
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 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,166 +25,123 @@ 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)
}
fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings {
return copy(hasSeenOnboarding = hasSeenOnboarding)
}
fun setTheme(theme: Theme): AppSettings {
return copy(theme = theme)
}
fun setLastRecording(lastRecording: RecordingInformation?): AppSettings {
return copy(lastRecording = lastRecording)
}
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
return copy(filenameFormat = filenameFormat)
}
fun setMaxDuration(duration: Long): AppSettings {
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
throw Exception("Max duration must be between 1 minute and 10 days")
}
if (duration < intervalDuration) {
throw Exception("Max duration must be greater than interval duration")
}
return copy(maxDuration = duration)
}
fun setIntervalDuration(duration: Long): AppSettings {
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
throw Exception("Interval duration must be between 10 seconds and 1 hour")
}
if (duration > maxDuration) {
throw Exception("Interval duration must be less than max duration")
}
return copy(intervalDuration = duration)
}
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AppSettings {
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
}
fun setSaveFolder(saveFolder: String?): AppSettings {
return copy(saveFolder = saveFolder)
}
fun setAppLockSettings(appLockSettings: AppLockSettings?): AppSettings {
return copy(appLockSettings = appLockSettings)
}
fun saveLastRecording(recorder: RecorderModel): AppSettings {
return if (deleteRecordingsImmediately) {
this
} else {
setLastRecording(
recorder.recorderService!!.getRecordingInformation()
)
}
}
// If the object is present, biometric authentication is enabled.
// To disable biometric authentication, set the instance to null.
fun isAppLockEnabled() = appLockSettings != null
fun requiresExternalStoragePermission(context: Context): Boolean {
return !SUPPORTS_SCOPED_STORAGE && (saveFolder == RECORDER_MEDIA_SELECTED_VALUE && !PermissionHelper.hasGranted(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE
))
}
fun exportToString(): String {
return Json.encodeToString(serializer(), this)
}
enum class Theme {
SYSTEM,
LIGHT,
DARK,
}
enum class FilenameFormat {
DATETIME_ABSOLUTE_START,
DATETIME_RELATIVE_START,
DATETIME_NOW,
}
companion object {
fun getDefaultInstance(): AppSettings = AppSettings()
fun fromExportedString(data: String): AppSettings {
return Json.decodeFromString(
serializer(),
data,
)
}
}
}
@Serializable
data class RecordingInformation(
data class LastRecording(
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()
val fileFolder: File
get() = File(folderPath)
Type.VIDEO -> VideoBatchesFolder.importFromFolder(folderPath, context)
.hasRecordingsAvailable()
}
val filePaths: List<File>
get() =
File(folderPath).listFiles()?.filter {
val name = it.nameWithoutExtension
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
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("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
outputFile.renameTo(rawFile)
val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
val session = FFmpegKit.execute(command)
if (!ReturnCode.isSuccess(session.returnCode)) {
Log.d(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
session.getState(),
session.getReturnCode(),
session.getFailStackTrace()
)
)
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
throw Exception("Failed to strip concatenated audio")
}
}
fun getFullDuration(): Long {
// 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()
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
val paths = filePaths.joinToString("|")
val fileName = recordingStart
.format(ISO_DATE_TIME)
.toString()
.replace(":", "-")
.replace(".", "_")
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
if (outputFile.exists() && !forceConcatenation) {
return outputFile
}
enum class Type {
AUDIO,
VIDEO,
val command = "-i 'concat:$paths' -y" +
" -acodec copy" +
" -metadata title='$fileName' " +
" -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
" -metadata batch_count='${filePaths.size}'" +
" -metadata batch_duration='${intervalDuration}'" +
" -metadata max_duration='${maxDuration}'" +
" $outputFile"
val session = FFmpegKit.execute(command)
if (!ReturnCode.isSuccess(session.returnCode)) {
Log.d(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
session.getState(),
session.getReturnCode(),
session.getFailStackTrace()
)
)
throw Exception("Failed to concatenate audios")
}
val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
stripConcatenatedFileToExactDuration(outputFile)
}
return outputFile
}
}
@Serializable
data class AudioRecorderSettings(
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,
) {
fun getOutputFormat(): Int {
if (outputFormat != null) {
@ -221,7 +154,7 @@ data class AudioRecorderSettings(
else MediaRecorder.OutputFormat.THREE_GPP
}
return when (encoder) {
return when(encoder) {
MediaRecorder.AudioEncoder.AAC -> MediaRecorder.OutputFormat.AAC_ADTS
MediaRecorder.AudioEncoder.AAC_ELD -> MediaRecorder.OutputFormat.AAC_ADTS
MediaRecorder.AudioEncoder.AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
@ -234,7 +167,6 @@ data class AudioRecorderSettings(
MediaRecorder.OutputFormat.AAC_ADTS
}
}
MediaRecorder.AudioEncoder.OPUS -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaRecorder.OutputFormat.OGG
@ -242,12 +174,11 @@ data class AudioRecorderSettings(
MediaRecorder.OutputFormat.AAC_ADTS
}
}
else -> MediaRecorder.OutputFormat.DEFAULT
}
}
fun getMimeType(): String = when (getOutputFormat()) {
fun getMimeType(): String = when(getOutputFormat()) {
MediaRecorder.OutputFormat.AAC_ADTS -> "audio/aac"
MediaRecorder.OutputFormat.THREE_GPP -> "audio/3gpp"
MediaRecorder.OutputFormat.MPEG_4 -> "audio/mp4"
@ -259,7 +190,7 @@ data class AudioRecorderSettings(
else -> "audio/3gpp"
}
fun getSamplingRate(): Int = samplingRate ?: when (getOutputFormat()) {
fun getSamplingRate(): Int = samplingRate ?: when(getOutputFormat()) {
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
MediaRecorder.OutputFormat.THREE_GPP -> 44100
MediaRecorder.OutputFormat.MPEG_4 -> 44100
@ -271,12 +202,26 @@ data class AudioRecorderSettings(
else -> 48000
}
fun getEncoder(): Int = encoder ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
fun getEncoder(): Int = encoder ?:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
MediaRecorder.AudioEncoder.AAC
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 {
println("bitRate: $bitRate")
if (bitRate !in 1000..320000) {
throw Exception("Bit rate must be between 1000 and 320000")
}
@ -308,8 +253,20 @@ data class AudioRecorderSettings(
return copy(encoder = encoder)
}
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
return copy(showAllMicrophones = showAllMicrophones)
fun setMaxDuration(duration: Long): AudioRecorderSettings {
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
throw Exception("Max duration must be between 1 minute and 1 hour")
}
if (duration < intervalDuration) {
throw Exception("Max duration must be greater than interval duration")
}
return copy(maxDuration = duration)
}
fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
return copy(forceExactMaxDuration = forceExactMaxDuration)
}
fun isEncoderCompatible(encoder: Int): Boolean {
@ -322,31 +279,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,
@ -444,174 +387,3 @@ data class AudioRecorderSettings(
}).toMap()
}
}
@Serializable
data class VideoRecorderSettings(
val targetedVideoBitRate: Int? = null,
val quality: String? = null,
val targetFrameRate: Int? = null,
) {
fun setTargetedVideoBitRate(bitRate: Int?): VideoRecorderSettings {
return copy(targetedVideoBitRate = bitRate)
}
fun setQuality(quality: Quality?): VideoRecorderSettings {
val invertedMap = QUALITY_NAME_QUALITY_MAP.entries.associateBy({ it.value }, { it.key })
return copy(quality = quality?.let { invertedMap[it] })
}
fun setTargetFrameRate(frameRate: Int?): VideoRecorderSettings {
return copy(targetFrameRate = frameRate)
}
fun getQuality(): Quality? =
quality?.let {
QUALITY_NAME_QUALITY_MAP[it]!!
}
fun getQualitySelector(): QualitySelector? =
quality?.let {
QualitySelector.from(
QUALITY_NAME_QUALITY_MAP[it]!!
)
}
fun getMimeType() = "video/$fileExtension"
val fileExtension
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "mp4" else "3gp"
companion object {
fun getDefaultInstance() = VideoRecorderSettings()
val QUALITY_NAME_QUALITY_MAP: Map<String, Quality> = mapOf(
"LOWEST" to Quality.LOWEST,
"HIGHEST" to Quality.HIGHEST,
"SD" to Quality.SD,
"HD" to Quality.HD,
"FHD" to Quality.FHD,
"UHD" to Quality.UHD,
)
val EXAMPLE_BITRATE_VALUES = listOf(
null,
500 * 1000,
// 1 Mbps
1 * 1000 * 1000,
2 * 1000 * 1000,
4 * 1000 * 1000,
8 * 1000 * 1000,
16 * 1000 * 1000,
32 * 1000 * 1000,
50 * 1000 * 1000,
100 * 1000 * 1000,
)
val EXAMPLE_FRAME_RATE_VALUES = listOf(
null,
24,
30,
60,
120,
240,
)
val AVAILABLE_QUALITIES = listOf(
Quality.HIGHEST,
Quality.UHD,
Quality.FHD,
Quality.HD,
Quality.SD,
Quality.LOWEST,
)
val EXAMPLE_QUALITY_VALUES = listOf(
null,
) + AVAILABLE_QUALITIES
}
}
@Serializable
data class NotificationSettings(
val title: String,
val message: String,
val iconID: Int,
val showOngoing: Boolean,
val preset: Preset? = null,
) {
@Serializable
sealed class Preset(
val titleID: Int,
val messageID: Int,
val showOngoing: Boolean,
val iconID: Int,
) {
@Serializable
data object Default : Preset(
R.string.ui_audioRecorder_state_recording_title,
R.string.ui_recorder_state_recording_description,
true,
R.drawable.launcher_monochrome_noopacity,
)
@Serializable
data object Weather : Preset(
R.string.ui_recorder_state_recording_fake_weather_title,
R.string.ui_recorder_state_recording_fake_weather_description,
false,
R.drawable.ic_cloud
)
@Serializable
data object Player : Preset(
R.string.ui_recorder_state_recording_fake_player_title,
R.string.ui_recorder_state_recording_fake_player_description,
true,
R.drawable.ic_note,
)
@Serializable
data object Browser : Preset(
R.string.ui_recorder_state_recording_fake_browser_title,
R.string.ui_recorder_state_recording_fake_browser_description,
true,
R.drawable.ic_download,
)
@Serializable
data object VPN : Preset(
R.string.ui_recorder_state_recording_fake_vpn_title,
R.string.ui_recorder_state_recording_fake_vpn_description,
false,
R.drawable.ic_vpn,
)
}
companion object {
fun fromPreset(preset: Preset): NotificationSettings {
return NotificationSettings(
title = "",
message = "",
showOngoing = preset.showOngoing,
iconID = preset.iconID,
preset = preset,
)
}
val PRESETS = listOf(
Preset.Default,
Preset.Weather,
Preset.Player,
Preset.Browser,
Preset.VPN,
)
}
}
@Serializable
class AppLockSettings {
companion object {
fun getDefaultInstance() = AppLockSettings()
}
}

View File

@ -13,7 +13,7 @@ import java.io.InputStream
import java.io.OutputStream
import java.time.LocalDateTime
class AppSettingsSerializer : Serializer<AppSettings> {
class AppSettingsSerializer: Serializer<AppSettings> {
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
override suspend fun readFrom(input: InputStream): AppSettings {
@ -39,9 +39,8 @@ class AppSettingsSerializer : Serializer<AppSettings> {
}
}
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString())

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

@ -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,43 +1,46 @@
package app.myzel394.alibi.services
import android.content.Context
import android.content.pm.ServiceInfo
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaRecorder
import android.media.MediaRecorder.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 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
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
var onError: () -> Unit = {}
// Callbacks
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
var onMicrophoneDisconnected: () -> Unit = {}
var onMicrophoneReconnected: () -> Unit = {}
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
val filePath: String
get() = "$folder/$counter.${settings!!.fileExtension}"
private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(filePath)
setOutputFormat(settings!!.outputFormat)
setAudioEncoder(settings!!.encoder)
setAudioEncodingBitRate(settings!!.bitRate)
setAudioSamplingRate(settings!!.samplingRate)
setOnErrorListener(OnErrorListener { _, _, _ ->
onError()
})
}
}
private fun resetRecorder() {
runCatching {
recorder?.let {
it.stop()
it.release()
}
}
}
override fun startNewCycle() {
super.startNewCycle()
@ -47,7 +50,6 @@ class AudioRecorderService :
}
resetRecorder()
startAudioDevice()
try {
recorder = newRecorder
@ -57,48 +59,21 @@ class AudioRecorderService :
}
}
override fun start() {
super.start()
createAmplitudesTimer()
registerMicrophoneListener()
}
override fun pause() {
super.pause()
resetRecorder()
}
override suspend fun stop() {
resetRecorder()
unregisterMicrophoneListener()
override fun stop() {
super.stop()
resetRecorder()
}
override fun resume() {
super.resume()
createAmplitudesTimer()
}
override fun getAmplitudeAmount(): Int = amplitudesAmount
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 {
override fun getAmplitude(): Int {
return try {
recorder!!.maxAmplitude
} catch (error: IllegalStateException) {
@ -107,209 +82,4 @@ class AudioRecorderService :
0
}
}
private fun updateAmplitude() {
if (state !== RecorderState.RECORDING) {
return
}
amplitudes.add(getAmplitude())
onAmplitudeChange?.invoke(amplitudes)
// Delete old amplitudes
if (amplitudes.size > getAmplitudeAmount()) {
// Should be more efficient than dropping the elements, getting a new list
// clearing old list and adding new elements to it
repeat(amplitudes.size - getAmplitudeAmount()) {
amplitudes.removeAt(0)
}
}
handler.postDelayed(::updateAmplitude, 100)
}
private fun createAmplitudesTimer() {
handler.postDelayed(::updateAmplitude, 100)
}
// ==== Audio device related ====
/// Tell Android to use the correct bluetooth microphone, if any selected
private fun startAudioDevice() {
if (selectedMicrophone == null) {
return
}
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManger.setCommunicationDevice(selectedMicrophone!!.deviceInfo)
} else {
audioManger.startBluetoothSco()
}
}
private fun clearAudioDevice() {
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManger.clearCommunicationDevice()
} else {
audioManger.stopBluetoothSco()
}
}
private fun getNameForMediaFile() =
"${batchesFolder.mediaPrefix}$counter.${settings.audioRecorderSettings.fileExtension}"
// ==== Actual recording related ====
private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
MediaRecorder()
}.apply {
val audioSettings = settings.audioRecorderSettings
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
// and Redmi Buds 3 Pro:
// - MIC: Uses the bottom microphone of the phone (17)
// - CAMCORDER: Uses the top microphone of the phone (2)
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
// - DEFAULT: Uses the bottom microphone of the phone (17)
setAudioSource(MediaRecorder.AudioSource.MIC)
when (batchesFolder.type) {
BatchesFolder.BatchType.INTERNAL -> {
setOutputFile(
batchesFolder.asInternalGetFile(
counter,
audioSettings.fileExtension
).absolutePath
)
}
BatchesFolder.BatchType.CUSTOM -> {
setOutputFile(
batchesFolder.asCustomGetFileDescriptor(
counter,
audioSettings.fileExtension
)
)
}
BatchesFolder.BatchType.MEDIA -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setOutputFile(
batchesFolder.asMediaGetScopedStorageFileDescriptor(
getNameForMediaFile(),
"audio/${audioSettings.fileExtension}"
)
)
} else {
val name = getNameForMediaFile()
val file = batchesFolder.asMediaGetLegacyFile(name)
setOutputFile(file.absolutePath)
}
}
}
setOutputFormat(audioSettings.getOutputFormat())
setAudioEncoder(audioSettings.getEncoder())
setAudioEncodingBitRate(audioSettings.bitRate)
setAudioSamplingRate(audioSettings.getSamplingRate())
setOnErrorListener(OnErrorListener { _, _, _ ->
onError()
})
}
}
// ==== Microphone related ====
private fun resetRecorder() {
runCatching {
recorder?.apply {
stop()
reset()
release()
}
clearAudioDevice()
batchesFolder.cleanup()
}
}
fun changeMicrophone(microphone: MicrophoneInfo?) {
selectedMicrophone = microphone
onSelectedMicrophoneChange(microphone)
if (state == RecorderState.RECORDING) {
startNewCycle()
}
}
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
super.onAudioDevicesAdded(addedDevices)
if (selectedMicrophone == null) {
return
}
// We can't compare the ID, as it seems to be changing on each reconnect
val newDevice = addedDevices?.find {
it.productName == selectedMicrophone!!.deviceInfo.productName &&
it.isSink == selectedMicrophone!!.deviceInfo.isSink &&
it.type == selectedMicrophone!!.deviceInfo.type && (
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
it.address == selectedMicrophone!!.deviceInfo.address
} else true
)
}
if (newDevice != null) {
changeMicrophone(MicrophoneInfo.fromDeviceInfo(newDevice))
onMicrophoneReconnected()
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
super.onAudioDevicesRemoved(removedDevices)
if (selectedMicrophone == null) {
return
}
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
onMicrophoneDisconnected()
}
}
}
private fun registerMicrophoneListener() {
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
audioManager.registerAudioDeviceCallback(
audioDeviceCallback,
Handler(Looper.getMainLooper())
)
}
private fun unregisterMicrophoneListener() {
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
}
// ==== Settings ====
override fun getRecordingInformation() =
RecordingInformation(
folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart,
maxDuration = settings.maxDuration,
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
fileExtension = settings.audioRecorderSettings.fileExtension,
intervalDuration = settings.intervalDuration,
type = RecordingInformation.Type.AUDIO,
)
}

View File

@ -0,0 +1,51 @@
package app.myzel394.alibi.services
import android.os.Handler
import android.os.Looper
import app.myzel394.alibi.enums.RecorderState
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class ExtraRecorderInformationService: RecorderService() {
abstract fun getAmplitudeAmount(): Int
abstract fun getAmplitude(): Int
var amplitudes = mutableListOf<Int>()
private set
private val handler = Handler(Looper.getMainLooper())
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
private fun updateAmplitude() {
if (state !== RecorderState.RECORDING) {
return
}
amplitudes.add(getAmplitude())
onAmplitudeChange?.invoke(amplitudes)
// Delete old amplitudes
if (amplitudes.size > getAmplitudeAmount()) {
amplitudes.drop(amplitudes.size - getAmplitudeAmount())
}
handler.postDelayed(::updateAmplitude, 100)
}
private fun createAmplitudesTimer() {
handler.postDelayed(::updateAmplitude, 100)
}
override fun start() {
createAmplitudesTimer()
}
override fun resume() {
createAmplitudesTimer()
}
}

View File

@ -1,45 +1,45 @@
package app.myzel394.alibi.services
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.helpers.BatchesFolder
import android.content.Context
import android.media.MediaRecorder
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AudioRecorderSettings
import app.myzel394.alibi.db.LastRecording
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
import java.time.LocalDateTime
import java.util.Timer
import java.util.TimerTask
import java.util.UUID
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.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
protected lateinit var folder: File
var settings: Settings? = null
protected set
private lateinit var cycleTimer: ScheduledExecutorService
abstract var batchesFolder: B
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 createLastRecording(): LastRecording = LastRecording(
folderPath = folder.absolutePath,
recordingStart = recordingStart,
maxDuration = settings!!.maxDuration,
fileExtension = settings!!.fileExtension,
intervalDuration = settings!!.intervalDuration,
forceExactMaxDuration = settings!!.forceExactMaxDuration,
)
// Make overrideable
open fun startNewCycle() {
@ -50,56 +50,103 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :
private fun createTimer() {
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
it.scheduleAtFixedRate(
::startNewCycle,
{
startNewCycle()
},
0,
settings.intervalDuration,
settings!!.intervalDuration,
TimeUnit.MILLISECONDS
)
}
}
private fun getRandomFileFolder(): String {
// uuid
val folder = UUID.randomUUID().toString()
return "${externalCacheDir!!.absolutePath}/$folder"
}
override fun start() {
super.start()
batchesFolder.initFolders()
folder = File(getRandomFileFolder())
folder.mkdirs()
if (!batchesFolder.checkIfFolderIsAccessible()) {
onBatchesFolderNotAccessible()
throw AvoidErrorDialogError()
}
scope.launch {
dataStore.data.collectLatest { preferenceSettings ->
if (settings == null) {
settings = Settings.from(preferenceSettings.audioRecorderSettings)
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
folder.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

@ -1,159 +0,0 @@
package app.myzel394.alibi.services
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import app.myzel394.alibi.MainActivity
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R
import app.myzel394.alibi.db.NotificationSettings
import app.myzel394.alibi.enums.RecorderState
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Calendar
import java.util.Date
data class RecorderNotificationHelper(
val context: Context,
val details: NotificationDetails? = null,
) {
@Serializable
data class NotificationDetails(
val title: String,
val description: String,
val icon: Int,
val isOngoing: Boolean,
) {
companion object {
fun fromNotificationSettings(
context: Context,
settings: NotificationSettings,
): NotificationDetails {
return if (settings.preset == null) {
NotificationDetails(
settings.title,
settings.message,
settings.iconID,
settings.showOngoing,
)
} else {
NotificationDetails(
context.getString(settings.preset.titleID),
context.getString(settings.preset.messageID),
settings.preset.iconID,
settings.preset.showOngoing,
)
}
}
}
}
private fun getNotificationChangeStateIntent(
newState: RecorderState,
requestCode: Int
): PendingIntent {
return PendingIntent.getService(
context,
requestCode,
Intent(context, context::class.java).apply {
action = "changeState"
putExtra("newState", newState.name)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun getIconID(): Int = details?.icon ?: R.drawable.launcher_monochrome_noopacity
private fun createBaseNotification(): NotificationCompat.Builder {
return NotificationCompat.Builder(
context,
NotificationHelper.RECORDER_CHANNEL_ID
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setSmallIcon(getIconID())
.setContentIntent(
PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
)
.setSilent(true)
.setOnlyAlertOnce(true)
.setChronometerCountDown(false)
}
private fun getStringForRecorder(audioRes: Int, videoRes: Int): String =
when (context::class.java) {
AudioRecorderService::class.java -> context.getString(audioRes)
VideoRecorderService::class.java -> context.getString(videoRes)
else -> ""
}
fun buildStartingNotification(): Notification {
return createBaseNotification()
.setContentTitle(
getStringForRecorder(
R.string.ui_audioRecorder_state_recording_title,
R.string.ui_videoRecorder_state_recording_title,
)
)
.setContentText(context.getString(R.string.ui_recorder_state_recording_description))
.build()
}
fun buildRecordingNotification(recordingTime: Long): Notification {
return createBaseNotification()
.setUsesChronometer(details?.isOngoing ?: true)
.setOngoing(details?.isOngoing ?: true)
.setShowWhen(details?.isOngoing ?: true)
.setWhen(
Date.from(
Calendar
.getInstance()
.also { it.add(Calendar.SECOND, -recordingTime.toInt()) }
.toInstant()
).time,
)
.addAction(
R.drawable.ic_pause,
context.getString(R.string.ui_recorder_action_pause_label),
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
)
.setContentTitle(
details?.title
?: getStringForRecorder(
R.string.ui_audioRecorder_state_recording_title,
R.string.ui_videoRecorder_state_recording_title,
)
)
.setContentText(
details?.description
?: context.getString(R.string.ui_recorder_state_recording_description)
)
.build()
}
fun buildPausedNotification(start: LocalDateTime): Notification {
return createBaseNotification()
.setContentTitle(context.getString(R.string.ui_recorder_state_paused_title))
.setContentText(context.getString(R.string.ui_recorder_state_paused_description))
.setOngoing(false)
.setUsesChronometer(false)
.setWhen(Date.from(start.atZone(ZoneId.systemDefault()).toInstant()).time)
.addAction(
R.drawable.ic_play,
context.getString(R.string.ui_recorder_action_resume_label),
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
)
.build()
}
}

View File

@ -2,117 +2,58 @@ package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LifecycleService
import app.myzel394.alibi.MainActivity
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.ui.utils.PermissionHelper
import kotlinx.serialization.json.Json
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Calendar
import java.util.Date
import java.util.concurrent.Executors
import java.util.concurrent.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
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) {
"init" -> {
notificationDetails = intent.getStringExtra("notificationDetails")?.let {
Json.decodeFromString(
RecorderNotificationHelper.NotificationDetails.serializer(),
it
)
}
}
"changeState" -> {
val newState = intent.getStringExtra("newState")?.let {
RecorderState.valueOf(it)
} ?: RecorderState.STOPPED
} ?: RecorderState.IDLE
changeState(newState)
}
}
@ -120,7 +61,7 @@ abstract class RecorderService : LifecycleService() {
return super.onStartCommand(intent, flags, startId)
}
inner class RecorderBinder : Binder() {
inner class RecorderBinder: Binder() {
fun getService(): RecorderService = this@RecorderService
}
@ -128,19 +69,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,57 +91,151 @@ abstract class RecorderService : LifecycleService() {
if (isPaused) {
resume()
isPaused = false
} else {
start()
}
}
RecorderState.PAUSED -> {
pause()
isPaused = true
}
RecorderState.IDLE -> {
stop()
onDestroy()
}
// `start` is handled by `startRecording`
}
RecorderState.PAUSED -> pause()
else -> {}
when (newState) {
RecorderState.RECORDING -> {
createRecordingTimeTimer()
}
RecorderState.PAUSED, RecorderState.IDLE -> {
recordingTimeTimer.shutdown()
}
}
// Update notification
if (
arrayOf(
RecorderState.RECORDING,
RecorderState.PAUSED
).contains(newState) &&
PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)
) {
){
val notification = buildNotification()
NotificationManagerCompat.from(this).notify(
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
notification
)
}
onStateChange?.invoke(newState)
}
protected fun getNotificationHelper(): RecorderNotificationHelper {
return RecorderNotificationHelper(this, notificationDetails)
// Must be immediately called after creating the service!
fun startRecording() {
recordingStart = LocalDateTime.now()
val notification = buildStartNotification()
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
// Start
changeState(RecorderState.RECORDING)
}
private fun buildNotification(): Notification {
val notificationHelper = getNotificationHelper()
override fun onDestroy() {
super.onDestroy()
return when (state) {
RecorderState.RECORDING -> {
notificationHelper.buildRecordingNotification(recordingTime)
changeState(RecorderState.IDLE)
stopForeground(STOP_FOREGROUND_REMOVE)
NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
stopSelf()
}
RecorderState.PAUSED -> {
notificationHelper.buildPausedNotification(recordingStart)
private fun buildStartNotification(): Notification = NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
.setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
private fun getNotificationChangeStateIntent(newState: RecorderState, requestCode: Int): PendingIntent {
return PendingIntent.getService(
this,
requestCode,
Intent(this, AudioRecorderService::class.java).apply {
action = "changeState"
putExtra("newState", newState.name)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
else -> {
throw IllegalStateException("Notification can't be built in state $state")
private fun buildNotification(): Notification = when(state) {
RecorderState.RECORDING -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
.setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(true)
.setWhen(
Date.from(
Calendar
.getInstance()
.also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
.toInstant()
).time,
)
.setSilent(true)
.setOnlyAlertOnce(true)
.setUsesChronometer(true)
.setChronometerCountDown(false)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
)
.addAction(
R.drawable.ic_cancel,
getString(R.string.ui_audioRecorder_action_delete_label),
getNotificationChangeStateIntent(RecorderState.IDLE, 1),
)
.addAction(
R.drawable.ic_pause,
getString(R.string.ui_audioRecorder_action_pause_label),
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
)
.build()
RecorderState.PAUSED -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
.setContentTitle(getString(R.string.ui_audioRecorder_state_paused_title))
.setContentText(getString(R.string.ui_audioRecorder_state_paused_description))
.setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(false)
.setOnlyAlertOnce(true)
.setUsesChronometer(false)
.setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
.setShowWhen(true)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
)
.addAction(
R.drawable.ic_play,
getString(R.string.ui_audioRecorder_action_resume_label),
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
)
.build()
else -> throw IllegalStateException("Invalid state passed to `buildNotification()`")
}
}
}
// 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

@ -1,80 +1,6 @@
package app.myzel394.alibi.ui
import android.os.Build
import androidx.compose.ui.unit.dp
import java.util.Base64
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"
// You are not allowed to change the constants below.
// If you do so, you will be blocked on GitHub.
const val REPO_URL = "https://github.com/Myzel394/Alibi"
const val TRANSLATION_HELP_URL = "https://crowdin.com/project/alibi"
const val GITHUB_SPONSORS_URL = "https://github.com/sponsors/Myzel394"
const val PUBLIC_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZTfvnhYJKwYBBAHaRw8BAQdAi2AiLsTaBoLhnQtY5vi3xBU/H428wbNfBSe+
2dhz3r60Jk15emVsMzk0IDxnaXRodWIuN2Eyb3BAc2ltcGxlbG9naW4uY28+iJkE
ExYKAEEWIQR9BS8nNHwqrNgV0B3NE0dCwel5WQUCZTfvngIbAwUJEswDAAULCQgH
AgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRDNE0dCwel5WcS8AQCf9g6eEaut1suW
l6jCLIg3b1nWLckmLJaonM6PruUtigEAmVnFOxMpOZEIcILT8CD2Riy+IVN9gTNH
qOHnaFsu8AK4OARlN++eEgorBgEEAZdVAQUBAQdAe4ffDtRundKH9kam746i2TBu
P9sfb3QVi5QqfK+bek8DAQgHiH4EGBYKACYWIQR9BS8nNHwqrNgV0B3NE0dCwel5
WQUCZTfvngIbDAUJEswDAAAKCRDNE0dCwel5WWwSAQDj4ZAl6bSqwbcptEMYQaPM
MMhMafm446MjkhQioeXw+wEAzA8mS6RBx7IZvu1dirmFHXOEYJclwjyQhNs4uEjq
/Ak=
=ICHe
-----END PGP PUBLIC KEY BLOCK-----"""
const val PUBLIC_KEY_FINGERPRINT = "7D05 2F27 347C 2AAC D815 D01D CD13 4742 C1E9 7959"
val CRYPTO_DONATIONS = mapOf(
"Bitcoin" to "bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6",
"Bitcoin Cash" to "qr9s64vfqedvurfef9ykf7szchmt0xyvnga452fc8l",
"Ethereum" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
"Tether USD" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
"Monero" to "83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8",
"Zcash" to "t1ZfvNpzfdaW6csT9Kc7iJA7LUU3hmNj2sx",
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
"Dash" to "XcTkni8CVAXBcuc5VwvHmsYftVK4CPLetU",
"Avalanche" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
"XRP" to "rNpfDm8UwDTumCebchBadjVW2FEPteFgNg",
"Solana" to "2h6CB3hz5Vb2nYS1RQiXZ4aWTzc5frBPR7Sp1b4muFqb",
"ADA" to "addr1q8vy2vcp6lacaw8lkc29gufuzajaytc5qc0c2mxlmw5lndxcg5esr4lm36u0lds523cnc9m96gh3gpsls4kdlkaflx6qf6qpvc",
"Dogecoin" to "DUA4j7mVoc7Rvezu8YgeRKwxNuMzKeDoxD",
"Tron" to "THWVLGhne5wDsGjd1CNenHDKQGzvGzrzLb",
"Polkadot" to "1642iaR6AoKyM6qnnMHkfCRfRqRKJ2wC6Cm3UEWEFEz6EtZR",
"Cosmos" to "cosmos1vt5z6rfj5sgnkdlddkuu8srw3xupyqxscva9hz",
"Algorand" to "QBOQ6VSLMD77QEF33P5J3HKGOM5RZLNO6P5P3FTWCMQM3ORF6QY2W34KUI",
"Tezos" to "tz1QUWNYuFqDibGCrwmkdaHSpTx3d6ZdxLMi",
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
"Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq",
)
// Base64encoding these values so that bots can't easily scrape them.
val b64d = Base64.getDecoder()
val CONTACT_METHODS = mapOf<String, String>(
"E-Mail" to String(b64d.decode("Z2" + "9vZ2xlLXBsYX" + "k" + "uMjlrMWFAYWxlZWFzL" + "mNvbQo=")).trim(),
"GitHub" to String(
b64d.decode(
"aHR" +
"0cHM6Ly9n" + "a" + "XRodWIuY29t" + "L015emVsMzk0L2NvbnRhY3QtbWUK"
)
).trim(),
"Mastodon" to String(b64d.decode("T" + "X" + "l6Z" + "WwzOTRAbWFzdG9kb24uc29" + "jaWFsCg" + "==")).trim(),
"Reddit" to "https://reddit.com/u/Myzel394"
)

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

@ -5,37 +5,34 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.LastRecording
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.CustomRecordingNotificationsScreen
import app.myzel394.alibi.ui.screens.RecorderScreen
import app.myzel394.alibi.ui.screens.AudioRecorder
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 +44,9 @@ fun Navigation(
DisposableEffect(Unit) {
audioRecorder.bindToService(context)
videoRecorder.bindToService(context)
onDispose {
audioRecorder.unbindFromService(context)
videoRecorder.unbindFromService(context)
}
}
@ -59,18 +54,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 +71,9 @@ fun Navigation(
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
}
) {
RecorderScreen(
onNavigateToSettingsScreen = {
navController.navigate(Screen.Settings.route)
},
AudioRecorder(
navController = navController,
audioRecorder = audioRecorder,
videoRecorder = videoRecorder,
settings = settings,
)
}
composable(
@ -103,43 +86,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(
Screen.CustomRecordingNotifications.route,
enterTransition = {
slideInHorizontally(
initialOffsetX = { it -> it / 2 }
) + fadeIn()
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { it -> it / 2 }
) + fadeOut(tween(150))
}
) {
CustomRecordingNotificationsScreen(
onBackNavigate = navController::popBackStack
)
}
composable(
Screen.About.route,
enterTransition = {
scaleIn()
},
exitTransition = {
scaleOut() + fadeOut(tween(150))
}
) {
AboutScreen(
onBackNavigate = navController::popBackStack,
)
}
}

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

@ -1,184 +0,0 @@
package app.myzel394.alibi.ui.components.AboutScreen.atoms
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.CurrencyFranc
import androidx.compose.material.icons.filled.CurrencyLira
import androidx.compose.material.icons.filled.CurrencyPound
import androidx.compose.material.icons.filled.CurrencyRuble
import androidx.compose.material.icons.filled.CurrencyRupee
import androidx.compose.material.icons.filled.CurrencyYen
import androidx.compose.material.icons.filled.CurrencyYuan
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.CRYPTO_DONATIONS
import app.myzel394.alibi.ui.GITHUB_SPONSORS_URL
import app.myzel394.alibi.ui.PUBLIC_KEY
@Composable
fun DonationsTile() {
var donationsOpened by rememberSaveable {
mutableStateOf(false)
}
val label = stringResource(R.string.ui_about_contribute_donatation)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = label
}
.clickable {
donationsOpened = !donationsOpened
}
.background(
MaterialTheme.colorScheme.surfaceVariant
)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
listOf(
Icons.Default.CurrencyBitcoin,
Icons.Default.CurrencyFranc,
Icons.Default.CurrencyLira,
Icons.Default.CurrencyPound,
Icons.Default.CurrencyRuble,
Icons.Default.CurrencyRupee,
Icons.Default.CurrencyYen,
Icons.Default.CurrencyYuan,
).asSequence().shuffled().first(),
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
)
Text(
stringResource(R.string.ui_about_contribute_donatation),
fontWeight = FontWeight.Bold,
)
}
val rotation by animateFloatAsState(
if (donationsOpened) -180f else 0f,
label = "iconRotation"
)
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize.times(1.2f))
.rotate(rotation)
)
}
val clipboardManager =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
AnimatedVisibility(
visible = donationsOpened,
enter = expandVertically(),
) {
Column {
val uriHandler = LocalUriHandler.current
TextButton(
onClick = {
uriHandler.openUri(GITHUB_SPONSORS_URL)
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(R.drawable.ic_github),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(
stringResource(R.string.ui_about_contribute_donation_githubSponsors)
)
}
for (crypto in CRYPTO_DONATIONS) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable {
val clip = ClipData.newPlainText("text", crypto.value)
clipboardManager.setPrimaryClip(clip)
}
.padding(16.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = null,
)
Text(
crypto.key,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
Text(
crypto.value,
fontSize = MaterialTheme.typography.bodyMedium.fontSize.times(0.5),
)
}
}
}
}
}

View File

@ -1,77 +0,0 @@
package app.myzel394.alibi.ui.components.AboutScreen.atoms
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.PUBLIC_KEY_FINGERPRINT
import app.myzel394.alibi.ui.PUBLIC_KEY
@Composable
fun GPGKeyOverview() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(
MaterialTheme.colorScheme.primaryContainer
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Default.Key,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Text(
stringResource(R.string.ui_about_gpg_key_hint),
style = MaterialTheme.typography.bodyMedium,
)
val clipboardManager =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
Text(
PUBLIC_KEY_FINGERPRINT,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(
MaterialTheme.colorScheme.surfaceVariant
)
.padding(8.dp),
)
TextButton(
onClick = {
val clip = ClipData.newPlainText("text", PUBLIC_KEY)
clipboardManager.setPrimaryClip(clip)
},
modifier = Modifier
.fillMaxWidth()
) {
Text(stringResource(R.string.ui_about_gpg_key_copy))
}
}
}

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,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,11 +35,10 @@ private const val GROW_END = BOX_DIFF * 4
@Composable
fun RealtimeAudioVisualizer(
modifier: Modifier = Modifier,
audioRecorder: AudioRecorderModel,
) {
val scope = rememberCoroutineScope()
val amplitudes = audioRecorder.amplitudes
val amplitudes = audioRecorder.amplitudes!!
val primary = MaterialTheme.colorScheme.primary
val primaryMuted = primary.copy(alpha = 0.3f)
@ -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,10 +87,8 @@ fun RealtimeAudioVisualizer(
val horizontalProgress = (
clamp(horizontalValue, GROW_START, GROW_END)
- GROW_START) / (GROW_END - GROW_START)
val amplitudePercentage =
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
val boxHeight =
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
val boxHeight = (height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
drawRoundRect(
color = if (amplitudePercentage > 0.05f && isOverThreshold) primary else primaryMuted,

View File

@ -0,0 +1,110 @@
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun SaveRecordingButton(
modifier: Modifier = Modifier,
service: RecorderService,
onSaveFile: (File) -> Unit,
label: String = stringResource(R.string.ui_audioRecorder_action_save_label),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isProcessingAudio by remember { mutableStateOf(false) }
if (isProcessingAudio)
AlertDialog(
onDismissRequest = { },
icon = {
Icon(
Icons.Default.Memory,
contentDescription = null,
)
},
title = {
Text(
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
)
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
)
Spacer(modifier = Modifier.height(32.dp))
LinearProgressIndicator()
}
},
confirmButton = {}
)
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.semantics {
contentDescription = label
}
.then(modifier),
onClick = {
isProcessingAudio = true
scope.launch {
try {
} catch (error: Exception) {
Log.getStackTraceString(error)
} finally {
isProcessingAudio = false
}
}
},
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
}

View File

@ -0,0 +1,219 @@
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LargeFloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
import app.myzel394.alibi.ui.components.atoms.Pulsating
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.KeepScreenOn
import app.myzel394.alibi.ui.utils.formatDuration
import kotlinx.coroutines.delay
import java.io.File
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
@Composable
fun RecordingStatus(
audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
var now by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
now = LocalDateTime.now()
delay(900)
}
}
// Only show animation when the recording has just started
val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
LaunchedEffect(Unit) {
progressVisible = true
}
KeepScreenOn()
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Box {}
RealtimeAudioVisualizer(audioRecorder = audioRecorder)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Pulsating {
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(Color.Red)
)
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = formatDuration(audioRecorder.recordingTime!!),
style = MaterialTheme.typography.headlineLarge,
)
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = progressVisible,
enter = expandHorizontally(
tween(1000)
)
) {
LinearProgressIndicator(
progress = audioRecorder.progress,
modifier = Modifier
.width(300.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
ConfirmDeletionDialog(
onDismiss = {
showDeleteDialog = false
},
onConfirm = {
showDeleteDialog = false
audioRecorder.stopRecording(context, saveAsLastRecording = false)
},
)
}
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
onClick = {
showDeleteDialog = true
},
colors = ButtonDefaults.textButtonColors(),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
}
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
LargeFloatingActionButton(
modifier = Modifier
.semantics {
contentDescription = if (audioRecorder.isPaused) resumeLabel else pauseLabel
},
onClick = {
if (audioRecorder.isPaused) {
audioRecorder.resumeRecording()
} else {
audioRecorder.pauseRecording()
}
},
) {
Icon(
if (audioRecorder.isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = null,
)
}
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.alpha(alpha)
.semantics {
contentDescription = label
},
onClick = {
runCatching {
audioRecorder.stopRecording(context)
}
audioRecorder.onRecordingSave()
},
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_audioRecorder_action_save_label))
}
}
}

View File

@ -0,0 +1,147 @@
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
import android.Manifest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun StartRecording(
audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(1f))
PermissionRequester(
permission = Manifest.permission.RECORD_AUDIO,
icon = Icons.Default.Mic,
onPermissionAvailable = {
audioRecorder.startRecording(context)
},
) { trigger ->
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
Button(
onClick = {
trigger()
},
modifier = Modifier
.semantics {
contentDescription = label
}
.size(200.dp)
.clip(shape = CircleShape),
colors = ButtonDefaults.outlinedButtonColors(),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.Mic,
contentDescription = null,
modifier = Modifier
.size(80.dp),
)
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
Text(
label,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
val settings = LocalContext
.current
.dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
Text(
stringResource(R.string.ui_audioRecorder_action_start_description, settings.audioRecorderSettings.maxDuration / 1000 / 60),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
modifier = Modifier
.widthIn(max = 300.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom,
) {
val label = stringResource(
R.string.ui_audioRecorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart),
)
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.semantics {
contentDescription = label
},
colors = ButtonDefaults.textButtonColors(),
onClick = {
audioRecorder.stopRecording(context)
audioRecorder.onRecordingSave()
},
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
}
}
else
Spacer(modifier = Modifier.weight(1f))
}
}

View File

@ -1,110 +0,0 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowRightAlt
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.utils.openNotificationsSettings
@Composable
fun LandingElement(
modifier: Modifier = Modifier,
onOpenEditor: () -> Unit,
) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp, vertical = 64.dp)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Box() {}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(id = R.drawable.ic_custom_recording_notifications_blob),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.tertiaryContainer),
contentDescription = null,
modifier = Modifier
.width(512.dp)
)
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.size(128.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.ui_settings_customNotifications_landing_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
stringResource(R.string.ui_settings_customNotifications_landing_description),
style = MaterialTheme.typography.bodySmall,
)
FilledTonalButton(onClick = onOpenEditor) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(
stringResource(
R.string.ui_settings_customNotifications_landing_getStarted
)
)
}
}
}
TextButton(
onClick = context::openNotificationsSettings,
) {
Text(
stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications),
)
}
}
}

View File

@ -1,61 +0,0 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.db.NotificationSettings
@Composable
fun NotificationPresetSelect(
modifier: Modifier = Modifier,
preset: NotificationSettings.Preset
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.clip(MaterialTheme.shapes.large)
.then(modifier)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.border(
width = 1.dp,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)
)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
PreviewIcon(
modifier = Modifier.size(32.dp),
painter = painterResource(id = preset.iconID),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(preset.titleID),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(preset.messageID),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
)
}
}
}

View File

@ -1,42 +0,0 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
@Composable
fun PreviewIcon(
modifier: Modifier = Modifier,
painter: Painter,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.then(modifier)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondary)
.padding(2.dp)
) {
Image(
painter = painter,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary),
)
}
}

View File

@ -1,80 +0,0 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import app.myzel394.alibi.R
import app.myzel394.alibi.db.NotificationSettings
class NotificationViewModel : ViewModel() {
// We want to show the actual translated strings of the preset
// in the preview but don't want to save them to the database
// because they should be retrieved in the notification itself.
// Thus we save whether the preset has been changed by the user
private var _presetChanged = false
private var _title = mutableStateOf("")
val title: String
get() = _title.value
private var _description = mutableStateOf("")
val description: String
get() = _description.value
var showOngoing: Boolean by mutableStateOf(true)
var icon: Int by mutableIntStateOf(R.drawable.launcher_monochrome_noopacity)
// `preset` can't be used as a variable name here because
// the compiler throws a strange error then
var notificationPreset: NotificationSettings.Preset? by mutableStateOf(null)
private var _hasBeenInitialized = false;
fun setPreset(title: String, description: String, preset: NotificationSettings.Preset) {
_presetChanged = false
_title.value = title
_description.value = description
showOngoing = preset.showOngoing
icon = preset.iconID
this.notificationPreset = preset
}
fun setTitle(title: String) {
_presetChanged = true
_title.value = title
}
fun setDescription(description: String) {
_presetChanged = true
_description.value = description
}
fun initialize(
title: String,
description: String,
showOngoing: Boolean = true,
icon: Int = R.drawable.launcher_monochrome_noopacity,
) {
_title.value = title
_description.value = description
this.showOngoing = showOngoing
this.icon = icon
_hasBeenInitialized = true
}
fun asNotificationSettings(): NotificationSettings {
return if (!_presetChanged && notificationPreset != null) {
NotificationSettings.fromPreset(notificationPreset!!)
} else {
NotificationSettings(
title = title,
message = description,
iconID = icon,
showOngoing = showOngoing,
)
}
}
}

View File

@ -1,171 +0,0 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.PreviewIcon
import app.myzel394.alibi.ui.effects.rememberForceUpdate
import java.time.Duration
import java.time.LocalDateTime
@Composable
fun EditNotificationInput(
modifier: Modifier = Modifier,
showOngoing: Boolean,
title: String,
description: String,
icon: Painter,
onShowOngoingChange: (Boolean) -> Unit,
onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onIconChange: (Int) -> Unit,
) {
var ongoingStartTime by remember { mutableStateOf(LocalDateTime.now()) }
val secondaryColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
LaunchedEffect(showOngoing) {
if (showOngoing) {
ongoingStartTime = LocalDateTime.now()
}
}
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
.padding(16.dp)
.then(modifier),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
val headlineSize = 22.dp
PreviewIcon(
modifier = Modifier.size(headlineSize),
painter = icon,
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(headlineSize),
) {
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.bodySmall,
color = secondaryColor,
)
if (showOngoing) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = secondaryColor,
modifier = Modifier
.size(8.dp)
)
val fakeAlpha = rememberForceUpdate()
val formattedTime = {
val difference =
Duration.between(
ongoingStartTime,
LocalDateTime.now(),
)
val minutes = difference.toMinutes()
val seconds = difference.minusMinutes(minutes).seconds
"${if (minutes < 10) "0$minutes" else minutes}:${if (seconds < 10) "0$seconds" else seconds}"
}
Text(
formattedTime(),
modifier = Modifier.alpha(fakeAlpha),
style = MaterialTheme.typography.bodySmall,
color = secondaryColor,
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
BasicTextField(
value = title,
onValueChange = onTitleChange,
textStyle = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
)
BasicTextField(
value = description,
onValueChange = onDescriptionChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done,
),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.ui_recorder_action_delete_label),
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
)
Text(
stringResource(R.string.ui_recorder_action_pause_label),
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
)
}
}
}
}

View File

@ -1,71 +0,0 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.db.NotificationSettings
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.NotificationPresetSelect
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NotificationPresetsRoulette(
onClick: (String, String, NotificationSettings.Preset) -> Unit,
) {
val state = rememberLazyListState()
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
state = state,
flingBehavior = rememberSnapFlingBehavior(lazyListState = state)
) {
items(NotificationSettings.PRESETS.size) {
val preset = NotificationSettings.PRESETS[it]
val label = stringResource(
R.string.ui_settings_customNotifications_preset_apply_label,
stringResource(preset.titleID)
)
val presetTitle = stringResource(preset.titleID)
val presetDescription = stringResource(preset.messageID)
Box(
modifier = Modifier.width(
LocalConfiguration.current.screenWidthDp.dp,
)
) {
NotificationPresetSelect(
modifier = Modifier
.fillMaxWidth(.95f)
.align(Alignment.Center)
.semantics {
contentDescription = label
}
.clickable {
onClick(
presetTitle,
presetDescription,
preset,
)
},
preset = preset,
)
}
}
}
}

View File

@ -1,203 +0,0 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.organisms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.NotificationSettings
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models.NotificationViewModel
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.EditNotificationInput
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.NotificationPresetsRoulette
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
val HORIZONTAL_PADDING = 16.dp;
@Composable
fun NotificationEditor(
modifier: Modifier = Modifier,
notificationModel: NotificationViewModel = viewModel(),
onNotificationChange: (NotificationSettings) -> Unit,
) {
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
if (settings.notificationSettings != null) {
val title = settings.notificationSettings.let {
if (it.preset != null)
stringResource(it.preset.titleID)
else
it.title
}
val description = settings.notificationSettings.let {
if (it.preset != null)
stringResource(it.preset.messageID)
else
it.message
}
LaunchedEffect(Unit) {
notificationModel.initialize(
title,
description,
settings.notificationSettings.showOngoing,
settings.notificationSettings.iconID,
)
if (settings.notificationSettings.preset != null) {
notificationModel.setPreset(
title,
description,
settings.notificationSettings.preset
)
}
}
} else {
val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title)
val defaultDescription =
stringResource(R.string.ui_recorder_state_recording_description)
LaunchedEffect(Unit) {
notificationModel.initialize(
defaultTitle,
defaultDescription,
)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.then(modifier),
verticalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.padding(horizontal = HORIZONTAL_PADDING),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
MessageBox(
type = MessageType.SURFACE,
message = stringResource(R.string.ui_settings_customNotifications_edit_help)
)
EditNotificationInput(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
showOngoing = notificationModel.showOngoing,
title = notificationModel.title,
description = notificationModel.description,
icon = painterResource(notificationModel.icon),
onShowOngoingChange = {
notificationModel.showOngoing = it
},
onTitleChange = notificationModel::setTitle,
onDescriptionChange = notificationModel::setDescription,
onIconChange = {
notificationModel.icon = it
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable {
notificationModel.showOngoing = notificationModel.showOngoing.not()
}
.background(MaterialTheme.colorScheme.tertiaryContainer)
.padding(8.dp),
) {
Checkbox(
checked = notificationModel.showOngoing,
onCheckedChange = {
notificationModel.showOngoing = it
},
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.tertiary,
checkmarkColor = MaterialTheme.colorScheme.onTertiary,
)
)
Text(
text = stringResource(R.string.ui_settings_customNotifications_showOngoing_label),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
fontWeight = FontWeight.Bold,
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
NotificationPresetsRoulette(
onClick = notificationModel::setPreset,
)
Button(
onClick = {
onNotificationChange(
notificationModel.asNotificationSettings()
)
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = HORIZONTAL_PADDING)
.height(48.dp),
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
Spacer(
modifier = Modifier
.width(ButtonDefaults.IconSpacing)
)
Text(
stringResource(R.string.ui_settings_customNotifications_save_label)
)
}
}
}
}

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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import app.myzel394.alibi.R
@Composable
fun DeleteButton(
modifier: Modifier = Modifier,
onDelete: () -> Unit,
) {
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
ConfirmDeletionDialog(
onDismiss = {
showDeleteDialog = false
},
onConfirm = {
showDeleteDialog = false
onDelete()
},
)
}
val label = stringResource(R.string.ui_recorder_action_delete_label)
TextButton(
modifier = Modifier
.semantics {
contentDescription = label
}
.then(modifier),
onClick = {
showDeleteDialog = true
},
) {
Text(
label,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
)
}
}

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,63 +0,0 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import app.myzel394.alibi.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MicrophoneDisconnectedDialog(
microphoneName: String,
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
title = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneDisconnected_title,
),
textAlign = TextAlign.Center,
)
},
text = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
microphoneName,
microphoneName,
)
)
},
icon = {
Icon(
Icons.Default.MicOff,
contentDescription = null,
)
},
confirmButton = {
val label = stringResource(R.string.dialog_close_neutral_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
onClick = onClose,
) {
Text(label)
}
}
)
}

View File

@ -1,62 +0,0 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import app.myzel394.alibi.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MicrophoneReconnectedDialog(
microphoneName: String,
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
title = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneReconnected_title,
),
textAlign = TextAlign.Center,
)
},
text = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneReconnected_message,
microphoneName,
)
)
},
icon = {
Icon(
Icons.Default.Star,
contentDescription = null,
)
},
confirmButton = {
val label = stringResource(R.string.dialog_close_neutral_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
onClick = onClose,
) {
Text(label)
}
}
)
}

View File

@ -1,86 +0,0 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MicExternalOn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.ui.utils.MicrophoneInfo
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
@Composable
fun MicrophoneSelectionButton(
microphone: MicrophoneInfo? = null,
selected: Boolean = false,
selectedAsFallback: Boolean = false,
disabled: Boolean = false,
onSelect: () -> Unit,
) {
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
// Copied from Android's [FilledButtonTokens]
val disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
Button(
onClick = onSelect,
enabled = !disabled,
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
contentPadding = if (selected) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.TextButtonContentPadding,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing),
) {
MicrophoneTypeInfo(
type = microphone?.type ?: MicrophoneInfo.MicrophoneType.PHONE,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Column {
Text(
text = microphone?.name
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone),
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && settings.audioRecorderSettings.showAllMicrophones && microphone?.deviceInfo?.address?.isNotBlank() == true)
Text(
microphone.deviceInfo.address.toString(),
fontSize = MaterialTheme.typography.bodySmall.toSpanStyle().fontSize,
color = if (disabled) disabledTextColor else if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.secondary,
)
}
if (selectedAsFallback)
Icon(
Icons.Default.MicExternalOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.size(ButtonDefaults.IconSize),
)
}
}
}

View File

@ -1,28 +0,0 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BluetoothAudio
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicExternalOn
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.myzel394.alibi.ui.utils.MicrophoneInfo
@Composable
fun MicrophoneTypeInfo(
modifier: Modifier = Modifier,
type: MicrophoneInfo.MicrophoneType,
) {
Icon(
imageVector = when (type) {
MicrophoneInfo.MicrophoneType.BLUETOOTH -> Icons.Filled.BluetoothAudio
MicrophoneInfo.MicrophoneType.WIRED -> Icons.Filled.MicExternalOn
MicrophoneInfo.MicrophoneType.PHONE -> Icons.Filled.Smartphone
MicrophoneInfo.MicrophoneType.OTHER -> Icons.Filled.Mic
},
modifier = modifier,
contentDescription = null,
)
}

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.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import app.myzel394.alibi.R
@Composable
fun PauseResumeButton(
modifier: Modifier = Modifier,
isPaused: Boolean,
onChange: () -> Unit,
) {
val pauseLabel = stringResource(R.string.ui_recorder_action_pause_label)
val resumeLabel = stringResource(R.string.ui_recorder_action_resume_label)
FloatingActionButton(
modifier = Modifier
.semantics {
contentDescription = if (isPaused) resumeLabel else pauseLabel
}
.then(modifier),
onClick = onChange,
) {
Icon(
if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = null,
)
}
}

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,223 +0,0 @@
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneSelectionButton
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneTypeInfo
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.MicrophoneInfo
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MicrophoneSelection(
audioRecorder: AudioRecorderModel
) {
val context = LocalContext.current
var showSelection by rememberSaveable {
mutableStateOf(false)
}
val sheetState = rememberModalBottomSheetState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
val allMicrophones = MicrophoneInfo.fetchDeviceMicrophones(context)
val visibleMicrophones = MicrophoneInfo.filterMicrophones(allMicrophones)
val hiddenMicrophones = allMicrophones - visibleMicrophones.toSet()
val isTryingToReconnect =
audioRecorder.selectedMicrophone != null && audioRecorder.microphoneStatus == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED
val shownMicrophones = if (isTryingToReconnect && visibleMicrophones.isEmpty()) {
listOf(audioRecorder.selectedMicrophone!!)
} else {
visibleMicrophones
}
val scope = rememberCoroutineScope()
fun hideSheet() {
scope.launch {
sheetState.hide()
showSelection = false
}
}
if (showSelection) {
ModalBottomSheet(
onDismissRequest = ::hideSheet,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = SHEET_BOTTOM_OFFSET),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(48.dp),
) {
Text(
stringResource(R.string.ui_audioRecorder_info_microphone_changeExplanation),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
if (isTryingToReconnect)
MessageBox(
type = MessageType.INFO,
message = stringResource(
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
audioRecorder.selectedMicrophone?.name ?: "",
audioRecorder.selectedMicrophone?.name ?: "",
)
)
LazyColumn(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
MicrophoneSelectionButton(
selected = audioRecorder.selectedMicrophone == null,
selectedAsFallback = isTryingToReconnect,
onSelect = {
audioRecorder.changeMicrophone(null)
hideSheet()
}
)
}
items(shownMicrophones.size) {
val microphone = shownMicrophones[it]
MicrophoneSelectionButton(
microphone = microphone,
selected = audioRecorder.selectedMicrophone == microphone,
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
onSelect = {
audioRecorder.changeMicrophone(microphone)
hideSheet()
}
)
}
if (settings.audioRecorderSettings.showAllMicrophones) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 32.dp),
) {
HorizontalDivider(
modifier = Modifier
.weight(1f)
)
Text(
stringResource(R.string.ui_audioRecorder_info_microphone_hiddenMicrophones),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center,
)
HorizontalDivider(
modifier = Modifier
.weight(1f),
)
}
}
items(hiddenMicrophones.size) {
val microphone = hiddenMicrophones[it]
MicrophoneSelectionButton(
microphone = microphone,
selected = audioRecorder.selectedMicrophone == microphone,
onSelect = {
audioRecorder.changeMicrophone(microphone)
hideSheet()
}
)
}
}
}
}
}
} else {
// We need to show a placeholder box to keep the the rest aligned correctly
Box {}
}
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
TextButton(
onClick = {
scope.launch {
showSelection = true
sheetState.show()
}
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
) {
MicrophoneTypeInfo(
type = audioRecorder.selectedMicrophone?.type
?: MicrophoneInfo.MicrophoneType.PHONE,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(
text = audioRecorder.selectedMicrophone.let {
it?.name
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone)
}
)
if (isTryingToReconnect) {
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
}
}
}
}

View File

@ -1,55 +0,0 @@
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneDisconnectedDialog
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneReconnectedDialog
import app.myzel394.alibi.ui.effects.rememberPrevious
import app.myzel394.alibi.ui.models.AudioRecorderModel
@Composable
fun MicrophoneStatus(
audioRecorder: AudioRecorderModel,
) {
val microphoneStatus = audioRecorder.microphoneStatus
val previousStatus = rememberPrevious(microphoneStatus)
var showMicrophoneStatusDialog by remember {
// null = no dialog
// `MicrophoneConnectivityStatus.CONNECTED` = Reconnected dialog
// `MicrophoneConnectivityStatus.DISCONNECTED` = Disconnected dialog
mutableStateOf<AudioRecorderModel.MicrophoneConnectivityStatus?>(null)
}
LaunchedEffect(microphoneStatus) {
if (microphoneStatus != previousStatus && showMicrophoneStatusDialog == null && previousStatus != null && audioRecorder.selectedMicrophone != null) {
showMicrophoneStatusDialog = microphoneStatus
}
}
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED) {
MicrophoneDisconnectedDialog(
onClose = {
showMicrophoneStatusDialog = null
},
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
)
}
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.CONNECTED) {
MicrophoneReconnectedDialog(
onClose = {
showMicrophoneStatusDialog = null
},
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
)
}
MicrophoneSelection(
audioRecorder = audioRecorder,
)
}

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,69 +0,0 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import app.myzel394.alibi.ui.enums.Screen
@Composable
fun AboutTile(
onNavigateToAboutScreen: () -> Unit,
) {
val label = stringResource(R.string.ui_about_title)
Row(
modifier = Modifier
.padding(horizontal = 32.dp, vertical = 48.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = label
}
.clickable {
onNavigateToAboutScreen()
}
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Info,
contentDescription = null,
)
Text(
text = label,
)
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
)
}
}

View File

@ -1,57 +0,0 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.navigation.NavController
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import app.myzel394.alibi.ui.enums.Screen
@Composable
fun CustomNotificationTile(
onNavigateToCustomRecordingNotifications: () -> Unit,
settings: AppSettings,
) {
val dataStore = LocalContext.current.dataStore
val label = if (settings.notificationSettings == null)
stringResource(R.string.ui_settings_option_customNotification_description_setup)
else stringResource(
R.string.ui_settings_option_customNotification_description_edit
)
SettingsTile(
firstModifier = Modifier
.clickable {
onNavigateToCustomRecordingNotifications()
}
.semantics { contentDescription = label },
title = stringResource(R.string.ui_settings_option_customNotification_title),
description = label,
leading = {
Icon(
Icons.Default.Notifications,
contentDescription = null,
)
},
trailing = {
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
)
}
)
}

View File

@ -1,48 +0,0 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import kotlinx.coroutines.launch
@Composable
fun DeleteRecordingsImmediatelyTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
SettingsTile(
title = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_title),
description = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_description),
leading = {
Icon(
Icons.Default.DeleteSweep,
contentDescription = null,
)
},
trailing = {
Switch(
checked = settings.deleteRecordingsImmediately,
onCheckedChange = {
scope.launch {
dataStore.updateData {
it.setDeleteRecordingsImmediately(it.deleteRecordingsImmediately.not())
}
}
}
)
}
)
}

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,164 +0,0 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import app.myzel394.alibi.ui.utils.rememberFileSelectorDialog
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun ImportExport(
snackbarHostState: SnackbarHostState,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
var settingsToBeImported by remember { mutableStateOf<AppSettings?>(null) }
val saveFile = rememberFileSaverDialog("application/json")
val openFile = rememberFileSelectorDialog { uri ->
val file = File.createTempFile("alibi_settings", ".json")
context.contentResolver.openInputStream(uri)!!.use {
it.copyTo(file.outputStream())
}
val rawContent = file.readText()
settingsToBeImported = AppSettings.fromExportedString(rawContent)
}
if (settingsToBeImported != null) {
val successMessage = stringResource(R.string.ui_settings_option_import_success)
AlertDialog(
onDismissRequest = {
settingsToBeImported = null
},
title = {
Text(stringResource(R.string.ui_settings_option_import_label))
},
text = {
Text(stringResource(R.string.ui_settings_option_import_dialog_text))
},
icon = {
Icon(
Icons.Default.Download,
contentDescription = null,
)
},
confirmButton = {
Button(
onClick = {
scope.launch {
dataStore.updateData {
settingsToBeImported!!
}
settingsToBeImported = null
snackbarHostState.showSnackbar(
message = successMessage,
withDismissAction = true,
duration = SnackbarDuration.Short,
)
}
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_import_dialog_confirm))
}
},
dismissButton = {
TextButton(
onClick = {
settingsToBeImported = null
},
) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
},
)
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
FilledTonalButton(
onClick = {
openFile("application/json")
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_import_label))
}
FilledTonalButton(
onClick = {
val rawContent = settings.exportToString()
val tempFile = File.createTempFile("alibi_settings", ".json")
tempFile.writeText(rawContent)
saveFile(tempFile, "alibi_settings.json")
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Upload,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_export_label))
}
}
}

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,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,12 +36,14 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderBitrateTile(
settings: AppSettings,
) {
fun BitrateTile() {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(bitRate: Int) {
scope.launch {
@ -77,11 +81,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 +94,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 +128,7 @@ fun AudioRecorderBitrateTile(
ExampleListRoulette(
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
onItemSelected = ::updateValue,
) { bitRate ->
) {bitRate ->
Text(
stringResource(
R.string.format_kbps,

View File

@ -1,7 +1,8 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import android.media.MediaRecorder
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -12,6 +13,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,16 +34,18 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderEncoderTile(
snackbarHostState: SnackbarHostState,
settings: AppSettings,
fun EncoderTile(
snackbarHostState: SnackbarHostState
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
val updatedOutputFormatLabel =
stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
val updatedOutputFormatLabel = stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
fun updateValue(encoder: Int?) {
scope.launch {
@ -88,7 +92,7 @@ fun AudioRecorderEncoderTile(
selected = settings.audioRecorderSettings.encoder == index,
)
}.toList()
) { index, _ ->
) {index, option ->
updateValue(index)
},
)

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

@ -1,10 +1,11 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MicExternalOn
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
@ -12,21 +13,25 @@ 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(
settings: AppSettings,
) {
fun ForceExactMaxDurationTile() {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(showAllMicrophones: Boolean) {
fun updateValue(forceExactMaxDuration: Boolean) {
scope.launch {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setShowAllMicrophones(showAllMicrophones)
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
)
}
}
@ -34,17 +39,17 @@ fun AudioRecorderShowAllMicrophonesTile(
SettingsTile(
title = stringResource(R.string.ui_settings_option_showAllMicrophones_title),
description = stringResource(R.string.ui_settings_option_showAllMicrophones_description),
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
leading = {
Icon(
Icons.Default.MicExternalOn,
Icons.Default.GraphicEq,
contentDescription = null,
)
},
trailing = {
Switch(
checked = settings.audioRecorderSettings.showAllMicrophones,
checked = settings.audioRecorderSettings.forceExactMaxDuration,
onCheckedChange = ::updateValue,
)
},

View File

@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
import androidx.core.os.LocaleListCompat
import app.myzel394.alibi.R
import app.myzel394.alibi.SUPPORTED_LOCALES
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.AudioRecorderSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import app.myzel394.alibi.ui.utils.IconResource

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
@ -32,23 +34,21 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IntervalDurationTile(
settings: AppSettings,
) {
fun IntervalDurationTile() {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
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 +67,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 +90,7 @@ fun IntervalDurationTile(
shape = MaterialTheme.shapes.medium,
) {
Text(
text = formatDuration(settings.intervalDuration),
text = formatDuration(settings.audioRecorderSettings.intervalDuration),
)
}
},
@ -98,7 +98,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
@ -31,23 +33,21 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaxDurationTile(
settings: AppSettings,
) {
fun MaxDurationTile() {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
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)
)
}
}
}
@ -65,10 +65,10 @@ fun MaxDurationTile(
updateValue(newTimeInSeconds * 1000L)
},
config = DurationConfig(
timeFormat = DurationFormat.HH_MM,
currentTime = settings.maxDuration / 1000,
timeFormat = DurationFormat.MM_SS,
currentTime = settings.audioRecorderSettings.maxDuration / 1000,
minTime = 60,
maxTime = 23 * 60 * 60 + 59 * 60,
maxTime = 24 * 60 * 60,
)
)
SettingsTile(
@ -88,14 +88,14 @@ fun MaxDurationTile(
),
shape = MaterialTheme.shapes.medium,
) {
Text(formatDuration(settings.maxDuration))
Text(formatDuration(settings.audioRecorderSettings.maxDuration))
}
},
extra = {
ExampleListRoulette(
items = AudioRecorderSettings.EXAMPLE_MAX_DURATIONS,
onItemSelected = ::updateValue,
) { maxDuration ->
) {maxDuration ->
Text(formatDuration(maxDuration))
}
}

View File

@ -1,5 +1,6 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import android.media.MediaRecorder
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material3.Button
@ -7,8 +8,13 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -29,12 +35,14 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderOutputFormatTile(
settings: AppSettings,
) {
fun OutputFormatTile() {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
val availableOptions = if (settings.audioRecorderSettings.encoder == null)
AudioRecorderSettings.OUTPUT_FORMAT_INDEX_TEXT_MAP.keys.toTypedArray()
else AudioRecorderSettings.ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[settings.audioRecorderSettings.encoder]!!
@ -66,7 +74,7 @@ fun AudioRecorderOutputFormatTile(
selected = settings.audioRecorderSettings.outputFormat == option,
)
}.toList()
) { index, _ ->
) {index, option ->
updateValue(availableOptions[index])
},
)

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,12 +36,14 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorderSamplingRateTile(
settings: AppSettings,
) {
fun SamplingRateTile() {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(samplingRate: Int?) {
scope.launch {
@ -58,8 +62,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 +81,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 +94,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 +117,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 +125,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,173 +0,0 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import kotlinx.coroutines.launch
@Composable
fun Preview(
modifier: Modifier = Modifier,
backgroundColor: Color,
primaryColor: Color,
textColor: Color,
onSelect: () -> Unit,
isSelected: Boolean = false,
) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(100.dp)
.height(200.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(width = 1.dp, color = textColor, shape = RoundedCornerShape(10.dp))
.background(backgroundColor)
.clickable { onSelect() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
Box(
modifier = Modifier
.width(30.dp)
.height(10.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(primaryColor)
)
Box(
modifier = Modifier
.size(10.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(primaryColor)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
Icons.Default.Mic,
contentDescription = null,
tint = primaryColor,
)
Box(
modifier = Modifier
.width(40.dp)
.height(6.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(primaryColor)
)
Box(
modifier = Modifier
.width(75.dp)
.height(10.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(textColor)
)
}
Box {}
}
if (isSelected) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize(),
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(30.dp),
)
}
}
}
}
@Composable
fun ThemeSelector() {
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Preview(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
backgroundColor = Color(0xFFF0F0F0),
primaryColor = Color(0xFFAAAAAA),
textColor = Color(0xFFCCCCCC),
onSelect = {
scope.launch {
dataStore.updateData {
it.setTheme(AppSettings.Theme.LIGHT)
}
}
},
isSelected = settings.theme == AppSettings.Theme.LIGHT,
)
Preview(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
backgroundColor = Color(0xFF444444),
primaryColor = Color(0xFF888888),
textColor = Color(0xFF606060),
onSelect = {
scope.launch {
dataStore.updateData {
it.setTheme(AppSettings.Theme.DARK)
}
}
},
isSelected = settings.theme == AppSettings.Theme.DARK,
)
}
}

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,

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