diff --git a/.github/workflows/build-testing.yaml b/.github/workflows/build-testing.yaml index 680ea901..98267c57 100644 --- a/.github/workflows/build-testing.yaml +++ b/.github/workflows/build-testing.yaml @@ -7,15 +7,15 @@ jobs: debug-builds: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: gradle/wrapper-validation-action@v1 + - uses: gradle/wrapper-validation-action@v2 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: "adopt" - java-version: 19 + java-version: 21 cache: "gradle" - name: Compile @@ -23,6 +23,7 @@ jobs: ./gradlew assembleDebug - name: Upload APK - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: + name: alibi-app-debug-apks path: app/build/outputs/apk/debug/app-*-debug.apk diff --git a/.github/workflows/release-app-github.yaml b/.github/workflows/release-app-github.yaml index 93c2e415..14b9c920 100644 --- a/.github/workflows/release-app-github.yaml +++ b/.github/workflows/release-app-github.yaml @@ -10,7 +10,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - uses: gradle/wrapper-validation-action@v2 - name: Write KeyStore 🗝️ uses: ./.github/actions/prepare-keystore @@ -21,10 +23,10 @@ jobs: keyStoreBase64: ${{ secrets.KEYSTORE }} - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: "17.x" + java-version: 21 cache: 'gradle' - name: Build APKs 📱 diff --git a/.github/workflows/release-app-google-play.yaml b/.github/workflows/release-app-google-play.yaml index dc0ecd43..e61b53ff 100644 --- a/.github/workflows/release-app-google-play.yaml +++ b/.github/workflows/release-app-google-play.yaml @@ -10,7 +10,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - uses: gradle/wrapper-validation-action@v2 - name: Write KeyStore 🗝️ uses: ./.github/actions/prepare-keystore @@ -21,10 +23,10 @@ jobs: keyStoreBase64: ${{ secrets.KEYSTORE }} - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'adopt' - java-version: "17.x" + java-version: 21 cache: 'gradle' - name: Build APKs 📱 diff --git a/app/build.gradle b/app/build.gradle index d6ea019f..1c8442ca 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,7 +36,7 @@ android { minSdk 24 targetSdk 34 versionCode 13 - versionName "0.4.1" + versionName "0.5.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -97,12 +97,12 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.activity:activity-ktx:1.8.2' - implementation platform('androidx.compose:compose-bom:2024.02.02') + implementation platform('androidx.compose:compose-bom:2024.03.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.3" + implementation "androidx.compose.material:material-icons-extended:1.6.4" implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.lifecycle:lifecycle-service:2.7.0' @@ -110,7 +110,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation platform('androidx.compose:compose-bom:2024.02.02') + androidTestImplementation platform('androidx.compose:compose-bom:2024.03.00') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt index 3b00d065..cfd47e94 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt @@ -31,7 +31,7 @@ data class AppSettings( /// Recording information // 30 minutes - val maxDuration: Long = 30 * 60 * 1000L, + val maxDuration: Long = 15 * 60 * 1000L, // 60 seconds val intervalDuration: Long = 60 * 1000L, @@ -308,14 +308,15 @@ data class AudioRecorderSettings( 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, diff --git a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt index 3c3716c7..7ec85363 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -7,12 +7,14 @@ 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.toFile import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.db.AppSettings @@ -28,6 +30,7 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlin.reflect.KFunction4 + abstract class BatchesFolder( open val context: Context, open val type: BatchType, @@ -523,11 +526,46 @@ abstract class BatchesFolder( } 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.CUSTOM -> customFolder!!.uri.toFile() - BatchType.MEDIA -> scopedMediaContentUri.toFile() + 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) { @@ -545,8 +583,8 @@ abstract class BatchesFolder( companion object { fun requiredBytesForOneMinuteOfRecording(appSettings: AppSettings): Long { - // 250 MiB sounds like a good default - return 250 * 1024 * 1024 + // 350 MiB sounds like a good default + return 350 * 1024 * 1024 } } } diff --git a/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt b/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt index 98819310..fc29e64a 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt @@ -1,17 +1,11 @@ package app.myzel394.alibi.helpers -import android.content.Context -import android.net.Uri import android.util.Log -import androidx.documentfile.provider.DocumentFile import com.arthenica.ffmpegkit.FFmpegKit -import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.ReturnCode import kotlinx.coroutines.CompletableDeferred import java.io.File -import java.lang.Compiler.command import java.util.UUID -import kotlin.math.log // Abstract class for concatenating audio and video files // The concatenator runs in its own thread to avoid unresponsiveness. @@ -56,7 +50,7 @@ data class AudioConcatenator( command ) { session -> if (!ReturnCode.isSuccess(session!!.returnCode)) { - Log.d( + Log.i( "Audio Concatenation", String.format( "Command failed with state %s and rc %s.%s", @@ -100,7 +94,7 @@ class MediaConverter { command, { session -> if (!ReturnCode.isSuccess(session!!.returnCode)) { - Log.d( + Log.i( "Audio Concatenation", String.format( "Command failed with state %s and rc %s.%s", @@ -162,7 +156,7 @@ class MediaConverter { if (ReturnCode.isSuccess(session!!.returnCode)) { completer.complete(Unit) } else { - Log.d( + Log.i( "Video Concatenation", String.format( "Command failed with state %s and rc %s.%s", diff --git a/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt index aa748674..4cfc641b 100644 --- a/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt @@ -132,7 +132,7 @@ class VideoRecorderService : _videoFinalizerListener = CompletableDeferred() activeRecording = newRecording.start(ContextCompat.getMainExecutor(this)) { event -> - if (event is VideoRecordEvent.Finalize && this@VideoRecorderService.state == RecorderState.STOPPED || this@VideoRecorderService.state == RecorderState.PAUSED) { + if (event is VideoRecordEvent.Finalize && (this@VideoRecorderService.state == RecorderState.STOPPED || this@VideoRecorderService.state == RecorderState.PAUSED)) { _videoFinalizerListener.complete(Unit) } } @@ -191,17 +191,22 @@ class VideoRecorderService : videoCapture = buildVideoCapture(recorder) runOnMain { - camera = cameraProvider!!.bindToLifecycle( - this, - selectedCamera, - videoCapture - ) - cameraControl = CameraControl(camera!!).also { - it.init() - } - onCameraControlAvailable() + try { + camera = cameraProvider!!.bindToLifecycle( + this, + selectedCamera, + videoCapture + ) + + cameraControl = CameraControl(camera!!).also { + it.init() + } + onCameraControlAvailable() - _cameraAvailableListener.complete(Unit) + _cameraAvailableListener.complete(Unit) + } catch (error: IllegalArgumentException) { + onError() + } } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/Constants.kt b/app/src/main/java/app/myzel394/alibi/ui/Constants.kt index 979ee51e..a8afa200 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Constants.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Constants.kt @@ -2,6 +2,7 @@ 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 @@ -63,3 +64,17 @@ val CRYPTO_DONATIONS = mapOf( "Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN", "Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq", ) + +// Base64encoding these values so that bots can't easily scrape them. +val b64d = Base64.getDecoder() +val CONTACT_METHODS = mapOf( + "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" +) diff --git a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt index a2d5b369..069fad65 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -29,6 +30,7 @@ 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( @@ -57,10 +59,18 @@ fun Navigation( modifier = Modifier .background(MaterialTheme.colorScheme.background), navController = navController, - startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route, + startDestination = if (settings.hasSeenOnboarding || DEBUG_SKIP_WELCOME) Screen.AudioRecorder.route else Screen.Welcome.route, ) { composable(Screen.Welcome.route) { - WelcomeScreen(onNavigateToAudioRecorderScreen = { navController.navigate(Screen.AudioRecorder.route) }) + WelcomeScreen( + onNavigateToAudioRecorderScreen = { + val mainHandler = ContextCompat.getMainExecutor(context) + + mainHandler.execute { + navController.navigate(Screen.AudioRecorder.route) + } + }, + ) } composable( Screen.AudioRecorder.route, diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/BigButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/BigButton.kt index 38565504..a7dc7f33 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/BigButton.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/BigButton.kt @@ -35,15 +35,17 @@ fun BigButton( description: String? = null, onClick: () -> Unit, onLongClick: () -> Unit = {}, + isBig: Boolean? = null, ) { val orientation = LocalConfiguration.current.orientation BoxWithConstraints { - val isLarge = maxWidth > 500.dp && orientation == Configuration.ORIENTATION_PORTRAIT + val isLarge = if (isBig == null) + maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT else isBig Column( modifier = Modifier - .size(if (isLarge) 250.dp else 200.dp) + .size(if (isLarge) 250.dp else 190.dp) .clip(CircleShape) .semantics { contentDescription = label diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/CameraPreview.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/CameraPreview.kt index d0530ac9..3046973a 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/CameraPreview.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/CameraPreview.kt @@ -1,56 +1,56 @@ 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.lifecycle.ProcessCameraProvider 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 = Modifier, - scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER, + modifier: Modifier, cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA ) { val coroutineScope = rememberCoroutineScope() val lifecycleOwner = LocalLifecycleOwner.current - AndroidView( - modifier = modifier, - factory = { context -> - val previewView = PreviewView(context).apply { - this.scaleType = scaleType - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - } - // CameraX Preview UseCase - val previewUseCase = Preview.Builder() - .build() - .also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - - coroutineScope.launch { - val cameraProvider = ProcessCameraProvider.getInstance(context).get() - try { - // Must unbind the use-cases before rebinding them. - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, cameraSelector, previewUseCase + Box(modifier = modifier) { + // Video preview + AndroidView( + factory = { context -> + val previewView = PreviewView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, ) - } catch (ex: Exception) { } - } + val previewUseCase = Preview.Builder() + .build() + .also { it.setSurfaceProvider(previewView.surfaceProvider) } - previewView - } - ) -} \ No newline at end of file + 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 + }, + ) + } +} diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/LowStorageInfo.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/LowStorageInfo.kt index d5677042..7642a78a 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/LowStorageInfo.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/LowStorageInfo.kt @@ -1,6 +1,7 @@ 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 @@ -13,9 +14,11 @@ 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 @@ -34,14 +37,17 @@ fun LowStorageInfo( println("LowStorageInfo: availableBytes: $availableBytes, requiredBytes: $requiredBytes, isLowOnStorage: $isLowOnStorage") if (isLowOnStorage) - Box( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.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) - ) + 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 + ) + } } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RealTimeAudioVisualizer.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RealTimeAudioVisualizer.kt index b2d08087..cf4c8738 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RealTimeAudioVisualizer.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RealTimeAudioVisualizer.kt @@ -4,11 +4,16 @@ 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.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 @@ -75,7 +80,15 @@ fun RealtimeAudioVisualizer( audioRecorder.setMaxAmplitudesAmount(ceil(availableSpace.toInt() / BOX_DIFF).toInt() + 1) } - Canvas(modifier = modifier) { + var scale by remember { mutableFloatStateOf(1f) } + val transformState = rememberTransformableState { zoomChange, _, _ -> + scale *= zoomChange + } + val amplitudePercentageModifier = MAX_AMPLITUDE * (1 / scale) + + Canvas( + modifier = modifier.transformable(transformState), + ) { val height = this.size.height / 2f val width = this.size.width @@ -88,7 +101,8 @@ fun RealtimeAudioVisualizer( val horizontalProgress = ( clamp(horizontalValue, GROW_START, GROW_END) - GROW_START) / (GROW_END - GROW_START) - val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f) + val amplitudePercentage = + (amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f) val boxHeight = (height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f) diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderErrorDialog.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderErrorDialog.kt index 92da6195..a05306ce 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderErrorDialog.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderErrorDialog.kt @@ -3,8 +3,6 @@ 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.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -15,7 +13,6 @@ import app.myzel394.alibi.R @Composable fun RecorderErrorDialog( onClose: () -> Unit, - onSave: () -> Unit, ) { AlertDialog( onDismissRequest = onClose, @@ -31,14 +28,9 @@ fun RecorderErrorDialog( text = { Text(stringResource(R.string.ui_recorder_error_recording_description)) }, - dismissButton = { - TextButton(onClick = onClose) { - Text(stringResource(R.string.dialog_close_cancel_label)) - } - }, confirmButton = { - TextButton(onClick = onSave) { - Text(stringResource(R.string.ui_recorder_action_save_label)) + TextButton(onClick = onClose) { + Text(stringResource(R.string.dialog_close_neutral_label)) } } ) diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt index fe5297a4..14ea861f 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt @@ -1,17 +1,16 @@ 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.Spacer -import androidx.compose.foundation.layout.height 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.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.myzel394.alibi.R @@ -38,15 +37,18 @@ fun RecorderProcessingDialog( text = { Column( horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp), ) { Text( stringResource(R.string.ui_recorder_action_save_processing_dialog_description), ) - Spacer(modifier = Modifier.height(32.dp)) + CircularProgressIndicator() if (progress == null) LinearProgressIndicator() else - LinearProgressIndicator(progress = progress) + LinearProgressIndicator( + progress = { progress }, + ) } }, confirmButton = {} diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/AudioRecordingStart.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/AudioRecordingStart.kt index 2eba2858..060ce390 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/AudioRecordingStart.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/AudioRecordingStart.kt @@ -22,6 +22,7 @@ import app.myzel394.alibi.ui.models.AudioRecorderModel fun AudioRecordingStart( audioRecorder: AudioRecorderModel, appSettings: AppSettings, + useLargeButtons: Boolean? = null, ) { val context = LocalContext.current @@ -59,6 +60,7 @@ fun AudioRecordingStart( label = stringResource(R.string.ui_audioRecorder_action_start_label), icon = Icons.Default.Mic, onClick = triggerRecordAudio, + isBig = useLargeButtons, ) } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/VideoRecordingStart.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/VideoRecordingStart.kt index 40fd2696..775fe634 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/VideoRecordingStart.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/molecules/VideoRecordingStart.kt @@ -27,6 +27,7 @@ fun VideoRecordingStart( onHideAudioRecording: () -> Unit, onShowAudioRecording: () -> Unit, showPreview: Boolean, + useLargeButtons: Boolean? = null, ) { val context = LocalContext.current @@ -87,6 +88,7 @@ fun VideoRecordingStart( showSheet = true } }, + isBig = useLargeButtons, ) } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/AudioRecordingStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/AudioRecordingStatus.kt index 673e573a..893b4501 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/AudioRecordingStatus.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/AudioRecordingStatus.kt @@ -38,7 +38,6 @@ import java.time.LocalDateTime fun AudioRecordingStatus( audioRecorder: AudioRecorderModel, ) { - val context = LocalContext.current val configuration = LocalConfiguration.current.orientation var now by remember { mutableStateOf(LocalDateTime.now()) } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt index 6db673d4..99a43d78 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt @@ -30,9 +30,11 @@ 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.delay +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< @@ -93,9 +95,16 @@ fun RecorderEventsHandler( 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( - recorder.recorderService!!.getRecordingInformation() + information ) } } @@ -129,13 +138,18 @@ fun RecorderEventsHandler( } } - suspend fun saveRecording(recorder: RecorderModel, cleanupOldFiles: Boolean = false): Thread { - isProcessing = true + fun saveRecording( + recorder: RecorderModel, + cleanupOldFiles: Boolean = false + ): CompletableDeferred { + val completer = CompletableDeferred() - // Give the user some time to see the processing dialog - delay(100) + // If processing takes this short, don't show the processing dialog + val timer = Timer().schedule(250L) { + isProcessing = true + } - return thread { + thread { runBlocking { try { if (recorder.isCurrentlyActivelyRecording) { @@ -222,95 +236,118 @@ fun RecorderEventsHandler( if (recorder.isCurrentlyActivelyRecording) { recorder.recorderService?.unlockFiles(cleanupOldFiles) } + timer.cancel() isProcessing = false + processingProgress = null + completer.complete(Unit) } } } + + return completer } // Register audio recorder events - DisposableEffect(key1 = audioRecorder, key2 = settings) { - audioRecorder.onRecordingSave = { cleanupOldFiles -> - // We create our own coroutine because we show our own dialog and we want to - // keep saving until it's finished. - // So it's smarter to take things into our own hands and use our local coroutine, - // instead of hoping that the coroutine from where this will be called will be alive - // until the end of the saving process - scope.launch { - saveRecording(audioRecorder as RecorderModel, cleanupOldFiles).join() + // 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) - - showRecorderError = true + audioRecorder.onRecordingStart = { + snackbarHostState.currentSnackbarData?.dismiss() } - } - audioRecorder.onBatchesFolderNotAccessible = { - scope.launch { - showBatchesInaccessibleError = true + audioRecorder.onError = { + scope.launch { + saveAsLastRecording(audioRecorder as RecorderModel) + + runCatching { + audioRecorder.stopRecording(context) + } + runCatching { + audioRecorder.destroyService(context) + } - runCatching { - audioRecorder.stopRecording(context) + showRecorderError = true } - runCatching { - audioRecorder.destroyService(context) + } + 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") + onDispose { + audioRecorder.onRecordingSave = { + throw NotImplementedError("onRecordingSave should not be called now") + } + audioRecorder.onError = {} } - audioRecorder.onError = {} } } // Register video recorder events - DisposableEffect(key1 = videoRecorder, key2 = settings) { - videoRecorder.onRecordingSave = { cleanupOldFiles -> - // We create our own coroutine because we show our own dialog and we want to - // keep saving until it's finished. - // So it's smarter to take things into our own hands and use our local coroutine, - // instead of hoping that the coroutine from where this will be called will be alive - // until the end of the saving process - scope.launch { - saveRecording(videoRecorder as RecorderModel, cleanupOldFiles).join() + 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) - - showRecorderError = true + videoRecorder.onRecordingStart = { + snackbarHostState.currentSnackbarData?.dismiss() } - } - videoRecorder.onBatchesFolderNotAccessible = { - scope.launch { - showBatchesInaccessibleError = true + videoRecorder.onError = { + scope.launch { + saveAsLastRecording(videoRecorder as RecorderModel) - runCatching { - videoRecorder.stopRecording(context) + runCatching { + videoRecorder.stopRecording(context) + } + runCatching { + videoRecorder.destroyService(context) + } + + showRecorderError = true } - runCatching { - videoRecorder.destroyService(context) + } + videoRecorder.onBatchesFolderNotAccessible = { + scope.launch { + showBatchesInaccessibleError = true + + runCatching { + videoRecorder.stopRecording(context) + } + runCatching { + videoRecorder.destroyService(context) + } } } - } - onDispose { - videoRecorder.onRecordingSave = { - throw NotImplementedError("onRecordingSave should not be called now") + onDispose { + Log.i("Alibi", "===== Disposing videoRecorder events") + videoRecorder.onRecordingSave = { + throw NotImplementedError("onRecordingSave should not be called now") + } + videoRecorder.onError = {} } - videoRecorder.onError = {} } } @@ -324,8 +361,6 @@ fun RecorderEventsHandler( onClose = { showRecorderError = false }, - onSave = { - }, ) if (showBatchesInaccessibleError) diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/StartRecording.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/StartRecording.kt index f59ad8e2..f93ce7cf 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/StartRecording.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/StartRecording.kt @@ -3,6 +3,7 @@ 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 @@ -100,24 +101,47 @@ fun StartRecording( ) } - Column( - modifier = Modifier - .fillMaxSize() - .padding(bottom = if (orientation == Configuration.ORIENTATION_PORTRAIT) 32.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, - ) { + 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, @@ -125,95 +149,86 @@ fun StartRecording( onHideAudioRecording = onHideTopBar, onShowAudioRecording = onShowTopBar, showPreview = !showAudioRecorder, + useLargeButtons = isLargeDisplay, ) } } - else -> { - Spacer(modifier = Modifier.weight(1f)) - if (showAudioRecorder) - AudioRecordingStart( - audioRecorder = audioRecorder, - appSettings = appSettings, + 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), ) - VideoRecordingStart( - videoRecorder = videoRecorder, - appSettings = appSettings, - onHideAudioRecording = onHideTopBar, - onShowAudioRecording = onShowTopBar, - showPreview = !showAudioRecorder, - ) - } - } - - - 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), + TextButton( modifier = Modifier - .size(ButtonDefaults.IconSize) - ) + .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, - ), - ) + 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(appSettings = appSettings) + LowStorageInfo( + modifier = if (isLargeDisplay) Modifier + .padding(16.dp) + .widthIn(max = 400.dp) else Modifier + .fillMaxWidth() + .padding(4.dp), + appSettings = appSettings + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/VideoRecordingStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/VideoRecordingStatus.kt index 9009a994..bfae5585 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/VideoRecordingStatus.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/VideoRecordingStatus.kt @@ -1,6 +1,7 @@ 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 @@ -241,18 +242,27 @@ fun _PrimitiveControls(videoRecorder: VideoRecorderModel) { } }, 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 = { diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/MaxDurationTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/MaxDurationTile.kt index abf067b9..28e995d7 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/MaxDurationTile.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/MaxDurationTile.kt @@ -68,7 +68,7 @@ fun MaxDurationTile( timeFormat = DurationFormat.HH_MM, currentTime = settings.maxDuration / 1000, minTime = 60, - maxTime = 10 * 24 * 60 * 60, + maxTime = 23 * 60 * 60 + 59 * 60, ) ) SettingsTile( diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt index ec9f11d6..2d815cab 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt @@ -26,7 +26,6 @@ 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.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -435,8 +434,6 @@ fun SelectionSheet( ) { val context = LocalContext.current - var showCustomFolderWarning by remember { mutableStateOf(false) } - val selectFolder = rememberFolderSelectorDialog { folder -> if (folder == null) { return@rememberFolderSelectorDialog @@ -445,18 +442,6 @@ fun SelectionSheet( updateValue(folder.toString()) } - if (showCustomFolderWarning) { - CustomFolderWarningDialog( - onDismiss = { - showCustomFolderWarning = false - }, - onConfirm = { - showCustomFolderWarning = false - selectFolder() - }, - ) - } - var showExternalPermissionRequired by remember { mutableStateOf(false) } if (showExternalPermissionRequired) { @@ -523,9 +508,7 @@ fun SelectionSheet( SelectionButton( label = stringResource(R.string.ui_settings_option_saveFolder_action_custom_label), icon = Icons.Default.Folder, - onClick = { - showCustomFolderWarning = true - }, + onClick = selectFolder, ) if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) { Column( @@ -581,52 +564,6 @@ fun SelectionButton( } } -@Composable -fun CustomFolderWarningDialog( - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - val title = stringResource(R.string.ui_settings_option_saveFolder_warning_title) - val text = stringResource(R.string.ui_settings_option_saveFolder_warning_text) - - AlertDialog( - icon = { - Icon( - Icons.Default.Warning, - contentDescription = null, - ) - }, - onDismissRequest = onDismiss, - title = { - Text(text = title) - }, - text = { - Text(text = text) - }, - confirmButton = { - Button(onClick = onConfirm) { - Text( - text = stringResource(R.string.ui_settings_option_saveFolder_warning_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)) - } - } - ) -} - @Composable fun ExternalPermissionRequiredDialog( onDismiss: () -> Unit, diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/MaxDurationSelector.kt b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/MaxDurationSelector.kt new file mode 100644 index 00000000..15044d7d --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/MaxDurationSelector.kt @@ -0,0 +1,196 @@ +package app.myzel394.alibi.ui.components.WelcomeScreen.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.dataStore +import app.myzel394.alibi.ui.utils.IconResource +import com.maxkeppeker.sheets.core.models.base.Header +import com.maxkeppeker.sheets.core.models.base.IconSource +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.duration.DurationDialog +import com.maxkeppeler.sheets.duration.models.DurationConfig +import com.maxkeppeler.sheets.duration.models.DurationFormat +import com.maxkeppeler.sheets.duration.models.DurationSelection +import kotlinx.coroutines.launch + +const val MINUTES_1 = 1000 * 60 * 1L +const val MINUTES_5 = 1000 * 60 * 5L +const val MINUTES_15 = 1000 * 60 * 15L +const val MINUTES_30 = 1000 * 60 * 30L + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MaxDurationSelector( + modifier: Modifier = Modifier, +) { + val OPTIONS = mapOf( + MINUTES_1 to stringResource(R.string.ui_welcome_timeSettings_values_1min), + MINUTES_5 to stringResource(R.string.ui_welcome_timeSettings_values_5min), + MINUTES_15 to stringResource(R.string.ui_welcome_timeSettings_values_15min), + MINUTES_30 to stringResource(R.string.ui_welcome_timeSettings_values_30min), + ) + + val scope = rememberCoroutineScope() + val dataStore = LocalContext.current.dataStore + + var selectedDuration by rememberSaveable { mutableLongStateOf(MINUTES_15) }; + + // Make sure appSettings is updated properly + LaunchedEffect(selectedDuration) { + scope.launch { + dataStore.updateData { + it.setMaxDuration(selectedDuration) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + .then(modifier), + verticalArrangement = Arrangement.Center, + ) { + for ((duration, label) in OPTIONS) { + val a11yLabel = stringResource( + R.string.a11y_selectValue, + label + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .semantics { + contentDescription = a11yLabel + } + .clickable { + selectedDuration = duration + } + .padding(16.dp) + ) { + RadioButton( + selected = selectedDuration == duration, + onClick = { selectedDuration = duration }, + ) + Text(label) + } + } + + let { + val showDialog = rememberUseCaseState() + val label = stringResource(R.string.ui_welcome_timeSettings_values_custom) + val selected = selectedDuration !in OPTIONS.keys + + DurationDialog( + state = showDialog, + header = Header.Default( + title = stringResource(R.string.ui_settings_option_maxDuration_title), + icon = IconSource( + painter = IconResource.fromImageVector(Icons.Default.Timer) + .asPainterResource(), + contentDescription = null, + ) + ), + selection = DurationSelection { newTimeInSeconds -> + selectedDuration = newTimeInSeconds * 1000L + }, + config = DurationConfig( + timeFormat = DurationFormat.HH_MM, + currentTime = selectedDuration / 1000, + minTime = 60, + maxTime = 23 * 60 * 60 + 60 * 59, + ) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = label + } + .clickable { + showDialog.show() + } + .clip(MaterialTheme.shapes.medium) + .padding(16.dp) + ) { + Icon( + Icons.Default.Edit, + contentDescription = null, + modifier = Modifier + .minimumInteractiveComponentSize() + .padding(2.dp), + tint = if (selected) MaterialTheme.colorScheme.primary else contentColorFor( + MaterialTheme.colorScheme.surfaceContainer + ) + ) + if (selected) { + val totalMinutes = selectedDuration / 1000 / 60 + val minutes = totalMinutes % 60 + val hours = (totalMinutes / 60).toInt() + + Text( + text = when (hours) { + 0 -> stringResource( + R.string.ui_welcome_timeSettings_values_customFormat_mm, + minutes + ) + + 1 -> stringResource( + R.string.ui_welcome_timeSettings_values_customFormat_h_mm, + minutes + ) + + else -> stringResource( + R.string.ui_welcome_timeSettings_values_customFormat_hh_mm, + hours, + minutes + ) + }, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + text = stringResource(R.string.ui_welcome_timeSettings_values_custom), + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/SaveFolderSelection.kt b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/SaveFolderSelection.kt new file mode 100644 index 00000000..2c1ca123 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/SaveFolderSelection.kt @@ -0,0 +1,206 @@ +package app.myzel394.alibi.ui.components.WelcomeScreen.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.PermMedia +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE +import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS +import app.myzel394.alibi.ui.components.atoms.MessageBox +import app.myzel394.alibi.ui.components.atoms.MessageType +import app.myzel394.alibi.ui.components.atoms.VisualDensity + +const val CUSTOM_FOLDER = "custom" + +@Composable +fun SaveFolderSelection( + modifier: Modifier = Modifier, + saveFolder: String?, + isLowOnStorage: Boolean, + onSaveFolderChange: (String?) -> Unit, +) { + @Composable + fun createModifier(a11yLabel: String, onClick: () -> Unit) = + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .semantics { + contentDescription = a11yLabel + } + .clickable(onClick = onClick) + .padding(16.dp) + .padding(end = 8.dp) + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + .then(modifier), + verticalArrangement = Arrangement.Center, + ) { + let { + val label = stringResource(R.string.ui_welcome_saveFolder_values_internal) + val a11yLabel = stringResource( + R.string.a11y_selectValue, + label + ) + val folder = null + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = createModifier(a11yLabel) { + onSaveFolderChange(folder) + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RadioButton( + selected = saveFolder == folder, + onClick = { onSaveFolderChange(folder) }, + ) + Text(label) + } + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.IconSize) + ) + } + } + let { + val label = stringResource(R.string.ui_welcome_saveFolder_values_media) + val a11yLabel = stringResource( + R.string.a11y_selectValue, + label + ) + val folder = RECORDER_MEDIA_SELECTED_VALUE + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = createModifier(a11yLabel) { + onSaveFolderChange(folder) + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RadioButton( + selected = saveFolder == folder, + onClick = { onSaveFolderChange(folder) }, + ) + Text(label) + } + Icon( + Icons.Default.PermMedia, + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.IconSize) + ) + } + } + let { + val label = stringResource(R.string.ui_welcome_saveFolder_values_custom) + val a11yLabel = stringResource( + R.string.a11y_selectValue, + label + ) + val folder = CUSTOM_FOLDER + + Column( + horizontalAlignment = Alignment.Start, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = createModifier(a11yLabel) { + onSaveFolderChange(folder) + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RadioButton( + selected = saveFolder == folder, + onClick = { onSaveFolderChange(folder) }, + ) + Text(label) + } + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.IconSize) + ) + } + if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) { + Column( + modifier = Modifier + .padding(horizontal = 32.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(R.string.ui_settings_option_saveFolder_videoUnsupported), + fontSize = MaterialTheme.typography.titleSmall.fontSize, + ) + Text( + stringResource(R.string.ui_minApiRequired, 8, 26), + fontSize = MaterialTheme.typography.bodySmall.fontSize, + ) + } + } + } + } + } + if (isLowOnStorage && saveFolder == null) + MessageBox( + type = MessageType.ERROR, + message = stringResource(R.string.ui_welcome_saveFolder_externalRequired) + ) + else + Box( + modifier = Modifier.widthIn(max = 400.dp) + ) { + MessageBox( + type = MessageType.INFO, + message = stringResource(R.string.ui_welcome_timeSettings_changeableHint), + density = VisualDensity.DENSE, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/ExplanationPage.kt b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ExplanationPage.kt similarity index 97% rename from app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/ExplanationPage.kt rename to app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ExplanationPage.kt index 70051fe9..33148309 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/ExplanationPage.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ExplanationPage.kt @@ -1,4 +1,4 @@ -package app.myzel394.alibi.ui.components.WelcomeScreen.atoms +package app.myzel394.alibi.ui.components.WelcomeScreen.pages import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/MaxDurationSettingsPage.kt b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/MaxDurationSettingsPage.kt new file mode 100644 index 00000000..a7790335 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/MaxDurationSettingsPage.kt @@ -0,0 +1,111 @@ +package app.myzel394.alibi.ui.components.WelcomeScreen.pages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE +import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.MaxDurationSelector +import app.myzel394.alibi.ui.components.atoms.MessageBox +import app.myzel394.alibi.ui.components.atoms.MessageType +import app.myzel394.alibi.ui.components.atoms.VisualDensity + +@Composable +fun MaxDurationSettingsPage( + onContinue: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(40.dp)) + Column( + modifier = Modifier + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.AccessTime, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(128.dp), + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + stringResource(R.string.ui_welcome_timeSettings_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringResource(R.string.ui_welcome_timeSettings_message), + fontStyle = MaterialTheme.typography.bodySmall.fontStyle, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + color = MaterialTheme.typography.bodySmall.color, + ) + } + Spacer(modifier = Modifier.height(40.dp)) + Box( + modifier = Modifier + .widthIn(max = 400.dp) + .padding(horizontal = 16.dp) + ) { + MaxDurationSelector() + } + Spacer(modifier = Modifier.height(20.dp)) + Box( + modifier = Modifier + .widthIn(max = 400.dp) + .padding(horizontal = 16.dp) + ) { + MessageBox( + type = MessageType.INFO, + message = stringResource(R.string.ui_welcome_timeSettings_changeableHint), + density = VisualDensity.DENSE, + ) + } + Spacer(modifier = Modifier.height(40.dp)) + Button( + onClick = { onContinue() }, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .height(BIG_PRIMARY_BUTTON_SIZE), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.continue_label)) + } + } +} diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ReadyPage.kt b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ReadyPage.kt new file mode 100644 index 00000000..cdee3cb7 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ReadyPage.kt @@ -0,0 +1,78 @@ +package app.myzel394.alibi.ui.components.WelcomeScreen.pages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Celebration +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE + +@Composable +fun ReadyPage( + onContinue: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + Column( + modifier = Modifier + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.Celebration, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(128.dp), + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + stringResource(R.string.ui_welcome_ready_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringResource(R.string.ui_welcome_ready_message), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onContinue, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .height(BIG_PRIMARY_BUTTON_SIZE), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.ui_welcome_ready_start)) + } + } +} diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/ResponsibilityPage.kt b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ResponsibilityPage.kt similarity index 91% rename from app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/ResponsibilityPage.kt rename to app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ResponsibilityPage.kt index 3b2b526f..537d61b2 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/atoms/ResponsibilityPage.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/ResponsibilityPage.kt @@ -1,4 +1,4 @@ -package app.myzel394.alibi.ui.components.WelcomeScreen.atoms +package app.myzel394.alibi.ui.components.WelcomeScreen.pages import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -59,7 +59,7 @@ fun ResponsibilityPage( } Spacer(modifier = Modifier.weight(1f)) Button( - onClick = { onContinue() }, + onClick = onContinue, modifier = Modifier .padding(16.dp) .fillMaxWidth() @@ -67,12 +67,12 @@ fun ResponsibilityPage( contentPadding = ButtonDefaults.ButtonWithIconContentPadding, ) { Icon( - Icons.Default.Check, + Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.ui_welcome_start_label)) + Text(stringResource(R.string.continue_label)) } } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/SaveFolderPage.kt b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/SaveFolderPage.kt new file mode 100644 index 00000000..8e1dec5d --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/WelcomeScreen/pages/SaveFolderPage.kt @@ -0,0 +1,256 @@ +package app.myzel394.alibi.ui.components.WelcomeScreen.pages + +import android.Manifest +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.db.AppSettings +import app.myzel394.alibi.helpers.BatchesFolder +import app.myzel394.alibi.helpers.VideoBatchesFolder +import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE +import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE +import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE +import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.SaveFolderSelection +import app.myzel394.alibi.ui.components.atoms.PermissionRequester +import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog +import kotlin.concurrent.thread + +@Composable +fun SaveFolderPage( + onBack: () -> Unit, + onContinue: (saveFolder: String?) -> Unit, + appSettings: AppSettings, +) { + var saveFolder by rememberSaveable { mutableStateOf(null) } + + val context = LocalContext.current + + var isLowOnStorage by rememberSaveable { + mutableStateOf(false) + } + // Fetching this synchronously results in the UI being blocked. + // Instead, we fetch this in a different thread and update the state when we have the result. + LaunchedEffect(appSettings, context) { + thread { + val availableBytes = VideoBatchesFolder.viaInternalFolder(context).getAvailableBytes() + + if (availableBytes == null) { + isLowOnStorage = false + return@thread + } + + val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings) + val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute + + // Allow for a 10% margin of error + isLowOnStorage = availableBytes < requiredBytes + } + } + + LaunchedEffect(isLowOnStorage, appSettings.maxDuration) { + if (isLowOnStorage) { + if (saveFolder == null) { + saveFolder = RECORDER_MEDIA_SELECTED_VALUE + } + } else { + saveFolder = null + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(40.dp)) + Column( + modifier = Modifier + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(128.dp), + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + stringResource(R.string.ui_welcome_saveFolder_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringResource(R.string.ui_welcome_saveFolder_message), + fontStyle = MaterialTheme.typography.bodySmall.fontStyle, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + color = MaterialTheme.typography.bodySmall.color, + ) + } + Spacer(modifier = Modifier.height(40.dp)) + Box( + modifier = Modifier + .widthIn(max = 400.dp) + .padding(horizontal = 16.dp) + ) { + SaveFolderSelection( + saveFolder = saveFolder, + isLowOnStorage = isLowOnStorage, + onSaveFolderChange = { saveFolder = it }, + ) + } + Spacer(modifier = Modifier.height(40.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .size(BIG_PRIMARY_BUTTON_SIZE), + ) { + Icon( + Icons.Default.ChevronLeft, + contentDescription = null, + ) + } + PermissionRequester( + permission = Manifest.permission.WRITE_EXTERNAL_STORAGE, + icon = Icons.AutoMirrored.Filled.InsertDriveFile, + onPermissionAvailable = { onContinue(saveFolder) }, + ) { requestWritePermission -> + val selectFolder = rememberFolderSelectorDialog { folder -> + if (folder == null) { + return@rememberFolderSelectorDialog + } + + onContinue(saveFolder) + } + var showCustomFolderHint by rememberSaveable { mutableStateOf(false) } + + if (showCustomFolderHint) { + _CustomFolderDialog( + onAbort = { showCustomFolderHint = false }, + onOk = { + showCustomFolderHint = false + selectFolder() + }, + ) + } + + Button( + onClick = { + when (saveFolder) { + null -> onContinue(saveFolder) + RECORDER_MEDIA_SELECTED_VALUE -> { + if (SUPPORTS_SCOPED_STORAGE) { + onContinue(saveFolder) + } else { + requestWritePermission() + } + } + + else -> { + showCustomFolderHint = true + } + } + }, + enabled = if (saveFolder == null) !isLowOnStorage else true, + modifier = Modifier + .fillMaxWidth() + .height(BIG_PRIMARY_BUTTON_SIZE), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.continue_label)) + } + } + } + } +} + +@Composable +fun _CustomFolderDialog( + onAbort: () -> Unit, + onOk: () -> Unit, +) { + AlertDialog( + onDismissRequest = onAbort, + icon = { + Icon( + Icons.Default.Folder, + contentDescription = null, + ) + }, + title = { + Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_title)) + }, + text = { + Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_message)) + }, + dismissButton = { + TextButton( + onClick = onAbort, + contentPadding = ButtonDefaults.TextButtonContentPadding, + colors = ButtonDefaults.textButtonColors(), + ) { + Text(stringResource(R.string.dialog_close_cancel_label)) + } + }, + confirmButton = { + Button( + onClick = onOk, + ) { + Text(stringResource(R.string.dialog_close_neutral_label)) + } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt b/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt index d67ad647..2af9d332 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt @@ -68,15 +68,15 @@ fun MessageBox( .clip(MaterialTheme.shapes.medium) .background(backgroundColor) .let { - if (density == VisualDensity.COMFORTABLE) { - it.padding(horizontal = 8.dp, vertical = 16.dp) - } else { - it.padding(8.dp) + when (density) { + VisualDensity.COMFORTABLE -> it.padding(horizontal = 8.dp, vertical = 16.dp) + VisualDensity.DENSE -> it.padding(8.dp) + VisualDensity.COMPACT -> it.padding(8.dp) } } .then(modifier) ) { - if (density == VisualDensity.COMFORTABLE) { + if (density == VisualDensity.COMFORTABLE || density == VisualDensity.DENSE) { Icon( imageVector = when (type) { MessageType.ERROR -> Icons.Default.Error @@ -121,4 +121,5 @@ enum class MessageType { enum class VisualDensity { COMPACT, COMFORTABLE, + DENSE, } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/BaseRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/BaseRecorderModel.kt index 77968492..d625743f 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/BaseRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/BaseRecorderModel.kt @@ -17,7 +17,7 @@ import app.myzel394.alibi.helpers.BatchesFolder import app.myzel394.alibi.services.IntervalRecorderService import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderService -import kotlinx.coroutines.Job +import kotlinx.coroutines.CompletableDeferred import kotlinx.serialization.json.Json abstract class BaseRecorderModel> : @@ -49,7 +49,7 @@ abstract class BaseRecorderModel Job = { + var onRecordingSave: (cleanupOldFiles: Boolean) -> CompletableDeferred = { throw NotImplementedError("onRecordingSave not implemented") } var onRecordingStart: () -> Unit = {} diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/AboutScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AboutScreen.kt index 4df51fbc..a5fb877f 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/AboutScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/AboutScreen.kt @@ -1,8 +1,12 @@ package app.myzel394.alibi.ui.screens +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context 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.Box import androidx.compose.foundation.layout.Column @@ -18,6 +22,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -34,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -43,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.myzel394.alibi.BuildConfig import app.myzel394.alibi.R +import app.myzel394.alibi.ui.CONTACT_METHODS import app.myzel394.alibi.ui.REPO_URL import app.myzel394.alibi.ui.TRANSLATION_HELP_URL import app.myzel394.alibi.ui.components.AboutScreen.atoms.DonationsTile @@ -82,8 +89,8 @@ fun AboutScreen( Column( modifier = Modifier .padding(padding) - .padding(horizontal = 32.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp), verticalArrangement = Arrangement.spacedBy(48.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -125,7 +132,7 @@ fun AboutScreen( ) Text( stringResource(R.string.ui_about_contribute_message), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.bodySmall, ) val githubLabel = stringResource(R.string.accessibility_open_in_browser, REPO_URL) @@ -203,6 +210,54 @@ fun AboutScreen( DonationsTile() + Text( + stringResource(R.string.ui_about_support_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + stringResource(R.string.ui_about_support_message), + style = MaterialTheme.typography.bodySmall, + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val clipboardManager = + LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + for (contact in CONTACT_METHODS) { + val name = contact.key + val uri = contact.value + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable { + val clip = ClipData.newPlainText("text", uri) + clipboardManager.setPrimaryClip(clip) + } + .padding(16.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = null, + ) + Text( + name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + Text( + uri, + fontSize = MaterialTheme.typography.bodyMedium.fontSize.times(0.5), + ) + } + } + } + GPGKeyOverview() } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt index a086840c..f3936cc1 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt @@ -16,17 +16,14 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.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.db.RecordingInformation import app.myzel394.alibi.ui.components.RecorderScreen.organisms.AudioRecordingStatus @@ -46,7 +43,6 @@ fun RecorderScreen( settings: AppSettings, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current val scope = rememberCoroutineScope() RecorderEventsHandler( @@ -104,9 +100,6 @@ fun RecorderScreen( .fillMaxSize() .padding(padding), ) { - val appSettings = - context.dataStore.data.collectAsState(AppSettings.getDefaultInstance()).value - if (audioRecorder.isInRecording) AudioRecordingStatus(audioRecorder = audioRecorder) else if (videoRecorder.isInRecording) @@ -115,7 +108,7 @@ fun RecorderScreen( StartRecording( audioRecorder = audioRecorder, videoRecorder = videoRecorder, - appSettings = appSettings, + appSettings = settings, onSaveLastRecording = { scope.launch { when (settings.lastRecording!!.type) { diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/WelcomeScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/WelcomeScreen.kt index ff15de07..6ed8ef23 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/WelcomeScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/WelcomeScreen.kt @@ -8,16 +8,17 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Scaffold 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.platform.LocalContext -import androidx.navigation.NavController import app.myzel394.alibi.dataStore -import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.ExplanationPage -import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.ResponsibilityPage -import app.myzel394.alibi.ui.enums.Screen +import app.myzel394.alibi.ui.components.WelcomeScreen.pages.ExplanationPage +import app.myzel394.alibi.ui.components.WelcomeScreen.pages.MaxDurationSettingsPage +import app.myzel394.alibi.ui.components.WelcomeScreen.pages.ReadyPage +import app.myzel394.alibi.ui.components.WelcomeScreen.pages.ResponsibilityPage +import app.myzel394.alibi.ui.components.WelcomeScreen.pages.SaveFolderPage +import app.myzel394.alibi.ui.effects.rememberSettings import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @@ -27,41 +28,76 @@ fun WelcomeScreen( ) { val context = LocalContext.current val dataStore = context.dataStore - val settings = dataStore - .data - .collectAsState(initial = null) - .value ?: return + val settings = rememberSettings() val scope = rememberCoroutineScope() val pagerState = rememberPagerState( initialPage = 0, initialPageOffsetFraction = 0f, - pageCount = {2} + pageCount = { 5 } ) - Scaffold() {padding -> + fun finishTutorial() { + scope.launch { + dataStore.updateData { + settings.setHasSeenOnboarding(true) + } + onNavigateToAudioRecorderScreen() + } + } + + Scaffold() { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, ) { - HorizontalPager(state = pagerState) {position -> + HorizontalPager( + state = pagerState, + ) { position -> when (position) { 0 -> ExplanationPage( onContinue = { scope.launch { - pagerState.animateScrollToPage(2) + pagerState.animateScrollToPage(1) } } ) + 1 -> ResponsibilityPage { scope.launch { - dataStore.updateData { - settings.setHasSeenOnboarding(true) - } - onNavigateToAudioRecorderScreen() + pagerState.animateScrollToPage(2) + } + } + + 2 -> MaxDurationSettingsPage { + scope.launch { + pagerState.animateScrollToPage(3) } } + + 3 -> SaveFolderPage( + onBack = { + scope.launch { + pagerState.animateScrollToPage(2) + } + }, + onContinue = { saveFolder -> + scope.launch { + dataStore.updateData { + settings.setSaveFolder(saveFolder) + } + + pagerState.animateScrollToPage(4) + } + }, + appSettings = settings + ) + + 4 -> ReadyPage { + finishTutorial() + } } } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/utils/Context.kt b/app/src/main/java/app/myzel394/alibi/ui/utils/Context.kt new file mode 100644 index 00000000..058f7dd1 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/utils/Context.kt @@ -0,0 +1,16 @@ +package app.myzel394.alibi.ui.utils + +import android.content.Context +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +suspend fun Context.getCameraProvider(): ProcessCameraProvider = suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(this).also { future -> + future.addListener({ + continuation.resume(future.get()) + }, ContextCompat.getMainExecutor(this)) + } +} + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index baefa03e..ec7cd340 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,8 @@ Please enter a number greater than %s Selected: %s + Select %s + Recorder Shows the current recording status @@ -37,7 +39,7 @@ \u0020at your request Processing - Processing your recording, do not close Alibi! You will automatically be prompted to save the file once it\'s ready + Processing your recording, do not close Alibi! You will automatically be prompted to save the file once it\'s ready. This process may take a few minutes if your time frame is big. Once this is finished and you are asked to save the file, please do so and then wait until you see Alibi\'s main screen again. Alibi keeps recording in the background @@ -78,7 +80,7 @@ Recording paused Alibi is paused An error occurred - Alibi encountered an error during recording. Would you like to try saving the recording? + Alibi encountered an error during recording. Try using different settings or restart the app. Language Change Device Microphone @@ -195,4 +197,29 @@ You can save the current ongoing recording by pressing and holding down on the save button. The recording will continue in the background. You are low on storage. Alibi may not function properly. Please free up some space. You are low on storage. Alibi may not function properly. Please free up some space. Alternatively, change the batches folder to a different location in the settings. + How long should Alibi remember? + Alibi will continuously record and delete old recordings to make space for new ones. You decide how long Alibi should remember the past. + 5 Minutes + 15 minutes + 30 minutes + 1 hour + You can change this anytime + Where should Alibi store the batches? + Select where you would like to let Alibi store the batches of the ongoing recordings. The internal folder is encrypted and only accessible by Alibi. This folder is recommended if you only want to record a small time frame. If you need a longer time frame, you will most likely need to select a different folder, as the internal storage is very limited. + Internal Storage + Custom Folder + Media Folder + Please select either the Media Folder or a Custom Folder. Alibi has not enough space to store the batches in the internal storage. Alternatively, go back one step and select a shorter duration. + Select a Custom Folder + You will now be asked to select a folder where Alibi should store the batches. Please select a folder where you have write access to. + Custom Duration + %s minutes + %s hour, %s minutes + 1 hour, %s minutes + You are ready! + You are ready to start using Alibi! Go ahead and try it out! + Start Alibi + Get Support + If you have any questions, feedback or face any issues, please don\'t hesitate to contact me. I\'m happy to help you! Below is a list of ways to get in touch with me: + 1 Minute \ No newline at end of file