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