From a101b528efdee74fc1970b7f1fe68263f0b20269 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Fri, 18 Aug 2017 14:39:40 -0700 Subject: Create media picker action items (#40) * Create action items * Increment version * Update camera action * Abstract camera action * Add and test * Refactor and add docs --- mediapicker/README.md | 21 ++- .../allanwang/kau/mediapicker/MediaActionItem.kt | 154 +++++++++++++++++++++ .../ca/allanwang/kau/mediapicker/MediaItemBasic.kt | 7 +- .../ca/allanwang/kau/mediapicker/MediaModel.kt | 8 ++ .../kau/mediapicker/MediaPickerActivityBase.kt | 12 +- .../mediapicker/MediaPickerActivityOverlayBase.kt | 5 +- .../allanwang/kau/mediapicker/MediaPickerCore.kt | 109 ++++++++++++++- .../ca/allanwang/kau/mediapicker/MediaType.kt | 16 ++- .../ca/allanwang/kau/mediapicker/MediaUtils.kt | 59 ++++++++ mediapicker/src/main/res/values/ids.xml | 4 + mediapicker/src/main/res/values/strings.xml | 7 + 11 files changed, 379 insertions(+), 23 deletions(-) create mode 100644 mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaActionItem.kt create mode 100644 mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaUtils.kt create mode 100644 mediapicker/src/main/res/values/ids.xml (limited to 'mediapicker') diff --git a/mediapicker/README.md b/mediapicker/README.md index a743a47..0eb6fd5 100644 --- a/mediapicker/README.md +++ b/mediapicker/README.md @@ -40,4 +40,23 @@ Note that this launches the activity through a `startActivityForResult` call You may get the activity response by overriding your `onActivityResult` method to first verify that the request code matches and then call `kauOnMediaPickerResult`, -which will return the list of MediaModels. \ No newline at end of file +which will return the list of MediaModels. + +## MediaActions + +On top of retrieving your media file, you may also add action items to the start +of the grid. All actions will return their results immediately, and retrieve media types based on the activity. + +### MediaActionCamera + +Gets an image or a video from the default camera. No permissions are necessary. +Note that since api 24, passing general uris may throw a [FileUriExposedException](https://developer.android.com/reference/android/os/FileUriExposedException.html), +so your own resolvers need to be passed for this to work. See the sample xml folder for an example. + +### MediaActionCameraVideo + +Given that getting videos do not require resolvers, this item can be used for videos only without any required arguments. + +### MediaActionGallery + +Defines whether you want to pick one or more media items from the default gallery app. \ No newline at end of file diff --git a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaActionItem.kt b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaActionItem.kt new file mode 100644 index 0000000..5910650 --- /dev/null +++ b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaActionItem.kt @@ -0,0 +1,154 @@ +package ca.allanwang.kau.mediapicker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import ca.allanwang.kau.iitems.KauIItem +import ca.allanwang.kau.permissions.PERMISSION_READ_EXTERNAL_STORAGE +import ca.allanwang.kau.permissions.PERMISSION_WRITE_EXTERNAL_STORAGE +import ca.allanwang.kau.permissions.kauRequestPermissions +import ca.allanwang.kau.utils.materialDialog +import ca.allanwang.kau.utils.string +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.mikepenz.iconics.typeface.IIcon +import java.io.File + + +/** + * Created by Allan Wang on 2017-08-17. + */ +class MediaActionItem( + val action: MediaAction, + val mediaType: MediaType +) : KauIItem(R.layout.kau_iitem_image_basic, { MediaItemBasic.ViewHolder(it) }, R.id.kau_item_media_action) { + + override fun isSelectable(): Boolean = false + + override fun bindView(holder: MediaItemBasic.ViewHolder, payloads: MutableList?) { + super.bindView(holder, payloads) + holder.image.apply { + setImageDrawable(MediaPickerCore.getIconDrawable(context, action.iicon(this@MediaActionItem), action.color)) + setOnClickListener { action.invoke(context, this@MediaActionItem) } + } + } + + override fun unbindView(holder: MediaItemBasic.ViewHolder) { + super.unbindView(holder) + holder.image.apply { + setImageDrawable(null) + setOnClickListener(null) + } + } +} + +interface MediaAction { + var color: Int + fun iicon(item: MediaActionItem): IIcon + fun invoke(c: Context, item: MediaActionItem) +} + +internal const val MEDIA_ACTION_REQUEST_CAMERA = 100 +internal const val MEDIA_ACTION_REQUEST_PICKER = 101 + +/** + * Dynamic camera items for both images and videos + * Given that images require a uri to save the file, they must be implemented on top + * of this abstract class. + * + * If you just wish to use videos, see [MediaActionCameraVideo] + */ +abstract class MediaActionCamera( + override var color: Int = MediaPickerCore.accentColor +) : MediaAction { + + abstract fun createFile(context: Context): File + abstract fun createUri(context: Context, file: File): Uri + + override fun iicon(item: MediaActionItem) = when (item.mediaType) { + MediaType.IMAGE -> GoogleMaterial.Icon.gmd_photo_camera + MediaType.VIDEO -> GoogleMaterial.Icon.gmd_videocam + } + + override fun invoke(c: Context, item: MediaActionItem) { + c.kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { + granted, _ -> + if (granted) { + val intent = Intent(item.mediaType.captureType) + if (intent.resolveActivity(c.packageManager) == null) { + c.materialDialog { + title(R.string.kau_no_camera_found) + content(R.string.kau_no_camera_found_content) + } + return@kauRequestPermissions + } + if (item.mediaType == MediaType.IMAGE) { + val file: File = try { + createFile(c) + } catch (e: java.io.IOException) { + c.materialDialog { + title(R.string.kau_error) + content(R.string.kau_temp_file_creation_failed) + } + return@kauRequestPermissions + } + intent.putExtra(MediaStore.EXTRA_OUTPUT, createUri(c, file)) + (c as? MediaPickerCore<*>)?.tempPath = file.absolutePath + } + (c as Activity).startActivityForResult(intent, MEDIA_ACTION_REQUEST_CAMERA) + } + } + } +} + +/** + * Basic camera action just for videos + */ +class MediaActionCameraVideo( + override var color: Int = MediaPickerCore.accentColor +) : MediaAction { + override fun iicon(item: MediaActionItem) = GoogleMaterial.Icon.gmd_videocam + override fun invoke(c: Context, item: MediaActionItem) { + val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE) + if (intent.resolveActivity(c.packageManager) == null) { + c.materialDialog { + title(R.string.kau_no_camera_found) + content(R.string.kau_no_camera_found_content) + } + return + } + (c as Activity).startActivityForResult(intent, MEDIA_ACTION_REQUEST_CAMERA) + } +} + +/** + * Opens a picker for the type specified in the activity + * The type will be added programmatically + */ +class MediaActionGallery( + val multiple: Boolean = false, + override var color: Int = MediaPickerCore.accentColor +) : MediaAction { + + override fun iicon(item: MediaActionItem) = when (item.mediaType) { + MediaType.IMAGE -> if (multiple) GoogleMaterial.Icon.gmd_photo_library else GoogleMaterial.Icon.gmd_photo + MediaType.VIDEO -> GoogleMaterial.Icon.gmd_video_library + } + + override fun invoke(c: Context, item: MediaActionItem) { + c.kauRequestPermissions(PERMISSION_READ_EXTERNAL_STORAGE) { + granted, _ -> + if (granted) { + val intent = Intent().apply { + type = item.mediaType.mimeType + action = Intent.ACTION_GET_CONTENT + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) + } + (c as Activity).startActivityForResult( + Intent.createChooser(intent, c.string(R.string.kau_select_media)), + MEDIA_ACTION_REQUEST_PICKER) + } + } + } +} \ No newline at end of file diff --git a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaItemBasic.kt b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaItemBasic.kt index c28ed29..e546afb 100644 --- a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaItemBasic.kt +++ b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaItemBasic.kt @@ -30,12 +30,7 @@ class MediaItemBasic(val data: MediaModel) fastAdapter.withSelectable(false) //add image data and return right away .withOnClickListener { _, _, item, _ -> - val intent = Intent() - val data = arrayListOf(item.data) - intent.putParcelableArrayListExtra(MEDIA_PICKER_RESULT, data) - activity.setResult(AppCompatActivity.RESULT_OK, intent) - if (buildIsLollipopAndUp) activity.finishAfterTransition() - else activity.finish() + activity.finish(arrayListOf(item.data)) true } } diff --git a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaModel.kt b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaModel.kt index 9188dd6..ae85558 100644 --- a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaModel.kt +++ b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaModel.kt @@ -27,6 +27,14 @@ data class MediaModel( cursor.getString(4) ) + constructor(f: File) : this( + f.absolutePath, + f.extension, // this isn't a mime type, but it does give some info + f.length(), + f.lastModified(), + f.nameWithoutExtension + ) + constructor(parcel: Parcel) : this( parcel.readString(), parcel.readString(), diff --git a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerActivityBase.kt b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerActivityBase.kt index 2ba6b43..c3b6396 100644 --- a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerActivityBase.kt +++ b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerActivityBase.kt @@ -1,6 +1,5 @@ package ca.allanwang.kau.mediapicker -import android.content.Intent import android.database.Cursor import android.os.Bundle import android.support.design.widget.AppBarLayout @@ -21,7 +20,10 @@ import com.mikepenz.google_material_typeface_library.GoogleMaterial * Images are blurred when selected, and multiple images can be selected at a time. * Having three layered images makes this slightly slower than [MediaPickerActivityOverlayBase] */ -abstract class MediaPickerActivityBase(mediaType: MediaType) : MediaPickerCore(mediaType) { +abstract class MediaPickerActivityBase( + mediaType: MediaType, + mediaActions: List = emptyList() +) : MediaPickerCore(mediaType, mediaActions) { val coordinator: CoordinatorLayout by bindView(R.id.kau_coordinator) val toolbar: Toolbar by bindView(R.id.kau_toolbar) @@ -57,11 +59,7 @@ abstract class MediaPickerActivityBase(mediaType: MediaType) : MediaPickerCore(mediaType) { +abstract class MediaPickerActivityOverlayBase( + mediaType: MediaType, + mediaActions: List = emptyList() +) : MediaPickerCore(mediaType, mediaActions) { val draggable: ElasticDragDismissFrameLayout by bindView(R.id.kau_draggable) val recycler: RecyclerView by bindView(R.id.kau_recyclerview) diff --git a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerCore.kt b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerCore.kt index 71449d3..6f0241c 100644 --- a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerCore.kt +++ b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaPickerCore.kt @@ -2,12 +2,16 @@ package ca.allanwang.kau.mediapicker import android.Manifest import android.app.Activity +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.database.Cursor import android.graphics.Color import android.graphics.drawable.Drawable +import android.net.Uri import android.os.Bundle +import android.provider.BaseColumns +import android.provider.DocumentsContract import android.provider.MediaStore import android.support.v4.app.LoaderManager import android.support.v4.content.CursorLoader @@ -18,15 +22,19 @@ import ca.allanwang.kau.animators.FadeScaleAnimatorAdd import ca.allanwang.kau.animators.KauAnimator import ca.allanwang.kau.internal.KauBaseActivity import ca.allanwang.kau.kotlin.lazyContext +import ca.allanwang.kau.logging.KL import ca.allanwang.kau.permissions.kauRequestPermissions import ca.allanwang.kau.utils.dimenPixelSize import ca.allanwang.kau.utils.toast import com.bumptech.glide.Glide import com.mikepenz.fastadapter.IItem +import com.mikepenz.fastadapter.adapters.HeaderAdapter import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter import com.mikepenz.google_material_typeface_library.GoogleMaterial import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.IIcon import org.jetbrains.anko.doAsync +import java.io.File import java.util.concurrent.ExecutionException import java.util.concurrent.Future @@ -35,7 +43,10 @@ import java.util.concurrent.Future * * Container for the main logic behind the both pickers */ -abstract class MediaPickerCore>(val mediaType: MediaType) : KauBaseActivity(), LoaderManager.LoaderCallbacks { +abstract class MediaPickerCore>( + val mediaType: MediaType, + val mediaActions: List +) : KauBaseActivity(), LoaderManager.LoaderCallbacks { companion object { val viewSize = lazyContext { computeViewSize(it) } @@ -62,11 +73,13 @@ abstract class MediaPickerCore>(val mediaType: MediaType) : KauB /** * Create error tile for a given item */ - fun getErrorDrawable(context: Context): Drawable { + fun getErrorDrawable(context: Context) = getIconDrawable(context, GoogleMaterial.Icon.gmd_error, accentColor) + + fun getIconDrawable(context: Context, iicon: IIcon, color: Int): Drawable { val sizePx = MediaPickerCore.computeViewSize(context) - return IconicsDrawable(context, GoogleMaterial.Icon.gmd_error) + return IconicsDrawable(context, iicon) .sizePx(sizePx) - .backgroundColor(accentColor) + .backgroundColor(color) .paddingPx(sizePx / 3) .color(Color.WHITE) } @@ -101,6 +114,9 @@ abstract class MediaPickerCore>(val mediaType: MediaType) : KauB val extraSpace: Int by lazy { resources.displayMetrics.heightPixels } fun initializeRecycler(recycler: RecyclerView) { + val adapterWrapper = HeaderAdapter() + adapterWrapper.wrap(adapter) + adapterWrapper.add(mediaActions.map { MediaActionItem(it, mediaType) }) recycler.apply { val manager = object : GridLayoutManager(context, computeColumnCount(context)) { override fun getExtraLayoutSpace(state: RecyclerView.State?): Int { @@ -110,7 +126,7 @@ abstract class MediaPickerCore>(val mediaType: MediaType) : KauB setItemViewCacheSize(CACHE_SIZE) isDrawingCacheEnabled = true layoutManager = manager - adapter = this@MediaPickerCore.adapter + adapter = adapterWrapper setHasFixedSize(true) itemAnimator = KauAnimator(FadeScaleAnimatorAdd(0.8f)) } @@ -209,4 +225,87 @@ abstract class MediaPickerCore>(val mediaType: MediaType) : KauB prefetcher?.cancel(true) super.onDestroy() } + + /** + * Method used to retrieve uri data for API 19+ + * See + */ + private fun ContentResolver.query(baseUri: Uri, uris: List, block: (cursor: Cursor) -> R) { + val ids = uris.map { + DocumentsContract.getDocumentId(it).split(":").getOrNull(1) + }.filterNotNull().joinToString(prefix = "(", separator = ",", postfix = ")") + //? query replacements are done for one arg at a time + //since we potentially have a list of ids, we'll just format the WHERE clause ourself + query(baseUri, MediaModel.projection, "${BaseColumns._ID} IN $ids", null, sortQuery)?.use(block) + } + + internal var tempPath: String? = null + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode != RESULT_OK) { + if (tempPath != null) { + val f = File(tempPath) + if (f.exists()) f.delete() + tempPath = null + } + return super.onActivityResult(requestCode, resultCode, data) + } + KL.d("Media result received") + if (data == null) { + KL.d("Media null intent") + return super.onActivityResult(requestCode, resultCode, data) + } + when (requestCode) { + MEDIA_ACTION_REQUEST_CAMERA -> onCameraResult(data) + MEDIA_ACTION_REQUEST_PICKER -> onPickerResult(data) + else -> super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun onCameraResult(data: Intent) { + val f: File + if (tempPath != null) { + f = File(tempPath) + tempPath = null + } else if (data.data != null) { + f = File(data.data.path) + } else { + KL.d("Media camera no file found") + return + } + if (f.exists()) { + KL.v("Media camera path found", f.absolutePath) + scanMedia(f) + finish(arrayListOf(MediaModel(f))) + } else { + KL.d("Media camera file not found") + } + } + + private fun onPickerResult(data: Intent) { + val items = mutableListOf() + if (data.data != null) { + KL.v("Media picker data uri", data.data.path) + items.add(data.data) + } else { + val clip = data.clipData + if (clip != null) { + items.addAll((0 until clip.itemCount).map { + clip.getItemAt(it).uri.apply { + KL.v("Media picker clip uri", path) + } + }) + } + } + if (items.isEmpty()) return KL.d("Media picker empty intent") + contentResolver.query(mediaType.contentUri, items) { + if (it.moveToFirst()) { + val models = arrayListOf() + do { + models.add(MediaModel(it)) + } while (it.moveToNext()) + finish(models) + } + } + } } \ No newline at end of file diff --git a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaType.kt b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaType.kt index cfec331..0af4c2e 100644 --- a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaType.kt +++ b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaType.kt @@ -7,7 +7,17 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy /** * Created by Allan Wang on 2017-07-30. */ -enum class MediaType(val cacheStrategy: DiskCacheStrategy, val contentUri: Uri) { - IMAGE(DiskCacheStrategy.AUTOMATIC, MediaStore.Images.Media.EXTERNAL_CONTENT_URI), - VIDEO(DiskCacheStrategy.AUTOMATIC, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) +enum class MediaType(val cacheStrategy: DiskCacheStrategy, + val mimeType: String, + val captureType: String, + val contentUri: Uri) { + IMAGE(DiskCacheStrategy.AUTOMATIC, + "image/*", + MediaStore.ACTION_IMAGE_CAPTURE, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI), + + VIDEO(DiskCacheStrategy.AUTOMATIC, + "video/*", + MediaStore.ACTION_VIDEO_CAPTURE, + MediaStore.Video.Media.EXTERNAL_CONTENT_URI) } \ No newline at end of file diff --git a/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaUtils.kt b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaUtils.kt new file mode 100644 index 0000000..0fb5824 --- /dev/null +++ b/mediapicker/src/main/kotlin/ca/allanwang/kau/mediapicker/MediaUtils.kt @@ -0,0 +1,59 @@ +package ca.allanwang.kau.mediapicker + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.support.v7.app.AppCompatActivity +import ca.allanwang.kau.utils.buildIsLollipopAndUp +import com.mikepenz.fastadapter.IItem +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* + + +/** + * Created by Allan Wang on 2017-08-17. + */ +@SuppressLint("NewApi") +internal fun Activity.finish(data: ArrayList) { + val intent = Intent() + intent.putParcelableArrayListExtra(MEDIA_PICKER_RESULT, data) + setResult(AppCompatActivity.RESULT_OK, intent) + if (buildIsLollipopAndUp) finishAfterTransition() + else finish() +} + +@Throws(IOException::class) +fun createMediaFile(prefix: String, extension: String): File { + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val imageFileName = "${prefix}_${timeStamp}_" + val storageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + val frostDir = File(storageDir, prefix) + if (!frostDir.exists()) frostDir.mkdirs() + return File.createTempFile(imageFileName, extension, frostDir) +} + +@Throws(IOException::class) +fun Context.createPrivateMediaFile(prefix: String, extension: String): File { + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val imageFileName = "${prefix}_${timeStamp}_" + val storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile(imageFileName, extension, storageDir) +} + +/** + * Scan the path so that the media item is properly added to galleries + * + * See Docs + */ +fun Context.scanMedia(f: File) { + if (!f.exists()) return + val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) + val contentUri = Uri.fromFile(f) + mediaScanIntent.data = contentUri + sendBroadcast(mediaScanIntent) +} \ No newline at end of file diff --git a/mediapicker/src/main/res/values/ids.xml b/mediapicker/src/main/res/values/ids.xml new file mode 100644 index 0000000..a533ecc --- /dev/null +++ b/mediapicker/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mediapicker/src/main/res/values/strings.xml b/mediapicker/src/main/res/values/strings.xml index 39ab16b..717e12b 100644 --- a/mediapicker/src/main/res/values/strings.xml +++ b/mediapicker/src/main/res/values/strings.xml @@ -4,4 +4,11 @@ No items have been selected Blurrable ImageView No items loaded + + No camera found + Please install a camera app and try again. + + Failed to create a temporary file. + + Select Media \ No newline at end of file -- cgit v1.2.3