You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

310 lines
11 KiB

package io.lowrespalmtree.harvestdawn
import android.Manifest
import android.app.Activity
import android.content.ContentValues
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.MediaController
import android.widget.ProgressBar
import android.widget.Toast
import android.widget.VideoView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS
import com.arthenica.mobileffmpeg.FFmpeg
import com.arthenica.mobileffmpeg.FFprobe
import com.google.android.material.floatingactionbutton.FloatingActionButton
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.lang.ref.WeakReference
class MainActivity : AppCompatActivity() {
private val spinner: ProgressBar? get() = findViewById(R.id.progressBar)
private val videoView: VideoView? get() = findViewById(R.id.videoView)
private var hasWritePermission = false
private var currentVideoPath: String? = null
private var currentVideoUri: Uri? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(findViewById(R.id.toolbar))
ensureWritePermission()
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener { view ->
if (hasWritePermission) {
openCamera()
} else {
ensureWritePermission()
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
menuInflater.inflate(R.menu.menu_main, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
return when (item.itemId) {
R.id.action_share -> {
share(); true
}
R.id.action_save -> {
save(); true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQ_TAKE_VID -> {
if (resultCode != Activity.RESULT_OK)
return
data?.data?.let { processVideo(it) }
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
private fun ensureWritePermission() {
val writePermission = Manifest.permission.WRITE_EXTERNAL_STORAGE
val permissionStatus = checkSelfPermission(writePermission)
if (permissionStatus == PackageManager.PERMISSION_GRANTED) {
hasWritePermission = true
} else {
requestPermissions(arrayOf(writePermission), REQ_WRITE_PERM)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == REQ_WRITE_PERM) {
if (permissions[0] == Manifest.permission.WRITE_EXTERNAL_STORAGE) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
hasWritePermission = true
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun openCamera() {
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
Log.e(TAG, "No camera available.")
return
}
// Provide output URI pointing to our shareable cache folder, so it is not written to the
// user's public camera dir.
val videoUri = FileProvider.getUriForFile(
this,
"dev.lowrespalmtree.harvestdawn.fileprovider",
File.createTempFile("input", ".mp4", getShareableDir() ?: return)
)
val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1) // might wanna make 0 an option
putExtra(MediaStore.EXTRA_OUTPUT, videoUri)
}
startActivityForResult(intent, REQ_TAKE_VID)
}
private fun processVideo(videoUri: Uri) {
currentVideoPath = null
currentVideoUri = null
// Save captured video for FFMPEG. Use MP4 extension, completely arbitrary.
val inputFile = File.createTempFile("input", ".mp4", cacheDir)
contentResolver.openInputStream(videoUri).use { inputStream ->
if (inputStream == null)
return Unit .also { Log.e(TAG, "Could not open input file") }
FileOutputStream(inputFile).use { fos ->
fos.buffered().write(inputStream.buffered().readBytes())
}
}
val mediaInfo = FFprobe.getMediaInformation(inputFile.canonicalPath)
// Detect vertical/horizontal resolution, because at the moment we downscale everything to
// fit Telegram's media preview requirements. If FFprobe fails to returns data, we consider
// by default the video to be vertical.
val isVertical = mediaInfo?.let {
it.streams.getOrNull(0)?.let { streamInformation ->
streamInformation.height > streamInformation.width
}
} ?: true
val duration = mediaInfo?.duration ?: 0
// Save the soundtrack as well cause we can't pass a resource to FFMPEG.
// Should be done only once, then it's kept in app internal storage.
val soundFile = File(filesDir, "harvestdawn.m4a")
if (!soundFile.exists()) {
resources.openRawResource(R.raw.harvestdawn).use { resource ->
FileOutputStream(soundFile).use { fos ->
fos.buffered().write(resource.buffered().readBytes())
}
}
}
// Mix both.
val outputDir = getShareableDir() ?: return
val outputFile = File.createTempFile("npc", ".mp4", outputDir)
MixTask(WeakReference(this), duration, isVertical)
.execute(inputFile.canonicalPath, soundFile.canonicalPath, outputFile.canonicalPath)
}
private fun getShareableDir(): File? {
val outputDir = File(cacheDir, "media")
if (!outputDir.exists() && !outputDir.mkdir())
return null .also { toast("Can't create output directory.") }
return outputDir
}
private class MixTask(
private val activity: WeakReference<MainActivity>,
private val duration: Long,
private val videoIsVertical: Boolean
): AsyncTask<String, Void, Boolean>() {
private lateinit var videoPath: String
private lateinit var outputPath: String
override fun onPreExecute() {
activity.get()?.setLoading(true)
}
override fun doInBackground(vararg params: String?): Boolean {
videoPath = params[0]!!
val audioPath = params[1]!!
outputPath = params[2]!!
val width = if (videoIsVertical) 480 else 640
val durationOpt = if (duration > 0) "-t ${duration}ms" else ""
val command = (
"-i $videoPath -i $audioPath"
+ " -filter_complex amix=duration=longest"
+ " -c:v libx264 -crf 26 -vf scale=$width:-1 -pix_fmt yuv420p"
+ " $durationOpt -y $outputPath"
)
Log.d(TAG, "Calling FFmpeg with command: $command")
return when (val rc = FFmpeg.execute(command)) {
RETURN_CODE_SUCCESS -> true.also { Log.i(TAG, "Mix succeeded.") }
RETURN_CODE_CANCEL -> false.also { Log.i(TAG, "Mix cancelled.") }
else -> false .also { Log.e(TAG, "Mix failed!") }
}
}
override fun onPostExecute(result: Boolean?) {
File(videoPath).delete()
activity.get()?.let {
if (result == true)
it.handleFfmpegOutput(outputPath)
else
it.setLoading(false)
}
}
}
private fun setLoading(isLoading: Boolean) {
if (isLoading) {
spinner?.visibility = View.VISIBLE
videoView?.visibility = View.INVISIBLE
} else {
spinner?.visibility = View.INVISIBLE
}
}
private fun handleFfmpegOutput(outputPath: String) {
setLoading(false)
currentVideoPath = outputPath
videoView?.let {
it.visibility = View.VISIBLE
it.setVideoPath(outputPath)
it.setOnPreparedListener { mp ->
val mediaController = MediaController(this)
mp.setOnVideoSizeChangedListener { _, _, _ ->
it.setMediaController(mediaController)
mediaController.setAnchorView(it)
}
mediaController.show(0) }
it.start()
}
}
private fun save(): Boolean {
if (currentVideoPath == null)
return false
val videoStore =
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q)
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
else
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
val videoDetails = ContentValues().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.IS_PENDING, 1)
}
}
currentVideoUri = contentResolver.insert(videoStore, videoDetails)
?: return false .also { toast("Could not save video to media store.") }
contentResolver.openFileDescriptor(currentVideoUri!!, "w").use { pfd ->
FileOutputStream(pfd?.fileDescriptor).use { fos ->
FileInputStream(currentVideoPath).use { fis ->
fos.buffered().write(fis.buffered().readBytes())
}
}
}
videoDetails.apply {
clear()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.IS_PENDING, 0)
}
}
contentResolver.update(currentVideoUri!!, videoDetails, null, null)
toast("Video saved to media store!")
return true
}
private fun share() {
if (currentVideoUri == null) {
if (currentVideoPath == null)
return
if (!save())
return
}
Log.i(TAG, "Sharing with URI $currentVideoUri")
val shareIntent = Intent().also {
it.action = Intent.ACTION_SEND
it.type = "video/mp4"
it.putExtra(Intent.EXTRA_STREAM, currentVideoUri)
}
startActivity(Intent.createChooser(shareIntent, getString(R.string.send)))
}
private fun toast(text: String) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
companion object {
private const val TAG = "HarvestDawn"
private const val REQ_TAKE_VID = 1
private const val REQ_WRITE_PERM = 2
}
}