Compare commits
7 commits
Author | SHA1 | Date | |
---|---|---|---|
|
1614cd1aa2 | ||
|
b87a9a6209 | ||
|
00c95cf270 | ||
|
37d5c191e9 | ||
|
86e06e8d31 | ||
|
38fdeb1667 | ||
|
7f51b02636 |
.gitignoreREADME.md
app
build.gradle
src
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,7 +8,6 @@
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/app/release
|
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
|
7
README.md
Normal file
7
README.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
Zmingz
|
||||||
|
======
|
||||||
|
|
||||||
|
Stupid little mirror app for Android using [(mobile) FFmpeg][mobile-ffmpeg] done
|
||||||
|
in an afternoon for the sake of it.
|
||||||
|
|
||||||
|
[mobile-ffmpeg]: https://github.com/tanersener/mobile-ffmpeg
|
|
@ -8,17 +8,16 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "dev.lowrespalmtree.zmingz"
|
applicationId "dev.lowrespalmtree.zmingz"
|
||||||
minSdkVersion 26
|
minSdkVersion 24
|
||||||
targetSdkVersion 29
|
targetSdkVersion 29
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
package dev.lowrespalmtree.zmingz
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ExampleInstrumentedTest {
|
|
||||||
@Test
|
|
||||||
fun useAppContext() {
|
|
||||||
// Context of the app under test.
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
assertEquals("dev.lowrespalmtree.zmingz", appContext.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,6 +2,8 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="dev.lowrespalmtree.zmingz">
|
package="dev.lowrespalmtree.zmingz">
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package dev.lowrespalmtree.zmingz
|
package dev.lowrespalmtree.zmingz
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
@ -16,6 +17,7 @@ import android.widget.PopupMenu
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL
|
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL
|
||||||
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS
|
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS
|
||||||
import com.arthenica.mobileffmpeg.FFmpeg
|
import com.arthenica.mobileffmpeg.FFmpeg
|
||||||
|
@ -35,6 +37,20 @@ class MainActivity: AppCompatActivity() {
|
||||||
private var savingPath = ""
|
private var savingPath = ""
|
||||||
/** Temporary type for the media to save, to use after perms being granted. */
|
/** Temporary type for the media to save, to use after perms being granted. */
|
||||||
private var savingType = MediaType.IMAGE
|
private var savingType = MediaType.IMAGE
|
||||||
|
/** Path for storing camera image. */
|
||||||
|
private var cameraImagePath = ""
|
||||||
|
|
||||||
|
/** Path to media cache dir, ensuring it exists. */
|
||||||
|
private val mediaCacheDir: File?
|
||||||
|
get() {
|
||||||
|
val mediaCacheDir = File(cacheDir, "media")
|
||||||
|
if (!mediaCacheDir.exists() && !mediaCacheDir.mkdir()) {
|
||||||
|
Log.e(TAG, "Could not create cache media dir")
|
||||||
|
Toast.makeText(this, R.string.write_error, Toast.LENGTH_SHORT).show()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return mediaCacheDir
|
||||||
|
}
|
||||||
|
|
||||||
enum class MediaType { IMAGE, VIDEO }
|
enum class MediaType { IMAGE, VIDEO }
|
||||||
|
|
||||||
|
@ -59,6 +75,8 @@ class MainActivity: AppCompatActivity() {
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
when (requestCode) {
|
when (requestCode) {
|
||||||
REQ_PICK_IMG, REQ_PICK_VID -> handlePickResult(requestCode, resultCode, data)
|
REQ_PICK_IMG, REQ_PICK_VID -> handlePickResult(requestCode, resultCode, data)
|
||||||
|
REQ_TAKE_IMG -> handleTakeImageResult(resultCode)
|
||||||
|
REQ_TAKE_VID -> handleTakeVideoResult(resultCode, data)
|
||||||
else -> super.onActivityResult(requestCode, resultCode, data)
|
else -> super.onActivityResult(requestCode, resultCode, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -79,6 +97,13 @@ class MainActivity: AppCompatActivity() {
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getUriForFile(file: File): Uri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
this,
|
||||||
|
"dev.lowrespalmtree.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
|
||||||
fun openFile(v: View) {
|
fun openFile(v: View) {
|
||||||
val req: Int
|
val req: Int
|
||||||
val pickIntent = when (v.id) {
|
val pickIntent = when (v.id) {
|
||||||
|
@ -116,6 +141,27 @@ class MainActivity: AppCompatActivity() {
|
||||||
menu.show()
|
menu.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openCamera(v: View) {
|
||||||
|
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
|
||||||
|
Log.e(TAG, "No camera feature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (v.id) {
|
||||||
|
buttonCamera.id -> {
|
||||||
|
val inputFile = File(mediaCacheDir, "${System.currentTimeMillis()}.jpg")
|
||||||
|
cameraImagePath = inputFile.canonicalPath
|
||||||
|
val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||||
|
.apply { putExtra(MediaStore.EXTRA_OUTPUT, getUriForFile(inputFile)) }
|
||||||
|
startActivityForResult(captureIntent, REQ_TAKE_IMG)
|
||||||
|
}
|
||||||
|
buttonCameraVideo.id -> {
|
||||||
|
val captureIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE)
|
||||||
|
startActivityForResult(captureIntent, REQ_TAKE_VID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun mediaTypeFromViewId(id: Int): MediaType? =
|
private fun mediaTypeFromViewId(id: Int): MediaType? =
|
||||||
when (id) {
|
when (id) {
|
||||||
imageView1.id, imageView2.id -> MediaType.IMAGE
|
imageView1.id, imageView2.id -> MediaType.IMAGE
|
||||||
|
@ -125,6 +171,8 @@ class MainActivity: AppCompatActivity() {
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
private fun handlePickResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
private fun handlePickResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
if (resultCode != Activity.RESULT_OK)
|
||||||
|
return
|
||||||
val uri = data?.data
|
val uri = data?.data
|
||||||
?: return Unit.also { Log.e(TAG, "No intent or data") }
|
?: return Unit.also { Log.e(TAG, "No intent or data") }
|
||||||
val type = when (requestCode) {
|
val type = when (requestCode) {
|
||||||
|
@ -135,6 +183,21 @@ class MainActivity: AppCompatActivity() {
|
||||||
processMedia(uri, type)
|
processMedia(uri, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleTakeImageResult(resultCode: Int) {
|
||||||
|
if (resultCode != Activity.RESULT_OK)
|
||||||
|
return
|
||||||
|
processMedia(cameraImagePath, MediaType.IMAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleTakeVideoResult(resultCode: Int, data: Intent?) {
|
||||||
|
if (resultCode != Activity.RESULT_OK)
|
||||||
|
return
|
||||||
|
data?.data
|
||||||
|
?.let { processMedia(it, MediaType.VIDEO) }
|
||||||
|
?: return Unit. also { Log.e(TAG, "No data in intent or invalid URI") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process media at URI, copying to a local cache file for FFmpeg beforehand. */
|
||||||
private fun processMedia(uri: Uri, type: MediaType) {
|
private fun processMedia(uri: Uri, type: MediaType) {
|
||||||
val uriPath = uri.path
|
val uriPath = uri.path
|
||||||
?: return Unit.also { Log.e(TAG, "No path in URI") }
|
?: return Unit.also { Log.e(TAG, "No path in URI") }
|
||||||
|
@ -149,23 +212,30 @@ class MainActivity: AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaCacheDir = File(cacheDir, "media")
|
processMedia(inputFile.canonicalPath, type)
|
||||||
if (!mediaCacheDir.exists() && !mediaCacheDir.mkdir()) {
|
|
||||||
Log.e(TAG, "Could not create cache media dir")
|
|
||||||
Toast.makeText(this, R.string.write_error, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Start the async mirroring tasks. */
|
||||||
|
private fun processMedia(path: String, type: MediaType) {
|
||||||
imageLayout.visibility = View.GONE
|
imageLayout.visibility = View.GONE
|
||||||
videoLayout.visibility = View.GONE
|
videoLayout.visibility = View.GONE
|
||||||
Toast.makeText(this, R.string.please_wait, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.please_wait, Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
val extension = getFileExtension(path).let {
|
||||||
|
if (it.isEmpty())
|
||||||
|
when (type) {
|
||||||
|
MediaType.IMAGE -> "jpg"
|
||||||
|
MediaType.VIDEO -> "mp4"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
it
|
||||||
|
}
|
||||||
val outputFile1 = File.createTempFile("output1", ".$extension", mediaCacheDir)
|
val outputFile1 = File.createTempFile("output1", ".$extension", mediaCacheDir)
|
||||||
MirrorTask(WeakReference(this), type, 1)
|
MirrorTask(WeakReference(this), type, 1)
|
||||||
.execute(inputFile.canonicalPath, outputFile1.canonicalPath, VF1)
|
.execute(path, outputFile1.canonicalPath, VF1)
|
||||||
val outputFile2 = File.createTempFile("output2", ".$extension", mediaCacheDir)
|
val outputFile2 = File.createTempFile("output2", ".$extension", mediaCacheDir)
|
||||||
MirrorTask(WeakReference(this), type, 2)
|
MirrorTask(WeakReference(this), type, 2)
|
||||||
.execute(inputFile.canonicalPath, outputFile2.canonicalPath, VF2)
|
.execute(path, outputFile2.canonicalPath, VF2)
|
||||||
}
|
}
|
||||||
|
|
||||||
class MirrorTask(
|
class MirrorTask(
|
||||||
|
@ -250,7 +320,15 @@ class MainActivity: AppCompatActivity() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val extension = getFileExtension(path).let { if (it.isEmpty()) "xxx" else it }
|
val extension = getFileExtension(path).let {
|
||||||
|
if (it.isEmpty())
|
||||||
|
when (type) {
|
||||||
|
MediaType.IMAGE -> "jpg"
|
||||||
|
MediaType.VIDEO -> "mp4"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
it
|
||||||
|
}
|
||||||
val outputFile = File(mediaDir.canonicalPath, "${System.currentTimeMillis()}.$extension")
|
val outputFile = File(mediaDir.canonicalPath, "${System.currentTimeMillis()}.$extension")
|
||||||
if (!outputFile.createNewFile()) {
|
if (!outputFile.createNewFile()) {
|
||||||
Log.e(TAG, "Failed to create new file: $outputFile")
|
Log.e(TAG, "Failed to create new file: $outputFile")
|
||||||
|
@ -267,11 +345,7 @@ class MainActivity: AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun share(path: String, type: MediaType) {
|
private fun share(path: String, type: MediaType) {
|
||||||
val uri = FileProvider.getUriForFile(
|
val uri = getUriForFile(File(path))
|
||||||
this,
|
|
||||||
"dev.lowrespalmtree.fileprovider",
|
|
||||||
File(path)
|
|
||||||
)
|
|
||||||
Log.i(TAG, "share with uri $uri")
|
Log.i(TAG, "share with uri $uri")
|
||||||
val shareIntent = Intent().also {
|
val shareIntent = Intent().also {
|
||||||
it.action = Intent.ACTION_SEND
|
it.action = Intent.ACTION_SEND
|
||||||
|
@ -289,6 +363,8 @@ class MainActivity: AppCompatActivity() {
|
||||||
private const val REQ_PICK_IMG = 1
|
private const val REQ_PICK_IMG = 1
|
||||||
private const val REQ_PICK_VID = 2
|
private const val REQ_PICK_VID = 2
|
||||||
private const val REQ_WRITE_PERM = 3
|
private const val REQ_WRITE_PERM = 3
|
||||||
|
private const val REQ_TAKE_IMG = 4
|
||||||
|
private const val REQ_TAKE_VID = 5
|
||||||
private const val VF1 = "crop=iw/2:ih:0:0,split[left][tmp];[tmp]hflip[right];[left][right] hstack"
|
private const val VF1 = "crop=iw/2:ih:0:0,split[left][tmp];[tmp]hflip[right];[left][right] hstack"
|
||||||
private const val VF2 = "crop=iw/2:ih:iw/2:0,split[left][tmp];[tmp]hflip[right];[right][left] hstack"
|
private const val VF2 = "crop=iw/2:ih:iw/2:0,split[left][tmp];[tmp]hflip[right];[right][left] hstack"
|
||||||
|
|
||||||
|
|
|
@ -83,6 +83,28 @@
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
style="@style/Widget.AppCompat.ButtonBar">
|
style="@style/Widget.AppCompat.ButtonBar">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/buttonCamera"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:onClick="openCamera"
|
||||||
|
android:src="@android:drawable/ic_menu_camera"
|
||||||
|
style="?android:attr/buttonBarButtonStyle"
|
||||||
|
android:contentDescription="@android:string/untitled" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/buttonCameraVideo"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:onClick="openCamera"
|
||||||
|
android:src="@android:drawable/ic_media_play"
|
||||||
|
style="?android:attr/buttonBarButtonStyle"
|
||||||
|
android:contentDescription="@android:string/untitled" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/buttonImage"
|
android:id="@+id/buttonImage"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Zmingz</string>
|
<string name="app_name">Zmingz</string>
|
||||||
|
|
||||||
<string name="open_image">Image</string>
|
<string name="open_image">Image…</string>
|
||||||
<string name="open_video">Video</string>
|
<string name="open_video">Video…</string>
|
||||||
<string name="please_wait">Please wait...</string>
|
<string name="please_wait">Please wait…</string>
|
||||||
<string name="save">Save</string>
|
<string name="save">Save</string>
|
||||||
<string name="share">Share</string>
|
<string name="share">Share</string>
|
||||||
<string name="write_error">Write error.</string>
|
<string name="write_error">Write error.</string>
|
||||||
<string name="send">Send</string>
|
<string name="send">Send</string>
|
||||||
|
<string name="open_camera">Camera</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
Reference in a new issue