Merge pull request #1155 from vector-im/feature/multipicker

Multiple attachment picker implementation
This commit is contained in:
Onuray Sahin 2020-03-26 14:30:02 +03:00 committed by GitHub
commit 3bb5e127d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1392 additions and 524 deletions

View file

@ -119,7 +119,7 @@ dependencies {
implementation "ru.noties.markwon:core:$markwon_version" implementation "ru.noties.markwon:core:$markwon_version"
// Image // Image
implementation 'androidx.exifinterface:exifinterface:1.1.0' implementation 'androidx.exifinterface:exifinterface:1.3.0-alpha01'
implementation 'id.zelory:compressor:3.0.0' implementation 'id.zelory:compressor:3.0.0'
// Database // Database

View file

@ -16,6 +16,7 @@
package im.vector.matrix.android.api.session.content package im.vector.matrix.android.api.session.content
import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
@ -29,8 +30,7 @@ data class ContentAttachmentData(
val width: Long? = 0, val width: Long? = 0,
val exifOrientation: Int = ExifInterface.ORIENTATION_UNDEFINED, val exifOrientation: Int = ExifInterface.ORIENTATION_UNDEFINED,
val name: String? = null, val name: String? = null,
val queryUri: String, val queryUri: Uri,
val path: String,
private val mimeType: String?, private val mimeType: String?,
val type: Type val type: Type
) : Parcelable { ) : Parcelable {

View file

@ -53,9 +53,9 @@ internal class FileUploader @Inject constructor(@Authenticated
suspend fun uploadByteArray(byteArray: ByteArray, suspend fun uploadByteArray(byteArray: ByteArray,
filename: String?, filename: String?,
mimeType: String, mimeType: String?,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
val uploadBody = byteArray.toRequestBody(mimeType.toMediaTypeOrNull()) val uploadBody = byteArray.toRequestBody(mimeType?.toMediaTypeOrNull())
return upload(uploadBody, filename, progressListener) return upload(uploadBody, filename, progressListener)
} }

View file

@ -16,12 +16,12 @@
package im.vector.matrix.android.internal.session.content package im.vector.matrix.android.internal.session.content
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.ThumbnailUtils import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import timber.log.Timber
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
internal object ThumbnailExtractor { internal object ThumbnailExtractor {
@ -33,34 +33,40 @@ internal object ThumbnailExtractor {
val mimeType: String val mimeType: String
) )
fun extractThumbnail(attachment: ContentAttachmentData): ThumbnailData? { fun extractThumbnail(context: Context, attachment: ContentAttachmentData): ThumbnailData? {
val file = File(attachment.path)
if (!file.exists() || !file.isFile) {
return null
}
return if (attachment.type == ContentAttachmentData.Type.VIDEO) { return if (attachment.type == ContentAttachmentData.Type.VIDEO) {
extractVideoThumbnail(attachment) extractVideoThumbnail(context, attachment)
} else { } else {
null null
} }
} }
private fun extractVideoThumbnail(attachment: ContentAttachmentData): ThumbnailData? { private fun extractVideoThumbnail(context: Context, attachment: ContentAttachmentData): ThumbnailData? {
val thumbnail = ThumbnailUtils.createVideoThumbnail(attachment.path, MediaStore.Video.Thumbnails.MINI_KIND) ?: return null var thumbnailData: ThumbnailData? = null
val outputStream = ByteArrayOutputStream() val mediaMetadataRetriever = MediaMetadataRetriever()
thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) try {
val thumbnailWidth = thumbnail.width mediaMetadataRetriever.setDataSource(context, attachment.queryUri)
val thumbnailHeight = thumbnail.height val thumbnail = mediaMetadataRetriever.frameAtTime
val thumbnailSize = outputStream.size()
val thumbnailData = ThumbnailData( val outputStream = ByteArrayOutputStream()
width = thumbnailWidth, thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
height = thumbnailHeight, val thumbnailWidth = thumbnail.width
size = thumbnailSize.toLong(), val thumbnailHeight = thumbnail.height
bytes = outputStream.toByteArray(), val thumbnailSize = outputStream.size()
mimeType = "image/jpeg" thumbnailData = ThumbnailData(
) width = thumbnailWidth,
thumbnail.recycle() height = thumbnailHeight,
outputStream.reset() size = thumbnailSize.toLong(),
bytes = outputStream.toByteArray(),
mimeType = "image/jpeg"
)
thumbnail.recycle()
outputStream.reset()
} catch (e: Exception) {
Timber.e(e, "Cannot extract video thumbnail")
} finally {
mediaMetadataRetriever.release()
}
return thumbnailData return thumbnailData
} }
} }

View file

@ -17,12 +17,9 @@
package im.vector.matrix.android.internal.session.content package im.vector.matrix.android.internal.session.content
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toContent import im.vector.matrix.android.api.session.events.model.toContent
@ -41,8 +38,6 @@ import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent import im.vector.matrix.android.internal.worker.getSessionComponent
import timber.log.Timber import timber.log.Timber
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import javax.inject.Inject import javax.inject.Inject
private data class NewImageAttributes( private data class NewImageAttributes(
@ -94,8 +89,90 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
var newImageAttributes: NewImageAttributes? = null var newImageAttributes: NewImageAttributes? = null
val attachmentFile = try { try {
File(attachment.path) val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
?: return Result.success(
WorkerParamsFactory.toData(
params.copy(
lastFailureMessage = "Cannot openInputStream for file: " + attachment.queryUri.toString()
)
)
)
inputStream.use {
var uploadedThumbnailUrl: String? = null
var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null
ThumbnailExtractor.extractThumbnail(context, params.attachment)?.let { thumbnailData ->
val thumbnailProgressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
}
}
try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt thumbnail")
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType)
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${attachment.name}",
"application/octet-stream",
thumbnailProgressListener)
} else {
fileUploader.uploadByteArray(thumbnailData.bytes,
"thumb_${attachment.name}",
thumbnailData.mimeType,
thumbnailProgressListener)
}
uploadedThumbnailUrl = contentUploadResponse.contentUri
} catch (t: Throwable) {
Timber.e(t, "Thumbnail update failed")
}
}
val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) {
if (isStopped) {
contentUploadStateTracker.setFailure(it, Throwable("Cancelled"))
} else {
contentUploadStateTracker.setProgress(it, current, total)
}
}
}
}
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
return try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt file")
notifyTracker(params) { contentUploadStateTracker.setEncrypting(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(inputStream, attachment.getSafeMimeType())
uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader
.uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener)
} else {
fileUploader
.uploadByteArray(inputStream.readBytes(), attachment.name, attachment.getSafeMimeType(), progressListener)
}
handleSuccess(params,
contentUploadResponse.contentUri,
uploadedFileEncryptedFileInfo,
uploadedThumbnailUrl,
uploadedThumbnailEncryptedFileInfo,
newImageAttributes)
} catch (t: Throwable) {
Timber.e(t)
handleFailure(params, t)
}
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
notifyTracker(params) { contentUploadStateTracker.setFailure(it, e) } notifyTracker(params) { contentUploadStateTracker.setFailure(it, e) }
@ -106,109 +183,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
) )
) )
) )
}
.let { originalFile ->
if (attachment.type == ContentAttachmentData.Type.IMAGE) {
if (params.compressBeforeSending) {
Compressor.compress(context, originalFile) {
default(
width = MAX_IMAGE_SIZE,
height = MAX_IMAGE_SIZE
)
}.also { compressedFile ->
// Update the params
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(compressedFile.absolutePath, options)
val fileSize = compressedFile.length().toInt()
newImageAttributes = NewImageAttributes(
options.outWidth,
options.outHeight,
fileSize
)
}
} else {
// TODO Fix here the image rotation issue
originalFile
}
} else {
// Other type
originalFile
}
}
var uploadedThumbnailUrl: String? = null
var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null
ThumbnailExtractor.extractThumbnail(params.attachment)?.let { thumbnailData ->
val thumbnailProgressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
}
}
try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt thumbnail")
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType)
uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${attachment.name}",
"application/octet-stream",
thumbnailProgressListener)
} else {
fileUploader.uploadByteArray(thumbnailData.bytes,
"thumb_${attachment.name}",
thumbnailData.mimeType,
thumbnailProgressListener)
}
uploadedThumbnailUrl = contentUploadResponse.contentUri
} catch (t: Throwable) {
Timber.e(t)
return handleFailure(params, t)
}
}
val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) {
if (isStopped) {
contentUploadStateTracker.setFailure(it, Throwable("Cancelled"))
} else {
contentUploadStateTracker.setProgress(it, current, total)
}
}
}
}
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
return try {
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("Encrypt file")
notifyTracker(params) { contentUploadStateTracker.setEncrypting(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(FileInputStream(attachmentFile), attachment.getSafeMimeType())
uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo
fileUploader
.uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener)
} else {
fileUploader
.uploadFile(attachmentFile, attachment.name, attachment.getSafeMimeType(), progressListener)
}
handleSuccess(params,
contentUploadResponse.contentUri,
uploadedFileEncryptedFileInfo,
uploadedThumbnailUrl,
uploadedThumbnailEncryptedFileInfo,
newImageAttributes)
} catch (t: Throwable) {
Timber.e(t)
handleFailure(params, t)
} }
} }

View file

@ -16,6 +16,7 @@
package im.vector.matrix.android.internal.session.room.send package im.vector.matrix.android.internal.session.room.send
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
@ -74,6 +75,7 @@ import javax.inject.Inject
* The transactionId is used as loc * The transactionId is used as loc
*/ */
internal class LocalEchoEventFactory @Inject constructor( internal class LocalEchoEventFactory @Inject constructor(
private val context: Context,
@UserId private val userId: String, @UserId private val userId: String,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val textPillsUtils: TextPillsUtils, private val textPillsUtils: TextPillsUtils,
@ -266,14 +268,14 @@ internal class LocalEchoEventFactory @Inject constructor(
height = height?.toInt() ?: 0, height = height?.toInt() ?: 0,
size = attachment.size.toInt() size = attachment.size.toInt()
), ),
url = attachment.path url = attachment.queryUri.toString()
) )
return createEvent(roomId, content) return createEvent(roomId, content)
} }
private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event { private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event {
val mediaDataRetriever = MediaMetadataRetriever() val mediaDataRetriever = MediaMetadataRetriever()
mediaDataRetriever.setDataSource(attachment.path) mediaDataRetriever.setDataSource(context, attachment.queryUri)
// Use frame to calculate height and width as we are sure to get the right ones // Use frame to calculate height and width as we are sure to get the right ones
val firstFrame: Bitmap? = mediaDataRetriever.frameAtTime val firstFrame: Bitmap? = mediaDataRetriever.frameAtTime
@ -281,7 +283,7 @@ internal class LocalEchoEventFactory @Inject constructor(
val width = firstFrame?.width ?: 0 val width = firstFrame?.width ?: 0
mediaDataRetriever.release() mediaDataRetriever.release()
val thumbnailInfo = ThumbnailExtractor.extractThumbnail(attachment)?.let { val thumbnailInfo = ThumbnailExtractor.extractThumbnail(context, attachment)?.let {
ThumbnailInfo( ThumbnailInfo(
width = it.width, width = it.width,
height = it.height, height = it.height,
@ -299,10 +301,10 @@ internal class LocalEchoEventFactory @Inject constructor(
size = attachment.size, size = attachment.size,
duration = attachment.duration?.toInt() ?: 0, duration = attachment.duration?.toInt() ?: 0,
// Glide will be able to use the local path and extract a thumbnail. // Glide will be able to use the local path and extract a thumbnail.
thumbnailUrl = attachment.path, thumbnailUrl = attachment.queryUri.toString(),
thumbnailInfo = thumbnailInfo thumbnailInfo = thumbnailInfo
), ),
url = attachment.path url = attachment.queryUri.toString()
) )
return createEvent(roomId, content) return createEvent(roomId, content)
} }
@ -315,7 +317,7 @@ internal class LocalEchoEventFactory @Inject constructor(
mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() } ?: "audio/mpeg", mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() } ?: "audio/mpeg",
size = attachment.size size = attachment.size
), ),
url = attachment.path url = attachment.queryUri.toString()
) )
return createEvent(roomId, content) return createEvent(roomId, content)
} }
@ -329,7 +331,7 @@ internal class LocalEchoEventFactory @Inject constructor(
?: "application/octet-stream", ?: "application/octet-stream",
size = attachment.size size = attachment.size
), ),
url = attachment.path url = attachment.queryUri.toString()
) )
return createEvent(roomId, content) return createEvent(roomId, content)
} }

1
multipicker/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

56
multipicker/build.gradle Normal file
View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 29
defaultConfig {
minSdkVersion 19
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles 'consumer-rules.pro'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation 'androidx.exifinterface:exifinterface:1.3.0-alpha01'
// Log
implementation 'com.jakewharton.timber:timber:4.7.1'
}

View file

21
multipicker/proguard-rules.pro vendored Normal file
View file

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

View file

@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="im.vector.riotx.multipicker">
<application>
<provider
android:name=".provider.MultiPickerFileProvider"
android:authorities="${applicationId}.multipicker.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/multipicker_provider_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,93 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.riotx.multipicker.entity.MultiPickerAudioType
/**
* Audio file picker implementation
*/
class AudioPicker(override val requestCode: Int) : Picker<MultiPickerAudioType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected audio files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerAudioType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val audioList = mutableListOf<MultiPickerAudioType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION).toLong()
}
audioList.add(
MultiPickerAudioType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
duration
)
)
}
}
}
return audioList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "audio/*"
}
}
}

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import im.vector.riotx.multipicker.utils.ImageUtils
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Implementation of taking a photo with Camera
*/
class CameraPicker(val requestCode: Int) {
/**
* Start camera by using an Activity
* @param activity Activity to handle onActivityResult().
* @return Uri of taken photo or null if the operation is cancelled.
*/
fun startWithExpectingFile(activity: Activity): Uri? {
val photoUri = createPhotoUri(activity)
val intent = createIntent().apply {
putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
}
activity.startActivityForResult(intent, requestCode)
return photoUri
}
/**
* Start camera by using a Fragment
* @param fragment Fragment to handle onActivityResult().
* @return Uri of taken photo or null if the operation is cancelled.
*/
fun startWithExpectingFile(fragment: Fragment): Uri? {
val photoUri = createPhotoUri(fragment.requireContext())
val intent = createIntent().apply {
putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
}
fragment.startActivityForResult(intent, requestCode)
return photoUri
}
/**
* Call this function from onActivityResult(int, int, Intent).
* @return Taken photo or null if request code is wrong
* or result code is not Activity.RESULT_OK
* or user cancelled the operation.
*/
fun getTakenPhoto(context: Context, requestCode: Int, resultCode: Int, photoUri: Uri): MultiPickerImageType? {
if (requestCode == this.requestCode && resultCode == Activity.RESULT_OK) {
val projection = arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
context.contentResolver.query(
photoUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val bitmap = ImageUtils.getBitmap(context, photoUri)
val orientation = ImageUtils.getOrientation(context, photoUri)
return MultiPickerImageType(
name,
size,
context.contentResolver.getType(photoUri),
photoUri,
bitmap?.width ?: 0,
bitmap?.height ?: 0,
orientation
)
}
}
}
return null
}
private fun createIntent(): Intent {
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
}
private fun createPhotoUri(context: Context): Uri {
val file = createImageFile(context)
val authority = context.packageName + ".multipicker.fileprovider"
return FileProvider.getUriForFile(context, authority, file)
}
private fun createImageFile(context: Context): File {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val storageDir: File = context.filesDir
return File.createTempFile(
"${timeStamp}_", /* prefix */
".jpg", /* suffix */
storageDir /* directory */
)
}
}

View file

@ -0,0 +1,135 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
import im.vector.riotx.multipicker.entity.MultiPickerContactType
/**
* Contact Picker implementation
*/
class ContactPicker(override val requestCode: Int) : Picker<MultiPickerContactType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected contact or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerContactType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val contactList = mutableListOf<MultiPickerContactType>()
data?.data?.let { selectedUri ->
val projection = arrayOf(
ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.Contacts.PHOTO_URI,
ContactsContract.Contacts._ID
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndex(ContactsContract.Contacts._ID)
val nameColumn = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
val photoUriColumn = cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
val contactId = cursor.getInt(idColumn)
var name = cursor.getString(nameColumn)
var photoUri = cursor.getString(photoUriColumn)
var phoneNumberList = mutableListOf<String>()
var emailList = mutableListOf<String>()
getRawContactId(context.contentResolver, contactId)?.let { rawContactId ->
val selection = ContactsContract.Data.RAW_CONTACT_ID + " = ?"
val selectionArgs = arrayOf(rawContactId.toString())
context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
arrayOf(
ContactsContract.Data.MIMETYPE,
ContactsContract.Data.DATA1
),
selection,
selectionArgs,
null
)?.use { cursor ->
while (cursor.moveToNext()) {
val mimeType = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.MIMETYPE))
val contactData = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.DATA1))
if (mimeType == ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) {
name = contactData
}
if (mimeType == ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) {
phoneNumberList.add(contactData)
}
if (mimeType == ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) {
emailList.add(contactData)
}
}
}
}
contactList.add(
MultiPickerContactType(
name,
photoUri,
phoneNumberList,
emailList
)
)
}
}
}
return contactList
}
private fun getRawContactId(contentResolver: ContentResolver, contactId: Int): Int? {
val projection = arrayOf(ContactsContract.RawContacts._ID)
val selection = ContactsContract.RawContacts.CONTACT_ID + " = ?"
val selectionArgs = arrayOf(contactId.toString() + "")
return contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
return if (cursor.moveToFirst()) cursor.getInt(cursor.getColumnIndex(ContactsContract.RawContacts._ID)) else null
}
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_PICK).apply {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = ContactsContract.Contacts.CONTENT_TYPE
}
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.provider.OpenableColumns
import im.vector.riotx.multipicker.entity.MultiPickerFileType
/**
* Implementation of selecting any type of files
*/
class FilePicker(override val requestCode: Int) : Picker<MultiPickerFileType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerFileType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val fileList = mutableListOf<MultiPickerFileType>()
getSelectedUriList(data).forEach { selectedUri ->
context.contentResolver.query(selectedUri, null, null, null, null)
?.use { cursor ->
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
fileList.add(
MultiPickerFileType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri
)
)
}
}
}
return fileList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "*/*"
}
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.provider.MediaStore
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import im.vector.riotx.multipicker.utils.ImageUtils
/**
* Image Picker implementation
*/
class ImagePicker(override val requestCode: Int) : Picker<MultiPickerImageType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected image files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerImageType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val imageList = mutableListOf<MultiPickerImageType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val bitmap = ImageUtils.getBitmap(context, selectedUri)
val orientation = ImageUtils.getOrientation(context, selectedUri)
imageList.add(
MultiPickerImageType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
bitmap?.width ?: 0,
bitmap?.height ?: 0,
orientation
)
)
}
}
}
return imageList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "image/*"
}
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
class MultiPicker<T> {
companion object Type {
val IMAGE by lazy { MultiPicker<ImagePicker>() }
val FILE by lazy { MultiPicker<FilePicker>() }
val VIDEO by lazy { MultiPicker<VideoPicker>() }
val AUDIO by lazy { MultiPicker<AudioPicker>() }
val CONTACT by lazy { MultiPicker<ContactPicker>() }
val CAMERA by lazy { MultiPicker<CameraPicker>() }
const val REQUEST_CODE_PICK_IMAGE = 5000
const val REQUEST_CODE_PICK_VIDEO = 5001
const val REQUEST_CODE_PICK_FILE = 5002
const val REQUEST_CODE_PICK_AUDIO = 5003
const val REQUEST_CODE_PICK_CONTACT = 5004
const val REQUEST_CODE_TAKE_PHOTO = 5005
@Suppress("UNCHECKED_CAST")
fun <T> get(type: MultiPicker<T>): T {
return when (type) {
IMAGE -> ImagePicker(REQUEST_CODE_PICK_IMAGE) as T
VIDEO -> VideoPicker(REQUEST_CODE_PICK_VIDEO) as T
FILE -> FilePicker(REQUEST_CODE_PICK_FILE) as T
AUDIO -> AudioPicker(REQUEST_CODE_PICK_AUDIO) as T
CONTACT -> ContactPicker(REQUEST_CODE_PICK_CONTACT) as T
CAMERA -> CameraPicker(REQUEST_CODE_TAKE_PHOTO) as T
else -> throw IllegalArgumentException("Unsupported type $type")
}
}
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import androidx.fragment.app.Fragment
/**
* Abstract class to provide all types of Pickers
*/
abstract class Picker<T>(open val requestCode: Int) {
protected var single = false
/**
* Call this function from onActivityResult(int, int, Intent).
* @return selected files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
abstract fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<T>
/**
* Use this function to retrieve files which are shared from another application or internally
* by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions.
*/
fun getIncomingFiles(context: Context, data: Intent?): List<T> {
if (data == null) return emptyList()
val uriList = mutableListOf<Uri>()
if (data.action == Intent.ACTION_SEND) {
(data.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { uriList.add(it) }
} else if (data.action == Intent.ACTION_SEND_MULTIPLE) {
val extraUriList: List<Uri>? = data.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
extraUriList?.let { uriList.addAll(it) }
}
val resInfoList: List<ResolveInfo> = context.packageManager.queryIntentActivities(data, PackageManager.MATCH_DEFAULT_ONLY)
uriList.forEach {
for (resolveInfo in resInfoList) {
val packageName: String = resolveInfo.activityInfo.packageName
context.grantUriPermission(packageName, it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
return getSelectedFiles(context, requestCode, Activity.RESULT_OK, data)
}
/**
* Call this function to disable multiple selection of files.
*/
fun single(): Picker<T> {
single = true
return this
}
abstract fun createIntent(): Intent
/**
* Start Storage Access Framework UI by using an Activity.
* @param activity Activity to handle onActivityResult().
*/
fun startWith(activity: Activity) {
activity.startActivityForResult(createIntent().apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }, requestCode)
}
/**
* Start Storage Access Framework UI by using a Fragment.
* @param fragment Fragment to handle onActivityResult().
*/
fun startWith(fragment: Fragment) {
fragment.startActivityForResult(createIntent().apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }, requestCode)
}
protected fun getSelectedUriList(data: Intent?): List<Uri> {
val selectedUriList = mutableListOf<Uri>()
val dataUri = data?.data
val clipData = data?.clipData
if (clipData != null) {
for (i in 0 until clipData.itemCount) {
selectedUriList.add(clipData.getItemAt(i).uri)
}
} else if (dataUri != null) {
selectedUriList.add(dataUri)
} else {
data?.extras?.get(Intent.EXTRA_STREAM)?.let {
(it as? List<*>)?.filterIsInstance<Uri>()?.let { uriList ->
selectedUriList.addAll(uriList)
}
if (it is Uri) {
selectedUriList.add(it)
}
}
}
return selectedUriList
}
}

View file

@ -0,0 +1,102 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.riotx.multipicker.entity.MultiPickerVideoType
/**
* Video Picker implementation
*/
class VideoPicker(override val requestCode: Int) : Picker<MultiPickerVideoType>(requestCode) {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected video files or empty list if request code is wrong
* or result code is not Activity.RESULT_OK
* or user did not select any files.
*/
override fun getSelectedFiles(context: Context, requestCode: Int, resultCode: Int, data: Intent?): List<MultiPickerVideoType> {
if (requestCode != this.requestCode && resultCode != Activity.RESULT_OK) {
return emptyList()
}
val videoList = mutableListOf<MultiPickerVideoType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
var width = 0
var height = 0
var orientation = 0
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION).toLong()
width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH).toInt()
height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT).toInt()
orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION).toInt()
}
videoList.add(
MultiPickerVideoType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
width,
height,
orientation,
duration
)
)
}
}
}
return videoList
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "video/*"
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.entity
import android.net.Uri
data class MultiPickerAudioType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val duration: Long
) : MultiPickerBaseType

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.entity
import android.net.Uri
interface MultiPickerBaseType {
val displayName: String?
val size: Long
val mimeType: String?
val contentUri: Uri
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.entity
data class MultiPickerContactType(
val displayName: String,
val photoUri: String?,
val phoneNumberList: List<String>,
val emailList: List<String>
)

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.entity
import android.net.Uri
data class MultiPickerFileType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri
) : MultiPickerBaseType

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.entity
import android.net.Uri
data class MultiPickerImageType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val width: Int,
val height: Int,
val orientation: Int
) : MultiPickerBaseType

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.entity
import android.net.Uri
data class MultiPickerVideoType(
override val displayName: String?,
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val width: Int,
val height: Int,
val orientation: Int,
val duration: Long
) : MultiPickerBaseType

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.provider
import androidx.core.content.FileProvider
class MultiPickerFileProvider : FileProvider()

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.multipicker.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import androidx.exifinterface.media.ExifInterface
import timber.log.Timber
object ImageUtils {
fun getBitmap(context: Context, uri: Uri): Bitmap? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri))
} else {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
}
} catch (e: Exception) {
Timber.e(e, "Cannot decode Bitmap: %s", uri.toString())
null
}
}
fun getOrientation(context: Context, uri: Uri): Int {
var orientation = 0
context.contentResolver.openInputStream(uri)?.use { inputStream ->
try {
ExifInterface(inputStream).let {
orientation = it.rotationDegrees
}
} catch (e: Exception) {
Timber.e(e, "Cannot read orientation: %s", uri.toString())
}
}
return orientation
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path
name="external_files"
path="." />
</paths>

View file

@ -1 +1,2 @@
include ':vector', ':matrix-sdk-android', ':matrix-sdk-android-rx', ':diff-match-patch' include ':vector', ':matrix-sdk-android', ':matrix-sdk-android-rx', ':diff-match-patch'
include ':multipicker'

View file

@ -254,6 +254,7 @@ dependencies {
implementation project(":matrix-sdk-android") implementation project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-rx") implementation project(":matrix-sdk-android-rx")
implementation project(":diff-match-patch") implementation project(":diff-match-patch")
implementation project(":multipicker")
implementation 'com.android.support:multidex:1.0.3' implementation 'com.android.support:multidex:1.0.3'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
@ -347,9 +348,6 @@ dependencies {
// Badge for compatibility // Badge for compatibility
implementation 'me.leolin:ShortcutBadger:1.1.22@aar' implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
// File picker
implementation 'com.kbeanie:multipicker:1.6@aar'
// DI // DI
implementation "com.google.dagger:dagger:$daggerVersion" implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion"

View file

@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Adding CAMERA permission prevents Chromebooks to see the application on the PlayStore --> <!-- Adding CAMERA permission prevents Chromebooks to see the application on the PlayStore -->
<!-- Tell that the Camera is not mandatory to install the application --> <!-- Tell that the Camera is not mandatory to install the application -->

View file

@ -18,20 +18,13 @@ package im.vector.riotx.features.attachments
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.kbeanie.multipicker.api.Picker.PICK_AUDIO
import com.kbeanie.multipicker.api.Picker.PICK_CONTACT
import com.kbeanie.multipicker.api.Picker.PICK_FILE
import com.kbeanie.multipicker.api.Picker.PICK_IMAGE_CAMERA
import com.kbeanie.multipicker.api.Picker.PICK_IMAGE_DEVICE
import com.kbeanie.multipicker.core.ImagePickerImpl
import com.kbeanie.multipicker.core.PickerManager
import com.kbeanie.multipicker.utils.IntentUtils
import im.vector.matrix.android.BuildConfig import im.vector.matrix.android.BuildConfig
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.core.platform.Restorable import im.vector.riotx.core.platform.Restorable
import im.vector.riotx.features.attachments.AttachmentsHelper.Callback import im.vector.riotx.multipicker.MultiPicker
import timber.log.Timber import timber.log.Timber
private const val CAPTURE_PATH_KEY = "CAPTURE_PATH_KEY" private const val CAPTURE_PATH_KEY = "CAPTURE_PATH_KEY"
@ -39,20 +32,8 @@ private const val PENDING_TYPE_KEY = "PENDING_TYPE_KEY"
/** /**
* This class helps to handle attachments by providing simple methods. * This class helps to handle attachments by providing simple methods.
* The process is asynchronous and you must implement [Callback] methods to get the data or a failure.
*/ */
class AttachmentsHelper private constructor(private val context: Context, class AttachmentsHelper(val context: Context, val callback: Callback) : Restorable {
private val pickerManagerFactory: PickerManagerFactory) : Restorable {
companion object {
fun create(fragment: Fragment, callback: Callback): AttachmentsHelper {
return AttachmentsHelper(fragment.requireContext(), FragmentPickerManagerFactory(fragment, callback))
}
fun create(activity: Activity, callback: Callback): AttachmentsHelper {
return AttachmentsHelper(activity, ActivityPickerManagerFactory(activity, callback))
}
}
interface Callback { interface Callback {
fun onContactAttachmentReady(contactAttachment: ContactAttachment) { fun onContactAttachmentReady(contactAttachment: ContactAttachment) {
@ -66,39 +47,15 @@ class AttachmentsHelper private constructor(private val context: Context,
} }
// Capture path allows to handle camera image picking. It must be restored if the activity gets killed. // Capture path allows to handle camera image picking. It must be restored if the activity gets killed.
private var capturePath: String? = null private var captureUri: Uri? = null
// The pending type is set if we have to handle permission request. It must be restored if the activity gets killed. // The pending type is set if we have to handle permission request. It must be restored if the activity gets killed.
var pendingType: AttachmentTypeSelectorView.Type? = null var pendingType: AttachmentTypeSelectorView.Type? = null
private val imagePicker by lazy {
pickerManagerFactory.createImagePicker()
}
private val videoPicker by lazy {
pickerManagerFactory.createVideoPicker()
}
private val cameraImagePicker by lazy {
pickerManagerFactory.createCameraImagePicker()
}
private val filePicker by lazy {
pickerManagerFactory.createFilePicker()
}
private val audioPicker by lazy {
pickerManagerFactory.createAudioPicker()
}
private val contactPicker by lazy {
pickerManagerFactory.createContactPicker()
}
// Restorable // Restorable
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
capturePath?.also { captureUri?.also {
outState.putString(CAPTURE_PATH_KEY, it) outState.putParcelable(CAPTURE_PATH_KEY, it)
} }
pendingType?.also { pendingType?.also {
outState.putSerializable(PENDING_TYPE_KEY, it) outState.putSerializable(PENDING_TYPE_KEY, it)
@ -106,10 +63,7 @@ class AttachmentsHelper private constructor(private val context: Context,
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle?) { override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
capturePath = savedInstanceState?.getString(CAPTURE_PATH_KEY) captureUri = savedInstanceState?.getParcelable(CAPTURE_PATH_KEY) as? Uri
if (capturePath != null) {
cameraImagePicker.reinitialize(capturePath)
}
pendingType = savedInstanceState?.getSerializable(PENDING_TYPE_KEY) as? AttachmentTypeSelectorView.Type pendingType = savedInstanceState?.getSerializable(PENDING_TYPE_KEY) as? AttachmentTypeSelectorView.Type
} }
@ -118,36 +72,36 @@ class AttachmentsHelper private constructor(private val context: Context,
/** /**
* Starts the process for handling file picking * Starts the process for handling file picking
*/ */
fun selectFile() { fun selectFile(fragment: Fragment) {
filePicker.pickFile() MultiPicker.get(MultiPicker.FILE).startWith(fragment)
} }
/** /**
* Starts the process for handling image picking * Starts the process for handling image picking
*/ */
fun selectGallery() { fun selectGallery(fragment: Fragment) {
imagePicker.pickImage() MultiPicker.get(MultiPicker.IMAGE).startWith(fragment)
} }
/** /**
* Starts the process for handling audio picking * Starts the process for handling audio picking
*/ */
fun selectAudio() { fun selectAudio(fragment: Fragment) {
audioPicker.pickAudio() MultiPicker.get(MultiPicker.AUDIO).startWith(fragment)
} }
/** /**
* Starts the process for handling capture image picking * Starts the process for handling capture image picking
*/ */
fun openCamera() { fun openCamera(fragment: Fragment) {
capturePath = cameraImagePicker.pickImage() captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(fragment)
} }
/** /**
* Starts the process for handling contact picking * Starts the process for handling contact picking
*/ */
fun selectContact() { fun selectContact(fragment: Fragment) {
contactPicker.pickContact() MultiPicker.get(MultiPicker.CONTACT).startWith(fragment)
} }
/** /**
@ -157,14 +111,58 @@ class AttachmentsHelper private constructor(private val context: Context,
*/ */
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (resultCode == Activity.RESULT_OK) { if (resultCode == Activity.RESULT_OK) {
val pickerManager = getPickerManagerForRequestCode(requestCode) when (requestCode) {
if (pickerManager != null) { MultiPicker.REQUEST_CODE_PICK_FILE -> {
if (pickerManager is ImagePickerImpl) { callback.onContentAttachmentsReady(
pickerManager.reinitialize(capturePath) MultiPicker.get(MultiPicker.FILE)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
} }
pickerManager.submit(data) MultiPicker.REQUEST_CODE_PICK_AUDIO -> {
return true callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.AUDIO)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
}
MultiPicker.REQUEST_CODE_PICK_CONTACT -> {
MultiPicker.get(MultiPicker.CONTACT)
.getSelectedFiles(context, requestCode, resultCode, data)
.firstOrNull()
?.toContactAttachment()
?.let {
callback.onContactAttachmentReady(it)
}
}
MultiPicker.REQUEST_CODE_PICK_IMAGE -> {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.IMAGE)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
}
MultiPicker.REQUEST_CODE_TAKE_PHOTO -> {
captureUri?.let { captureUri ->
MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(context, requestCode, resultCode, captureUri)
?.let {
callback.onContentAttachmentsReady(
listOf(it).map { it.toContentAttachmentData() }
)
}
}
}
MultiPicker.REQUEST_CODE_PICK_VIDEO -> {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.VIDEO)
.getSelectedFiles(context, requestCode, resultCode, data)
.map { it.toContentAttachmentData() }
)
}
else -> return false
} }
return true
} }
return false return false
} }
@ -174,39 +172,35 @@ class AttachmentsHelper private constructor(private val context: Context,
* *
* @return true if it can handle the intent data, false otherwise * @return true if it can handle the intent data, false otherwise
*/ */
fun handleShareIntent(intent: Intent): Boolean { fun handleShareIntent(context: Context, intent: Intent): Boolean {
val type = intent.resolveType(context) ?: return false val type = intent.resolveType(context) ?: return false
if (type.startsWith("image")) { if (type.startsWith("image")) {
imagePicker.submit(safeShareIntent(intent)) callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.IMAGE).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else if (type.startsWith("video")) { } else if (type.startsWith("video")) {
videoPicker.submit(safeShareIntent(intent)) callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.VIDEO).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else if (type.startsWith("audio")) { } else if (type.startsWith("audio")) {
videoPicker.submit(safeShareIntent(intent)) callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.AUDIO).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else if (type.startsWith("application") || type.startsWith("file") || type.startsWith("*")) { } else if (type.startsWith("application") || type.startsWith("file") || type.startsWith("*")) {
filePicker.submit(safeShareIntent(intent)) callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.FILE).getIncomingFiles(context, intent).map {
it.toContentAttachmentData()
}
)
} else { } else {
return false return false
} }
return true return true
} }
private fun safeShareIntent(intent: Intent): Intent {
// Work around for getPickerIntentForSharing doing NPE in android 10
return try {
IntentUtils.getPickerIntentForSharing(intent)
} catch (failure: Throwable) {
intent
}
}
private fun getPickerManagerForRequestCode(requestCode: Int): PickerManager? {
return when (requestCode) {
PICK_IMAGE_DEVICE -> imagePicker
PICK_IMAGE_CAMERA -> cameraImagePicker
PICK_FILE -> filePicker
PICK_CONTACT -> contactPicker
PICK_AUDIO -> audioPicker
else -> null
}
}
} }

View file

@ -16,51 +16,48 @@
package im.vector.riotx.features.attachments package im.vector.riotx.features.attachments
import com.kbeanie.multipicker.api.entity.ChosenAudio
import com.kbeanie.multipicker.api.entity.ChosenContact
import com.kbeanie.multipicker.api.entity.ChosenFile
import com.kbeanie.multipicker.api.entity.ChosenImage
import com.kbeanie.multipicker.api.entity.ChosenVideo
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.riotx.multipicker.entity.MultiPickerAudioType
import im.vector.riotx.multipicker.entity.MultiPickerBaseType
import im.vector.riotx.multipicker.entity.MultiPickerContactType
import im.vector.riotx.multipicker.entity.MultiPickerFileType
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import im.vector.riotx.multipicker.entity.MultiPickerVideoType
import timber.log.Timber import timber.log.Timber
fun ChosenContact.toContactAttachment(): ContactAttachment { fun MultiPickerContactType.toContactAttachment(): ContactAttachment {
return ContactAttachment( return ContactAttachment(
displayName = displayName, displayName = displayName,
photoUri = photoUri, photoUri = photoUri,
emails = emails.toList(), emails = emailList.toList(),
phones = phones.toList() phones = phoneNumberList.toList()
) )
} }
fun ChosenFile.toContentAttachmentData(): ContentAttachmentData { fun MultiPickerFileType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType") if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData( return ContentAttachmentData(
path = originalPath,
mimeType = mimeType, mimeType = mimeType,
type = mapType(), type = mapType(),
size = size, size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
name = displayName, name = displayName,
queryUri = queryUri queryUri = contentUri
) )
} }
fun ChosenAudio.toContentAttachmentData(): ContentAttachmentData { fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType") if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData( return ContentAttachmentData(
path = originalPath,
mimeType = mimeType, mimeType = mimeType,
type = mapType(), type = mapType(),
size = size, size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
name = displayName, name = displayName,
duration = duration, duration = duration,
queryUri = queryUri queryUri = contentUri
) )
} }
private fun ChosenFile.mapType(): ContentAttachmentData.Type { private fun MultiPickerBaseType.mapType(): ContentAttachmentData.Type {
return when { return when {
mimeType?.startsWith("image/") == true -> ContentAttachmentData.Type.IMAGE mimeType?.startsWith("image/") == true -> ContentAttachmentData.Type.IMAGE
mimeType?.startsWith("video/") == true -> ContentAttachmentData.Type.VIDEO mimeType?.startsWith("video/") == true -> ContentAttachmentData.Type.VIDEO
@ -69,10 +66,9 @@ private fun ChosenFile.mapType(): ContentAttachmentData.Type {
} }
} }
fun ChosenImage.toContentAttachmentData(): ContentAttachmentData { fun MultiPickerImageType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType") if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData( return ContentAttachmentData(
path = originalPath,
mimeType = mimeType, mimeType = mimeType,
type = mapType(), type = mapType(),
name = displayName, name = displayName,
@ -80,23 +76,20 @@ fun ChosenImage.toContentAttachmentData(): ContentAttachmentData {
height = height.toLong(), height = height.toLong(),
width = width.toLong(), width = width.toLong(),
exifOrientation = orientation, exifOrientation = orientation,
date = createdAt?.time ?: System.currentTimeMillis(), queryUri = contentUri
queryUri = queryUri
) )
} }
fun ChosenVideo.toContentAttachmentData(): ContentAttachmentData { fun MultiPickerVideoType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType") if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData( return ContentAttachmentData(
path = originalPath,
mimeType = mimeType, mimeType = mimeType,
type = ContentAttachmentData.Type.VIDEO, type = ContentAttachmentData.Type.VIDEO,
size = size, size = size,
date = createdAt?.time ?: System.currentTimeMillis(),
height = height.toLong(), height = height.toLong(),
width = width.toLong(), width = width.toLong(),
duration = duration, duration = duration,
name = displayName, name = displayName,
queryUri = queryUri queryUri = contentUri
) )
} }

View file

@ -1,96 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments
import com.kbeanie.multipicker.api.callbacks.AudioPickerCallback
import com.kbeanie.multipicker.api.callbacks.ContactPickerCallback
import com.kbeanie.multipicker.api.callbacks.FilePickerCallback
import com.kbeanie.multipicker.api.callbacks.ImagePickerCallback
import com.kbeanie.multipicker.api.callbacks.VideoPickerCallback
import com.kbeanie.multipicker.api.entity.ChosenAudio
import com.kbeanie.multipicker.api.entity.ChosenContact
import com.kbeanie.multipicker.api.entity.ChosenFile
import com.kbeanie.multipicker.api.entity.ChosenImage
import com.kbeanie.multipicker.api.entity.ChosenVideo
/**
* This class delegates the PickerManager callbacks to an [AttachmentsHelper.Callback]
*/
class AttachmentsPickerCallback(private val callback: AttachmentsHelper.Callback)
: ImagePickerCallback,
FilePickerCallback,
VideoPickerCallback,
AudioPickerCallback,
ContactPickerCallback {
override fun onContactChosen(contact: ChosenContact?) {
if (contact == null) {
callback.onAttachmentsProcessFailed()
} else {
val contactAttachment = contact.toContactAttachment()
callback.onContactAttachmentReady(contactAttachment)
}
}
override fun onAudiosChosen(audios: MutableList<ChosenAudio>?) {
if (audios.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = audios.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onFilesChosen(files: MutableList<ChosenFile>?) {
if (files.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = files.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onImagesChosen(images: MutableList<ChosenImage>?) {
if (images.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = images.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onVideosChosen(videos: MutableList<ChosenVideo>?) {
if (videos.isNullOrEmpty()) {
callback.onAttachmentsProcessFailed()
} else {
val attachments = videos.map {
it.toContentAttachmentData()
}
callback.onContentAttachmentsReady(attachments)
}
}
override fun onError(error: String?) {
callback.onAttachmentsProcessFailed()
}
}

View file

@ -1,134 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.attachments
import android.app.Activity
import androidx.fragment.app.Fragment
import com.kbeanie.multipicker.api.AudioPicker
import com.kbeanie.multipicker.api.CameraImagePicker
import com.kbeanie.multipicker.api.ContactPicker
import com.kbeanie.multipicker.api.FilePicker
import com.kbeanie.multipicker.api.ImagePicker
import com.kbeanie.multipicker.api.VideoPicker
/**
* Factory for creating different pickers. It allows to use with fragment or activity builders.
*/
interface PickerManagerFactory {
fun createImagePicker(): ImagePicker
fun createCameraImagePicker(): CameraImagePicker
fun createVideoPicker(): VideoPicker
fun createFilePicker(): FilePicker
fun createAudioPicker(): AudioPicker
fun createContactPicker(): ContactPicker
}
class ActivityPickerManagerFactory(private val activity: Activity, callback: AttachmentsHelper.Callback) : PickerManagerFactory {
private val attachmentsPickerCallback = AttachmentsPickerCallback(callback)
override fun createImagePicker(): ImagePicker {
return ImagePicker(activity).also {
it.setImagePickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createCameraImagePicker(): CameraImagePicker {
return CameraImagePicker(activity).also {
it.setImagePickerCallback(attachmentsPickerCallback)
}
}
override fun createVideoPicker(): VideoPicker {
return VideoPicker(activity).also {
it.setVideoPickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createFilePicker(): FilePicker {
return FilePicker(activity).also {
it.allowMultiple()
it.setFilePickerCallback(attachmentsPickerCallback)
}
}
override fun createAudioPicker(): AudioPicker {
return AudioPicker(activity).also {
it.allowMultiple()
it.setAudioPickerCallback(attachmentsPickerCallback)
}
}
override fun createContactPicker(): ContactPicker {
return ContactPicker(activity).also {
it.setContactPickerCallback(attachmentsPickerCallback)
}
}
}
class FragmentPickerManagerFactory(private val fragment: Fragment, callback: AttachmentsHelper.Callback) : PickerManagerFactory {
private val attachmentsPickerCallback = AttachmentsPickerCallback(callback)
override fun createImagePicker(): ImagePicker {
return ImagePicker(fragment).also {
it.setImagePickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createCameraImagePicker(): CameraImagePicker {
return CameraImagePicker(fragment).also {
it.setImagePickerCallback(attachmentsPickerCallback)
}
}
override fun createVideoPicker(): VideoPicker {
return VideoPicker(fragment).also {
it.setVideoPickerCallback(attachmentsPickerCallback)
it.allowMultiple()
}
}
override fun createFilePicker(): FilePicker {
return FilePicker(fragment).also {
it.allowMultiple()
it.setFilePickerCallback(attachmentsPickerCallback)
}
}
override fun createAudioPicker(): AudioPicker {
return AudioPicker(fragment).also {
it.allowMultiple()
it.setAudioPickerCallback(attachmentsPickerCallback)
}
}
override fun createContactPicker(): ContactPicker {
return ContactPicker(fragment).also {
it.setContactPickerCallback(attachmentsPickerCallback)
}
}
}

View file

@ -25,7 +25,7 @@ class AttachmentBigPreviewController @Inject constructor() : TypedEpoxyControlle
override fun buildModels(data: AttachmentsPreviewViewState) { override fun buildModels(data: AttachmentsPreviewViewState) {
data.attachments.forEach { data.attachments.forEach {
attachmentBigPreviewItem { attachmentBigPreviewItem {
id(it.path) id(it.queryUri.toString())
attachment(it) attachment(it)
} }
} }
@ -43,7 +43,7 @@ class AttachmentMiniaturePreviewController @Inject constructor() : TypedEpoxyCon
override fun buildModels(data: AttachmentsPreviewViewState) { override fun buildModels(data: AttachmentsPreviewViewState) {
data.attachments.forEachIndexed { index, contentAttachmentData -> data.attachments.forEachIndexed { index, contentAttachmentData ->
attachmentMiniaturePreviewItem { attachmentMiniaturePreviewItem {
id(contentAttachmentData.path) id(contentAttachmentData.queryUri.toString())
attachment(contentAttachmentData) attachment(contentAttachmentData)
checked(data.currentAttachmentIndex == index) checked(data.currentAttachmentIndex == index)
clickListener { _ -> clickListener { _ ->

View file

@ -33,11 +33,10 @@ abstract class AttachmentPreviewItem<H : AttachmentPreviewItem.Holder> : VectorE
abstract val attachment: ContentAttachmentData abstract val attachment: ContentAttachmentData
override fun bind(holder: H) { override fun bind(holder: H) {
val path = attachment.path
if (attachment.type == ContentAttachmentData.Type.VIDEO || attachment.type == ContentAttachmentData.Type.IMAGE) { if (attachment.type == ContentAttachmentData.Type.VIDEO || attachment.type == ContentAttachmentData.Type.IMAGE) {
Glide.with(holder.view.context) Glide.with(holder.view.context)
.asBitmap() .asBitmap()
.load(path) .load(attachment.queryUri)
.apply(RequestOptions().frame(0)) .apply(RequestOptions().frame(0))
.into(holder.imageView) .into(holder.imageView)
} else { } else {

View file

@ -17,10 +17,11 @@
package im.vector.riotx.features.attachments.preview package im.vector.riotx.features.attachments.preview
import android.net.Uri
import im.vector.riotx.core.platform.VectorViewModelAction import im.vector.riotx.core.platform.VectorViewModelAction
sealed class AttachmentsPreviewAction : VectorViewModelAction { sealed class AttachmentsPreviewAction : VectorViewModelAction {
object RemoveCurrentAttachment : AttachmentsPreviewAction() object RemoveCurrentAttachment : AttachmentsPreviewAction()
data class SetCurrentAttachment(val index: Int): AttachmentsPreviewAction() data class SetCurrentAttachment(val index: Int): AttachmentsPreviewAction()
data class UpdatePathOfCurrentAttachment(val newPath: String): AttachmentsPreviewAction() data class UpdatePathOfCurrentAttachment(val newUri: Uri): AttachmentsPreviewAction()
} }

View file

@ -172,9 +172,9 @@ class AttachmentsPreviewFragment @Inject constructor(
} }
private fun handleCropResult(result: Intent) { private fun handleCropResult(result: Intent) {
val resultPath = UCrop.getOutput(result)?.path val resultUri = UCrop.getOutput(result)
if (resultPath != null) { if (resultUri != null) {
viewModel.handle(AttachmentsPreviewAction.UpdatePathOfCurrentAttachment(resultPath)) viewModel.handle(AttachmentsPreviewAction.UpdatePathOfCurrentAttachment(resultUri))
} else { } else {
Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
} }
@ -202,8 +202,7 @@ class AttachmentsPreviewFragment @Inject constructor(
private fun doHandleEditAction() = withState(viewModel) { private fun doHandleEditAction() = withState(viewModel) {
val currentAttachment = it.attachments.getOrNull(it.currentAttachmentIndex) ?: return@withState val currentAttachment = it.attachments.getOrNull(it.currentAttachmentIndex) ?: return@withState
val destinationFile = File(requireContext().cacheDir, "${currentAttachment.name}_edited_image_${System.currentTimeMillis()}") val destinationFile = File(requireContext().cacheDir, "${currentAttachment.name}_edited_image_${System.currentTimeMillis()}")
// Note: using currentAttachment.queryUri.toUri() make the app crash when sharing from Google Photos val uri = currentAttachment.queryUri
val uri = File(currentAttachment.path).toUri()
UCrop.of(uri, destinationFile.toUri()) UCrop.of(uri, destinationFile.toUri())
.withOptions( .withOptions(
UCrop.Options() UCrop.Options()

View file

@ -62,7 +62,7 @@ class AttachmentsPreviewViewModel @AssistedInject constructor(@Assisted initialS
private fun handleUpdatePathOfCurrentAttachment(action: AttachmentsPreviewAction.UpdatePathOfCurrentAttachment) = withState { private fun handleUpdatePathOfCurrentAttachment(action: AttachmentsPreviewAction.UpdatePathOfCurrentAttachment) = withState {
val attachments = it.attachments.mapIndexed { index, contentAttachmentData -> val attachments = it.attachments.mapIndexed { index, contentAttachmentData ->
if (index == it.currentAttachmentIndex) { if (index == it.currentAttachmentIndex) {
contentAttachmentData.copy(path = action.newPath) contentAttachmentData.copy(queryUri = action.newUri)
} else { } else {
contentAttachmentData contentAttachmentData
} }

View file

@ -250,7 +250,7 @@ class RoomDetailFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java)
attachmentsHelper = AttachmentsHelper.create(this, this).register() attachmentsHelper = AttachmentsHelper(requireContext(), this).register()
keyboardStateUtils = KeyboardStateUtils(requireActivity()) keyboardStateUtils = KeyboardStateUtils(requireActivity())
setupToolbar(roomToolbar) setupToolbar(roomToolbar)
setupRecyclerView() setupRecyclerView()
@ -290,9 +290,9 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.observeViewEvents { roomDetailViewModel.observeViewEvents {
when (it) { when (it) {
is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable)
is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds)
is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it) is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it)
is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it)
is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message, Snackbar.LENGTH_LONG) is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message, Snackbar.LENGTH_LONG)
is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it)
@ -665,7 +665,7 @@ class RoomDetailFragment @Inject constructor(
private fun sendUri(uri: Uri): Boolean { private fun sendUri(uri: Uri): Boolean {
roomDetailViewModel.preventAttachmentPreview = true roomDetailViewModel.preventAttachmentPreview = true
val shareIntent = Intent(Intent.ACTION_SEND, uri) val shareIntent = Intent(Intent.ACTION_SEND, uri)
val isHandled = attachmentsHelper.handleShareIntent(shareIntent) val isHandled = attachmentsHelper.handleShareIntent(requireContext(), shareIntent)
if (!isHandled) { if (!isHandled) {
roomDetailViewModel.preventAttachmentPreview = false roomDetailViewModel.preventAttachmentPreview = false
Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show()
@ -1350,11 +1350,11 @@ class RoomDetailFragment @Inject constructor(
private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) {
when (type) { when (type) {
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera() AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(this)
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile() AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(this)
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery() AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(this)
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio() AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(this)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact() AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(this)
AttachmentTypeSelectorView.Type.STICKER -> vectorBaseActivity.notImplemented("Adding stickers") AttachmentTypeSelectorView.Type.STICKER -> vectorBaseActivity.notImplemented("Adding stickers")
}.exhaustive }.exhaustive
} }

View file

@ -610,7 +610,7 @@ class RoomDetailViewModel @AssistedInject constructor(
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) { when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
null -> room.sendMedias(attachments, action.compressBeforeSending, emptySet()) null -> room.sendMedias(attachments, action.compressBeforeSending, emptySet())
else -> _viewEvents.post(RoomDetailViewEvents.FileTooBigError( else -> _viewEvents.post(RoomDetailViewEvents.FileTooBigError(
tooBigFile.name ?: tooBigFile.path, tooBigFile.name ?: tooBigFile.queryUri.toString(),
tooBigFile.size, tooBigFile.size,
maxUploadFileSize maxUploadFileSize
)) ))

View file

@ -72,18 +72,18 @@ class IncomingShareFragment @Inject constructor(
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setupRecyclerView() setupRecyclerView()
setupToolbar(incomingShareToolbar) setupToolbar(incomingShareToolbar)
attachmentsHelper = AttachmentsHelper.create(this, this).register() attachmentsHelper = AttachmentsHelper(requireContext(), this).register()
val intent = vectorBaseActivity.intent val intent = vectorBaseActivity.intent
val isShareManaged = when (intent?.action) { val isShareManaged = when (intent?.action) {
Intent.ACTION_SEND -> { Intent.ACTION_SEND -> {
var isShareManaged = attachmentsHelper.handleShareIntent(intent) var isShareManaged = attachmentsHelper.handleShareIntent(requireContext(), intent)
if (!isShareManaged) { if (!isShareManaged) {
isShareManaged = handleTextShare(intent) isShareManaged = handleTextShare(intent)
} }
isShareManaged isShareManaged
} }
Intent.ACTION_SEND_MULTIPLE -> attachmentsHelper.handleShareIntent(intent) Intent.ACTION_SEND_MULTIPLE -> attachmentsHelper.handleShareIntent(requireContext(), intent)
else -> false else -> false
} }