diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCard.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCard.kt index 892d3f717e..de2b72740f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCard.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCard.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt index 41a649a812..a3e51d0a2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProgressCardDialogFragment.kt @@ -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(R.id.progress_card).setTitleText(args.title) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 3489cd621d..cc28f52d3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index e86ef6eae9..748bef2950 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 60336e7c85..a91a37754a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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) { + 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) { - // TODO [cfv2] -- Not implemented yet. + viewModel.copyToClipboard(requireContext(), messageParts).subscribe().addTo(disposables) } private fun handleDisplayDetails(conversationMessage: ConversationMessage) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index 69c5031907..ff53143169 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -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): Maybe { + return Maybe.fromCallable { extractBodies(context, messageParts) } + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSuccess { + Util.copyToClipboard(context, it) + } + } + + private fun extractBodies(context: Context, messageParts: Set): 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index e68063ff27..9970eda32f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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 { return repository.getTemporaryViewOnceUri(mmsMessageRecord).observeOn(AndroidSchedulers.mainThread()) } + + fun copyToClipboard(context: Context, messageParts: Set): Maybe { + return repository.copyToClipboard(context, messageParts) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt new file mode 100644 index 0000000000..1810c1ffde --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt @@ -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> + +/** + * 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 { + 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): Single { + return Single.fromCallable { + saveAttachmentsSync(attachments) + } + } + + @WorkerThread + private fun saveAttachmentsSync(attachments: Set): 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 = 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 { + 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? + ) +} diff --git a/app/src/main/res/layout/progress_card.xml b/app/src/main/res/layout/progress_card.xml index 413df39505..e384dd0bce 100644 --- a/app/src/main/res/layout/progress_card.xml +++ b/app/src/main/res/layout/progress_card.xml @@ -5,13 +5,34 @@ tools:parentTag="com.google.android.material.card.MaterialCardView" tools:visibility="visible"> - + android:layout_margin="24dp"> + + + + + diff --git a/app/src/main/res/layout/progress_card_dialog.xml b/app/src/main/res/layout/progress_card_dialog.xml index 2130169a6b..3d56eab808 100644 --- a/app/src/main/res/layout/progress_card_dialog.xml +++ b/app/src/main/res/layout/progress_card_dialog.xml @@ -5,6 +5,7 @@ android:layout_height="wrap_content"> diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index 8af1f1d37f..0c86f286f6 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -51,6 +51,14 @@ tools:itemCount="20" tools:listitem="@layout/conversation_item_sent_text_only" /> + + - - + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 0c59b39b6a..498ab2a089 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -403,6 +403,10 @@ true + + + +