CFV2 Save to Disk / Copy Text Content.

This commit is contained in:
Alex Hart
2023-05-26 15:51:30 -03:00
committed by Cody Henthorne
parent 399421e20e
commit b785b3f887
13 changed files with 554 additions and 26 deletions

View File

@@ -2,8 +2,11 @@ package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.core.content.withStyledAttributes
import com.google.android.material.card.MaterialCardView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.visible
/**
* A small card with a circular progress indicator in it. Usable in place
@@ -16,7 +19,25 @@ class ProgressCard @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : MaterialCardView(context, attrs) {
private val title: TextView
init {
inflate(context, R.layout.progress_card, this)
title = findViewById(R.id.progress_card_text)
if (attrs != null) {
context.withStyledAttributes(attrs, R.styleable.ProgressCard) {
setTitleText(getString(R.styleable.ProgressCard_progressCardTitle))
}
} else {
setTitleText(null)
}
}
fun setTitleText(titleText: String?) {
title.visible = !titleText.isNullOrEmpty()
title.text = titleText
}
}

View File

@@ -4,17 +4,26 @@ import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.navigation.fragment.navArgs
import org.thoughtcrime.securesms.R
/**
* Displays a small progress spinner in a card view, as a non-cancellable dialog fragment.
*/
class ProgressCardDialogFragment : DialogFragment(R.layout.progress_card_dialog) {
private val args: ProgressCardDialogFragmentArgs by navArgs()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
isCancelable = false
return super.onCreateDialog(savedInstanceState).apply {
this.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<ProgressCard>(R.id.progress_card).setTitleText(args.title)
}
}

View File

@@ -33,7 +33,7 @@ import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.logging.Log;
import org.signal.glide.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
@@ -394,7 +394,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
private void updateToolbarShade(@NonNull Activity activity) {
View toolbar = activity.findViewById(R.id.toolbar);
View bannerContainer = activity.findViewById(R.id.conversation_banner_container);
View bannerContainer = activity.findViewById(SignalStore.internalValues().useConversationFragmentV2() ? R.id.conversation_banner
: R.id.conversation_banner_container);
LayoutParams layoutParams = (LayoutParams) toolbarShade.getLayoutParams();
layoutParams.height = toolbar.getHeight() + bannerContainer.getHeight();
@@ -410,9 +411,9 @@ public final class ConversationReactionOverlay extends FrameLayout {
private int getInputPanelHeight(@NonNull Activity activity) {
if (SignalStore.internalValues().useConversationFragmentV2()) {
Barrier conversationBottomPanelBarrier = activity.findViewById(R.id.conversation_bottom_panel_barrier);
View bottomPanel = activity.findViewById(R.id.conversation_input_panel);
return activity.getResources().getDisplayMetrics().heightPixels - conversationBottomPanelBarrier.getTop();
return bottomPanel.getHeight();
}
View bottomPanel = activity.findViewById(R.id.conversation_activity_panel_parent);

View File

@@ -161,7 +161,6 @@ class MultiselectItemDecoration(
/**
* Draws the background shade.
*/
@Suppress("DEPRECATION")
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val adapter = parent.adapter as ConversationAdapterBridge

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
import android.annotation.SuppressLint
import android.app.ActivityOptions
import android.content.Intent
@@ -84,6 +85,8 @@ import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.components.HidingLinearLayout
import org.thoughtcrime.securesms.components.InputAwareConstraintLayout
import org.thoughtcrime.securesms.components.InputPanel
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -171,6 +174,7 @@ import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
@@ -194,7 +198,9 @@ import org.thoughtcrime.securesms.util.DrawableUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.SaveAttachmentUtil
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.StorageUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.WindowUtil
@@ -743,9 +749,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
ConversationAdapter.initializePool(binding.conversationItemRecycler.recycledViewPool)
adapter.setPagingController(viewModel.pagingController)
binding.conversationItemRecycler.adapter = adapter
giphyMp4ProjectionRecycler = initializeGiphyMp4()
recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler)
recyclerViewColorizer.setChatColors(args.chatColors)
binding.conversationItemRecycler.adapter = adapter
multiselectItemDecoration = MultiselectItemDecoration(
requireContext()
) { viewModel.wallpaperSnapshot }
@@ -756,12 +763,11 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
binding.conversationItemRecycler.addItemDecoration(multiselectItemDecoration)
viewLifecycleOwner.lifecycle.addObserver(multiselectItemDecoration)
giphyMp4ProjectionRecycler = initializeGiphyMp4()
val layoutTransitionListener = BubbleLayoutTransitionListener(binding.conversationItemRecycler)
viewLifecycleOwner.lifecycle.addObserver(layoutTransitionListener)
recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler)
recyclerViewColorizer.setChatColors(args.chatColors)
binding.conversationItemRecycler.itemAnimator = ConversationItemAnimator(
isInMultiSelectMode = adapter.selectedItems::isNotEmpty,
shouldPlayMessageAnimations = {
@@ -1118,11 +1124,44 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
}
private fun handleSaveAttachment(record: MediaMmsMessageRecord) {
// TODO [cfv2] -- Not implemented yet.
if (record.isViewOnce) {
error("Cannot save a view-once message")
}
val attachments = SaveAttachmentUtil.getAttachmentsForRecord(record)
SaveAttachmentUtil.showWarningDialog(requireContext(), attachments.size) { _, _ ->
if (StorageUtil.canWriteToMediaStore()) {
performAttachmentSave(attachments)
} else {
Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied { toast(R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, toastDuration = Toast.LENGTH_LONG) }
.onAllGranted { performAttachmentSave(attachments) }
.execute()
}
}
}
private fun performAttachmentSave(attachments: Set<SaveAttachmentUtil.SaveAttachment>) {
val progressDialog = ProgressCardDialogFragment()
progressDialog.arguments = ProgressCardDialogFragmentArgs.Builder(
resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, attachments.size, attachments.size)
).build().toBundle()
SaveAttachmentUtil.saveAttachments(attachments)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { progressDialog.show(parentFragmentManager, null) }
.doOnTerminate { progressDialog.dismissAllowingStateLoss() }
.subscribeBy { it.toast(requireContext()) }
.addTo(disposables)
}
private fun handleCopyMessage(messageParts: Set<MultiselectPart>) {
// TODO [cfv2] -- Not implemented yet.
viewModel.copyToClipboard(requireContext(), messageParts).subscribe().addTo(disposables)
}
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {

View File

@@ -8,12 +8,14 @@ package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.net.Uri
import android.os.Build
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.toOptional
@@ -27,8 +29,10 @@ import org.thoughtcrime.securesms.components.reminder.PendingGroupJoinRequestsRe
import org.thoughtcrime.securesms.components.reminder.Reminder
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.v2.data.ConversationDataSource
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock
import org.thoughtcrime.securesms.database.GroupTable
@@ -59,6 +63,9 @@ import org.thoughtcrime.securesms.recipients.RecipientFormattingException
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.Optional
import kotlin.math.max
@@ -228,15 +235,19 @@ class ConversationRepository(
ApplicationDependencies.getJobManager().add(ServiceOutageDetectionJob())
ServiceOutageReminder()
}
groupRecord != null && groupRecord.actionableRequestingMembersCount > 0 -> {
PendingGroupJoinRequestsReminder(groupRecord.actionableRequestingMembersCount)
}
groupRecord != null && groupRecord.gv1MigrationSuggestions.isNotEmpty() -> {
GroupsV1MigrationSuggestionsReminder(groupRecord.gv1MigrationSuggestions)
}
isInBubble && !SignalStore.tooltips().hasSeenBubbleOptOutTooltip() && Build.VERSION.SDK_INT > 29 -> {
BubbleOptOutReminder()
}
else -> null
}
@@ -301,6 +312,50 @@ class ConversationRepository(
}.subscribeOn(Schedulers.io())
}
/**
* Copies the selected content to the clipboard. Maybe will emit either the copied contents or
* a complete which means there were no contents to be copied.
*/
fun copyToClipboard(context: Context, messageParts: Set<MultiselectPart>): Maybe<CharSequence> {
return Maybe.fromCallable { extractBodies(context, messageParts) }
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess {
Util.copyToClipboard(context, it)
}
}
private fun extractBodies(context: Context, messageParts: Set<MultiselectPart>): CharSequence {
return messageParts
.asSequence()
.sortedBy { it.getMessageRecord().dateReceived }
.map { it.conversationMessage }
.distinct()
.mapNotNull { message ->
if (message.messageRecord.hasTextSlide()) {
val textSlideUri = message.messageRecord.requireTextSlide().uri
if (textSlideUri == null) {
message.getDisplayBody(context)
} else {
try {
PartAuthority.getAttachmentStream(context, textSlideUri).use {
val body = StreamUtil.readFullyAsString(it)
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, message.messageRecord, body, message.threadRecipient)
.getDisplayBody(context)
}
} catch (e: IOException) {
Log.w(TAG, "failed to read text slide data.")
null
}
}
} else {
message.getDisplayBody(context)
}
}
.filterNot(Util::isEmpty)
.joinToString("\n")
}
data class MessageCounts(
val unread: Int,
val mentions: Int

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -27,6 +28,7 @@ import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.components.reminder.Reminder
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.model.IdentityRecord
@@ -258,4 +260,8 @@ class ConversationViewModel(
fun getTemporaryViewOnceUri(mmsMessageRecord: MmsMessageRecord): Maybe<Uri> {
return repository.getTemporaryViewOnceUri(mmsMessageRecord).observeOn(AndroidSchedulers.mainThread())
}
fun copyToClipboard(context: Context, messageParts: Set<MultiselectPart>): Maybe<CharSequence> {
return repository.copyToClipboard(context, messageParts)
}
}

View File

@@ -0,0 +1,349 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.DialogInterface.OnClickListener
import android.database.Cursor
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.annotation.WorkerThread
import androidx.core.content.contentValuesOf
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.StreamUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.concurrent.TimeUnit
/**
* Helper type to ensure we don't reuse names when downloading a large set of
* unnamed files. Android is quite slow to update its internal data-sets so reading
* from them as we're processing and saving attachments won't always give the most
* up to date information.
*/
private typealias BatchOperationNameCache = HashMap<Uri, HashSet<String>>
/**
* This is a rewrite of [SaveAttachmentTask] that does not handle displaying
* a progress dialog and is not backed by an async task.
*/
object SaveAttachmentUtil {
private val TAG = Log.tag(SaveAttachmentUtil::class.java)
fun showWarningDialog(context: Context, count: Int, onAcceptListener: OnClickListener) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ConversationFragment_save_to_sd_card)
.setIcon(R.drawable.ic_warning)
.setCancelable(true)
.setMessage(context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_media_to_storage_warning, count, count))
.setPositiveButton(R.string.yes, onAcceptListener)
.setNegativeButton(R.string.no, null)
.show()
}
fun getAttachmentsForRecord(record: MediaMmsMessageRecord): Set<SaveAttachment> {
return record.slideDeck.slides
.filter { it.uri != null && (it.hasImage() || it.hasVideo() || it.hasAudio() || it.hasDocument()) }
.map { SaveAttachment(it.uri!!, it.contentType, record.dateSent, it.fileName.orNull()) }
.toSet()
}
fun saveAttachments(attachments: Set<SaveAttachment>): Single<SaveResult> {
return Single.fromCallable {
saveAttachmentsSync(attachments)
}
}
@WorkerThread
private fun saveAttachmentsSync(attachments: Set<SaveAttachment>): SaveResult {
check(attachments.isNotEmpty()) { "must pass in at least one attachment" }
if (!StorageUtil.canWriteToMediaStore()) {
return SaveResult.WriteAccessFailure
}
val nameCache: BatchOperationNameCache = HashMap()
try {
var directory: String?
attachments.forEach {
directory = saveAttachment(it, nameCache)
if (directory == null) {
return SaveResult.Failure(attachments.size)
}
}
return SaveResult.Success
} catch (e: IOException) {
Log.w(TAG, "Failed to save attachments", e)
return SaveResult.Failure(attachments.size)
}
}
@Throws(IOException::class)
private fun saveAttachment(attachment: SaveAttachment, nameCache: BatchOperationNameCache): String? {
val contentType: String = MediaUtil.getCorrectedMimeType(attachment.contentType)!!
val fileName: String = sanitizeOutputFileName(attachment.fileName ?: generateOutputFileName(contentType, attachment.date))
val result: CreateMediaUriResult = createMediaUri(getMediaStoreContentUriForType(contentType), contentType, fileName, nameCache)
val updateValues = ContentValues()
val mediaUri = result.mediaUri ?: return null
val inputStream: InputStream = PartAuthority.getAttachmentStream(ApplicationDependencies.getApplication(), attachment.uri) ?: return null
inputStream.use { inStream ->
if (result.outputUri.scheme == ContentResolver.SCHEME_FILE) {
FileOutputStream(mediaUri.path).use { outStream ->
StreamUtil.copy(inStream, outStream)
MediaScannerConnection.scanFile(ApplicationDependencies.getApplication(), arrayOf(mediaUri.path), arrayOf(contentType), null)
}
} else {
ApplicationDependencies.getApplication().contentResolver.openOutputStream(mediaUri, "w").use { outStream ->
val total = StreamUtil.copy(inStream, outStream)
if (total > 0) {
updateValues.put(MediaStore.MediaColumns.SIZE, total)
}
}
}
}
if (Build.VERSION.SDK_INT > 28) {
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
}
if (updateValues.size() > 0) {
ApplicationDependencies.getApplication().contentResolver.update(mediaUri, updateValues, null, null)
}
return result.outputUri.lastPathSegment
}
private fun getMediaStoreContentUriForType(contentType: String): Uri {
return when {
contentType.startsWith("video/") -> StorageUtil.getVideoUri()
contentType.startsWith("audio/") -> StorageUtil.getAudioUri()
contentType.startsWith("image/") -> StorageUtil.getImageUri()
else -> StorageUtil.getDownloadUri()
}
}
@SuppressLint("SimpleDateFormat")
private fun generateOutputFileName(contentType: String, timestamp: Long): String {
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach"
val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS")
val base = "signal-${dateFormatter.format(timestamp)}"
return "$base.$extension"
}
private fun sanitizeOutputFileName(fileName: String): String {
return File(fileName).name
}
@Throws(IOException::class)
private fun createMediaUri(outputUri: Uri, contentType: String, fileName: String, nameCache: BatchOperationNameCache): CreateMediaUriResult {
val (base, extension) = getFileNameParts(fileName)
var mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
if (MediaUtil.isOctetStream(mimeType) && MediaUtil.isImageOrVideoType(contentType)) {
Log.d(TAG, "MimeTypeMap returned octet stream for media, changing to provided content type [$contentType] instead.")
mimeType = contentType
}
if (MediaUtil.isOctetStream(mimeType)) {
mimeType = when {
outputUri == StorageUtil.getAudioUri() -> "audio/*"
outputUri == StorageUtil.getVideoUri() -> "video/*"
outputUri == StorageUtil.getImageUri() -> "image/*"
else -> mimeType
}
}
val contentValues = contentValuesOf(
MediaStore.MediaColumns.DISPLAY_NAME to fileName,
MediaStore.MediaColumns.MIME_TYPE to mimeType,
MediaStore.MediaColumns.DATE_ADDED to TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
MediaStore.MediaColumns.DATE_MODIFIED to TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
)
if (Build.VERSION.SDK_INT > 28) {
var i = 0
var displayName = fileName
while (nameCache.pathInCache(outputUri, displayName) || displayNameTaken(outputUri, displayName)) {
displayName = "$base-${++i}.$extension"
}
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
nameCache.putInCache(outputUri, displayName)
} else if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
val outputDirectory = File(outputUri.path!!)
var outputFile = File(outputDirectory, "$base.$extension")
var i = 0
while (nameCache.pathInCache(outputUri, outputFile.path) || outputFile.exists()) {
outputFile = File(outputDirectory, "$base-${++i}.$extension")
}
if (outputFile.isHidden) {
throw IOException("Specified name would not be visible.")
}
nameCache.putInCache(outputUri, outputFile.path)
return CreateMediaUriResult(outputUri, Uri.fromFile(outputFile))
} else {
val dir = getExternalPathForType(contentType) ?: throw IOException("Path for type: $contentType was not available")
var outputFileName = fileName
var dataPath = "$dir/$outputFileName"
var i = 0
while (nameCache.pathInCache(outputUri, dataPath) || pathTaken(outputUri, dataPath)) {
Log.d(TAG, "The content exists. Rename and check again.")
outputFileName = "$base-${++i}.$extension"
dataPath = "$dir/$outputFileName"
}
nameCache.putInCache(outputUri, outputFileName)
contentValues.put(MediaStore.MediaColumns.DATA, dataPath)
}
return try {
CreateMediaUriResult(outputUri, ApplicationDependencies.getApplication().contentResolver.insert(outputUri, contentValues))
} catch (e: RuntimeException) {
if (e is IllegalArgumentException || e.cause is IllegalArgumentException) {
Log.w(TAG, "Unable to create uri in $outputUri with mimeType [$mimeType]")
CreateMediaUriResult(StorageUtil.getDownloadUri(), ApplicationDependencies.getApplication().contentResolver.insert(StorageUtil.getDownloadUri(), contentValues))
} else {
throw e
}
}
}
private fun getExternalPathForType(contentType: String): String? {
val storage: File? = when {
contentType.startsWith("video/") -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
contentType.startsWith("audio/") -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)
contentType.startsWith("image/") -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
else -> Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
}
return storage?.let { ensureExternalPath(storage) }?.absolutePath
}
private fun ensureExternalPath(path: File): File? {
return path.takeIf { path.exists() || path.mkdirs() }
}
private fun BatchOperationNameCache.putInCache(outputUri: Uri, dataPath: String) {
val pathSet: HashSet<String> = this.getOrElse(outputUri) { HashSet() }
if (!pathSet.add(dataPath)) {
error("Path already used in data set.")
}
this[outputUri] = pathSet
}
private fun BatchOperationNameCache.pathInCache(outputUri: Uri, dataPath: String): Boolean {
return this[outputUri]?.contains(dataPath) ?: return false
}
@Throws(IOException::class)
private fun pathTaken(outputUri: Uri, dataPath: String): Boolean {
val cursor: Cursor = ApplicationDependencies.getApplication().contentResolver.query(
outputUri,
arrayOf(MediaStore.MediaColumns.DATA),
"${MediaStore.MediaColumns.DATA} = ?",
arrayOf(dataPath),
null
) ?: throw IOException("Something is wrong with the file name to save")
return cursor.use { it.moveToFirst() }
}
@Throws(IOException::class)
private fun displayNameTaken(outputUri: Uri, displayName: String): Boolean {
val cursor: Cursor = ApplicationDependencies.getApplication().contentResolver.query(
outputUri,
arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf(displayName),
null
) ?: throw IOException("Something is wrong with the displayName to save")
return cursor.use { it.moveToFirst() }
}
private fun getFileNameParts(fileName: String): Pair<String, String> {
val tokens = fileName.split(Regex("\\.(?=[^\\.]+$)"))
return Pair(
tokens[0],
if (tokens.size > 1) tokens[1] else ""
)
}
sealed interface SaveResult {
object Success : SaveResult {
override fun toast(context: Context) {
Toast.makeText(context, R.string.SaveAttachmentTask_saved, Toast.LENGTH_LONG).show()
}
}
data class Failure(val attachmentCount: Int) : SaveResult {
override fun toast(context: Context) {
Toast.makeText(
context,
context.resources.getQuantityText(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, attachmentCount),
Toast.LENGTH_LONG
).show()
}
}
object WriteAccessFailure : SaveResult {
override fun toast(context: Context) {
Toast.makeText(context, R.string.ConversationFragment_unable_to_write_to_sd_card_exclamation, Toast.LENGTH_LONG).show()
}
}
fun toast(context: Context)
}
data class SaveAttachment(
val uri: Uri,
val contentType: String,
val date: Long,
val fileName: String?
) {
init {
check(date > 0L) { "Date must be greater than zero." }
}
}
private data class CreateMediaUriResult(
val outputUri: Uri,
val mediaUri: Uri?
)
}