From dc89c02b9f6d04f350b75d060413e065455b713a Mon Sep 17 00:00:00 2001 From: Hardcore Sushi Date: Sun, 3 Oct 2021 14:36:06 +0200 Subject: [PATCH] Video recording --- app/CMakeLists.txt | 56 +- app/build.gradle | 14 +- app/ffmpeg/.gitignore | 2 + app/ffmpeg/build.sh | 81 + app/src/main/AndroidManifest.xml | 1 + .../sushi/hardcore/droidfs/CameraActivity.kt | 129 +- .../droidfs/explorers/ExplorerActivity.kt | 4 +- .../droidfs/video_recording/MediaMuxer.kt | 92 + .../droidfs/video_recording/SeekableWriter.kt | 7 + .../droidfs/video_recording/VideoCapture.java | 1778 +++++++++++++++++ .../video_recording/VideoCaptureConfig.java | 212 ++ app/src/main/native/libmux.c | 125 ++ app/src/main/res/drawable/icon_video.xml | 5 + .../main/res/drawable/record_video_button.xml | 17 + .../drawable/stop_recording_video_button.xml | 16 + app/src/main/res/layout/activity_camera.xml | 39 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 3 +- build.gradle | 2 +- 20 files changed, 2534 insertions(+), 53 deletions(-) create mode 100644 app/ffmpeg/.gitignore create mode 100755 app/ffmpeg/build.sh create mode 100644 app/src/main/java/sushi/hardcore/droidfs/video_recording/MediaMuxer.kt create mode 100644 app/src/main/java/sushi/hardcore/droidfs/video_recording/SeekableWriter.kt create mode 100644 app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCapture.java create mode 100644 app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCaptureConfig.java create mode 100644 app/src/main/native/libmux.c create mode 100644 app/src/main/res/drawable/icon_video.xml create mode 100644 app/src/main/res/drawable/record_video_button.xml create mode 100644 app/src/main/res/drawable/stop_recording_video_button.xml diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index eeeae9c..7ddad15 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.4.1) +cmake_minimum_required(VERSION 3.10) add_library( gocryptfs @@ -23,6 +23,56 @@ target_link_libraries( gocryptfs ) -include_directories( - ${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}/ +add_library( + avformat + STATIC + IMPORTED ) + +set_target_properties( + avformat + PROPERTIES IMPORTED_LOCATION + ${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI}/libavformat/libavformat.a +) + +add_library( + avcodec + STATIC + IMPORTED +) + +set_target_properties( + avcodec + PROPERTIES IMPORTED_LOCATION + ${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI}/libavcodec/libavcodec.a +) + +add_library( + avutil + STATIC + IMPORTED +) + +set_target_properties( + avutil + PROPERTIES IMPORTED_LOCATION + ${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI}/libavutil/libavutil.a +) + +add_library( + mux + SHARED + src/main/native/libmux.c +) + +target_link_libraries( + mux + avformat + avcodec + avutil +) + +include_directories( + ${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI} + ${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI} +) \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 8b87741..c837f20 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,8 +2,8 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' android { - compileSdkVersion 30 - buildToolsVersion "30.0.3" + compileSdkVersion 31 + buildToolsVersion "31" ndkVersion "21.4.7075529" compileOptions { @@ -57,7 +57,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.core:core-ktx:1.6.0" implementation "androidx.appcompat:appcompat:1.3.1" - implementation "androidx.constraintlayout:constraintlayout:2.1.0" + implementation "androidx.constraintlayout:constraintlayout:2.1.1" implementation "androidx.sqlite:sqlite-ktx:2.1.0" implementation "androidx.preference:preference-ktx:1.1.1" @@ -66,14 +66,16 @@ dependencies { implementation "com.github.bumptech.glide:glide:4.12.0" implementation "androidx.biometric:biometric:1.1.0" - def exoplayer_version = "2.15.0" + def exoplayer_version = "2.15.1" implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" - def camerax_v1 = "1.1.0-alpha08" + implementation "androidx.concurrent:concurrent-futures:1.1.0" + + def camerax_v1 = "1.1.0-alpha09" implementation "androidx.camera:camera-camera2:$camerax_v1" implementation "androidx.camera:camera-lifecycle:$camerax_v1" - def camerax_v2 = "1.0.0-alpha28" + def camerax_v2 = "1.0.0-alpha29" implementation "androidx.camera:camera-view:$camerax_v2" implementation "androidx.camera:camera-extensions:$camerax_v2" } diff --git a/app/ffmpeg/.gitignore b/app/ffmpeg/.gitignore new file mode 100644 index 0000000..e12f70e --- /dev/null +++ b/app/ffmpeg/.gitignore @@ -0,0 +1,2 @@ +ffmpeg +build diff --git a/app/ffmpeg/build.sh b/app/ffmpeg/build.sh new file mode 100755 index 0000000..2469964 --- /dev/null +++ b/app/ffmpeg/build.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +if [ -z ${ANDROID_NDK_HOME+x} ]; then + echo "Error: \$ANDROID_NDK_HOME is not defined." +else + compile_for_arch() { + case $1 in + "x86_64") + CFN="x86_64-linux-android21-clang" + ARCH="x86_64" + ;; + "x86") + CFN="i686-linux-android21-clang" + ARCH="i686" + ;; + "arm64-v8a") + CFN="aarch64-linux-android21-clang" + ARCH="aarch64" + ;; + "armeabi-v7a") + CFN="armv7a-linux-androideabi19-clang" + ARCH="arm" + ;; + esac + cd ffmpeg && make clean && + ./configure \ + --cc="$CFN" \ + --cxx="$CFN++" \ + --arch="$ARCH" \ + --target-os=android \ + --enable-cross-compile \ + --enable-version3 \ + --disable-programs \ + --disable-bsfs \ + --disable-parsers \ + --disable-demuxers \ + --disable-decoders \ + --disable-avdevice \ + --disable-swresample \ + --disable-swscale \ + --disable-postproc \ + --disable-avfilter \ + --disable-network \ + --disable-doc \ + --disable-htmlpages \ + --disable-manpages \ + --disable-podpages \ + --disable-txtpages \ + --disable-sndio \ + --disable-schannel \ + --disable-securetransport \ + --disable-xlib \ + --disable-zlib \ + --disable-cuvid \ + --disable-nvenc \ + --disable-vdpau \ + --disable-videotoolbox \ + --disable-audiotoolbox \ + --disable-appkit \ + --disable-alsa \ + --disable-debug \ + >/dev/null && + make -j 8 >/dev/null && + mkdir -p ../build/$1/libavformat ../build/$1/libavcodec ../build/$1/libavutil && + cp libavformat/*.h libavformat/libavformat.a ../build/$1/libavformat && + cp libavcodec/*.h libavcodec/libavcodec.a ../build/$1/libavcodec && + cp libavutil/*.h libavutil/libavutil.a ../build/$1/libavutil && + cd .. + } + + export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH + if [ "$#" -eq 1 ]; then + compile_for_arch $1 + else + declare -a ABIs=("x86_64" "x86" "arm64-v8a" "armeabi-v7a") + for abi in ${ABIs[@]}; do + echo "Compiling for $abi..." + compile_for_arch $abi + done + fi +fi diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6e1accd..cd95198 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + private lateinit var gocryptfsVolume: GocryptfsVolume private lateinit var outputDirectory: String - private lateinit var fileName: String private var isFinishingIntentionally = false + private var isAskingPermissions = false private var permissionsGranted = false private lateinit var executor: Executor private lateinit var cameraProvider: ProcessCameraProvider private lateinit var extensionsManager: ExtensionsManager + private lateinit var cameraSelector: CameraSelector private val cameraPreview = Preview.Builder().build() private var imageCapture: ImageCapture? = null + private var videoCapture: VideoCapture? = null private var camera: Camera? = null private var resolutions: List? = null private var currentResolutionIndex: Int = 0 private var captureMode = ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY private var isBackCamera = true + private var isInVideoMode = false + private var isRecording = false private lateinit var binding: ActivityCameraBinding override fun onCreate(savedInstanceState: Bundle?) { @@ -88,6 +98,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){ permissionsGranted = true } else { + isAskingPermissions = true requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE) } } else { @@ -174,10 +185,6 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) dialog.show() } - binding.imageClose.setOnClickListener { - isFinishingIntentionally = true - finish() - } binding.imageFlash.setOnClickListener { binding.imageFlash.setImageResource(when (imageCapture?.flashMode) { ImageCapture.FLASH_MODE_AUTO -> { @@ -194,6 +201,22 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { } }) } + binding.imageModeSwitch.setOnClickListener { + isInVideoMode = !isInVideoMode + if (isInVideoMode) { + binding.recordVideoButton.visibility = View.VISIBLE + binding.takePhotoButton.visibility = View.GONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + isAskingPermissions = true + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), AUDIO_PERMISSION_REQUEST_CODE) + } + } + } else { + binding.recordVideoButton.visibility = View.GONE + binding.takePhotoButton.visibility = View.VISIBLE + } + } binding.imageCameraSwitch.setOnClickListener { isBackCamera = if (isBackCamera) { binding.imageCameraSwitch.setImageResource(R.drawable.icon_camera_back) @@ -205,7 +228,8 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { setupCamera() } binding.takePhotoButton.onClick = ::onClickTakePhoto - orientedIcons = listOf(binding.imageRatio, binding.imageTimer, binding.imageClose, binding.imageFlash, binding.imageCameraSwitch) + binding.recordVideoButton.setOnClickListener { onClickRecordVideo() } + orientedIcons = listOf(binding.imageRatio, binding.imageTimer, binding.imageCaptureMode, binding.imageFlash, binding.imageModeSwitch, binding.imageCameraSwitch) sensorOrientationListener = SensorOrientationListener(this) val scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener(){ @@ -232,11 +256,13 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { } } + @RequiresApi(Build.VERSION_CODES.M) override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - when (requestCode) { - CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults.size == 1) { - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + isAskingPermissions = false + if (grantResults.size == 1) { + when (requestCode) { + CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { permissionsGranted = true setupCamera() } else { @@ -249,6 +275,12 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { finish() }.show() } + AUDIO_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + if (videoCapture != null) { + cameraProvider.unbind(videoCapture) + camera = cameraProvider.bindToLifecycle(this, cameraSelector, videoCapture) + } + } } } } @@ -266,6 +298,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { (binding.cameraPreview.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.CENTER_IN_PARENT) } + @SuppressLint("RestrictedApi") private fun setupCamera(resolution: Size? = null){ if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) { imageCapture = ImageCapture.Builder() @@ -278,13 +311,19 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { } .build() - var cameraSelector = if (isBackCamera){ CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA } + videoCapture = VideoCapture.Builder().apply { + resolution?.let { + setTargetResolution(it) + } + }.build() + + cameraSelector = if (isBackCamera){ CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA } if (extensionsManager.isExtensionAvailable(cameraProvider, cameraSelector, ExtensionMode.HDR)) { cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraProvider, cameraSelector, ExtensionMode.HDR) } cameraProvider.unbindAll() - camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture) + camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture, videoCapture) adaptPreviewSize(resolution ?: imageCapture!!.attachedSurfaceResolution!!.swap()) @@ -299,15 +338,15 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { } } - private fun takePhoto() { + private fun takePhoto(outputPath: String) { val imageCapture = imageCapture ?: return val outputBuff = ByteArrayOutputStream() val outputOptions = ImageCapture.OutputFileOptions.Builder(outputBuff).build() imageCapture.takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { binding.takePhotoButton.onPhotoTaken() - if (gocryptfsVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), PathUtils.pathJoin(outputDirectory, fileName))){ - Toast.makeText(applicationContext, getString(R.string.picture_save_success, fileName), Toast.LENGTH_SHORT).show() + if (gocryptfsVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), outputPath)){ + Toast.makeText(applicationContext, getString(R.string.picture_save_success, outputPath), Toast.LENGTH_SHORT).show() } else { ColoredAlertDialogBuilder(this@CameraActivity) .setTitle(R.string.error) @@ -327,11 +366,17 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { }) } - private fun onClickTakePhoto() { - val baseName = "IMG_"+dateFormat.format(Date())+"_" + private fun getOutputPath(isVideo: Boolean): String { + val baseName = if (isVideo) {"VID"} else {"IMG"}+'_'+dateFormat.format(Date())+'_' + var fileName: String do { - fileName = baseName+(random.nextInt(fileNameRandomMax-fileNameRandomMin)+fileNameRandomMin)+".jpg" + fileName = baseName+(random.nextInt(fileNameRandomMax-fileNameRandomMin)+fileNameRandomMin)+'.'+ if (isVideo) {"mp4"} else {"jpg"} } while (gocryptfsVolume.pathExists(fileName)) + return PathUtils.pathJoin(outputDirectory, fileName) + } + + private fun onClickTakePhoto() { + val path = getOutputPath(false) if (timerDuration > 0){ binding.textTimer.visibility = View.VISIBLE Thread{ @@ -340,12 +385,47 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { Thread.sleep(1000) } runOnUiThread { - takePhoto() + takePhoto(path) binding.textTimer.visibility = View.GONE } }.start() } else { - takePhoto() + takePhoto(path) + } + } + + @SuppressLint("MissingPermission") + private fun onClickRecordVideo() { + isRecording = if (isRecording) { + videoCapture?.stopRecording() + false + } else { + val path = getOutputPath(true) + val handleId = gocryptfsVolume.openWriteMode(path) + videoCapture?.startRecording(VideoCapture.OutputFileOptions(object : SeekableWriter { + var offset = 0L + override fun write(byteArray: ByteArray) { + offset += gocryptfsVolume.writeFile(handleId, offset, byteArray, byteArray.size) + } + override fun seek(offset: Long) { + this.offset = offset + } + override fun close() { + gocryptfsVolume.closeFile(handleId) + } + }), executor, object : VideoCapture.OnVideoSavedCallback { + override fun onVideoSaved() { + Toast.makeText(applicationContext, getString(R.string.video_save_success, path), Toast.LENGTH_SHORT).show() + binding.recordVideoButton.setImageResource(R.drawable.record_video_button) + } + override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) { + Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() + cause?.printStackTrace() + binding.recordVideoButton.setImageResource(R.drawable.record_video_button) + } + }) + binding.recordVideoButton.setImageResource(R.drawable.stop_recording_video_button) + true } } @@ -367,10 +447,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { override fun onPause() { super.onPause() sensorOrientationListener.remove(this) - if ( - (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) //not asking for permission - && !usf_keep_open - ){ + if (!isAskingPermissions && !usf_keep_open) { finish() } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt index a62fc1c..5dc71aa 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/explorers/ExplorerActivity.kt @@ -171,7 +171,7 @@ class ExplorerActivity : BaseExplorerActivity() { listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder), listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown), listOf("createFolder", R.string.mkdir, R.drawable.icon_create_new_folder), - listOf("takePhoto", R.string.take_photo, R.drawable.icon_camera) + listOf("camera", R.string.camera, R.drawable.icon_camera) ) ColoredAlertDialogBuilder(this) .setSingleChoiceItems(adapter, -1){ thisDialog, which -> @@ -215,7 +215,7 @@ class ExplorerActivity : BaseExplorerActivity() { "createFolder" -> { openDialogCreateFolder() } - "takePhoto" -> { + "camera" -> { val intent = Intent(this, CameraActivity::class.java) intent.putExtra("path", currentDirectoryPath) intent.putExtra("sessionID", gocryptfsVolume.sessionID) diff --git a/app/src/main/java/sushi/hardcore/droidfs/video_recording/MediaMuxer.kt b/app/src/main/java/sushi/hardcore/droidfs/video_recording/MediaMuxer.kt new file mode 100644 index 0000000..b82efc7 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/video_recording/MediaMuxer.kt @@ -0,0 +1,92 @@ +package sushi.hardcore.droidfs.video_recording + +import android.media.MediaCodec +import android.media.MediaFormat +import java.nio.ByteBuffer + +class MediaMuxer(val writer: SeekableWriter) { + external fun allocContext(): Long + external fun addVideoTrack(formatContext: Long, bitrate: Int, width: Int, height: Int): Int + external fun addAudioTrack(formatContext: Long, bitrate: Int, sampleRate: Int, channelCount: Int): Int + external fun writeHeaders(formatContext: Long): Int + external fun writePacket(formatContext: Long, buffer: ByteArray, pts: Long, streamIndex: Int, isKeyFrame: Boolean) + external fun writeTrailer(formatContext: Long) + external fun release(formatContext: Long) + + companion object { + const val VIDEO_TRACK_INDEX = 0 + const val AUDIO_TRACK_INDEX = 1 + } + + var formatContext: Long? + + var realVideoTrackIndex: Int? = null + var audioFrameSize: Int? = null + var firstPts: Long? = null + private var audioPts = 0L + + init { + System.loadLibrary("mux") + formatContext = allocContext() + } + + fun writeSampleData(trackIndex: Int, buffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) { + val byteArray = ByteArray(bufferInfo.size) + buffer.get(byteArray) + if (firstPts == null) { + firstPts = bufferInfo.presentationTimeUs + } + if (trackIndex == AUDIO_TRACK_INDEX) { + writePacket(formatContext!!, byteArray, audioPts, -1, false) + audioPts += audioFrameSize!! + } else { + writePacket( + formatContext!!, byteArray, bufferInfo.presentationTimeUs - firstPts!!, realVideoTrackIndex!!, + bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0 + ) + } + } + + fun addTrack(format: MediaFormat): Int { + val mime = format.getString("mime")!!.split('/') + val bitrate = format.getInteger("bitrate") + return if (mime[0] == "audio") { + audioFrameSize = addAudioTrack( + formatContext!!, + bitrate, + format.getInteger("sample-rate"), + format.getInteger("channel-count") + ) + AUDIO_TRACK_INDEX + } else { + realVideoTrackIndex = addVideoTrack( + formatContext!!, + bitrate, + format.getInteger("width"), + format.getInteger("height") + ) + VIDEO_TRACK_INDEX + } + } + + fun start() { + writeHeaders(formatContext!!) + } + fun stop() { + writeTrailer(formatContext!!) + } + fun release() { + writer.close() + release(formatContext!!) + firstPts = null + audioPts = 0 + formatContext = null + } + + fun writePacket(buff: ByteArray) { + writer.write(buff) + } + fun seek(offset: Long) { + writer.seek(offset) + } +} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/video_recording/SeekableWriter.kt b/app/src/main/java/sushi/hardcore/droidfs/video_recording/SeekableWriter.kt new file mode 100644 index 0000000..e0b1c4f --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/video_recording/SeekableWriter.kt @@ -0,0 +1,7 @@ +package sushi.hardcore.droidfs.video_recording + +interface SeekableWriter { + fun write(byteArray: ByteArray) + fun seek(offset: Long) + fun close() +} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCapture.java b/app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCapture.java new file mode 100644 index 0000000..d255f3d --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCapture.java @@ -0,0 +1,1778 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sushi.hardcore.droidfs.video_recording; + +import static androidx.camera.core.impl.ImageOutputConfig.OPTION_DEFAULT_RESOLUTION; +import static androidx.camera.core.impl.ImageOutputConfig.OPTION_MAX_RESOLUTION; +import static androidx.camera.core.impl.ImageOutputConfig.OPTION_SUPPORTED_RESOLUTIONS; +import static androidx.camera.core.impl.ImageOutputConfig.OPTION_TARGET_ASPECT_RATIO; +import static androidx.camera.core.impl.ImageOutputConfig.OPTION_TARGET_RESOLUTION; +import static androidx.camera.core.impl.ImageOutputConfig.OPTION_TARGET_ROTATION; +import static androidx.camera.core.impl.UseCaseConfig.OPTION_ATTACHED_USE_CASES_UPDATE_LISTENER; +import static androidx.camera.core.impl.UseCaseConfig.OPTION_CAMERA_SELECTOR; +import static androidx.camera.core.impl.UseCaseConfig.OPTION_CAPTURE_CONFIG_UNPACKER; +import static androidx.camera.core.impl.UseCaseConfig.OPTION_DEFAULT_CAPTURE_CONFIG; +import static androidx.camera.core.impl.UseCaseConfig.OPTION_DEFAULT_SESSION_CONFIG; +import static androidx.camera.core.impl.UseCaseConfig.OPTION_SESSION_CONFIG_UNPACKER; +import static androidx.camera.core.impl.UseCaseConfig.OPTION_SURFACE_OCCUPANCY_PRIORITY; +import static androidx.camera.core.impl.VideoCaptureConfig.OPTION_AUDIO_BIT_RATE; +import static androidx.camera.core.impl.VideoCaptureConfig.OPTION_AUDIO_CHANNEL_COUNT; +import static androidx.camera.core.impl.VideoCaptureConfig.OPTION_AUDIO_MIN_BUFFER_SIZE; +import static androidx.camera.core.impl.VideoCaptureConfig.OPTION_AUDIO_SAMPLE_RATE; +import static androidx.camera.core.impl.VideoCaptureConfig.OPTION_BIT_RATE; +import static androidx.camera.core.impl.VideoCaptureConfig.OPTION_INTRA_FRAME_INTERVAL; +import static androidx.camera.core.impl.VideoCaptureConfig.OPTION_VIDEO_FRAME_RATE; +import static androidx.camera.core.internal.TargetConfig.OPTION_TARGET_CLASS; +import static androidx.camera.core.internal.TargetConfig.OPTION_TARGET_NAME; +import static androidx.camera.core.internal.ThreadConfig.OPTION_BACKGROUND_EXECUTOR; +import static androidx.camera.core.internal.UseCaseEventConfig.OPTION_USE_CASE_EVENT_CALLBACK; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.CamcorderProfile; +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodecInfo; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaFormat; +import android.media.MediaRecorder.AudioSource; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.util.Pair; +import android.util.Size; +import android.view.Display; +import android.view.Surface; + +import androidx.annotation.DoNotInline; +import androidx.annotation.GuardedBy; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; +import androidx.annotation.RestrictTo; +import androidx.annotation.RestrictTo.Scope; +import androidx.annotation.UiThread; +import androidx.annotation.VisibleForTesting; +import androidx.camera.core.AspectRatio; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.CameraXThreads; +import androidx.camera.core.Logger; +import androidx.camera.core.UseCase; +import androidx.camera.core.impl.CameraInternal; +import androidx.camera.core.impl.CaptureConfig; +import androidx.camera.core.impl.Config; +import androidx.camera.core.impl.ConfigProvider; +import androidx.camera.core.impl.DeferrableSurface; +import androidx.camera.core.impl.ImageOutputConfig; +import androidx.camera.core.impl.ImageOutputConfig.RotationValue; +import androidx.camera.core.impl.ImmediateSurface; +import androidx.camera.core.impl.MutableConfig; +import androidx.camera.core.impl.MutableOptionsBundle; +import androidx.camera.core.impl.OptionsBundle; +import androidx.camera.core.impl.SessionConfig; +import androidx.camera.core.impl.UseCaseConfig; +import androidx.camera.core.impl.UseCaseConfigFactory; +import androidx.camera.core.impl.utils.executor.CameraXExecutors; +import androidx.camera.core.internal.ThreadConfig; +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.concurrent.futures.CallbackToFutureAdapter.Completer; +import androidx.core.util.Consumer; +import androidx.core.util.Preconditions; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A use case for taking a video. + * + *

This class is designed for simple video capturing. It gives basic configuration of the + * recorded video such as resolution and file format. + * + * @hide + */ +@SuppressLint("RestrictedApi") +public final class VideoCapture extends UseCase { + + //////////////////////////////////////////////////////////////////////////////////////////// + // [UseCase lifetime constant] - Stays constant for the lifetime of the UseCase. Which means + // they could be created in the constructor. + //////////////////////////////////////////////////////////////////////////////////////////// + + /** + * An unknown error occurred. + * + *

See message parameter in onError callback or log for more details. + */ + public static final int ERROR_UNKNOWN = 0; + /** + * An error occurred with encoder state, either when trying to change state or when an + * unexpected state change occurred. + */ + public static final int ERROR_ENCODER = 1; + /** An error with muxer state such as during creation or when stopping. */ + public static final int ERROR_MUXER = 2; + /** + * An error indicating start recording was called when video recording is still in progress. + */ + public static final int ERROR_RECORDING_IN_PROGRESS = 3; + /** + * An error indicating the file saving operations. + */ + public static final int ERROR_FILE_IO = 4; + /** + * An error indicating this VideoCapture is not bound to a camera. + */ + public static final int ERROR_INVALID_CAMERA = 5; + /** + * An error indicating the video file is too short. + *

The output file will be deleted if the OutputFileOptions is backed by File or uri. + */ + public static final int ERROR_RECORDING_TOO_SHORT = 6; + + /** + * Provides a static configuration with implementation-agnostic options. + * + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + public static final Defaults DEFAULT_CONFIG = new Defaults(); + private static final String TAG = "VideoCapture"; + /** Amount of time to wait for dequeuing a buffer from the videoEncoder. */ + private static final int DEQUE_TIMEOUT_USEC = 10000; + /** Android preferred mime type for AVC video. */ + private static final String VIDEO_MIME_TYPE = "video/avc"; + private static final String AUDIO_MIME_TYPE = "audio/mp4a-latm"; + /** Camcorder profiles quality list */ + private static final int[] CamcorderQuality = { + CamcorderProfile.QUALITY_2160P, + CamcorderProfile.QUALITY_1080P, + CamcorderProfile.QUALITY_720P, + CamcorderProfile.QUALITY_480P + }; + + private final BufferInfo mVideoBufferInfo = new BufferInfo(); + private final Object mMuxerLock = new Object(); + private final AtomicBoolean mEndOfVideoStreamSignal = new AtomicBoolean(true); + private final AtomicBoolean mEndOfAudioStreamSignal = new AtomicBoolean(true); + private final AtomicBoolean mEndOfAudioVideoSignal = new AtomicBoolean(true); + private final BufferInfo mAudioBufferInfo = new BufferInfo(); + /** For record the first sample written time. */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + public final AtomicBoolean mIsFirstVideoKeyFrameWrite = new AtomicBoolean(false); + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + public final AtomicBoolean mIsFirstAudioSampleWrite = new AtomicBoolean(false); + + //////////////////////////////////////////////////////////////////////////////////////////// + // [UseCase attached constant] - Is only valid when the UseCase is attached to a camera. + //////////////////////////////////////////////////////////////////////////////////////////// + + /** Thread on which all encoding occurs. */ + private HandlerThread mVideoHandlerThread; + private Handler mVideoHandler; + /** Thread on which audio encoding occurs. */ + private HandlerThread mAudioHandlerThread; + private Handler mAudioHandler; + + @NonNull + MediaCodec mVideoEncoder; + @NonNull + private MediaCodec mAudioEncoder; + @Nullable + private ListenableFuture mRecordingFuture = null; + @NonNull + private SessionConfig.Builder mSessionConfigBuilder = new SessionConfig.Builder(); + + //////////////////////////////////////////////////////////////////////////////////////////// + // [UseCase attached dynamic] - Can change but is only available when the UseCase is attached. + //////////////////////////////////////////////////////////////////////////////////////////// + + /** The muxer that writes the encoding data to file. */ + @GuardedBy("mMuxerLock") + private MediaMuxer mMuxer; + private final AtomicBoolean mMuxerStarted = new AtomicBoolean(false); + /** The index of the video track used by the muxer. */ + @GuardedBy("mMuxerLock") + private int mVideoTrackIndex; + /** The index of the audio track used by the muxer. */ + @GuardedBy("mMuxerLock") + private int mAudioTrackIndex; + /** Surface the camera writes to, which the videoEncoder uses as input. */ + Surface mCameraSurface; + + /** audio raw data */ + @Nullable + private volatile AudioRecord mAudioRecorder; + private volatile int mAudioBufferSize; + private volatile boolean mIsRecording = false; + private int mAudioChannelCount; + private int mAudioSampleRate; + private int mAudioBitRate; + private DeferrableSurface mDeferrableSurface; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + private volatile ParcelFileDescriptor mParcelFileDescriptor; + private final AtomicBoolean mIsAudioEnabled = new AtomicBoolean(true); + + private VideoEncoderInitStatus mVideoEncoderInitStatus = + VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_UNINITIALIZED; + @Nullable + private Throwable mVideoEncoderErrorMessage; + + /** + * Creates a new video capture use case from the given configuration. + * + * @param config for this use case instance + */ + VideoCapture(@NonNull VideoCaptureConfig config) { + super(config); + } + + /** Creates a {@link MediaFormat} using parameters from the configuration */ + private static MediaFormat createVideoMediaFormat(VideoCaptureConfig config, Size resolution) { + MediaFormat format = + MediaFormat.createVideoFormat( + VIDEO_MIME_TYPE, resolution.getWidth(), resolution.getHeight()); + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatSurface); + format.setInteger(MediaFormat.KEY_BIT_RATE, config.getBitRate()); + format.setInteger(MediaFormat.KEY_FRAME_RATE, config.getVideoFrameRate()); + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, config.getIFrameInterval()); + + return format; + } + + /** + * {@inheritDoc} + * + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @Nullable + public UseCaseConfig getDefaultConfig(boolean applyDefaultConfig, + @NonNull UseCaseConfigFactory factory) { + Config captureConfig = factory.getConfig(UseCaseConfigFactory.CaptureType.VIDEO_CAPTURE); + + if (applyDefaultConfig) { + captureConfig = Config.mergeConfigs(captureConfig, DEFAULT_CONFIG.getConfig()); + } + + return captureConfig == null ? null : + getUseCaseConfigBuilder(captureConfig).getUseCaseConfig(); + } + + /** + * {@inheritDoc} + * + * @hide + */ + @SuppressWarnings("WrongConstant") + @Override + @RestrictTo(Scope.LIBRARY_GROUP) + public void onAttached() { + mVideoHandlerThread = new HandlerThread(CameraXThreads.TAG + "video encoding thread"); + mAudioHandlerThread = new HandlerThread(CameraXThreads.TAG + "audio encoding thread"); + + // video thread start + mVideoHandlerThread.start(); + mVideoHandler = new Handler(mVideoHandlerThread.getLooper()); + + // audio thread start + mAudioHandlerThread.start(); + mAudioHandler = new Handler(mAudioHandlerThread.getLooper()); + } + + /** + * {@inheritDoc} + * + * @hide + */ + @Override + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + protected Size onSuggestedResolutionUpdated(@NonNull Size suggestedResolution) { + if (mCameraSurface != null) { + mVideoEncoder.stop(); + mVideoEncoder.release(); + mAudioEncoder.stop(); + mAudioEncoder.release(); + releaseCameraSurface(false); + } + + try { + mVideoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME_TYPE); + mAudioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME_TYPE); + } catch (IOException e) { + throw new IllegalStateException("Unable to create MediaCodec due to: " + e.getCause()); + } + + setupEncoder(getCameraId(), suggestedResolution); + // VideoCapture has to be active to apply SessionConfig's template type. + notifyActive(); + return suggestedResolution; + } + + /** + * Starts recording video, which continues until {@link VideoCapture#stopRecording()} is + * called. + * + *

StartRecording() is asynchronous. User needs to check if any error occurs by setting the + * {@link OnVideoSavedCallback#onError(int, String, Throwable)}. + * + * @param outputFileOptions Location to save the video capture + * @param executor The executor in which the callback methods will be run. + * @param callback Callback for when the recorded video saving completion or failure. + */ + @SuppressWarnings("ObjectToString") + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + public void startRecording( + @NonNull OutputFileOptions outputFileOptions, @NonNull Executor executor, + @NonNull OnVideoSavedCallback callback) { + if (Looper.getMainLooper() != Looper.myLooper()) { + CameraXExecutors.mainThreadExecutor().execute(() -> startRecording(outputFileOptions, + executor, callback)); + return; + } + Logger.i(TAG, "startRecording"); + mIsFirstVideoKeyFrameWrite.set(false); + mIsFirstAudioSampleWrite.set(false); + + OnVideoSavedCallback postListener = new VideoSavedListenerWrapper(executor, callback); + + CameraInternal attachedCamera = getCamera(); + if (attachedCamera == null) { + // Not bound. Notify callback. + postListener.onError(ERROR_INVALID_CAMERA, + "Not bound to a Camera [" + VideoCapture.this + "]", null); + return; + } + + // Check video encoder initialization status, if there is any error happened + // return error callback directly. + if (mVideoEncoderInitStatus + == VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_INSUFFICIENT_RESOURCE + || mVideoEncoderInitStatus + == VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_INITIALIZED_FAILED + || mVideoEncoderInitStatus + == VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_RESOURCE_RECLAIMED) { + postListener.onError(ERROR_ENCODER, "Video encoder initialization failed before start" + + " recording ", mVideoEncoderErrorMessage); + return; + } + + if (!mEndOfAudioVideoSignal.get()) { + postListener.onError( + ERROR_RECORDING_IN_PROGRESS, "It is still in video recording!", + null); + return; + } + + if (mIsAudioEnabled.get()) { + try { + // Audio input start + if (mAudioRecorder.getState() == AudioRecord.STATE_INITIALIZED) { + mAudioRecorder.startRecording(); + } + } catch (IllegalStateException e) { + // Disable the audio if the audio input cannot start. And Continue the recording + // without audio. + Logger.i(TAG, + "AudioRecorder cannot start recording, disable audio." + e.getMessage()); + mIsAudioEnabled.set(false); + releaseAudioInputResource(); + } + + // Gets the AudioRecorder's state + if (mAudioRecorder.getRecordingState() != AudioRecord.RECORDSTATE_RECORDING) { + Logger.i(TAG, + "AudioRecorder startRecording failed - incorrect state: " + + mAudioRecorder.getRecordingState()); + mIsAudioEnabled.set(false); + releaseAudioInputResource(); + } + } + + AtomicReference> recordingCompleterRef = new AtomicReference<>(); + mRecordingFuture = CallbackToFutureAdapter.getFuture( + completer -> { + recordingCompleterRef.set(completer); + return "startRecording"; + }); + Completer recordingCompleter = + Preconditions.checkNotNull(recordingCompleterRef.get()); + + mRecordingFuture.addListener(() -> { + mRecordingFuture = null; + // Do the setup of the videoEncoder at the end of video recording instead of at the + // start of recording because it requires attaching a new Surface. This causes a + // glitch so we don't want that to incur latency at the start of capture. + if (getCamera() != null) { + // Ensure the use case is bound. Asynchronous stopping procedure may occur after + // the use case is unbound, i.e. after onDetached(). + setupEncoder(getCameraId(), getAttachedSurfaceResolution()); + notifyReset(); + } + }, CameraXExecutors.mainThreadExecutor()); + + try { + // video encoder start + Logger.i(TAG, "videoEncoder start"); + mVideoEncoder.start(); + + // audio encoder start + if (mIsAudioEnabled.get()) { + Logger.i(TAG, "audioEncoder start"); + mAudioEncoder.start(); + } + } catch (IllegalStateException e) { + recordingCompleter.set(null); + postListener.onError(ERROR_ENCODER, "Audio/Video encoder start fail", e); + return; + } + + synchronized (mMuxerLock) { + mMuxer = new MediaMuxer(outputFileOptions.mWriter); + } + + mEndOfVideoStreamSignal.set(false); + mEndOfAudioStreamSignal.set(false); + mEndOfAudioVideoSignal.set(false); + mIsRecording = true; + + // Attach Surface to repeating request. + mSessionConfigBuilder.clearSurfaces(); + mSessionConfigBuilder.addSurface(mDeferrableSurface); + updateSessionConfig(mSessionConfigBuilder.build()); + notifyUpdated(); + + if (mIsAudioEnabled.get()) { + mAudioHandler.post(() -> audioEncode(postListener)); + } + + String cameraId = getCameraId(); + Size resolution = getAttachedSurfaceResolution(); + mVideoHandler.post( + () -> { + boolean errorOccurred = videoEncode(postListener, cameraId, resolution, + outputFileOptions); + if (!errorOccurred) { + postListener.onVideoSaved(); + } + recordingCompleter.set(null); + }); + } + + /** + * Stops recording video, this must be called after {@link + * VideoCapture#startRecording(OutputFileOptions, Executor, OnVideoSavedCallback)} is + * called. + * + *

stopRecording() is asynchronous API. User need to check if {@link + * OnVideoSavedCallback#onVideoSaved()} or + * {@link OnVideoSavedCallback#onError(int, String, Throwable)} be called + * before startRecording. + */ + public void stopRecording() { + if (Looper.getMainLooper() != Looper.myLooper()) { + CameraXExecutors.mainThreadExecutor().execute(() -> stopRecording()); + return; + } + Logger.i(TAG, "stopRecording"); + + mSessionConfigBuilder.clearSurfaces(); + mSessionConfigBuilder.addNonRepeatingSurface(mDeferrableSurface); + updateSessionConfig(mSessionConfigBuilder.build()); + notifyUpdated(); + + if (mIsRecording) { + if (mIsAudioEnabled.get()) { + // Stop audio encoder thread, and wait video encoder and muxer stop. + mEndOfAudioStreamSignal.set(true); + } else { + // Audio is disabled, stop video encoder thread directly. + mEndOfVideoStreamSignal.set(true); + } + } + } + + /** + * {@inheritDoc} + * + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + public void onDetached() { + stopRecording(); + + if (mRecordingFuture != null) { + mRecordingFuture.addListener(() -> releaseResources(), + CameraXExecutors.mainThreadExecutor()); + } else { + releaseResources(); + } + } + + private void releaseResources() { + mVideoHandlerThread.quitSafely(); + + // audio encoder release + releaseAudioInputResource(); + + if (mCameraSurface != null) { + releaseCameraSurface(true); + } + } + + private void releaseAudioInputResource() { + mAudioHandlerThread.quitSafely(); + if (mAudioEncoder != null) { + mAudioEncoder.release(); + mAudioEncoder = null; + } + + if (mAudioRecorder != null) { + mAudioRecorder.release(); + mAudioRecorder = null; + } + } + + /** + * {@inheritDoc} + * + * @hide + */ + @NonNull + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + public UseCaseConfig.Builder getUseCaseConfigBuilder(@NonNull Config config) { + return Builder.fromConfig(config); + } + + /** + * {@inheritDoc} + * + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @UiThread + @Override + public void onStateDetached() { + stopRecording(); + } + + @UiThread + private void releaseCameraSurface(final boolean releaseVideoEncoder) { + if (mDeferrableSurface == null) { + return; + } + + final MediaCodec videoEncoder = mVideoEncoder; + + // Calling close should allow termination future to complete and close the surface with + // the listener that was added after constructing the DeferrableSurface. + mDeferrableSurface.close(); + mDeferrableSurface.getTerminationFuture().addListener( + () -> { + if (releaseVideoEncoder && videoEncoder != null) { + videoEncoder.release(); + } + }, CameraXExecutors.mainThreadExecutor()); + + if (releaseVideoEncoder) { + mVideoEncoder = null; + } + mCameraSurface = null; + mDeferrableSurface = null; + } + + /** + * Sets the desired rotation of the output video. + * + *

In most cases this should be set to the current rotation returned by {@link + * Display#getRotation()}. + * + * @param rotation Desired rotation of the output video. + */ + public void setTargetRotation(@RotationValue int rotation) { + setTargetRotationInternal(rotation); + } + + /** + * Setup the {@link MediaCodec} for encoding video from a camera {@link Surface} and encoding + * audio from selected audio source. + */ + @UiThread + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + void setupEncoder(@NonNull String cameraId, @NonNull Size resolution) { + VideoCaptureConfig config = (VideoCaptureConfig) getCurrentConfig(); + + // video encoder setup + mVideoEncoder.reset(); + mVideoEncoderInitStatus = VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_UNINITIALIZED; + + // Configures a Video encoder, if there is any exception, will abort follow up actions + try { + mVideoEncoder.configure( + createVideoMediaFormat(config, resolution), /*surface*/ + null, /*crypto*/ + null, + MediaCodec.CONFIGURE_FLAG_ENCODE); + } catch (MediaCodec.CodecException e) { + int errorCode = 0; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + errorCode = Api23Impl.getCodecExceptionErrorCode(e); + String diagnosticInfo = e.getDiagnosticInfo(); + if (errorCode == MediaCodec.CodecException.ERROR_INSUFFICIENT_RESOURCE) { + Logger.i(TAG, + "CodecException: code: " + errorCode + " diagnostic: " + + diagnosticInfo); + mVideoEncoderInitStatus = + VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_INSUFFICIENT_RESOURCE; + } else if (errorCode == MediaCodec.CodecException.ERROR_RECLAIMED) { + Logger.i(TAG, + "CodecException: code: " + errorCode + " diagnostic: " + + diagnosticInfo); + mVideoEncoderInitStatus = + VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_RESOURCE_RECLAIMED; + } + } else { + mVideoEncoderInitStatus = + VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_INITIALIZED_FAILED; + } + mVideoEncoderErrorMessage = e; + return; + } catch (IllegalArgumentException | IllegalStateException e) { + mVideoEncoderInitStatus = + VideoEncoderInitStatus.VIDEO_ENCODER_INIT_STATUS_INITIALIZED_FAILED; + mVideoEncoderErrorMessage = e; + return; + } + + if (mCameraSurface != null) { + releaseCameraSurface(false); + } + Surface cameraSurface = mVideoEncoder.createInputSurface(); + mCameraSurface = cameraSurface; + + mSessionConfigBuilder = SessionConfig.Builder.createFrom(config); + + if (mDeferrableSurface != null) { + mDeferrableSurface.close(); + } + mDeferrableSurface = new ImmediateSurface(mCameraSurface, resolution, getImageFormat()); + mDeferrableSurface.getTerminationFuture().addListener( + cameraSurface::release, CameraXExecutors.mainThreadExecutor() + ); + + mSessionConfigBuilder.addNonRepeatingSurface(mDeferrableSurface); + + mSessionConfigBuilder.addErrorListener(new SessionConfig.ErrorListener() { + @Override + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + public void onError(@NonNull SessionConfig sessionConfig, + @NonNull SessionConfig.SessionError error) { + // Ensure the attached camera has not changed before calling setupEncoder. + // TODO(b/143915543): Ensure this never gets called by a camera that is not attached + // to this use case so we don't need to do this check. + if (isCurrentCamera(cameraId)) { + // Only reset the pipeline when the bound camera is the same. + setupEncoder(cameraId, resolution); + notifyReset(); + } + } + }); + + updateSessionConfig(mSessionConfigBuilder.build()); + + // audio encoder setup + // reset audio inout flag + mIsAudioEnabled.set(true); + + setAudioParametersByCamcorderProfile(resolution, cameraId); + mAudioEncoder.reset(); + mAudioEncoder.configure( + createAudioMediaFormat(), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + + if (mAudioRecorder != null) { + mAudioRecorder.release(); + } + mAudioRecorder = autoConfigAudioRecordSource(config); + // check mAudioRecorder + if (mAudioRecorder == null) { + Logger.e(TAG, "AudioRecord object cannot initialized correctly!"); + mIsAudioEnabled.set(false); + } + + synchronized (mMuxerLock) { + mVideoTrackIndex = -1; + mAudioTrackIndex = -1; + } + mIsRecording = false; + } + + /** + * Write a buffer that has been encoded to file. + * + * @param bufferIndex the index of the buffer in the videoEncoder that has available data + * @return returns true if this buffer is the end of the stream + */ + private boolean writeVideoEncodedBuffer(int bufferIndex) { + if (bufferIndex < 0) { + Logger.e(TAG, "Output buffer should not have negative index: " + bufferIndex); + return false; + } + // Get data from buffer + ByteBuffer outputBuffer = mVideoEncoder.getOutputBuffer(bufferIndex); + + // Check if buffer is valid, if not then return + if (outputBuffer == null) { + Logger.d(TAG, "OutputBuffer was null."); + return false; + } + + // Write data to mMuxer if available + if (mMuxerStarted.get()) { + if (mVideoBufferInfo.size > 0) { + outputBuffer.position(mVideoBufferInfo.offset); + outputBuffer.limit(mVideoBufferInfo.offset + mVideoBufferInfo.size); + mVideoBufferInfo.presentationTimeUs = (System.nanoTime() / 1000); + + synchronized (mMuxerLock) { + if (!mIsFirstVideoKeyFrameWrite.get()) { + boolean isKeyFrame = + (mVideoBufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0; + if (isKeyFrame) { + Logger.i(TAG, + "First video key frame written."); + mIsFirstVideoKeyFrameWrite.set(true); + } else { + // Request a sync frame immediately + final Bundle syncFrame = new Bundle(); + syncFrame.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); + mVideoEncoder.setParameters(syncFrame); + } + } + mMuxer.writeSampleData(mVideoTrackIndex, outputBuffer, mVideoBufferInfo); + } + } else { + Logger.i(TAG, "mVideoBufferInfo.size <= 0, index " + bufferIndex); + } + } + + // Release data + mVideoEncoder.releaseOutputBuffer(bufferIndex, false); + + // Return true if EOS is set + return (mVideoBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + } + + private boolean writeAudioEncodedBuffer(int bufferIndex) { + ByteBuffer buffer = getOutputBuffer(mAudioEncoder, bufferIndex); + buffer.position(mAudioBufferInfo.offset); + if (mMuxerStarted.get()) { + try { + if (mAudioBufferInfo.size > 0 && mAudioBufferInfo.presentationTimeUs > 0) { + synchronized (mMuxerLock) { + if (!mIsFirstAudioSampleWrite.get()) { + Logger.i(TAG, "First audio sample written."); + mIsFirstAudioSampleWrite.set(true); + } + mMuxer.writeSampleData(mAudioTrackIndex, buffer, mAudioBufferInfo); + } + } else { + Logger.i(TAG, "mAudioBufferInfo size: " + mAudioBufferInfo.size + " " + + "presentationTimeUs: " + mAudioBufferInfo.presentationTimeUs); + } + } catch (Exception e) { + Logger.e( + TAG, + "audio error:size=" + + mAudioBufferInfo.size + + "/offset=" + + mAudioBufferInfo.offset + + "/timeUs=" + + mAudioBufferInfo.presentationTimeUs); + e.printStackTrace(); + } + } + mAudioEncoder.releaseOutputBuffer(bufferIndex, false); + return (mAudioBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + } + + /** + * Encoding which runs indefinitely until end of stream is signaled. This should not run on the + * main thread otherwise it will cause the application to block. + * + * @return returns {@code true} if an error condition occurred, otherwise returns {@code false} + */ + boolean videoEncode(@NonNull OnVideoSavedCallback videoSavedCallback, @NonNull String cameraId, + @NonNull Size resolution, + @NonNull OutputFileOptions outputFileOptions) { + // Main encoding loop. Exits on end of stream. + boolean errorOccurred = false; + boolean videoEos = false; + while (!videoEos && !errorOccurred) { + // Check for end of stream from main thread + if (mEndOfVideoStreamSignal.get()) { + mVideoEncoder.signalEndOfInputStream(); + mEndOfVideoStreamSignal.set(false); + } + + // Deque buffer to check for processing step + int outputBufferId = + mVideoEncoder.dequeueOutputBuffer(mVideoBufferInfo, DEQUE_TIMEOUT_USEC); + switch (outputBufferId) { + case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED: + if (mMuxerStarted.get()) { + videoSavedCallback.onError( + ERROR_ENCODER, + "Unexpected change in video encoding format.", + null); + errorOccurred = true; + } + + synchronized (mMuxerLock) { + mVideoTrackIndex = mMuxer.addTrack(mVideoEncoder.getOutputFormat()); + + if ((mIsAudioEnabled.get() && mAudioTrackIndex >= 0 + && mVideoTrackIndex >= 0) + || (!mIsAudioEnabled.get() && mVideoTrackIndex >= 0)) { + Logger.i(TAG, "MediaMuxer started on video encode thread and audio " + + "enabled: " + mIsAudioEnabled); + mMuxer.start(); + mMuxerStarted.set(true); + } + } + break; + case MediaCodec.INFO_TRY_AGAIN_LATER: + // Timed out. Just wait until next attempt to deque. + break; + default: + videoEos = writeVideoEncodedBuffer(outputBufferId); + } + } + + try { + Logger.i(TAG, "videoEncoder stop"); + mVideoEncoder.stop(); + } catch (IllegalStateException e) { + videoSavedCallback.onError(ERROR_ENCODER, + "Video encoder stop failed!", e); + errorOccurred = true; + } + + try { + // new MediaMuxer instance required for each new file written, and release current one. + synchronized (mMuxerLock) { + if (mMuxer != null) { + if (mMuxerStarted.get()) { + Logger.i(TAG, "Muxer already started"); + mMuxer.stop(); + } + mMuxer.release(); + mMuxer = null; + } + } + + // A final checking for recording result, if the recorded file has no key + // frame, then the video file is not playable, needs to call + // onError() and will be removed. + + boolean checkResult = removeRecordingResultIfNoVideoKeyFrameArrived(outputFileOptions); + + if (!checkResult) { + videoSavedCallback.onError(ERROR_RECORDING_TOO_SHORT, + "The file has no video key frame.", null); + errorOccurred = true; + } + } catch (IllegalStateException e) { + // The video encoder has not got the key frame yet. + Logger.i(TAG, "muxer stop IllegalStateException: " + System.currentTimeMillis()); + Logger.i(TAG, + "muxer stop exception, mIsFirstVideoKeyFrameWrite: " + + mIsFirstVideoKeyFrameWrite.get()); + if (mIsFirstVideoKeyFrameWrite.get()) { + // If muxer throws IllegalStateException at this moment and also the key frame + // has received, this will reported as a Muxer stop failed. + // Otherwise, this error will be ERROR_RECORDING_TOO_SHORT. + videoSavedCallback.onError(ERROR_MUXER, "Muxer stop failed!", e); + } else { + videoSavedCallback.onError(ERROR_RECORDING_TOO_SHORT, + "The file has no video key frame.", null); + } + errorOccurred = true; + } + + if (mParcelFileDescriptor != null) { + try { + mParcelFileDescriptor.close(); + mParcelFileDescriptor = null; + } catch (IOException e) { + videoSavedCallback.onError(ERROR_MUXER, "File descriptor close failed!", e); + errorOccurred = true; + } + } + + mMuxerStarted.set(false); + + // notify the UI thread that the video recording has finished + mEndOfAudioVideoSignal.set(true); + mIsFirstVideoKeyFrameWrite.set(false); + + Logger.i(TAG, "Video encode thread end."); + return errorOccurred; + } + + boolean audioEncode(OnVideoSavedCallback videoSavedCallback) { + // Audio encoding loop. Exits on end of stream. + boolean audioEos = false; + int outIndex; + long lastAudioTimestamp = 0; + while (!audioEos && mIsRecording) { + // Check for end of stream from main thread + if (mEndOfAudioStreamSignal.get()) { + mEndOfAudioStreamSignal.set(false); + mIsRecording = false; + } + + // get audio deque input buffer + if (mAudioEncoder != null && mAudioRecorder != null) { + try { + int index = mAudioEncoder.dequeueInputBuffer(-1); + if (index >= 0) { + final ByteBuffer buffer = getInputBuffer(mAudioEncoder, index); + buffer.clear(); + int length = mAudioRecorder.read(buffer, mAudioBufferSize); + if (length > 0) { + mAudioEncoder.queueInputBuffer( + index, + 0, + length, + (System.nanoTime() / 1000), + mIsRecording ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } + } + } catch (MediaCodec.CodecException e) { + Logger.i(TAG, "audio dequeueInputBuffer CodecException " + e.getMessage()); + } catch (IllegalStateException e) { + Logger.i(TAG, + "audio dequeueInputBuffer IllegalStateException " + e.getMessage()); + } + + // start to dequeue audio output buffer + do { + outIndex = mAudioEncoder.dequeueOutputBuffer(mAudioBufferInfo, 0); + switch (outIndex) { + case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED: + synchronized (mMuxerLock) { + mAudioTrackIndex = mMuxer.addTrack(mAudioEncoder.getOutputFormat()); + if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0) { + Logger.i(TAG, "MediaMuxer start on audio encoder thread."); + mMuxer.start(); + mMuxerStarted.set(true); + } + } + break; + case MediaCodec.INFO_TRY_AGAIN_LATER: + break; + default: + // Drops out of order audio frame if the frame's earlier than last + // frame. + if (mAudioBufferInfo.presentationTimeUs > lastAudioTimestamp) { + audioEos = writeAudioEncodedBuffer(outIndex); + lastAudioTimestamp = mAudioBufferInfo.presentationTimeUs; + } else { + Logger.w(TAG, + "Drops frame, current frame's timestamp " + + mAudioBufferInfo.presentationTimeUs + + " is earlier that last frame " + + lastAudioTimestamp); + // Releases this frame from output buffer + mAudioEncoder.releaseOutputBuffer(outIndex, false); + } + } + } while (outIndex >= 0 && !audioEos); // end of dequeue output buffer + } + } // end of while loop + + // Audio Stop + try { + Logger.i(TAG, "audioRecorder stop"); + mAudioRecorder.stop(); + } catch (IllegalStateException e) { + videoSavedCallback.onError( + ERROR_ENCODER, "Audio recorder stop failed!", e); + } + + try { + mAudioEncoder.stop(); + } catch (IllegalStateException e) { + videoSavedCallback.onError(ERROR_ENCODER, + "Audio encoder stop failed!", e); + } + + Logger.i(TAG, "Audio encode thread end"); + // Use AtomicBoolean to signal because MediaCodec.signalEndOfInputStream() is not thread + // safe + mEndOfVideoStreamSignal.set(true); + + return false; + } + + private ByteBuffer getInputBuffer(MediaCodec codec, int index) { + return codec.getInputBuffer(index); + } + + private ByteBuffer getOutputBuffer(MediaCodec codec, int index) { + return codec.getOutputBuffer(index); + } + + /** Creates a {@link MediaFormat} using parameters for audio from the configuration */ + private MediaFormat createAudioMediaFormat() { + MediaFormat format = + MediaFormat.createAudioFormat(AUDIO_MIME_TYPE, mAudioSampleRate, + mAudioChannelCount); + format.setInteger( + MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); + format.setInteger(MediaFormat.KEY_BIT_RATE, mAudioBitRate); + + return format; + } + + /** Create a AudioRecord object to get raw data */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private AudioRecord autoConfigAudioRecordSource(VideoCaptureConfig config) { + // Use channel count to determine stereo vs mono + int channelConfig = + mAudioChannelCount == 1 + ? AudioFormat.CHANNEL_IN_MONO + : AudioFormat.CHANNEL_IN_STEREO; + + try { + // Use only ENCODING_PCM_16BIT because it mandatory supported. + int bufferSize = + AudioRecord.getMinBufferSize(mAudioSampleRate, channelConfig, + AudioFormat.ENCODING_PCM_16BIT); + + if (bufferSize <= 0) { + bufferSize = config.getAudioMinBufferSize(); + } + + AudioRecord recorder = + new AudioRecord( + AudioSource.CAMCORDER, + mAudioSampleRate, + channelConfig, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize * 2); + + if (recorder.getState() == AudioRecord.STATE_INITIALIZED) { + mAudioBufferSize = bufferSize; + Logger.i( + TAG, + "source: " + + AudioSource.CAMCORDER + + " audioSampleRate: " + + mAudioSampleRate + + " channelConfig: " + + channelConfig + + " audioFormat: " + + AudioFormat.ENCODING_PCM_16BIT + + " bufferSize: " + + bufferSize); + return recorder; + } + } catch (Exception e) { + Logger.e(TAG, "Exception, keep trying.", e); + } + return null; + } + + /** Set audio record parameters by CamcorderProfile */ + private void setAudioParametersByCamcorderProfile(Size currentResolution, String cameraId) { + CamcorderProfile profile; + boolean isCamcorderProfileFound = false; + + try { + for (int quality : CamcorderQuality) { + if (CamcorderProfile.hasProfile(Integer.parseInt(cameraId), quality)) { + profile = CamcorderProfile.get(Integer.parseInt(cameraId), quality); + if (currentResolution.getWidth() == profile.videoFrameWidth + && currentResolution.getHeight() == profile.videoFrameHeight) { + mAudioChannelCount = profile.audioChannels; + mAudioSampleRate = profile.audioSampleRate; + mAudioBitRate = profile.audioBitRate; + isCamcorderProfileFound = true; + break; + } + } + } + } catch (NumberFormatException e) { + Logger.i(TAG, "The camera Id is not an integer because the camera may be a removable " + + "device. Use the default values for the audio related settings."); + } + + // In case no corresponding camcorder profile can be founded, * get default value from + // VideoCaptureConfig. + if (!isCamcorderProfileFound) { + VideoCaptureConfig config = (VideoCaptureConfig) getCurrentConfig(); + mAudioChannelCount = config.getAudioChannelCount(); + mAudioSampleRate = config.getAudioSampleRate(); + mAudioBitRate = config.getAudioBitRate(); + } + } + + private boolean removeRecordingResultIfNoVideoKeyFrameArrived( + @NonNull OutputFileOptions outputFileOptions) { + boolean checkKeyFrame; + + // 1. There should be one video key frame at least. + Logger.i(TAG, + "check Recording Result First Video Key Frame Write: " + + mIsFirstVideoKeyFrameWrite.get()); + if (!mIsFirstVideoKeyFrameWrite.get()) { + Logger.i(TAG, "The recording result has no key frame."); + checkKeyFrame = false; + } else { + checkKeyFrame = true; + } + + return checkKeyFrame; + } + + /** + * Describes the error that occurred during video capture operations. + * + *

This is a parameter sent to the error callback functions set in listeners such as {@link + * VideoCapture.OnVideoSavedCallback#onError(int, String, Throwable)}. + * + *

See message parameter in onError callback or log for more details. + * + * @hide + */ + @IntDef({ERROR_UNKNOWN, ERROR_ENCODER, ERROR_MUXER, ERROR_RECORDING_IN_PROGRESS, + ERROR_FILE_IO, ERROR_INVALID_CAMERA, ERROR_RECORDING_TOO_SHORT}) + @Retention(RetentionPolicy.SOURCE) + @RestrictTo(Scope.LIBRARY_GROUP) + public @interface VideoCaptureError { + } + + enum VideoEncoderInitStatus { + VIDEO_ENCODER_INIT_STATUS_UNINITIALIZED, + VIDEO_ENCODER_INIT_STATUS_INITIALIZED_FAILED, + VIDEO_ENCODER_INIT_STATUS_INSUFFICIENT_RESOURCE, + VIDEO_ENCODER_INIT_STATUS_RESOURCE_RECLAIMED, + } + + /** Listener containing callbacks for video file I/O events. */ + public interface OnVideoSavedCallback { + /** Called when the video has been successfully saved. */ + void onVideoSaved(); + + /** Called when an error occurs while attempting to save the video. */ + void onError(@VideoCaptureError int videoCaptureError, @NonNull String message, + @Nullable Throwable cause); + } + + /** + * Provides a base static default configuration for the VideoCapture + * + *

These values may be overridden by the implementation. They only provide a minimum set of + * defaults that are implementation independent. + * + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + public static final class Defaults + implements ConfigProvider { + private static final int DEFAULT_VIDEO_FRAME_RATE = 30; + /** 8Mb/s the recommend rate for 30fps 1080p */ + private static final int DEFAULT_BIT_RATE = 8 * 1024 * 1024; + /** Seconds between each key frame */ + private static final int DEFAULT_INTRA_FRAME_INTERVAL = 1; + /** audio bit rate */ + private static final int DEFAULT_AUDIO_BIT_RATE = 64000; + /** audio sample rate */ + private static final int DEFAULT_AUDIO_SAMPLE_RATE = 8000; + /** audio channel count */ + private static final int DEFAULT_AUDIO_CHANNEL_COUNT = 1; + /** audio default minimum buffer size */ + private static final int DEFAULT_AUDIO_MIN_BUFFER_SIZE = 1024; + /** Current max resolution of VideoCapture is set as FHD */ + private static final Size DEFAULT_MAX_RESOLUTION = new Size(1920, 1080); + /** Surface occupancy priority to this use case */ + private static final int DEFAULT_SURFACE_OCCUPANCY_PRIORITY = 3; + private static final int DEFAULT_ASPECT_RATIO = AspectRatio.RATIO_16_9; + + private static final VideoCaptureConfig DEFAULT_CONFIG; + + static { + Builder builder = new Builder() + .setVideoFrameRate(DEFAULT_VIDEO_FRAME_RATE) + .setBitRate(DEFAULT_BIT_RATE) + .setIFrameInterval(DEFAULT_INTRA_FRAME_INTERVAL) + .setAudioBitRate(DEFAULT_AUDIO_BIT_RATE) + .setAudioSampleRate(DEFAULT_AUDIO_SAMPLE_RATE) + .setAudioChannelCount(DEFAULT_AUDIO_CHANNEL_COUNT) + .setAudioMinBufferSize(DEFAULT_AUDIO_MIN_BUFFER_SIZE) + .setMaxResolution(DEFAULT_MAX_RESOLUTION) + .setSurfaceOccupancyPriority(DEFAULT_SURFACE_OCCUPANCY_PRIORITY) + .setTargetAspectRatio(DEFAULT_ASPECT_RATIO); + + DEFAULT_CONFIG = builder.getUseCaseConfig(); + } + + @NonNull + @Override + public VideoCaptureConfig getConfig() { + return DEFAULT_CONFIG; + } + } + + private static final class VideoSavedListenerWrapper implements OnVideoSavedCallback { + + @NonNull + Executor mExecutor; + @NonNull + OnVideoSavedCallback mOnVideoSavedCallback; + + VideoSavedListenerWrapper(@NonNull Executor executor, + @NonNull OnVideoSavedCallback onVideoSavedCallback) { + mExecutor = executor; + mOnVideoSavedCallback = onVideoSavedCallback; + } + + @Override + public void onVideoSaved() { + try { + mExecutor.execute(() -> mOnVideoSavedCallback.onVideoSaved()); + } catch (RejectedExecutionException e) { + Logger.e(TAG, "Unable to post to the supplied executor."); + } + } + + @Override + public void onError(@VideoCaptureError int videoCaptureError, @NonNull String message, + @Nullable Throwable cause) { + try { + mExecutor.execute( + () -> mOnVideoSavedCallback.onError(videoCaptureError, message, cause)); + } catch (RejectedExecutionException e) { + Logger.e(TAG, "Unable to post to the supplied executor."); + } + } + + } + + /** Builder for a {@link VideoCapture}. */ + @SuppressWarnings("ObjectToString") + public static final class Builder + implements + UseCaseConfig.Builder, + ImageOutputConfig.Builder, + ThreadConfig.Builder { + + private final MutableOptionsBundle mMutableConfig; + + /** Creates a new Builder object. */ + public Builder() { + this(MutableOptionsBundle.create()); + } + + private Builder(@NonNull MutableOptionsBundle mutableConfig) { + mMutableConfig = mutableConfig; + + Class oldConfigClass = + mutableConfig.retrieveOption(OPTION_TARGET_CLASS, null); + if (oldConfigClass != null && !oldConfigClass.equals(VideoCapture.class)) { + throw new IllegalArgumentException( + "Invalid target class configuration for " + + Builder.this + + ": " + + oldConfigClass); + } + + setTargetClass(VideoCapture.class); + } + + /** + * Generates a Builder from another Config object. + * + * @param configuration An immutable configuration to pre-populate this builder. + * @return The new Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + static Builder fromConfig(@NonNull Config configuration) { + return new Builder(MutableOptionsBundle.from(configuration)); + } + + + /** + * Generates a Builder from another Config object + * + * @param configuration An immutable configuration to pre-populate this builder. + * @return The new Builder. + */ + @NonNull + public static Builder fromConfig(@NonNull VideoCaptureConfig configuration) { + return new Builder(MutableOptionsBundle.from(configuration)); + } + + /** + * {@inheritDoc} + * + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public MutableConfig getMutableConfig() { + return mMutableConfig; + } + + /** + * {@inheritDoc} + * + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + @Override + public VideoCaptureConfig getUseCaseConfig() { + return new VideoCaptureConfig(OptionsBundle.from(mMutableConfig)); + } + + /** + * Builds an immutable {@link VideoCaptureConfig} from the current state. + * + * @return A {@link VideoCaptureConfig} populated with the current state. + */ + @Override + @NonNull + public VideoCapture build() { + // Error at runtime for using both setTargetResolution and setTargetAspectRatio on + // the same config. + if (getMutableConfig().retrieveOption(OPTION_TARGET_ASPECT_RATIO, null) != null + && getMutableConfig().retrieveOption(OPTION_TARGET_RESOLUTION, null) != null) { + throw new IllegalArgumentException( + "Cannot use both setTargetResolution and setTargetAspectRatio on the same " + + "config."); + } + return new VideoCapture(getUseCaseConfig()); + } + + /** + * Sets the recording frames per second. + * + * @param videoFrameRate The requested interval in seconds. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + public Builder setVideoFrameRate(int videoFrameRate) { + getMutableConfig().insertOption(OPTION_VIDEO_FRAME_RATE, videoFrameRate); + return this; + } + + /** + * Sets the encoding bit rate. + * + * @param bitRate The requested bit rate in bits per second. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + public Builder setBitRate(int bitRate) { + getMutableConfig().insertOption(OPTION_BIT_RATE, bitRate); + return this; + } + + /** + * Sets number of seconds between each key frame in seconds. + * + * @param interval The requested interval in seconds. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + public Builder setIFrameInterval(int interval) { + getMutableConfig().insertOption(OPTION_INTRA_FRAME_INTERVAL, interval); + return this; + } + + /** + * Sets the bit rate of the audio stream. + * + * @param bitRate The requested bit rate in bits/s. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + public Builder setAudioBitRate(int bitRate) { + getMutableConfig().insertOption(OPTION_AUDIO_BIT_RATE, bitRate); + return this; + } + + /** + * Sets the sample rate of the audio stream. + * + * @param sampleRate The requested sample rate in bits/s. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + public Builder setAudioSampleRate(int sampleRate) { + getMutableConfig().insertOption(OPTION_AUDIO_SAMPLE_RATE, sampleRate); + return this; + } + + /** + * Sets the number of audio channels. + * + * @param channelCount The requested number of audio channels. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + public Builder setAudioChannelCount(int channelCount) { + getMutableConfig().insertOption(OPTION_AUDIO_CHANNEL_COUNT, channelCount); + return this; + } + + /** + * Sets the audio min buffer size. + * + * @param minBufferSize The requested audio minimum buffer size, in bytes. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + public Builder setAudioMinBufferSize(int minBufferSize) { + getMutableConfig().insertOption(OPTION_AUDIO_MIN_BUFFER_SIZE, minBufferSize); + return this; + } + + // Implementations of TargetConfig.Builder default methods + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setTargetClass(@NonNull Class targetClass) { + getMutableConfig().insertOption(OPTION_TARGET_CLASS, targetClass); + + // If no name is set yet, then generate a unique name + if (null == getMutableConfig().retrieveOption(OPTION_TARGET_NAME, null)) { + String targetName = targetClass.getCanonicalName() + "-" + UUID.randomUUID(); + setTargetName(targetName); + } + + return this; + } + + /** + * Sets the name of the target object being configured, used only for debug logging. + * + *

The name should be a value that can uniquely identify an instance of the object being + * configured. + * + *

If not set, the target name will default to an unique name automatically generated + * with the class canonical name and random UUID. + * + * @param targetName A unique string identifier for the instance of the class being + * configured. + * @return the current Builder. + */ + @Override + @NonNull + public Builder setTargetName(@NonNull String targetName) { + getMutableConfig().insertOption(OPTION_TARGET_NAME, targetName); + return this; + } + + // Implementations of ImageOutputConfig.Builder default methods + + /** + * Sets the aspect ratio of the intended target for images from this configuration. + * + *

It is not allowed to set both target aspect ratio and target resolution on the same + * use case. + * + *

The target aspect ratio is used as a hint when determining the resulting output aspect + * ratio which may differ from the request, possibly due to device constraints. + * Application code should check the resulting output's resolution. + * + *

If not set, resolutions with aspect ratio 4:3 will be considered in higher + * priority. + * + * @param aspectRatio A {@link AspectRatio} representing the ratio of the + * target's width and height. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + @Override + public Builder setTargetAspectRatio(@AspectRatio.Ratio int aspectRatio) { + getMutableConfig().insertOption(OPTION_TARGET_ASPECT_RATIO, aspectRatio); + return this; + } + + /** + * Sets the rotation of the intended target for images from this configuration. + * + *

This is one of four valid values: {@link Surface#ROTATION_0}, {@link + * Surface#ROTATION_90}, {@link Surface#ROTATION_180}, {@link Surface#ROTATION_270}. + * Rotation values are relative to the "natural" rotation, {@link Surface#ROTATION_0}. + * + *

If not set, the target rotation will default to the value of + * {@link Display#getRotation()} of the default display at the time the use case is + * created. The use case is fully created once it has been attached to a camera. + * + * @param rotation The rotation of the intended target. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + @Override + public Builder setTargetRotation(@RotationValue int rotation) { + getMutableConfig().insertOption(OPTION_TARGET_ROTATION, rotation); + return this; + } + + /** + * Sets the resolution of the intended target from this configuration. + * + *

The target resolution attempts to establish a minimum bound for the image resolution. + * The actual image resolution will be the closest available resolution in size that is not + * smaller than the target resolution, as determined by the Camera implementation. However, + * if no resolution exists that is equal to or larger than the target resolution, the + * nearest available resolution smaller than the target resolution will be chosen. + * + *

It is not allowed to set both target aspect ratio and target resolution on the same + * use case. + * + *

The target aspect ratio will also be set the same as the aspect ratio of the provided + * {@link Size}. Make sure to set the target resolution with the correct orientation. + * + * @param resolution The target resolution to choose from supported output sizes list. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + @Override + public Builder setTargetResolution(@NonNull Size resolution) { + getMutableConfig().insertOption(OPTION_TARGET_RESOLUTION, resolution); + return this; + } + + /** + * Sets the default resolution of the intended target from this configuration. + * + * @param resolution The default resolution to choose from supported output sizes list. + * @return The current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + @Override + public Builder setDefaultResolution(@NonNull Size resolution) { + getMutableConfig().insertOption(OPTION_DEFAULT_RESOLUTION, resolution); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @NonNull + @Override + public Builder setMaxResolution(@NonNull Size resolution) { + getMutableConfig().insertOption(OPTION_MAX_RESOLUTION, resolution); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setSupportedResolutions(@NonNull List> resolutions) { + getMutableConfig().insertOption(OPTION_SUPPORTED_RESOLUTIONS, resolutions); + return this; + } + + // Implementations of ThreadConfig.Builder default methods + + /** + * Sets the default executor that will be used for background tasks. + * + *

If not set, the background executor will default to an automatically generated + * {@link Executor}. + * + * @param executor The executor which will be used for background tasks. + * @return the current Builder. + * @hide + */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setBackgroundExecutor(@NonNull Executor executor) { + getMutableConfig().insertOption(OPTION_BACKGROUND_EXECUTOR, executor); + return this; + } + + // Implementations of UseCaseConfig.Builder default methods + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setDefaultSessionConfig(@NonNull SessionConfig sessionConfig) { + getMutableConfig().insertOption(OPTION_DEFAULT_SESSION_CONFIG, sessionConfig); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setDefaultCaptureConfig(@NonNull CaptureConfig captureConfig) { + getMutableConfig().insertOption(OPTION_DEFAULT_CAPTURE_CONFIG, captureConfig); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setSessionOptionUnpacker( + @NonNull SessionConfig.OptionUnpacker optionUnpacker) { + getMutableConfig().insertOption(OPTION_SESSION_CONFIG_UNPACKER, optionUnpacker); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setCaptureOptionUnpacker( + @NonNull CaptureConfig.OptionUnpacker optionUnpacker) { + getMutableConfig().insertOption(OPTION_CAPTURE_CONFIG_UNPACKER, optionUnpacker); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setSurfaceOccupancyPriority(int priority) { + getMutableConfig().insertOption(OPTION_SURFACE_OCCUPANCY_PRIORITY, priority); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY) + @Override + @NonNull + public Builder setCameraSelector(@NonNull CameraSelector cameraSelector) { + getMutableConfig().insertOption(OPTION_CAMERA_SELECTOR, cameraSelector); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setUseCaseEventCallback( + @NonNull UseCase.EventCallback useCaseEventCallback) { + getMutableConfig().insertOption(OPTION_USE_CASE_EVENT_CALLBACK, useCaseEventCallback); + return this; + } + + /** @hide */ + @RestrictTo(Scope.LIBRARY_GROUP) + @Override + @NonNull + public Builder setAttachedUseCasesUpdateListener( + @NonNull Consumer> attachedUseCasesUpdateListener) { + getMutableConfig().insertOption(OPTION_ATTACHED_USE_CASES_UPDATE_LISTENER, + attachedUseCasesUpdateListener); + return this; + } + + } + + /** + * Options for saving newly captured video. + * + *

this class is used to configure save location and metadata. Save location can be + * either a {@link File}, {@link MediaStore}. The metadata will be + * stored with the saved video. + */ + public static final class OutputFileOptions { + + private final SeekableWriter mWriter; + + public OutputFileOptions(SeekableWriter writer) { + mWriter = writer; + } + } + + /** + * Nested class to avoid verification errors for methods introduced in Android 6.0 (API 23). + */ + @RequiresApi(23) + private static class Api23Impl { + + private Api23Impl() { + } + + @DoNotInline + static int getCodecExceptionErrorCode(MediaCodec.CodecException e) { + return e.getErrorCode(); + } + } +} diff --git a/app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCaptureConfig.java b/app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCaptureConfig.java new file mode 100644 index 0000000..83dd74a --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/video_recording/VideoCaptureConfig.java @@ -0,0 +1,212 @@ +package sushi.hardcore.droidfs.video_recording; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; +import androidx.camera.core.impl.Config; +import androidx.camera.core.impl.ImageFormatConstants; +import androidx.camera.core.impl.ImageOutputConfig; +import androidx.camera.core.impl.OptionsBundle; +import androidx.camera.core.impl.UseCaseConfig; +import androidx.camera.core.internal.ThreadConfig; + +/** + * Config for a video capture use case. + * + *

In the earlier stage, the VideoCapture is deprioritized. + */ +@SuppressLint("RestrictedApi") +public final class VideoCaptureConfig + implements UseCaseConfig, + ImageOutputConfig, + ThreadConfig { + + // Option Declarations: + // ********************************************************************************************* + + public static final Option OPTION_VIDEO_FRAME_RATE = + Option.create("camerax.core.videoCapture.recordingFrameRate", int.class); + public static final Option OPTION_BIT_RATE = + Option.create("camerax.core.videoCapture.bitRate", int.class); + public static final Option OPTION_INTRA_FRAME_INTERVAL = + Option.create("camerax.core.videoCapture.intraFrameInterval", int.class); + public static final Option OPTION_AUDIO_BIT_RATE = + Option.create("camerax.core.videoCapture.audioBitRate", int.class); + public static final Option OPTION_AUDIO_SAMPLE_RATE = + Option.create("camerax.core.videoCapture.audioSampleRate", int.class); + public static final Option OPTION_AUDIO_CHANNEL_COUNT = + Option.create("camerax.core.videoCapture.audioChannelCount", int.class); + public static final Option OPTION_AUDIO_MIN_BUFFER_SIZE = + Option.create("camerax.core.videoCapture.audioMinBufferSize", int.class); + + // ********************************************************************************************* + + private final OptionsBundle mConfig; + + public VideoCaptureConfig(@NonNull OptionsBundle config) { + mConfig = config; + } + + /** + * Returns the recording frames per second. + * + * @param valueIfMissing The value to return if this configuration option has not been set. + * @return The stored value or valueIfMissing if the value does not exist in this + * configuration. + */ + public int getVideoFrameRate(int valueIfMissing) { + return retrieveOption(OPTION_VIDEO_FRAME_RATE, valueIfMissing); + } + + /** + * Returns the recording frames per second. + * + * @return The stored value, if it exists in this configuration. + * @throws IllegalArgumentException if the option does not exist in this configuration. + */ + public int getVideoFrameRate() { + return retrieveOption(OPTION_VIDEO_FRAME_RATE); + } + + /** + * Returns the encoding bit rate. + * + * @param valueIfMissing The value to return if this configuration option has not been set. + * @return The stored value or valueIfMissing if the value does not exist in this + * configuration. + */ + public int getBitRate(int valueIfMissing) { + return retrieveOption(OPTION_BIT_RATE, valueIfMissing); + } + + /** + * Returns the encoding bit rate. + * + * @return The stored value, if it exists in this configuration. + * @throws IllegalArgumentException if the option does not exist in this configuration. + */ + public int getBitRate() { + return retrieveOption(OPTION_BIT_RATE); + } + + /** + * Returns the number of seconds between each key frame. + * + * @param valueIfMissing The value to return if this configuration option has not been set. + * @return The stored value or valueIfMissing if the value does not exist in this + * configuration. + */ + public int getIFrameInterval(int valueIfMissing) { + return retrieveOption(OPTION_INTRA_FRAME_INTERVAL, valueIfMissing); + } + + /** + * Returns the number of seconds between each key frame. + * + * @return The stored value, if it exists in this configuration. + * @throws IllegalArgumentException if the option does not exist in this configuration. + */ + public int getIFrameInterval() { + return retrieveOption(OPTION_INTRA_FRAME_INTERVAL); + } + + /** + * Returns the audio encoding bit rate. + * + * @param valueIfMissing The value to return if this configuration option has not been set. + * @return The stored value or valueIfMissing if the value does not exist in this + * configuration. + */ + public int getAudioBitRate(int valueIfMissing) { + return retrieveOption(OPTION_AUDIO_BIT_RATE, valueIfMissing); + } + + /** + * Returns the audio encoding bit rate. + * + * @return The stored value, if it exists in this configuration. + * @throws IllegalArgumentException if the option does not exist in this configuration. + */ + public int getAudioBitRate() { + return retrieveOption(OPTION_AUDIO_BIT_RATE); + } + + /** + * Returns the audio sample rate. + * + * @param valueIfMissing The value to return if this configuration option has not been set. + * @return The stored value or valueIfMissing if the value does not exist in this + * configuration. + */ + public int getAudioSampleRate(int valueIfMissing) { + return retrieveOption(OPTION_AUDIO_SAMPLE_RATE, valueIfMissing); + } + + /** + * Returns the audio sample rate. + * + * @return The stored value, if it exists in this configuration. + * @throws IllegalArgumentException if the option does not exist in this configuration. + */ + public int getAudioSampleRate() { + return retrieveOption(OPTION_AUDIO_SAMPLE_RATE); + } + + /** + * Returns the audio channel count. + * + * @param valueIfMissing The value to return if this configuration option has not been set. + * @return The stored value or valueIfMissing if the value does not exist in this + * configuration. + */ + public int getAudioChannelCount(int valueIfMissing) { + return retrieveOption(OPTION_AUDIO_CHANNEL_COUNT, valueIfMissing); + } + + /** + * Returns the audio channel count. + * + * @return The stored value, if it exists in this configuration. + * @throws IllegalArgumentException if the option does not exist in this configuration. + */ + public int getAudioChannelCount() { + return retrieveOption(OPTION_AUDIO_CHANNEL_COUNT); + } + + /** + * Returns the audio minimum buffer size, in bytes. + * + * @param valueIfMissing The value to return if this configuration option has not been set. + * @return The stored value or valueIfMissing if the value does not exist in this + * configuration. + */ + public int getAudioMinBufferSize(int valueIfMissing) { + return retrieveOption(OPTION_AUDIO_MIN_BUFFER_SIZE, valueIfMissing); + } + + /** + * Returns the audio minimum buffer size, in bytes. + * + * @return The stored value, if it exists in this configuration. + * @throws IllegalArgumentException if the option does not exist in this configuration. + */ + public int getAudioMinBufferSize() { + return retrieveOption(OPTION_AUDIO_MIN_BUFFER_SIZE); + } + + /** + * Retrieves the format of the image that is fed as input. + * + *

This should always be PRIVATE for VideoCapture. + */ + @Override + public int getInputFormat() { + return ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE; + } + + @NonNull + @Override + public Config getConfig() { + return mConfig; + } +} diff --git a/app/src/main/native/libmux.c b/app/src/main/native/libmux.c new file mode 100644 index 0000000..56f84da --- /dev/null +++ b/app/src/main/native/libmux.c @@ -0,0 +1,125 @@ +#include +#include +#include +#include + +const size_t BUFF_SIZE = 4096; + +struct Muxer { + JavaVM* jvm; + jobject thiz; + jmethodID write_packet_method_id; + jmethodID seek_method_id; +}; + +int write_packet(void* opaque, uint8_t* buff, int buff_size) { + struct Muxer* muxer = opaque; + JNIEnv *env; + (*muxer->jvm)->GetEnv(muxer->jvm, (void **) &env, JNI_VERSION_1_6); + jbyteArray jarray = (*env)->NewByteArray(env, buff_size); + (*env)->SetByteArrayRegion(env, jarray, 0, buff_size, buff); + (*env)->CallVoidMethod(env, muxer->thiz, muxer->write_packet_method_id, jarray, buff_size); + return buff_size; +} + +int64_t seek(void* opaque, int64_t offset, int whence) { + struct Muxer* muxer = opaque; + JNIEnv *env; + (*muxer->jvm)->GetEnv(muxer->jvm, (void **) &env, JNI_VERSION_1_6); + (*env)->CallVoidMethod(env, muxer->thiz, muxer->seek_method_id, offset); + return offset; +} + +jlong Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_allocContext(JNIEnv *env, jobject thiz) { + const AVOutputFormat *format = av_guess_format("mp4", NULL, NULL); + struct Muxer* muxer = malloc(sizeof(struct Muxer)); + (*env)->GetJavaVM(env, &muxer->jvm); + muxer->thiz = (*env)->NewGlobalRef(env, thiz); + jclass class = (*env)->GetObjectClass(env, thiz); + muxer->write_packet_method_id = (*env)->GetMethodID(env, class, "writePacket", "([B)V"); + muxer->seek_method_id = (*env)->GetMethodID(env, class, "seek", "(J)V"); + AVIOContext* avio_context = avio_alloc_context(av_malloc(BUFF_SIZE), BUFF_SIZE, 1, muxer, NULL, write_packet, seek); + AVFormatContext* fc = avformat_alloc_context(); + fc->oformat = format; + fc->pb = avio_context; + return (jlong) fc; +} + +JNIEXPORT jint JNICALL +Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_addAudioTrack(JNIEnv *env, jobject thiz, jlong format_context, jint bitrate, jint sample_rate, + jint channel_count) { + const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_AAC); + AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL); + AVCodecContext* codec_context = avcodec_alloc_context3(encoder); + codec_context->channels = channel_count; + codec_context->channel_layout = av_get_default_channel_layout(channel_count); + codec_context->sample_rate = sample_rate; + codec_context->sample_fmt = encoder->sample_fmts[0]; + codec_context->bit_rate = bitrate; + codec_context->strict_std_compliance = FF_COMPLIANCE_EXPERIMENTAL; + stream->time_base.den = sample_rate; + stream->time_base.num = 1; + codec_context->flags = AV_CODEC_FLAG_GLOBAL_HEADER; + avcodec_open2(codec_context, encoder, NULL); + avcodec_parameters_from_context(stream->codecpar, codec_context); + int frame_size = codec_context->frame_size; + avcodec_free_context(&codec_context); + return frame_size; +} + +JNIEXPORT jint JNICALL +Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_addVideoTrack(JNIEnv *env, jobject thiz, jlong format_context, jint bitrate, jint width, + jint height) { + AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL); + stream->codecpar->codec_type = AVMEDIA_TYPE_VIDEO; + stream->codecpar->codec_id = AV_CODEC_ID_H264; + stream->codecpar->bit_rate = bitrate; + stream->codecpar->width = width; + stream->codecpar->height = height; + return stream->index; +} + +JNIEXPORT jint JNICALL +Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_writeHeaders(JNIEnv *env, jobject thiz, jlong format_context) { + return avformat_write_header((AVFormatContext *) format_context, NULL); +} + +JNIEXPORT void JNICALL +Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_writePacket(JNIEnv *env, jobject thiz, jlong format_context, + jbyteArray buffer, jlong pts, jint stream_index, + jboolean is_key_frame) { + AVPacket* packet = av_packet_alloc(); + int size = (*env)->GetArrayLength(env, buffer); + av_new_packet(packet, size); + packet->pts = pts; + if (stream_index >= 0) { //video + packet->stream_index = stream_index; + AVRational r; + r.num = 1; + r.den = 1000000; + av_packet_rescale_ts(packet, r, ((AVFormatContext *)format_context)->streams[stream_index]->time_base); + } + unsigned char* buff = malloc(size); + (*env)->GetByteArrayRegion(env, buffer, 0, size, buff); + packet->data = buff; + if (is_key_frame) { + packet->flags = AV_PKT_FLAG_KEY; + } + av_write_frame((AVFormatContext *)format_context, packet); + free(buff); + av_packet_free(&packet); +} + +JNIEXPORT void JNICALL +Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_writeTrailer(JNIEnv *env, jobject thiz, jlong format_context) { + av_write_trailer((AVFormatContext *) format_context); +} + +JNIEXPORT void JNICALL +Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_release(JNIEnv *env, jobject thiz, jlong format_context) { + AVFormatContext* fc = (AVFormatContext *) format_context; + av_free(fc->pb->buffer); + free(fc->pb->opaque); + avio_context_free(&fc->pb); + avformat_free_context(fc); +} \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_video.xml b/app/src/main/res/drawable/icon_video.xml new file mode 100644 index 0000000..8082b1e --- /dev/null +++ b/app/src/main/res/drawable/icon_video.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/record_video_button.xml b/app/src/main/res/drawable/record_video_button.xml new file mode 100644 index 0000000..fda7d2f --- /dev/null +++ b/app/src/main/res/drawable/record_video_button.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/stop_recording_video_button.xml b/app/src/main/res/drawable/stop_recording_video_button.xml new file mode 100644 index 0000000..55d0ee4 --- /dev/null +++ b/app/src/main/res/drawable/stop_recording_video_button.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_camera.xml b/app/src/main/res/layout/activity_camera.xml index 3d92ffd..a70ba60 100644 --- a/app/src/main/res/layout/activity_camera.xml +++ b/app/src/main/res/layout/activity_camera.xml @@ -4,7 +4,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto" - tools:context=".CameraActivity"> + tools:context=".CameraActivity" + android:background="#000000"> - + android:layout_centerInParent="true"> + + + + + + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 5c198b2..9683649 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -144,7 +144,7 @@ Falha ao copiar o %s . Cópia feita! Adicionar - Tirar foto + Tirar foto Foto salva ao %s Falha ao salvar esta imagem. %s já existe, você quer substitui-lo ? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 751d8e0..7da1103 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -141,7 +141,7 @@ Ошибка при копировании %s. Копирование выполнено! Добавить - Сфотографировать + Сфотографировать Изображение сохранено в %s Невозможно сохранить изображение. %s уже существует, переписать его? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0d0764..4d805ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -146,9 +146,10 @@ Copy of %s failed. Copy successful ! Add - Take photo + Camera Picture saved to %s Failed to save this picture. + Video saved to %s %s already exists, do you want to overwrite it ? %s already exists, do you want to merge its content ? Enter new name diff --git a/build.gradle b/build.gradle index 97ea868..6ea876b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.5.30" + ext.kotlin_version = "1.5.31" repositories { google() mavenCentral()