Video recording

This commit is contained in:
Matéo Duparc 2021-10-03 14:36:06 +02:00
parent fd98c42014
commit dc89c02b9f
Signed by untrusted user: hardcoresushi
GPG Key ID: 007F84120107191E
20 changed files with 2534 additions and 53 deletions

View File

@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.4.1) cmake_minimum_required(VERSION 3.10)
add_library( add_library(
gocryptfs gocryptfs
@ -23,6 +23,56 @@ target_link_libraries(
gocryptfs gocryptfs
) )
include_directories( add_library(
${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}/ 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}
)

View File

@ -2,8 +2,8 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
android { android {
compileSdkVersion 30 compileSdkVersion 31
buildToolsVersion "30.0.3" buildToolsVersion "31"
ndkVersion "21.4.7075529" ndkVersion "21.4.7075529"
compileOptions { compileOptions {
@ -57,7 +57,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.core:core-ktx:1.6.0" implementation "androidx.core:core-ktx:1.6.0"
implementation "androidx.appcompat:appcompat:1.3.1" 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.sqlite:sqlite-ktx:2.1.0"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.1.1"
@ -66,14 +66,16 @@ dependencies {
implementation "com.github.bumptech.glide:glide:4.12.0" implementation "com.github.bumptech.glide:glide:4.12.0"
implementation "androidx.biometric:biometric:1.1.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-core:$exoplayer_version"
implementation "com.google.android.exoplayer:exoplayer-ui:$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-camera2:$camerax_v1"
implementation "androidx.camera:camera-lifecycle:$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-view:$camerax_v2"
implementation "androidx.camera:camera-extensions:$camerax_v2" implementation "androidx.camera:camera-extensions:$camerax_v2"
} }

2
app/ffmpeg/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
ffmpeg
build

81
app/ffmpeg/build.sh Executable file
View File

@ -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

View File

@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature <uses-feature
android:name="android.hardware.camera.any" android:name="android.hardware.camera.any"

View File

@ -1,6 +1,7 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraCharacteristics
@ -20,26 +21,31 @@ import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.camera.camera2.interop.Camera2CameraInfo import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.* import androidx.camera.core.*
//import androidx.camera.core.VideoCapture
import androidx.camera.extensions.ExtensionMode import androidx.camera.extensions.ExtensionMode
import androidx.camera.extensions.ExtensionsManager import androidx.camera.extensions.ExtensionsManager
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import sushi.hardcore.droidfs.adapters.DialogSingleChoiceAdapter import sushi.hardcore.droidfs.adapters.DialogSingleChoiceAdapter
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.databinding.ActivityCameraBinding import sushi.hardcore.droidfs.databinding.ActivityCameraBinding
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.video_recording.SeekableWriter
import sushi.hardcore.droidfs.video_recording.VideoCapture
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
import java.io.ByteArrayInputStream import java.io.*
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.Executor import java.util.concurrent.Executor
class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
companion object { companion object {
private const val CAMERA_PERMISSION_REQUEST_CODE = 1 private const val CAMERA_PERMISSION_REQUEST_CODE = 0
private const val AUDIO_PERMISSION_REQUEST_CODE = 1
private const val fileNameRandomMin = 100000 private const val fileNameRandomMin = 100000
private const val fileNameRandomMax = 999999 private const val fileNameRandomMax = 999999
private val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) private val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
@ -61,19 +67,23 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
private lateinit var orientedIcons: List<ImageView> private lateinit var orientedIcons: List<ImageView>
private lateinit var gocryptfsVolume: GocryptfsVolume private lateinit var gocryptfsVolume: GocryptfsVolume
private lateinit var outputDirectory: String private lateinit var outputDirectory: String
private lateinit var fileName: String
private var isFinishingIntentionally = false private var isFinishingIntentionally = false
private var isAskingPermissions = false
private var permissionsGranted = false private var permissionsGranted = false
private lateinit var executor: Executor private lateinit var executor: Executor
private lateinit var cameraProvider: ProcessCameraProvider private lateinit var cameraProvider: ProcessCameraProvider
private lateinit var extensionsManager: ExtensionsManager private lateinit var extensionsManager: ExtensionsManager
private lateinit var cameraSelector: CameraSelector
private val cameraPreview = Preview.Builder().build() private val cameraPreview = Preview.Builder().build()
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
private var videoCapture: VideoCapture? = null
private var camera: Camera? = null private var camera: Camera? = null
private var resolutions: List<Size>? = null private var resolutions: List<Size>? = null
private var currentResolutionIndex: Int = 0 private var currentResolutionIndex: Int = 0
private var captureMode = ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY private var captureMode = ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
private var isBackCamera = true private var isBackCamera = true
private var isInVideoMode = false
private var isRecording = false
private lateinit var binding: ActivityCameraBinding private lateinit var binding: ActivityCameraBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -88,6 +98,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){
permissionsGranted = true permissionsGranted = true
} else { } else {
isAskingPermissions = true
requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE) requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE)
} }
} else { } else {
@ -174,10 +185,6 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.show() dialog.show()
} }
binding.imageClose.setOnClickListener {
isFinishingIntentionally = true
finish()
}
binding.imageFlash.setOnClickListener { binding.imageFlash.setOnClickListener {
binding.imageFlash.setImageResource(when (imageCapture?.flashMode) { binding.imageFlash.setImageResource(when (imageCapture?.flashMode) {
ImageCapture.FLASH_MODE_AUTO -> { 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 { binding.imageCameraSwitch.setOnClickListener {
isBackCamera = if (isBackCamera) { isBackCamera = if (isBackCamera) {
binding.imageCameraSwitch.setImageResource(R.drawable.icon_camera_back) binding.imageCameraSwitch.setImageResource(R.drawable.icon_camera_back)
@ -205,7 +228,8 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
setupCamera() setupCamera()
} }
binding.takePhotoButton.onClick = ::onClickTakePhoto 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) sensorOrientationListener = SensorOrientationListener(this)
val scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener(){ 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<String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) { isAskingPermissions = false
CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults.size == 1) { if (grantResults.size == 1) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { when (requestCode) {
CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
permissionsGranted = true permissionsGranted = true
setupCamera() setupCamera()
} else { } else {
@ -249,6 +275,12 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
finish() finish()
}.show() }.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) (binding.cameraPreview.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.CENTER_IN_PARENT)
} }
@SuppressLint("RestrictedApi")
private fun setupCamera(resolution: Size? = null){ private fun setupCamera(resolution: Size? = null){
if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) { if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) {
imageCapture = ImageCapture.Builder() imageCapture = ImageCapture.Builder()
@ -278,13 +311,19 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
} }
.build() .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)) { if (extensionsManager.isExtensionAvailable(cameraProvider, cameraSelector, ExtensionMode.HDR)) {
cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraProvider, cameraSelector, ExtensionMode.HDR) cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraProvider, cameraSelector, ExtensionMode.HDR)
} }
cameraProvider.unbindAll() cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture) camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture, videoCapture)
adaptPreviewSize(resolution ?: imageCapture!!.attachedSurfaceResolution!!.swap()) 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 imageCapture = imageCapture ?: return
val outputBuff = ByteArrayOutputStream() val outputBuff = ByteArrayOutputStream()
val outputOptions = ImageCapture.OutputFileOptions.Builder(outputBuff).build() val outputOptions = ImageCapture.OutputFileOptions.Builder(outputBuff).build()
imageCapture.takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback { imageCapture.takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
binding.takePhotoButton.onPhotoTaken() binding.takePhotoButton.onPhotoTaken()
if (gocryptfsVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), PathUtils.pathJoin(outputDirectory, fileName))){ if (gocryptfsVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), outputPath)){
Toast.makeText(applicationContext, getString(R.string.picture_save_success, fileName), Toast.LENGTH_SHORT).show() Toast.makeText(applicationContext, getString(R.string.picture_save_success, outputPath), Toast.LENGTH_SHORT).show()
} else { } else {
ColoredAlertDialogBuilder(this@CameraActivity) ColoredAlertDialogBuilder(this@CameraActivity)
.setTitle(R.string.error) .setTitle(R.string.error)
@ -327,11 +366,17 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
}) })
} }
private fun onClickTakePhoto() { private fun getOutputPath(isVideo: Boolean): String {
val baseName = "IMG_"+dateFormat.format(Date())+"_" val baseName = if (isVideo) {"VID"} else {"IMG"}+'_'+dateFormat.format(Date())+'_'
var fileName: String
do { 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)) } while (gocryptfsVolume.pathExists(fileName))
return PathUtils.pathJoin(outputDirectory, fileName)
}
private fun onClickTakePhoto() {
val path = getOutputPath(false)
if (timerDuration > 0){ if (timerDuration > 0){
binding.textTimer.visibility = View.VISIBLE binding.textTimer.visibility = View.VISIBLE
Thread{ Thread{
@ -340,12 +385,47 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
Thread.sleep(1000) Thread.sleep(1000)
} }
runOnUiThread { runOnUiThread {
takePhoto() takePhoto(path)
binding.textTimer.visibility = View.GONE binding.textTimer.visibility = View.GONE
} }
}.start() }.start()
} else { } 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() { override fun onPause() {
super.onPause() super.onPause()
sensorOrientationListener.remove(this) sensorOrientationListener.remove(this)
if ( if (!isAskingPermissions && !usf_keep_open) {
(ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) //not asking for permission
&& !usf_keep_open
){
finish() finish()
} }
} }

View File

@ -171,7 +171,7 @@ class ExplorerActivity : BaseExplorerActivity() {
listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder), listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder),
listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown), listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown),
listOf("createFolder", R.string.mkdir, R.drawable.icon_create_new_folder), 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) ColoredAlertDialogBuilder(this)
.setSingleChoiceItems(adapter, -1){ thisDialog, which -> .setSingleChoiceItems(adapter, -1){ thisDialog, which ->
@ -215,7 +215,7 @@ class ExplorerActivity : BaseExplorerActivity() {
"createFolder" -> { "createFolder" -> {
openDialogCreateFolder() openDialogCreateFolder()
} }
"takePhoto" -> { "camera" -> {
val intent = Intent(this, CameraActivity::class.java) val intent = Intent(this, CameraActivity::class.java)
intent.putExtra("path", currentDirectoryPath) intent.putExtra("path", currentDirectoryPath)
intent.putExtra("sessionID", gocryptfsVolume.sessionID) intent.putExtra("sessionID", gocryptfsVolume.sessionID)

View File

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

View File

@ -0,0 +1,7 @@
package sushi.hardcore.droidfs.video_recording
interface SeekableWriter {
fun write(byteArray: ByteArray)
fun seek(offset: Long)
fun close()
}

File diff suppressed because it is too large Load Diff

View File

@ -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.
*
* <p>In the earlier stage, the VideoCapture is deprioritized.
*/
@SuppressLint("RestrictedApi")
public final class VideoCaptureConfig
implements UseCaseConfig<VideoCapture>,
ImageOutputConfig,
ThreadConfig {
// Option Declarations:
// *********************************************************************************************
public static final Option<Integer> OPTION_VIDEO_FRAME_RATE =
Option.create("camerax.core.videoCapture.recordingFrameRate", int.class);
public static final Option<Integer> OPTION_BIT_RATE =
Option.create("camerax.core.videoCapture.bitRate", int.class);
public static final Option<Integer> OPTION_INTRA_FRAME_INTERVAL =
Option.create("camerax.core.videoCapture.intraFrameInterval", int.class);
public static final Option<Integer> OPTION_AUDIO_BIT_RATE =
Option.create("camerax.core.videoCapture.audioBitRate", int.class);
public static final Option<Integer> OPTION_AUDIO_SAMPLE_RATE =
Option.create("camerax.core.videoCapture.audioSampleRate", int.class);
public static final Option<Integer> OPTION_AUDIO_CHANNEL_COUNT =
Option.create("camerax.core.videoCapture.audioChannelCount", int.class);
public static final Option<Integer> 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 <code>valueIfMissing</code> 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 <code>valueIfMissing</code> 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 <code>valueIfMissing</code> 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 <code>valueIfMissing</code> 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 <code>valueIfMissing</code> 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 <code>valueIfMissing</code> 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 <code>valueIfMissing</code> 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.
*
* <p>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;
}
}

View File

@ -0,0 +1,125 @@
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/channel_layout.h>
#include <jni.h>
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);
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17,10.5V7c0,-0.55 -0.45,-1 -1,-1H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1v-3.5l4,4v-11l-4,4z"/>
</vector>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="oval">
<solid android:color="#ff0000" />
<size android:width="75dp" android:height="75dp" />
<stroke android:width="15dp" android:color="#444444"/>
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="#ff0000"/>
<size android:width="75dp" android:height="75dp" />
<stroke android:width="8dp" android:color="#444444"/>
</shape>
</item>
</selector>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#ff0000"/>
<size android:width="5dp" android:height="5dp" />
<stroke android:color="#00000000" android:width="45dp"/>
</shape>
</item>
<item>
<shape android:shape="oval">
<size android:width="75dp" android:height="75dp" />
<stroke android:width="8dp" android:color="#444444"/>
</shape>
</item>
</layer-list>

View File

@ -4,7 +4,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".CameraActivity"> tools:context=".CameraActivity"
android:background="#000000">
<androidx.camera.view.PreviewView <androidx.camera.view.PreviewView
android:id="@+id/camera_preview" android:id="@+id/camera_preview"
@ -43,15 +44,15 @@
android:layout_height="30dp" android:layout_height="30dp"
android:src="@drawable/icon_timer_off" android:src="@drawable/icon_timer_off"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/image_close" app:layout_constraintEnd_toStartOf="@id/image_flash"
app:layout_constraintStart_toEndOf="@id/image_ratio" app:layout_constraintStart_toEndOf="@id/image_ratio"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<ImageView <ImageView
android:id="@+id/image_close" android:id="@+id/image_flash"
android:layout_width="30dp" android:layout_width="30dp"
android:layout_height="30dp" android:layout_height="30dp"
android:src="@drawable/icon_close" android:src="@drawable/icon_flash_auto"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/image_timer" app:layout_constraintStart_toEndOf="@id/image_timer"
@ -74,27 +75,41 @@
android:layout_marginBottom="30dp"> android:layout_marginBottom="30dp">
<ImageView <ImageView
android:id="@+id/image_flash" android:id="@+id/image_mode_switch"
android:layout_width="30dp" android:layout_width="30dp"
android:layout_height="30dp" android:layout_height="30dp"
android:src="@drawable/icon_flash_auto" android:src="@drawable/icon_video"
android:layout_alignEnd="@id/take_photo_button" android:layout_alignEnd="@id/layout_record_buttons"
android:layout_marginEnd="120dp" android:layout_marginEnd="120dp"
android:layout_centerVertical="true"/> android:layout_centerVertical="true"/>
<sushi.hardcore.droidfs.widgets.TakePhotoButton <RelativeLayout
android:id="@+id/take_photo_button" android:id="@+id/layout_record_buttons"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:src="@drawable/take_photo_button" android:layout_centerInParent="true">
android:layout_centerInParent="true"/>
<ImageView
android:id="@+id/record_video_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/record_video_button"
android:visibility="gone"/>
<sushi.hardcore.droidfs.widgets.TakePhotoButton
android:id="@+id/take_photo_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/take_photo_button"/>
</RelativeLayout>
<ImageView <ImageView
android:id="@+id/image_camera_switch" android:id="@+id/image_camera_switch"
android:layout_width="30dp" android:layout_width="30dp"
android:layout_height="30dp" android:layout_height="30dp"
android:src="@drawable/icon_camera_front" android:src="@drawable/icon_camera_front"
android:layout_alignStart="@id/take_photo_button" android:layout_alignStart="@id/layout_record_buttons"
android:layout_marginStart="120dp" android:layout_marginStart="120dp"
android:layout_centerVertical="true"/> android:layout_centerVertical="true"/>

View File

@ -144,7 +144,7 @@
<string name="copy_failed">Falha ao copiar o %s .</string> <string name="copy_failed">Falha ao copiar o %s .</string>
<string name="copy_success">Cópia feita!</string> <string name="copy_success">Cópia feita!</string>
<string name="fab_dialog_title">Adicionar</string> <string name="fab_dialog_title">Adicionar</string>
<string name="take_photo">Tirar foto</string> <string name="camera">Tirar foto</string>
<string name="picture_save_success">Foto salva ao %s</string> <string name="picture_save_success">Foto salva ao %s</string>
<string name="picture_save_failed">Falha ao salvar esta imagem.</string> <string name="picture_save_failed">Falha ao salvar esta imagem.</string>
<string name="file_overwrite_question">%s já existe, você quer substitui-lo ?</string> <string name="file_overwrite_question">%s já existe, você quer substitui-lo ?</string>

View File

@ -141,7 +141,7 @@
<string name="copy_failed">Ошибка при копировании %s.</string> <string name="copy_failed">Ошибка при копировании %s.</string>
<string name="copy_success">Копирование выполнено!</string> <string name="copy_success">Копирование выполнено!</string>
<string name="fab_dialog_title">Добавить</string> <string name="fab_dialog_title">Добавить</string>
<string name="take_photo">Сфотографировать</string> <string name="camera">Сфотографировать</string>
<string name="picture_save_success">Изображение сохранено в %s</string> <string name="picture_save_success">Изображение сохранено в %s</string>
<string name="picture_save_failed">Невозможно сохранить изображение.</string> <string name="picture_save_failed">Невозможно сохранить изображение.</string>
<string name="file_overwrite_question">%s уже существует, переписать его?</string> <string name="file_overwrite_question">%s уже существует, переписать его?</string>

View File

@ -146,9 +146,10 @@
<string name="copy_failed">Copy of %s failed.</string> <string name="copy_failed">Copy of %s failed.</string>
<string name="copy_success">Copy successful !</string> <string name="copy_success">Copy successful !</string>
<string name="fab_dialog_title">Add</string> <string name="fab_dialog_title">Add</string>
<string name="take_photo">Take photo</string> <string name="camera">Camera</string>
<string name="picture_save_success">Picture saved to %s</string> <string name="picture_save_success">Picture saved to %s</string>
<string name="picture_save_failed">Failed to save this picture.</string> <string name="picture_save_failed">Failed to save this picture.</string>
<string name="video_save_success">Video saved to %s</string>
<string name="file_overwrite_question">%s already exists, do you want to overwrite it ?</string> <string name="file_overwrite_question">%s already exists, do you want to overwrite it ?</string>
<string name="dir_overwrite_question">%s already exists, do you want to merge its content ?</string> <string name="dir_overwrite_question">%s already exists, do you want to merge its content ?</string>
<string name="enter_new_name">Enter new name</string> <string name="enter_new_name">Enter new name</string>

View File

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = "1.5.30" ext.kotlin_version = "1.5.31"
repositories { repositories {
google() google()
mavenCentral() mavenCentral()