From 4b2eb8def9f4f20f7b0c572e7f365cad993aa460 Mon Sep 17 00:00:00 2001 From: "Urko." Date: Tue, 30 Jan 2024 20:03:20 +0100 Subject: [PATCH] WIP: was storing first time but issues while stopping video --- .idea/.gitignore | 3 + .idea/.name | 1 + .idea/compiler.xml | 6 + .idea/deploymentTargetDropDown.xml | 10 + .idea/gradle.xml | 19 ++ .idea/inspectionProfiles/Project_Default.xml | 41 +++ .idea/kotlinc.xml | 6 + .idea/migrations.xml | 10 + .idea/misc.xml | 9 + .idea/vcs.xml | 6 + app/build.gradle.kts | 15 +- app/src/main/AndroidManifest.xml | 16 ++ .../urkob/wittrail_android/MainActivity.kt | 164 +++++++++--- .../wittrail_android/RecordingService.kt | 213 ++++++++++++++++ .../wittrail_android/RecordingService.kt.txt | 236 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 28 +++ app/src/main/res/values/themes.xml | 8 +- 17 files changed, 748 insertions(+), 43 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetDropDown.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/kotlinc.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml create mode 100644 app/src/main/java/com/urkob/wittrail_android/RecordingService.kt create mode 100644 app/src/main/java/com/urkob/wittrail_android/RecordingService.kt.txt create mode 100644 app/src/main/res/layout/activity_main.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..4e1fd7f --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Wittrail-Android \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..0c0c338 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..0897082 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..44ca2d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c1c931..3199ec4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,17 +48,22 @@ android { } } } - +val cameraxVersion = "1.3.1" dependencies { - - implementation("androidx.core:core-ktx:1.10.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") - implementation("androidx.activity:activity-compose:1.7.0") + implementation("androidx.camera:camera-camera2:$cameraxVersion") + implementation("androidx.camera:camera-lifecycle:$cameraxVersion") + implementation("androidx.camera:camera-video:$cameraxVersion") + implementation("androidx.camera:camera-view:$cameraxVersion") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") implementation(platform("androidx.compose:compose-bom:2023.08.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.lifecycle:lifecycle-service:2.7.0") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea712c3..15806b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,16 @@ + + + + + + + + + + + + + // Handle Permission granted/rejected + var permissionGranted = true + permissions.entries.forEach { + if (it.key in REQUIRED_PERMISSIONS && !it.value) + permissionGranted = false + } + if (!permissionGranted) { + Toast.makeText(baseContext, + "Permission request denied", + Toast.LENGTH_SHORT).show() + } else { + Log.e("main", "INIT START CAMERA") + initStartCamera() + } + } + + private fun hasCameraPermission() = + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + + private fun requestCameraPermission() { + ActivityCompat.requestPermissions( + this, arrayOf(Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun initStartCamera(){ + startFrontCameraButton.setOnClickListener { + useFrontCamera = true + if (hasCameraPermission()) { + startRecordingService(useFrontCamera) + } else { + requestCameraPermission() + } + } + + startBackCameraButton.setOnClickListener { + useFrontCamera = false + if (hasCameraPermission()) { + startRecordingService(useFrontCamera) + } else { + requestCameraPermission() + } + } + + stopCameraButton.setOnClickListener { + stopRecordingService() + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun startRecordingService(useFrontCamera: Boolean) { + Log.e("main", "startRecordingService") + val serviceIntent = Intent(this, RecordingService::class.java).apply { + action = RecordingService.ACTION_START_RECORDING + putExtra("useFrontCamera", useFrontCamera) + } + startForegroundService(serviceIntent) + // startService(serviceIntent) + } + + private fun stopRecordingService() { + Log.e("main", "stopRecordingService") + val serviceIntent = Intent(this, RecordingService::class.java).apply { + action = RecordingService.ACTION_STOP_RECORDING + } + stopForeground(STOP_FOREGROUND_DETACH) + stopService(serviceIntent) + } + +} diff --git a/app/src/main/java/com/urkob/wittrail_android/RecordingService.kt b/app/src/main/java/com/urkob/wittrail_android/RecordingService.kt new file mode 100644 index 0000000..8ae3180 --- /dev/null +++ b/app/src/main/java/com/urkob/wittrail_android/RecordingService.kt @@ -0,0 +1,213 @@ +package com.urkob.wittrail_android + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ContentValues +import android.content.Intent +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import android.widget.Toast +import androidx.camera.core.CameraSelector +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.MediaStoreOutputOptions +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleService +import com.google.common.util.concurrent.ListenableFuture +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + + +class RecordingService : LifecycleService() { + private lateinit var cameraProviderFuture: ListenableFuture + private val tag = "RecordingService" + private val fileNameFormat = "yyyy-MM-dd-HH-mm-ss-SSS" + private var useFrontCamera = false + private var videoCapture: VideoCapture? = null + private var recording: Recording? = null + private lateinit var cameraExecutor: ExecutorService + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + startForeground(NOTIFICATION_ID, createNotification()) + + cameraExecutor = Executors.newSingleThreadExecutor() + cameraProviderFuture = ProcessCameraProvider.getInstance(this) + + Log.e(tag, "onCreate") + cameraProviderFuture.addListener({ + // Initialize videoCapture here + val recorder = Recorder.Builder() + .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) + .build() + + videoCapture = VideoCapture.withOutput(recorder) + + // Now we can start the camera + startCamera() + }, ContextCompat.getMainExecutor(this)) + + Log.e(tag, "END onCreate") + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + "Foreground Service Channel", + NotificationManager.IMPORTANCE_DEFAULT + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(serviceChannel) + } + } + + private fun createNotification(): Notification { + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Recording Service") + .setContentText("Recording is running in the background") + .setSmallIcon(R.drawable.ic_launcher_foreground) // Replace with your own drawable resource + .setContentIntent(pendingIntent) + .setTicker("Ticker text") + .build() + } + + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + useFrontCamera = intent?.getBooleanExtra("useFrontCamera", false) ?: false + when (intent?.action) { + ACTION_START_RECORDING -> { + Log.e(tag, ACTION_START_RECORDING) + captureVideo() + } + ACTION_STOP_RECORDING -> { + Log.e(tag, ACTION_STOP_RECORDING) + + stopRecording() + // return START_NOT_STICKY + } + } + // return START_STICKY + return super.onStartCommand(intent, flags, startId) + } + +// override fun onDestroy() { +// Log.e(tag, "ON DESTROY IS CALLED SHOUlD STOP CAMERA") +// super.onDestroy() +// } + + private fun startCamera() { + Log.e(tag, "startCamera") + // Used to bind the lifecycle of cameras to the lifecycle owner + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + val cameraSelector = if (useFrontCamera) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA + try { + // Unbind use cases before rebinding + cameraProvider.unbindAll() + + // Bind use cases to camera + cameraProvider.bindToLifecycle(this, cameraSelector, videoCapture) + + } catch(exc: Exception) { + Log.e(tag, "Use case binding failed", exc) + } + } + + // Implements VideoCapture use case, including start and stop capturing. + private fun captureVideo() { + Log.e(tag, "START CAPTURE VIDEO") + val videoCapture = this.videoCapture ?: return + + val curRecording = recording + if (curRecording != null) { + Log.e(tag, "curRecording != null") + // Stop the current recording session. + curRecording.stop() + recording = null + return + } + + // create and start a new recording session + val name = SimpleDateFormat(fileNameFormat, Locale.US) + .format(System.currentTimeMillis()) + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/wittrail") + } + } + Log.e(tag, "contentValues") + val mediaStoreOutputOptions = MediaStoreOutputOptions + .Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + .setContentValues(contentValues) + .build() + Log.e(tag, "mediaStoreOutputOptions") + recording = videoCapture.output + .prepareRecording(this, mediaStoreOutputOptions) + .start(ContextCompat.getMainExecutor(this)) { recordEvent -> + when(recordEvent) { + is VideoRecordEvent.Start -> { + // TODO: should only display stop button + Log.e(tag, "START RECORDING") + } + is VideoRecordEvent.Finalize -> { + Log.e(tag, "ON FINALIZE") + if (!recordEvent.hasError()) { + Log.d(tag, "Saved URI: " + recordEvent.outputResults.outputUri) + val msg = "Video capture succeeded: " + + "${recordEvent.outputResults.outputUri}" + Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT) + .show() + Log.d(tag, msg) + } else { + Log.e(tag, "Video capture ends with error: " + + "${recordEvent.error} $recordEvent") + Log.d(tag, "Saved URI: " + recordEvent.outputResults.outputUri) + recording?.close() + recording = null + } + Log.e(tag, "RECORDING STOPPED") + } + } + } + } + companion object { + const val ACTION_START_RECORDING = "com.urkob.wittrail_android.action.START_RECORDING" + const val ACTION_STOP_RECORDING = "com.urkob.wittrail_android.action.STOP_RECORDING" + private const val CHANNEL_ID = "ForegroundServiceChannel" + private const val NOTIFICATION_ID = 1 + } + private fun stopRecording() { + Log.e(tag, "stopRecording") + + val curRecording = recording + if (curRecording != null) { + Log.e(tag, "stopRecording curRecording != null Stop the current recording session") + curRecording.stop() + recording = null + return + } + } +} + diff --git a/app/src/main/java/com/urkob/wittrail_android/RecordingService.kt.txt b/app/src/main/java/com/urkob/wittrail_android/RecordingService.kt.txt new file mode 100644 index 0000000..873b945 --- /dev/null +++ b/app/src/main/java/com/urkob/wittrail_android/RecordingService.kt.txt @@ -0,0 +1,236 @@ +package com.urkob.wittrail_android + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.IBinder +import android.provider.MediaStore +import android.util.Log +import android.widget.Toast +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.MediaStoreOutputOptions +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.google.common.util.concurrent.ListenableFuture +import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + + +class RecordingServices : Service() { + private lateinit var cameraExecutor: ExecutorService + private lateinit var cameraProviderFuture: ListenableFuture + private var recording: Recording? = null + private lateinit var videoCapture: VideoCapture + + private val notificationChannelId = "RECORDING_CHANNEL" + private val notificationId = 1 + private var videoFile: File? = null + + override fun onCreate() { + super.onCreate() + cameraExecutor = Executors.newSingleThreadExecutor() + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + private fun bindCamera() { + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + // You need to build the use cases. For example: + val previewUseCase = Preview.Builder().build() + val videoCaptureUseCase = VideoCapture.withOutput(Recorder.Builder().build()) + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(this, cameraSelector, previewUseCase, videoCaptureUseCase) + } catch (exc: Exception) { + Log.e(TAG, "Use case binding failed", exc) + } + } + + private fun startRecording() { + val mediaStoreOutputOptions = MediaStoreOutputOptions.Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + .build() + + val outputOptions = VideoCapture.OutputFileOptions.Builder(videoFile).build() + // Start recording + videoCapture.startRecording(outputOptions, cameraExecutor, object : VideoCapture.OnVideoSavedCallback { + fun onVideoSaved(outputFileResults: VideoCapture.OutputFileResults) { + val savedUri = outputFileResults.savedUri ?: Uri.fromFile(videoFile) + Log.d(TAG, "Video File Saved at $savedUri") + // Here you can handle the saved video file + } + + fun onError(videoCaptureError: Int, message: String, cause: Throwable?) { + Log.e(TAG, "Video capture error: $message", cause) + } + }) + } + + + private val captureListener = object : VideoCapture.OnVideoSavedCallback { + fun onVideoSaved(file: File) { + Log.d(TAG, "Video File: $file") + // Here you can handle the saved video file + } + fun onError(videoCaptureError: Int, message: String, cause: Throwable?) { + Log.e(TAG, "Video capture error: $message", cause) + } + } + + override fun onDestroy() { + super.onDestroy() + cameraExecutor.shutdown() + recording?.stop() // Stop the recording + } + + // Helper function to create a file + private fun createFile(baseFolder: File, format: String, extension: String) = + File(baseFolder, SimpleDateFormat(format, Locale.US) + .format(System.currentTimeMillis()) + extension) + + companion object { + private const val TAG = "RecordingService" + private const val FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS" + private const val VIDEO_EXTENSION = ".mp4" + private val outputDirectory: File by lazy { + getOutputDirectory() + } + } + + // Method to get the output directory + private fun getOutputDirectory(): File { + val mediaDir = externalMediaDirs.firstOrNull()?.let { + File(it, resources.getString(R.string.app_name)).apply { mkdirs() } + } + return if (mediaDir != null && mediaDir.exists()) + mediaDir else filesDir + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + createNotificationChannel() + startForeground(notificationId, createNotification()) + + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + val previewUseCase = Preview.Builder().build() + val videoCaptureUseCase = VideoCapture.withOutput(Recorder.Builder().build()) + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(this, cameraSelector, previewUseCase, videoCaptureUseCase) + } catch (exc: Exception) { + Log.e(TAG, "Use case binding failed", exc) + } + + cameraProviderFuture = ProcessCameraProvider.getInstance(this) + cameraProviderFuture.addListener(Runnable { + val cameraProvider = cameraProviderFuture.get() + bindCamera() + }, ContextCompat.getMainExecutor(this)) + + return START_STICKY + } + + private fun startCamera() { + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider) + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + val videoCapture = VideoCapture.withOutput(Recorder.Builder().build()) + + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + this, cameraSelector, preview) + + cameraProvider.bindToLifecycle( + this, cameraSelector, preview, imageCapture, videoCapture) + } catch (exc: Exception) { + Log.e(TAG, "Use case binding failed", exc) + } + } + + + private fun stopRecording() { + try { + Log.d("RecordingService", "Recording stopped. File saved at: ${videoFile?.absolutePath}") + // Now scan the file + videoFile?.let { file -> + Log.d("RecordingService", "Recording stopped. File saved at: ${file.absolutePath}") + scanFile(file) // Use 'let' to ensure videoFile is not null when calling scanFile + } + } catch (e: RuntimeException) { + e.printStackTrace() + Toast.makeText(this, "Recording stop failed: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + + private fun scanFile(file: File) { + MediaScannerConnection.scanFile( + this@`RecordingService.txt`, + arrayOf(file.absolutePath), + null + ) { path: String?, uri: Uri? -> + Log.d("RecordingService", "Scanned $path:") + Log.d("RecordingService", "-> Uri = $uri") + } + } + + + private fun createNotification(): Notification { + val notificationIntent = Intent(this, MainActivity::class.java) + // val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0) + + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + ) + + return NotificationCompat.Builder(this, notificationChannelId) + .setContentTitle("Recording Service") + .setContentText("Recording in progress...") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(pendingIntent) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + notificationChannelId, + "Foreground Service Channel", + NotificationManager.IMPORTANCE_DEFAULT + ) + + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(serviceChannel) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b364530 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,28 @@ + + + +