diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2518136..dbaa892 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,7 +52,7 @@ android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" /> diff --git a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt index dcbcd2b..fdaac51 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -289,7 +289,7 @@ class AudioRecorderService : } companion object { - fun from(audioRecorderSettings: AudioRecorderSettings): IntervalRecorderService.Settings { + fun from(audioRecorderSettings: AudioRecorderSettings): Settings { return Settings( intervalDuration = audioRecorderSettings.intervalDuration, bitRate = audioRecorderSettings.bitRate, diff --git a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt index 8bcaede..3bf7b7d 100644 --- a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -43,9 +43,7 @@ abstract class IntervalRecorderService private fun createTimer() { cycleTimer = Executors.newSingleThreadScheduledExecutor().also { it.scheduleAtFixedRate( - { - startNewCycle() - }, + ::startNewCycle, 0, settings.intervalDuration, TimeUnit.MILLISECONDS diff --git a/app/src/main/java/app/myzel394/alibi/services/OldVideoService.kt b/app/src/main/java/app/myzel394/alibi/services/OldVideoService.kt index 2d109a9..8ad2b7e 100644 --- a/app/src/main/java/app/myzel394/alibi/services/OldVideoService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/OldVideoService.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.ContentValues import android.content.pm.ServiceInfo import android.os.Build +import android.os.Environment import android.provider.MediaStore import androidx.camera.core.CameraSelector import androidx.camera.lifecycle.ProcessCameraProvider @@ -12,21 +13,177 @@ 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.VideoCapture.withOutput import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleService import app.myzel394.alibi.NotificationHelper +import app.myzel394.alibi.db.RecordingInformation +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 java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit -class VideoService : IntervalRecorderService() { +class VideoService : IntervalRecorderService() { + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + + private var cameraProvider: ProcessCameraProvider? = null + private var videoCapture: VideoCapture? = null + private var activeRecording: Recording? = null + + // Used to listen and check if the camera is available + private var _cameraAvailableListener = CompletableDeferred() + + // Runs a function in the main thread + private fun runInMain(callback: () -> Unit) { + val mainHandler = ContextCompat.getMainExecutor(this) + + mainHandler.execute(callback) + } + + // 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@VideoService).get() + } + + val recorder = Recorder.Builder() + .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) + .build() + videoCapture = withOutput(recorder) + + runInMain { + cameraProvider!!.bindToLifecycle( + this, + settings.camera, + videoCapture + ) + + _cameraAvailableListener.complete(true) + } + } + + // 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() { + clearOldVideoRecording() + + runCatching { + cameraProvider?.unbindAll() + } + + cameraProvider = null + videoCapture = null + } + + override fun start() { + super.start() + + scope.launch { + openCamera() + } + } + + override fun stop() { + super.stop() + + closeCamera() + } + + private fun clearOldVideoRecording() { + runCatching { + activeRecording?.stop() + } + } + + @SuppressLint("MissingPermission") + private fun prepareVideoRecording() = + videoCapture!!.output + .prepareRecording(this, settings.getOutputOptions(this)) + .withAudioEnabled() + + @SuppressLint("MissingPermission") + override fun startNewCycle() { + super.startNewCycle() + + fun action() { + println("=======================") + activeRecording?.stop() + val newRecording = prepareVideoRecording() + + activeRecording = newRecording.start(ContextCompat.getMainExecutor(this), {}) + } + + if (_cameraAvailableListener.isCompleted) { + action() + } else { + // Race condition of `startNewCycle` being called before `invpkeOnCompletion` + // 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() + } + } + } + + override fun getRecordingInformation(): RecordingInformation = RecordingInformation( + folderPath = batchesFolder.exportFolderForSettings(), + recordingStart = recordingStart, + maxDuration = settings.maxDuration, + fileExtension = settings.fileExtension, + intervalDuration = settings.intervalDuration, + ) + + data class Settings( + override val maxDuration: Long, + override val intervalDuration: Long, + val folder: String? = null, + val camera: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA, + ) : IntervalRecorderService.Settings( + maxDuration = maxDuration, + intervalDuration = intervalDuration + ) { + val fileExtension + get() = "mp4" + + fun getOutputOptions(video: VideoService): MediaStoreOutputOptions { + val contentValues = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, "${video.counter}.$fileExtension") + + put( + MediaStore.MediaColumns.RELATIVE_PATH, + "DCIM/Recordings" + ) + } + + // TODO: Find a way to make this work with the internal storage + return MediaStoreOutputOptions.Builder( + video.contentResolver, + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + ) + .setContentValues(contentValues) + .build() + } + + companion object { + fun from() = Settings( + maxDuration = 60_000, + intervalDuration = 10_000, + ) + } + } } class OldVideoService : LifecycleService() { diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt index 14540c1..44a5459 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -92,7 +92,7 @@ class AudioRecorderModel : ViewModel() { } recorder.batchesFolder = batchesFolder ?: recorder.batchesFolder recorder.settings = - IntervalRecorderService.Settings.from(settings.audioRecorderSettings) + AudioRecorderService.Settings.from(settings.audioRecorderSettings) recorder.clearAllRecordings() }.also { diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/POCVideo.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/POCVideo.kt index fc42e09..634a848 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/POCVideo.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/POCVideo.kt @@ -1,6 +1,9 @@ package app.myzel394.alibi.ui.screens +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.os.IBinder import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -11,7 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import app.myzel394.alibi.services.OldVideoService +import app.myzel394.alibi.services.AudioRecorderService +import app.myzel394.alibi.services.RecorderService +import app.myzel394.alibi.services.VideoService @Composable fun POCVideo() { @@ -23,8 +28,24 @@ fun POCVideo() { .padding(64.dp) ) { Button(onClick = { - val intent = Intent(context, OldVideoService::class.java) + val connection = object : android.content.ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = + ((service as RecorderService.RecorderBinder).getService() as VideoService).also { recorder -> + recorder.settings = VideoService.Settings.from() + recorder.startRecording() + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + } + } + + val intent = Intent(context, VideoService::class.java).apply { + action = "init" + } ContextCompat.startForegroundService(context, intent) + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) }) { Text("Start") }