commit 5c4990bd29d63097de9398890966639d85c9d4a2 Author: dece Date: Mon Mar 8 16:04:00 2021 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f1d137 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..67e07b8 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +/release diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..e05d9c3 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' + +android { + compileSdkVersion 29 + + defaultConfig { + applicationId "io.lowrespalmtree.harvestdawn" + minSdkVersion 24 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildToolsVersion '29.0.2' +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.2' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.3' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.3' + + implementation 'com.arthenica:mobile-ffmpeg-min-gpl:4.3.2' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..da905b1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..9462f26 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/io/lowrespalmtree/harvestdawn/MainActivity.kt b/app/src/main/java/io/lowrespalmtree/harvestdawn/MainActivity.kt new file mode 100644 index 0000000..d2dac0d --- /dev/null +++ b/app/src/main/java/io/lowrespalmtree/harvestdawn/MainActivity.kt @@ -0,0 +1,310 @@ +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(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, + 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, + private val duration: Long, + private val videoIsVertical: Boolean + ): AsyncTask() { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..43e8572 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml new file mode 100644 index 0000000..7451fcd --- /dev/null +++ b/app/src/main/res/menu/menu_main.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..da66b81 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..87a7c1c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..678e790 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..f7d6953 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4de1b42 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9a994da Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7fff462 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..59804d8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0b77dbf Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..63f4ec3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0dd1816 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4501a65 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ae1cacb Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6b97a8a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da109e2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/raw/harvestdawn.m4a b/app/src/main/res/raw/harvestdawn.m4a new file mode 100644 index 0000000..2b2da5e Binary files /dev/null and b/app/src/main/res/raw/harvestdawn.m4a differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4faecfa --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #6200EE + #3700B3 + #03DAC5 + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..125df87 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + 16dp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..980442f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + Harvest Dawn + Share + + Hello first fragment + Hello second fragment. Arg: %1$s + Share + Save + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..21d9ced --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + +