diff --git a/android-smsmms/src/main/java/com/android/mms/transaction/RetryScheduler.java b/android-smsmms/src/main/java/com/android/mms/transaction/RetryScheduler.java index 414f14e7..a107f3ba 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/RetryScheduler.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/RetryScheduler.java @@ -143,6 +143,11 @@ public class RetryScheduler implements Observer { case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND: errorString = R.string.service_message_not_found; break; + case PduHeaders.RESPONSE_STATUS_ERROR_CONTENT_NOT_ACCEPTED: + case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_CONTENT_NOT_ACCEPTED: + errorString = R.string.service_message_content_not_accepted; + break; + } if (errorString != 0) { DownloadManager.init(mContext.getApplicationContext()); diff --git a/android-smsmms/src/main/res/values/strings.xml b/android-smsmms/src/main/res/values/strings.xml index c48bc4b8..edcfd758 100755 --- a/android-smsmms/src/main/res/values/strings.xml +++ b/android-smsmms/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ limitations under the License. Invalid destination address. Service not activated on network. Message expired or not available. + Message content is not accepted. Couldn\'t send due to network problem. Currently can\'t send your message. It will be sent when the service becomes available. Can\'t download right now. Try again later. diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml index b1ca12fe..a65cc645 100644 --- a/data/src/main/AndroidManifest.xml +++ b/data/src/main/AndroidManifest.xml @@ -19,4 +19,5 @@ + diff --git a/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt b/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt new file mode 100644 index 00000000..7ebe330a --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 Moez Bhatti + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ +package dev.octoshrimpy.quik.extensions + +import android.content.Context +import android.net.Uri + +fun Uri.resourceExists(context: Context): Boolean { + // check if resource at uri still exists + try { + return context.contentResolver.query( + this, null, null, null, null + )?.use { + it.moveToFirst() + } ?: false + } + catch (e: Exception) { /* nothing */ } + + return false +} diff --git a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt index e776c705..c4741436 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt @@ -25,7 +25,6 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.graphics.BitmapFactory -import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.os.Environment @@ -65,14 +64,7 @@ import io.realm.Case import io.realm.Realm import io.realm.RealmResults import io.realm.Sort -import okio.buffer -import okio.source import timber.log.Timber -import java.io.File -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.io.IOException -import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -376,27 +368,37 @@ class MessageRepositoryImpl @Inject constructor( parts += MMSPart("text", ContentType.TEXT_PLAIN, bytes) } - // Attach contacts + // Attach those that can't be compressed (ie. everything but not images) parts += attachments - .mapNotNull { attachment -> attachment as? Attachment.Contact } - .map { attachment -> attachment.vCard.toByteArray() } - .map { vCard -> - remainingBytes -= vCard.size - MMSPart("contact", ContentType.TEXT_VCARD, vCard) - } + // filter out images + .filter { !it.isImage(context) } + // filter out items that have no data available (ie user may have deleted + // the file from storage) + .filter { it.getResourceBytes(context).isNotEmpty() } + .map { + remainingBytes -= it.getResourceBytes(context).size + MMSPart( + it.getName(context), + it.getType(context), + it.getResourceBytes(context) + ) + } val imageBytesByAttachment = attachments - .mapNotNull { attachment -> attachment as? Attachment.Image } - .associateWith { attachment -> - val uri = attachment.getUri() ?: return@associateWith byteArrayOf() - when (attachment.isGif(context)) { - true -> ImageUtils.getScaledGif(context, uri, maxWidth, maxHeight) - false -> ImageUtils.getScaledImage(context, uri, maxWidth, maxHeight) - } + // filter out non-images + .filter { it.isImage(context) } + // filter out items that have no data available (ie user may have deleted + // the file from storage) + .filter { it.getResourceBytes(context).isNotEmpty() } + .associateWith { + when (it.getType(context) == "image/gif") { + true -> ImageUtils.getScaledGif(context, it.getUri(), maxWidth, maxHeight) + false -> ImageUtils.getScaledImage(context, it.getUri(), maxWidth, maxHeight) } - .toMutableMap() + } + .toMutableMap() - val imageByteCount = imageBytesByAttachment.values.sumBy { byteArray -> byteArray.size } + val imageByteCount = imageBytesByAttachment.values.sumOf { it.size } if (imageByteCount > remainingBytes) { imageBytesByAttachment.forEach { (attachment, originalBytes) -> val uri = attachment.getUri() ?: return@forEach @@ -426,9 +428,9 @@ class MessageRepositoryImpl @Inject constructor( val newHeight = (newWidth / aspectRatio).toInt() attempts++ - scaledBytes = when (attachment.isGif(context)) { - true -> ImageUtils.getScaledGif(context, uri, newWidth, newHeight, 80) - false -> ImageUtils.getScaledImage(context, uri, newWidth, newHeight, 80) + scaledBytes = when (attachment.getType(context) == "image/gif") { + true -> ImageUtils.getScaledGif(context, attachment.getUri(), maxWidth, maxHeight) + false -> ImageUtils.getScaledImage(context, attachment.getUri(), maxWidth, maxHeight) } Timber.d("Compression attempt $attempts: ${scaledBytes.size / 1024}/${maxBytes.toInt() / 1024}Kb ($width*$height -> $newWidth*$newHeight)") @@ -440,7 +442,7 @@ class MessageRepositoryImpl @Inject constructor( } imageBytesByAttachment.forEach { (attachment, bytes) -> - parts += when (attachment.isGif(context)) { + parts += when (attachment.getType(context) == "image/gif") { true -> MMSPart("image", ContentType.IMAGE_GIF, bytes) false -> MMSPart("image", ContentType.IMAGE_JPEG, bytes) } diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt b/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt index 8b76318b..0047fdd8 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt @@ -47,7 +47,7 @@ class SendScheduledMessage @Inject constructor( } .map { message -> val threadId = TelephonyCompat.getOrCreateThreadId(context, message.recipients) - val attachments = message.attachments.mapNotNull(Uri::parse).map { Attachment.Image(it) } + val attachments = message.attachments.mapNotNull(Uri::parse).map { Attachment(it) } SendMessage.Params(message.subId, threadId, message.recipients, message.body, attachments) } .flatMap(sendMessage::buildObservable) diff --git a/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt b/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt index c354f60f..bb25625e 100644 --- a/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt +++ b/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt @@ -21,33 +21,106 @@ package dev.octoshrimpy.quik.model import android.content.Context import android.net.Uri import android.os.Build +import android.provider.OpenableColumns import androidx.core.view.inputmethod.InputContentInfoCompat -sealed class Attachment { - data class Image( - private val uri: Uri? = null, - private val inputContent: InputContentInfoCompat? = null - ) : Attachment() { +class Attachment( + private val uri: Uri? = null, + private val inputContent: InputContentInfoCompat? = null +) { + private var resourceBytes: ByteArray? = null - fun getUri(): Uri? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - inputContent?.contentUri ?: uri - } else { - uri - } - } - - fun isGif(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && inputContent != null) { - inputContent.description.hasMimeType("image/gif") - } else { - uri?.let(context.contentResolver::getType) == "image/gif" - } - } + fun getUri(): Uri { + return ( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) + inputContent?.contentUri ?: uri ?: Uri.EMPTY + else uri ?: Uri.EMPTY + ) } - data class Contact(val vCard: String) : Attachment() + fun getType(context: Context): String { + return context.contentResolver.getType(getUri()) ?: "application/octect-stream" + } + + fun getName(context: Context): String { + try { + context.contentResolver.query( + getUri(), + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null + ) + ?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex == -1) + return "unknown" + cursor.moveToFirst() + return cursor.getString(nameIndex) ?: "unknown" + } + } + catch (e: Exception) { /* nothing*/ } + + return "unknown" + } + + fun isVCard(context: Context): Boolean { + return (getType(context) == "text/x-vcard") + } + + fun hasDisplayableImage(context: Context): Boolean { + val mimeType = getType(context) + return (mimeType.startsWith("image/") || mimeType.startsWith("video/")) + } + + fun isAudio(context: Context): Boolean { + val mimeType = getType(context) + return (mimeType.startsWith("audio/")) + } + + fun isImage(context: Context): Boolean { + val mimeType = getType(context) + return (mimeType.startsWith("image/")) + } + + fun getSize(context: Context): Long { + try { + context.contentResolver.query( + getUri(), + arrayOf(OpenableColumns.SIZE), + null, + null, + null + ) + ?.use { + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex == -1) + return -1 + it.moveToFirst() + return it.getLong(sizeIndex) + } + } + catch (e: Exception) { /* nothing */ } + + return -1 + } + + fun getResourceBytes(context: Context): ByteArray { + if (resourceBytes != null) + return resourceBytes!! + + resourceBytes = ByteArray(0) + + try { + context.contentResolver.openInputStream(getUri())?.use { + resourceBytes = it.readBytes() + } + } catch (e: Exception) { + } + + return resourceBytes!! + } } diff --git a/domain/src/main/java/com/moez/QKSMS/model/MmsPart.kt b/domain/src/main/java/com/moez/QKSMS/model/MmsPart.kt index f9b62eb4..0afb53b1 100644 --- a/domain/src/main/java/com/moez/QKSMS/model/MmsPart.kt +++ b/domain/src/main/java/com/moez/QKSMS/model/MmsPart.kt @@ -40,11 +40,13 @@ open class MmsPart : RealmObject() { fun getUri() = "content://mms/part/$id".toUri() fun getSummary(): String? = when { + type == "application/smil" -> null type == "text/plain" -> text - type == "text/x-vCard" -> "Contact card" + type == "text/x-vcard" -> "Contact card" type.startsWith("image") -> "Photo" type.startsWith("video") -> "Video" - else -> null + type.startsWith("audio") -> "Audio" + else -> type.substring(type.indexOf('/') + 1) } } \ No newline at end of file diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 4153b2c3..7b1c55ab 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -88,22 +88,12 @@ - - - - - - + - - - - - - + () { companion object { - private const val VIEW_TYPE_IMAGE = 0 + private const val VIEW_TYPE_FILE = 0 private const val VIEW_TYPE_CONTACT = 1 } @@ -53,14 +54,13 @@ class AttachmentAdapter @Inject constructor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { val inflater = LayoutInflater.from(parent.context) - val view = when (viewType) { - VIEW_TYPE_IMAGE -> inflater.inflate(R.layout.attachment_image_list_item, parent, false) - .apply { thumbnailBounds.clipToOutline = true } - VIEW_TYPE_CONTACT -> inflater.inflate(R.layout.attachment_contact_list_item, parent, false) - - else -> null!! // Impossible - } + val view = inflater.inflate( + if (viewType == VIEW_TYPE_CONTACT) R.layout.attachment_contact_list_item + else R.layout.attachment_file_list_item, + parent, + false + ) return QkViewHolder(view).apply { view.setOnClickListener { @@ -73,26 +73,81 @@ class AttachmentAdapter @Inject constructor( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val attachment = getItem(position) - when (attachment) { - is Attachment.Image -> Glide.with(context) - .load(attachment.getUri()) - .into(holder.thumbnail) - - is Attachment.Contact -> Observable.just(attachment.vCard) - .mapNotNull { vCard -> Ezvcard.parse(vCard).first() } - .map { vcard -> vcard.getDisplayName() ?: "" } - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { displayName -> - holder.name?.text = displayName - holder.name?.isVisible = displayName.isNotEmpty() - } + if (attachment.isVCard(context)) { + val displayName = Ezvcard.parse( + String(attachment.getResourceBytes(context)) + ).first().getDisplayName() ?: "" + holder.name.text = displayName + holder.name.isVisible = displayName.isNotEmpty() + return } + + // if attachment uri is missing + if (!attachment.getUri().resourceExists(context)) { + holder.thumbnail.setImageResource(android.R.drawable.ic_delete) + holder.fileName.text = context.getString(R.string.attachment_missing) + holder.fileName.visibility = View.VISIBLE + return + } + + val uri = attachment.getUri() + val mimeType = attachment.getType(context) + + // if attachment mime type is image/* or video/*, use image/frame + if (attachment.hasDisplayableImage(context)) { + Glide + .with(context) + .load(uri) + .into(holder.thumbnail) + return + } + + // if audio mime type, try and use embedded image if one exists + if (attachment.isAudio(context)) { + val metaDataRetriever = MediaMetadataRetriever() + metaDataRetriever.setDataSource(context, uri) + val embeddedPicture = metaDataRetriever.embeddedPicture + if (embeddedPicture != null) { + Glide + .with(context) + .load(embeddedPicture) + .into(holder.thumbnail) + return + } + } + + // try and use icon from default app for type + val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, mimeType) + val appIcon = context + .packageManager + .resolveActivity(intent, MATCH_DEFAULT) + ?.loadIcon(context.packageManager) + if (appIcon != null) + Glide + .with(context) + .load(appIcon) + .into(holder.thumbnail) + else if (mimeType?.startsWith("audio/") == true) + // else, if audio, use default local audio icon + Glide + .with(context) + .load(R.drawable.ic_round_volume_up_24) + .into(holder.thumbnail) + else + // else, use default attachment icon + Glide + .with(context) + .load(R.drawable.ic_attachment_black_24dp) + .into(holder.thumbnail) + + // else, show file name + holder.fileName.text = attachment.getName(context) + holder.fileName.visibility = View.VISIBLE } - override fun getItemViewType(position: Int) = when (getItem(position)) { - is Attachment.Image -> VIEW_TYPE_IMAGE - is Attachment.Contact -> VIEW_TYPE_CONTACT + override fun getItemViewType(position: Int) = when (getItem(position).isVCard(context)) { + true -> VIEW_TYPE_CONTACT + else -> VIEW_TYPE_FILE } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt index ace8d547..c76ad421 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt @@ -82,15 +82,6 @@ import javax.inject.Inject class ComposeActivity : QkThemedActivity(), ComposeView { - companion object { - private const val SelectContactRequestCode = 0 - private const val TakePhotoRequestCode = 1 - private const val AttachPhotoRequestCode = 2 - private const val AttachContactRequestCode = 3 - - private const val CameraDestinationKey = "camera_destination" - } - @Inject lateinit var attachmentAdapter: AttachmentAdapter @Inject lateinit var chipsAdapter: ChipsAdapter @Inject lateinit var dateFormatter: DateFormatter @@ -112,10 +103,11 @@ class ComposeActivity : QkThemedActivity(), ComposeView { override val textChangedIntent by lazy { message.textChanges() } override val attachIntent by lazy { Observable.merge(attach.clicks(), attachingBackground.clicks()) } override val cameraIntent by lazy { Observable.merge(camera.clicks(), cameraLabel.clicks()) } - override val galleryIntent by lazy { Observable.merge(gallery.clicks(), galleryLabel.clicks()) } + override val attachImageFileIntent by lazy { Observable.merge(gallery.clicks(), galleryLabel.clicks()) } + override val attachAnyFileIntent by lazy { Observable.merge(attachAFileIcon.clicks(), attachAFileLabel.clicks()) } override val scheduleIntent by lazy { Observable.merge(schedule.clicks(), scheduleLabel.clicks()) } override val attachContactIntent by lazy { Observable.merge(contact.clicks(), contactLabel.clicks()) } - override val attachmentSelectedIntent: Subject = PublishSubject.create() + override val attachAnyFileSelectedIntent: Subject = PublishSubject.create() override val contactSelectedIntent: Subject = PublishSubject.create() override val inputContentIntent by lazy { message.inputContentSelected } override val scheduleSelectedIntent: Subject = PublishSubject.create() @@ -386,7 +378,7 @@ class ComposeActivity : QkThemedActivity(), ComposeView { val intent = Intent(Intent.ACTION_PICK) .setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE) - startActivityForResult(Intent.createChooser(intent, null), AttachContactRequestCode) + startActivityForResult(Intent.createChooser(intent, null), ComposeView.AttachContactRequestCode) } override fun showContacts(sharing: Boolean, chips: List) { @@ -395,7 +387,7 @@ class ComposeActivity : QkThemedActivity(), ComposeView { val intent = Intent(this, ContactsActivity::class.java) .putExtra(ContactsActivity.SharingKey, sharing) .putExtra(ContactsActivity.ChipsKey, serialized) - startActivityForResult(intent, SelectContactRequestCode) + startActivityForResult(intent, ComposeView.SelectContactRequestCode) } override fun themeChanged() { @@ -415,17 +407,17 @@ class ComposeActivity : QkThemedActivity(), ComposeView { val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) .putExtra(MediaStore.EXTRA_OUTPUT, cameraDestination) - startActivityForResult(Intent.createChooser(intent, null), TakePhotoRequestCode) + startActivityForResult(Intent.createChooser(intent, null), ComposeView.TakePhotoRequestCode) } - override fun requestGallery() { + override fun requestGallery(mimeType: String, requestCode: Int) { val intent = Intent(Intent.ACTION_PICK) .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) .putExtra(Intent.EXTRA_LOCAL_ONLY, false) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setType("image/*") - startActivityForResult(Intent.createChooser(intent, null), AttachPhotoRequestCode) + .setType(mimeType) + startActivityForResult(Intent.createChooser(intent, null), requestCode) } override fun setDraft(draft: String) { @@ -473,36 +465,43 @@ class ComposeActivity : QkThemedActivity(), ComposeView { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when { - requestCode == SelectContactRequestCode -> { + if (resultCode != Activity.RESULT_OK) + return + + when (requestCode) { + ComposeView.SelectContactRequestCode -> { chipsSelectedIntent.onNext(data?.getSerializableExtra(ContactsActivity.ChipsKey) ?.let { serializable -> serializable as? HashMap } ?: hashMapOf()) } - requestCode == TakePhotoRequestCode && resultCode == Activity.RESULT_OK -> { - cameraDestination?.let(attachmentSelectedIntent::onNext) + + ComposeView.TakePhotoRequestCode -> { + cameraDestination?.let(attachAnyFileSelectedIntent::onNext) } - requestCode == AttachPhotoRequestCode && resultCode == Activity.RESULT_OK -> { + + ComposeView.AttachAFileRequestCode -> { data?.clipData?.itemCount - ?.let { count -> 0 until count } - ?.mapNotNull { i -> data.clipData?.getItemAt(i)?.uri } - ?.forEach(attachmentSelectedIntent::onNext) - ?: data?.data?.let(attachmentSelectedIntent::onNext) + ?.let { count -> 0 until count } + ?.mapNotNull { i -> data.clipData?.getItemAt(i)?.uri } + ?.forEach(attachAnyFileSelectedIntent::onNext) + ?: data?.data?.let(attachAnyFileSelectedIntent::onNext) } - requestCode == AttachContactRequestCode && resultCode == Activity.RESULT_OK -> { + + ComposeView.AttachContactRequestCode -> { data?.data?.let(contactSelectedIntent::onNext) } + else -> super.onActivityResult(requestCode, resultCode, data) } } override fun onSaveInstanceState(outState: Bundle) { - outState.putParcelable(CameraDestinationKey, cameraDestination) + outState.putParcelable(ComposeView.CameraDestinationKey, cameraDestination) super.onSaveInstanceState(outState) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { - cameraDestination = savedInstanceState.getParcelable(CameraDestinationKey) + cameraDestination = savedInstanceState.getParcelable(ComposeView.CameraDestinationKey) super.onRestoreInstanceState(savedInstanceState) } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt index 209680be..8580cb49 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt @@ -78,22 +78,8 @@ class ComposeActivityModule { val uris = mutableListOf() activity.intent.getParcelableExtra(Intent.EXTRA_STREAM)?.run(uris::add) activity.intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.run(uris::addAll) - return Attachments(uris.mapNotNull { uri -> - val mimeType = activity.contentResolver.getType(uri) - when { - ContentType.isImageType(mimeType) -> { - Attachment.Image(uri) - } - ContentType.TEXT_VCARD.equals(mimeType, true) -> { - val inputStream = activity.contentResolver.openInputStream(uri) - val text = inputStream?.reader(Charset.forName("utf-8"))?.readText() - text?.let(Attachment::Contact) - } - - else -> null - } - }) + return Attachments(uris.mapNotNull { Attachment(it) }) } @Provides diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt index af37016d..7d4f3d2e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt @@ -29,6 +29,15 @@ import io.reactivex.subjects.Subject interface ComposeView : QkView { + companion object { + const val SelectContactRequestCode = 0 + const val TakePhotoRequestCode = 1 + const val AttachAFileRequestCode = 4 + const val AttachContactRequestCode = 3 + + const val CameraDestinationKey = "camera_destination" + } + val activityVisibleIntent: Observable val chipsSelectedIntent: Subject> val chipDeletedIntent: Subject @@ -43,10 +52,11 @@ interface ComposeView : QkView { val textChangedIntent: Observable val attachIntent: Observable val cameraIntent: Observable<*> - val galleryIntent: Observable<*> + val attachAnyFileIntent: Observable<*> + val attachImageFileIntent: Observable<*> val scheduleIntent: Observable<*> val attachContactIntent: Observable<*> - val attachmentSelectedIntent: Observable + val attachAnyFileSelectedIntent: Observable val contactSelectedIntent: Observable val inputContentIntent: Observable val scheduleSelectedIntent: Observable @@ -68,7 +78,7 @@ interface ComposeView : QkView { fun themeChanged() fun showKeyboard() fun requestCamera() - fun requestGallery() + fun requestGallery(mimeType: String, requestCode: Int) fun requestDatePicker() fun requestContact() fun setDraft(draft: String) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt index bf65a224..aa6b27ec 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt @@ -536,11 +536,17 @@ class ComposeViewModel @Inject constructor( } } - // Attach a photo from gallery - view.galleryIntent - .doOnNext { newState { copy(attaching = false) } } - .autoDisposable(view.scope()) - .subscribe { view.requestGallery() } + // pick a photo (specifically) from image provider apps + view.attachImageFileIntent + .doOnNext { newState { copy(attaching = false) } } + .autoDisposable(view.scope()) + .subscribe { view.requestGallery("image/*", ComposeView.AttachAFileRequestCode) } + + // pick any file from any provider apps + view.attachAnyFileIntent + .doOnNext { newState { copy(attaching = false) } } + .autoDisposable(view.scope()) + .subscribe { view.requestGallery("*/*", ComposeView.AttachAFileRequestCode) } // Choose a time to schedule the message view.scheduleIntent @@ -552,15 +558,15 @@ class ComposeViewModel @Inject constructor( .autoDisposable(view.scope()) .subscribe { view.requestDatePicker() } - // A photo was selected + // a file, photo or otherwise, was picked by the user Observable.merge( - view.attachmentSelectedIntent.map { uri -> Attachment.Image(uri) }, - view.inputContentIntent.map { inputContent -> Attachment.Image(inputContent = inputContent) }) - .withLatestFrom(attachments) { attachment, attachments -> attachments + attachment } - .doOnNext(attachments::onNext) - .autoDisposable(view.scope()) - .subscribe { newState { copy(attaching = false) } } - + view.attachAnyFileSelectedIntent.map { uri -> Attachment(uri) }, + view.inputContentIntent.map { inputContent -> Attachment(inputContent = inputContent) } + ) + .withLatestFrom(attachments) { attachment, attachments -> attachments + attachment } + .doOnNext(attachments::onNext) + .autoDisposable(view.scope()) + .subscribe { newState { copy(attaching = false) } } // Set the scheduled time view.scheduleSelectedIntent .filter { scheduled -> @@ -579,7 +585,7 @@ class ComposeViewModel @Inject constructor( // Contact was selected for attachment view.contactSelectedIntent - .map { uri -> Attachment.Contact(getVCard(uri)!!) } + .map { uri -> Attachment(getFullVCardUri(uri)) } .withLatestFrom(attachments) { attachment, attachments -> attachments + attachment } .subscribeOn(Schedulers.io()) .autoDisposable(view.scope()) @@ -704,7 +710,6 @@ class ComposeViewModel @Inject constructor( state.scheduled != 0L -> { newState { copy(scheduled = 0) } val uris = attachments - .mapNotNull { it as? Attachment.Image } .map { it.getUri() } .map { it.toString() } val params = AddScheduledMessage @@ -784,17 +789,17 @@ class ComposeViewModel @Inject constructor( } - private fun getVCard(contactData: Uri): String? { - val lookupKey = context.contentResolver.query(contactData, null, null, null, null)?.use { cursor -> - cursor.moveToFirst() - cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY)) + private fun getFullVCardUri(contactData: Uri): Uri { + val lookupKey = context.contentResolver.query(contactData, null, null, null, null)?.use { + it.moveToFirst() + val index = it.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY) + if (index >= 0) + it.getString(index) + else + "" } - val vCardUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey) - return context.contentResolver.openAssetFileDescriptor(vCardUri, "r") - ?.createInputStream() - ?.readBytes() - ?.let { bytes -> String(bytes) } + return Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey) } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt index 96a10ffc..e5f3e1b4 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt @@ -19,32 +19,112 @@ package dev.octoshrimpy.quik.feature.scheduled import android.content.Context +import android.content.Intent +import android.media.MediaMetadataRetriever import android.net.Uri +import android.provider.MediaStore.MATCH_DEFAULT +import android.provider.OpenableColumns import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import com.bumptech.glide.Glide import dev.octoshrimpy.quik.R import dev.octoshrimpy.quik.common.base.QkAdapter import dev.octoshrimpy.quik.common.base.QkViewHolder -import dev.octoshrimpy.quik.util.GlideApp -import kotlinx.android.synthetic.main.attachment_image_list_item.view.* -import kotlinx.android.synthetic.main.scheduled_message_image_list_item.* +import dev.octoshrimpy.quik.extensions.resourceExists +import kotlinx.android.synthetic.main.scheduled_message_image_list_item.fileName +import kotlinx.android.synthetic.main.scheduled_message_image_list_item.thumbnail import javax.inject.Inject + class ScheduledMessageAttachmentAdapter @Inject constructor( private val context: Context ) : QkAdapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { - val view = LayoutInflater.from(parent.context).inflate(R.layout.scheduled_message_image_list_item, parent, false) - view.thumbnail.clipToOutline = true - - return QkViewHolder(view) + return QkViewHolder( + LayoutInflater + .from(parent.context) + .inflate(R.layout.scheduled_message_image_list_item, parent, false) + ) } override fun onBindViewHolder(holder: QkViewHolder, position: Int) { - val attachment = getItem(position) + val uri = getItem(position) - GlideApp.with(context).load(attachment).into(holder.thumbnail) + // if uri target doesn't exist anymore + if (!uri.resourceExists(context)) { + holder.thumbnail.setImageResource(android.R.drawable.ic_delete) + holder.fileName.text = context.getString(R.string.attachment_missing) + holder.fileName.visibility = View.VISIBLE + return + } + + val mimeType = context.contentResolver.getType(uri) + + // if attachment mime type is image/* or video/*, use image/video-frame + if ((mimeType?.startsWith("image/") == true) || + (mimeType?.startsWith("video/") == true) + ) { + Glide + .with(context) + .load(uri) + .into(holder.thumbnail) + return + } + + // if audio mime type, try and use embedded image if one exists + if (mimeType?.startsWith("audio/") == true) { + val metaDataRetriever = MediaMetadataRetriever() + metaDataRetriever.setDataSource(context, uri) + val embeddedPicture = metaDataRetriever.embeddedPicture + if (embeddedPicture != null) { + Glide + .with(context) + .load(embeddedPicture) + .into(holder.thumbnail) + return + } + } + + // try and use icon from default app for type + val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, mimeType) + val appIcon = context + .packageManager + .resolveActivity(intent, MATCH_DEFAULT) + ?.loadIcon(context.packageManager) + if (appIcon != null) + Glide + .with(context) + .load(appIcon) + .into(holder.thumbnail) + else if (mimeType?.startsWith("audio/") == true) + // else, if audio, use default local audio icon + Glide + .with(context) + .load(R.drawable.ic_round_volume_up_24) + .into(holder.thumbnail) + else + // else, use default attachment icon + Glide + .with(context) + .load(R.drawable.ic_attachment_black_24dp) + .into(holder.thumbnail) + + // else, show file name + val cursor = context.contentResolver.query( + uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null + ) + cursor?.use { + try { + cursor.moveToFirst() + holder.fileName.text = cursor.getString( + cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) ?: 0 + ) + holder.fileName.visibility = View.VISIBLE + } catch (e: Exception) { + } + } } } diff --git a/presentation/src/main/res/drawable/ic_round_volume_up_24.xml b/presentation/src/main/res/drawable/ic_round_volume_up_24.xml new file mode 100644 index 00000000..cb631566 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_round_volume_up_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/presentation/src/main/res/layout/attachment_image_list_item.xml b/presentation/src/main/res/layout/attachment_file_list_item.xml similarity index 82% rename from presentation/src/main/res/layout/attachment_image_list_item.xml rename to presentation/src/main/res/layout/attachment_file_list_item.xml index ff757f03..b14e7827 100644 --- a/presentation/src/main/res/layout/attachment_image_list_item.xml +++ b/presentation/src/main/res/layout/attachment_file_list_item.xml @@ -37,7 +37,17 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="centerCrop" - tools:src="@tools:sample/avatars" /> + tools:src="@tools:sample/backgrounds/scenic[0]" /> + + diff --git a/presentation/src/main/res/layout/compose_activity.xml b/presentation/src/main/res/layout/compose_activity.xml index eac3add4..c8cf1ea5 100644 --- a/presentation/src/main/res/layout/compose_activity.xml +++ b/presentation/src/main/res/layout/compose_activity.xml @@ -204,7 +204,7 @@ android:layout_height="56dp" android:padding="10dp" android:src="@drawable/ic_cancel_black_24dp" - android:tint="?android:attr/textColorSecondary" + app:tint="?android:attr/textColorSecondary" app:layout_constraintBottom_toTopOf="@id/scheduledSeparator" app:layout_constraintEnd_toEndOf="@id/messageBackground" /> @@ -230,7 +230,7 @@ app:layout_constraintBottom_toTopOf="@id/message" app:layout_constraintEnd_toEndOf="@id/messageBackground" app:layout_constraintStart_toStartOf="@id/messageBackground" - tools:listitem="@layout/attachment_image_list_item" /> + tools:listitem="@layout/attachment_file_list_item" /> @@ -304,7 +304,7 @@ android:contentDescription="@string/compose_send_cd" android:padding="10dp" android:src="@drawable/ic_send_black_24dp" - android:tint="?android:textColorSecondary" + app:tint="?android:textColorSecondary" app:layout_constraintBottom_toBottomOf="@id/messageBackground" app:layout_constraintEnd_toEndOf="parent" /> @@ -371,7 +371,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone" - app:constraint_referenced_ids="contact,contactLabel,schedule,scheduleLabel,gallery,galleryLabel,camera,cameraLabel,attachingBackground" /> + app:constraint_referenced_ids="contact,contactLabel,schedule,scheduleLabel,gallery,galleryLabel,attachAFileIcon,attachAFileLabel,camera,cameraLabel,attachingBackground" /> @@ -452,6 +452,43 @@ app:layout_constraintStart_toEndOf="@id/schedule" app:layout_constraintTop_toTopOf="@id/schedule" /> + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/values-el/strings.xml b/presentation/src/main/res/values-el/strings.xml index 9e8a538b..06ac0917 100644 --- a/presentation/src/main/res/values-el/strings.xml +++ b/presentation/src/main/res/values-el/strings.xml @@ -250,7 +250,7 @@ Send long messages as MMS If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments Συγχρονισμός μηνυμάτων Επανασυγχρονισμός μηνυμάτων με τη βάση δεδομένων SMS του Android Σχετικά με το QUIK diff --git a/presentation/src/main/res/values-gd/strings.xml b/presentation/src/main/res/values-gd/strings.xml index e9e43c73..8c790a8e 100644 --- a/presentation/src/main/res/values-gd/strings.xml +++ b/presentation/src/main/res/values-gd/strings.xml @@ -254,7 +254,7 @@ Send long messages as MMS If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments Sync messages Re-sync your messages with the native Android SMS database About QUIK diff --git a/presentation/src/main/res/values-lt/strings.xml b/presentation/src/main/res/values-lt/strings.xml index 22e2c9a0..ab1559e0 100644 --- a/presentation/src/main/res/values-lt/strings.xml +++ b/presentation/src/main/res/values-lt/strings.xml @@ -254,7 +254,7 @@ Send long messages as MMS If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments Sinchronizuoti pranešimus Iš naujo sinchronizuoti Android pranešimų duomenų bazę Apie QUIK diff --git a/presentation/src/main/res/values-ne/strings.xml b/presentation/src/main/res/values-ne/strings.xml index 5921fd84..cad960c0 100644 --- a/presentation/src/main/res/values-ne/strings.xml +++ b/presentation/src/main/res/values-ne/strings.xml @@ -250,7 +250,7 @@ Send long messages as MMS If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments Sync messages Re-sync your messages with the native Android SMS database About QUIK diff --git a/presentation/src/main/res/values-ta/strings.xml b/presentation/src/main/res/values-ta/strings.xml index 1319f51c..75b60836 100644 --- a/presentation/src/main/res/values-ta/strings.xml +++ b/presentation/src/main/res/values-ta/strings.xml @@ -250,7 +250,7 @@ நீண்ட செய்திகளை எம்.எம்.எஸ் ஆக அனுப்பு If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments தகவல்களை ஒத்திசை Re-sync your messages with the native Android SMS database QUIK பற்றி diff --git a/presentation/src/main/res/values-tl/strings.xml b/presentation/src/main/res/values-tl/strings.xml index 3940f2bc..c156eaff 100644 --- a/presentation/src/main/res/values-tl/strings.xml +++ b/presentation/src/main/res/values-tl/strings.xml @@ -250,7 +250,7 @@ Send long messages as MMS If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments Sync messages Re-sync your messages with the native Android SMS database About QUIK diff --git a/presentation/src/main/res/values-ur/strings.xml b/presentation/src/main/res/values-ur/strings.xml index 731e6f6e..029c80cc 100644 --- a/presentation/src/main/res/values-ur/strings.xml +++ b/presentation/src/main/res/values-ur/strings.xml @@ -250,7 +250,7 @@ Send long messages as MMS If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments Sync messages Re-sync your messages with the native Android SMS database About QUIK diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 99f8d9b6..9e61419d 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -141,6 +141,7 @@ Delivered: %s Error code: %d Add an attachment + Attach any file Attach a photo Take a photo Schedule message @@ -211,6 +212,7 @@ Sending on December 23rd  Schedule a message Scheduled message + Missing Send now Copy text @@ -284,7 +286,7 @@ Send long messages as MMS If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply - Auto-compress MMS attachments + Auto-compress MMS image attachments Sync messages Re-sync your messages with the native Android SMS database About QUIK