Merge pull request #254 from gavine99/mms-attachment-share-242

fixes for mms attachment view/share is suboptimal (#242) and other small fixups
This commit is contained in:
octoshrimpy
2025-02-06 17:00:37 -07:00
committed by GitHub
14 changed files with 285 additions and 93 deletions
@@ -21,14 +21,14 @@ package dev.octoshrimpy.quik.extensions
import com.google.android.mms.ContentType
import dev.octoshrimpy.quik.model.MmsPart
fun MmsPart.isSmil() = ContentType.APP_SMIL == type
fun MmsPart.isSmil() = ContentType.APP_SMIL.lowercase() == type.lowercase()
fun MmsPart.isImage() = ContentType.isImageType(type)
fun MmsPart.isImage() = ContentType.isImageType(type.lowercase())
fun MmsPart.isVideo() = ContentType.isVideoType(type)
fun MmsPart.isVideo() = ContentType.isVideoType(type.lowercase())
fun MmsPart.isAudio() = ContentType.isAudioType(type)
fun MmsPart.isAudio() = ContentType.isAudioType(type.lowercase())
fun MmsPart.isText() = ContentType.TEXT_PLAIN == type
fun MmsPart.isText() = ContentType.TEXT_PLAIN.lowercase() == type.lowercase()
fun MmsPart.isVCard() = ContentType.TEXT_VCARD == type
fun MmsPart.isVCard() = ContentType.TEXT_VCARD.lowercase() == type.lowercase()
@@ -443,8 +443,8 @@ class MessageRepositoryImpl @Inject constructor(
imageBytesByAttachment.forEach { (attachment, bytes) ->
parts += when (attachment.getType(context) == "image/gif") {
true -> MMSPart("image", ContentType.IMAGE_GIF, bytes)
false -> MMSPart("image", ContentType.IMAGE_JPEG, bytes)
true -> MMSPart(attachment.getName(context), ContentType.IMAGE_GIF, bytes)
false -> MMSPart(attachment.getName(context), ContentType.IMAGE_JPEG, bytes)
}
}
+11 -1
View File
@@ -16,4 +16,14 @@
~ You should have received a copy of the GNU General Public License
~ along with QKSMS. If not, see <http://www.gnu.org/licenses/>.
-->
<manifest />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="com.moez.QKSMS.contentproviders.MmsPartProvider"
android:authorities="dev.octoshrimpy.quik.mmspart"
android:enabled="true"
android:exported="false"
android:grantUriPermissions="true">
</provider>
</application>
</manifest>
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.contentproviders
import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.FileNotFoundException
// the primary use case for this provider is to re-provide mms attachments but with a file name
// as the last part of the uri as opposed to the system mms provider uri which has a part id as
// it's 'filename' component. with this provider external applications can save/display using the
// original file name, if available, of the attachment when it is shared and/or saved
// it seems a needlessly complicated way to achieve the requirement, but it has to be done
class MmsPartProvider : ContentProvider() {
companion object {
private fun getMmsPartIdFromUri(uri: Uri): String? {
// ensure at least 2 path segments exist
if (uri.pathSegments.size < 2)
return null
return try {
return uri.pathSegments[uri.pathSegments.size - 2]
} catch (e: Exception) {
null
}
}
private fun getUnderlyingUriFromUri(uri: Uri): Uri {
val partId = getMmsPartIdFromUri(uri) ?: return Uri.EMPTY
return Uri
.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority("mms")
.encodedPath("part/$partId")
.build()
}
fun getUriForMmsPartId(partId: Long, partName: String): Uri {
return Uri
.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority("dev.octoshrimpy.quik.mmspart")
.encodedPath("part/$partId/$partName")
.build()
}
}
override fun onCreate(): Boolean {
return true
}
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? {
return context?.contentResolver?.query(
getUnderlyingUriFromUri(uri),
projection,
selection,
selectionArgs,
sortOrder
)
}
override fun getType(uri: Uri): String? {
return context?.contentResolver?.getType(getUnderlyingUriFromUri(uri))
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
return context?.contentResolver?.insert(getUnderlyingUriFromUri(uri), values)
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?
): Int {
return context?.contentResolver?.update(
getUnderlyingUriFromUri(uri),
values,
selection,
selectionArgs
) ?: 0
}
override fun delete(
uri: Uri,
selection: String?,
selectionArgs: Array<String>?
): Int {
return context?.contentResolver?.delete(
getUnderlyingUriFromUri(uri),
selection,
selectionArgs
) ?: 0
}
@Throws(FileNotFoundException::class)
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
return context?.contentResolver?.openFileDescriptor(
getUnderlyingUriFromUri(uri),
mode)
}
}
@@ -18,12 +18,17 @@
*/
package dev.octoshrimpy.quik.model
import android.content.ContentResolver
import android.content.ContentResolver.MimeTypeInfo
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.Index
import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey
import java.io.File
open class MmsPart : RealmObject() {
@@ -37,13 +42,23 @@ open class MmsPart : RealmObject() {
@LinkingObjects("parts")
val messages: RealmResults<Message>? = null
fun getUri() = "content://mms/part/$id".toUri()
fun getUri(): Uri = Uri
.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority("mms")
.encodedPath("part/$id")
.build()
fun getBestFilename(): String =
if (File(name).extension.isNotEmpty()) name ?: "unknown"
else "$name." + (MimeTypeMap.getSingleton().getExtensionFromMimeType(type)
?: type.substringAfter("/"))
fun getSummary(): String? = when {
type == "application/smil" -> null
type == "text/plain" -> text
type == "text/x-vcard" -> "Contact card"
type.startsWith("image") -> "Photo"
type.startsWith("image") -> "Picture"
type.startsWith("video") -> "Video"
type.startsWith("audio") -> "Audio"
else -> type.substring(type.indexOf('/') + 1)
-11
View File
@@ -252,17 +252,6 @@
<meta-data
android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
<queries>
@@ -90,6 +90,8 @@ class AttachmentAdapter @Inject constructor(
return
}
holder.fileName.visibility = View.GONE
// if attachment uri is missing
if (!attachment.getUri().resourceExists(context)) {
holder.thumbnail.setImageResource(android.R.drawable.ic_delete)
@@ -24,6 +24,7 @@ import android.os.Vibrator
import android.provider.ContactsContract
import android.telephony.SmsMessage
import androidx.core.content.getSystemService
import com.moez.QKSMS.contentproviders.MmsPartProvider
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.Navigator
import dev.octoshrimpy.quik.common.base.QkViewModel
@@ -470,12 +471,11 @@ class ComposeViewModel @Inject constructor(
.mapNotNull(messageRepo::getPart)
.filter { part -> !part.isImage() && !part.isVideo() }
.autoDisposable(view.scope())
.subscribe { part ->
if (permissionManager.hasStorage()) {
messageRepo.savePart(part.id)?.let { navigator.viewFile(it, part.type) }
} else {
view.requestStoragePermission()
}
.subscribe {
navigator.viewFile(
MmsPartProvider.getUriForMmsPartId(it.id, it.getBestFilename()),
it.type
)
}
// Update the State when the message selected count changes
@@ -25,7 +25,9 @@ import android.media.MediaMetadataRetriever
import android.view.View
import android.widget.SeekBar
import com.moez.QKSMS.common.QkMediaPlayer
import com.moez.QKSMS.contentproviders.MmsPartProvider
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.Navigator
import dev.octoshrimpy.quik.common.base.QkViewHolder
import dev.octoshrimpy.quik.common.util.Colors
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
@@ -49,6 +51,12 @@ import javax.inject.Inject
class AudioBinder @Inject constructor(colors: Colors, private val context: Context) :
PartBinder() {
companion object {
const val DEFAULT_SHARE_FILENAME = "quik-audio-attachment.mp3"
}
@Inject lateinit var navigator: Navigator
override val partLayout = R.layout.mms_audio_preview_list_item
override var theme = colors.theme()
@@ -169,7 +177,13 @@ class AudioBinder @Inject constructor(colors: Colors, private val context: Conte
}
}
holder.containerView.setOnClickListener { clicks.onNext(part.id) }
// share button click handling
holder.share.setOnClickListener {
navigator.shareFile(
MmsPartProvider.getUriForMmsPartId(part.id, part.getBestFilename()),
part.type
)
}
// if this item is the active active audio item update the active view holder
if (audioState.partId == part.id)
@@ -224,6 +238,10 @@ class AudioBinder @Inject constructor(colors: Colors, private val context: Conte
setBackgroundTint(primaryColor)
}
// share button
holder.share.setTint(secondaryColor)
holder.share.setBackgroundTint(primaryColor)
MediaMetadataRetriever().use {
it.setDataSource(context, part.getUri())
@@ -22,7 +22,9 @@ import android.annotation.SuppressLint
import android.content.Context
import android.view.Gravity
import android.widget.FrameLayout
import com.moez.QKSMS.contentproviders.MmsPartProvider
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.Navigator
import dev.octoshrimpy.quik.common.base.QkViewHolder
import dev.octoshrimpy.quik.common.util.Colors
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
@@ -34,11 +36,14 @@ import dev.octoshrimpy.quik.model.MmsPart
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import kotlinx.android.synthetic.main.mms_audio_preview_list_item.share
import kotlinx.android.synthetic.main.mms_file_list_item.*
import javax.inject.Inject
class FileBinder @Inject constructor(colors: Colors, private val context: Context) : PartBinder() {
@Inject lateinit var navigator: Navigator
override val partLayout = R.layout.mms_file_list_item
override var theme = colors.theme()
@@ -56,8 +61,6 @@ class FileBinder @Inject constructor(colors: Colors, private val context: Contex
BubbleUtils.getBubble(false, canGroupWithPrevious, canGroupWithNext, message.isMe())
.let(holder.fileBackground::setBackgroundResource)
holder.containerView.setOnClickListener { clicks.onNext(part.id) }
Observable.just(part.getUri())
.map(context.contentResolver::openInputStream)
.map { inputStream -> inputStream.use { it.available() } }
@@ -73,6 +76,14 @@ class FileBinder @Inject constructor(colors: Colors, private val context: Contex
.observeOn(AndroidSchedulers.mainThread())
.subscribe { size -> holder.size.text = size }
// share button click handling
holder.share.setOnClickListener {
navigator.shareFile(
MmsPartProvider.getUriForMmsPartId(part.id, part.getBestFilename()),
part.type
)
}
holder.filename.text = part.name
val params = holder.fileBackground.layoutParams as FrameLayout.LayoutParams
@@ -82,12 +93,16 @@ class FileBinder @Inject constructor(colors: Colors, private val context: Contex
holder.icon.setTint(theme.textPrimary)
holder.filename.setTextColor(theme.textPrimary)
holder.size.setTextColor(theme.textTertiary)
holder.share.setTint(theme.theme)
holder.share.setBackgroundTint(theme.textPrimary)
} else {
holder.fileBackground.layoutParams = params.apply { gravity = Gravity.END }
holder.fileBackground.setBackgroundTint(holder.containerView.context.resolveThemeColor(R.attr.bubbleColor))
holder.icon.setTint(holder.containerView.context.resolveThemeColor(android.R.attr.textColorSecondary))
holder.filename.setTextColor(holder.containerView.context.resolveThemeColor(android.R.attr.textColorPrimary))
holder.size.setTextColor(holder.containerView.context.resolveThemeColor(android.R.attr.textColorTertiary))
holder.share.setTint(holder.containerView.context.resolveThemeColor(R.attr.bubbleColor))
holder.share.setBackgroundTint(holder.containerView.context.resolveThemeColor(android.R.attr.textColorTertiary))
}
}
@@ -19,6 +19,7 @@
package dev.octoshrimpy.quik.feature.gallery
import android.content.Context
import com.moez.QKSMS.contentproviders.MmsPartProvider
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.Navigator
import dev.octoshrimpy.quik.common.base.QkViewModel
@@ -45,6 +46,9 @@ class GalleryViewModel @Inject constructor(
private val saveImage: SaveImage,
private val permissions: PermissionManager
) : QkViewModel<GalleryView, GalleryState>(GalleryState()) {
companion object {
const val DEFAULT_SHARE_FILENAME = "quik-media-attachment.jpg"
}
init {
disposables += Flowable.just(partId)
@@ -80,11 +84,13 @@ class GalleryViewModel @Inject constructor(
// Share image externally
view.optionsItemSelected()
.filter { itemId -> itemId == R.id.share }
.filter { permissions.hasStorage().also { if (!it) view.requestStoragePermission() } }
.withLatestFrom(view.pageChanged()) { _, part -> part }
.autoDisposable(view.scope())
.subscribe { part ->
messageRepo.savePart(part.id)?.let { navigator.shareFile(it, part.type) }
.subscribe {
navigator.shareFile(
MmsPartProvider.getUriForMmsPartId(it.id, it.getBestFilename()),
it.type
)
}
}
@@ -53,30 +53,29 @@
android:progressTint="@color/tools_theme"
android:thumbTint="@color/tools_theme" />
<LinearLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="12dp"
android:clipChildren="false"
android:orientation="horizontal">
android:layout_marginBottom="12dp">
<ImageView
android:id="@+id/playPause"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="bottom"
android:layout_marginStart="12dp"
android:background="@drawable/circle"
android:elevation="4dp"
android:padding="6dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:srcCompat="@drawable/exo_icon_play"
app:tint="@color/tools_theme" />
<dev.octoshrimpy.quik.common.widget.QkTextView
android:id="@+id/metadataTitle"
style="@style/TextPrimary"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="12dp"
@@ -91,8 +90,24 @@
android:scrollHorizontally="true"
android:singleLine="true"
android:text="@tools:sample/lorem/random[0]"
android:textColor="@color/black" />
android:textColor="@color/black"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/share"
app:layout_constraintStart_toEndOf="@id/playPause" />
</LinearLayout>
<ImageView
android:id="@+id/share"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="bottom"
android:layout_marginEnd="12dp"
android:background="@drawable/circle"
android:elevation="4dp"
android:padding="6dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="?attr/actionModeShareDrawable"
app:tint="@color/tools_theme" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
@@ -35,36 +35,53 @@
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:src="@drawable/ic_attachment_black_24dp"
android:tint="?android:attr/textColorSecondary"
app:layout_constraintBottom_toBottomOf="@id/fileBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/fileBackground" />
<dev.octoshrimpy.quik.common.widget.QkTextView
android:id="@+id/filename"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
app:layout_constraintTop_toTopOf="parent"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintStart_toEndOf="@id/icon"
app:textSize="primary"
tools:text="Resume.pdf" />
app:tint="?android:attr/textColorSecondary" />
<dev.octoshrimpy.quik.common.widget.QkTextView
android:id="@+id/size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="12dp"
tools:text="35 KB"
android:paddingBottom="8dp"
android:textColor="?android:attr/textColorTertiary"
app:layout_constraintStart_toStartOf="@id/filename"
app:layout_constraintTop_toBottomOf="@id/filename"
app:textSize="secondary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintEnd_toStartOf="@id/share"
app:layout_constraintStart_toEndOf="@id/icon">
<dev.octoshrimpy.quik.common.widget.QkTextView
android:id="@+id/filename"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:textColor="?android:attr/textColorPrimary"
app:textSize="primary"
tools:text="Resume.pdf" />
<dev.octoshrimpy.quik.common.widget.QkTextView
android:id="@+id/size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp"
android:textColor="?android:attr/textColorTertiary"
app:textSize="secondary"
tools:text="35 KB" />
</LinearLayout>
<ImageView
android:id="@+id/share"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="12dp"
android:background="@drawable/circle"
android:padding="6dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="?attr/actionModeShareDrawable"
app:tint="@color/tools_theme" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2019 Moez Bhatti <moez.bhatti@gmail.com>
~
~ 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 <http://www.gnu.org/licenses/>.
-->
<paths>
<external-path
name="external_files"
path="." />
</paths>