mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 08:39:22 +01:00
CFV2 Save to Disk / Copy Text Content.
This commit is contained in:
committed by
Cody Henthorne
parent
399421e20e
commit
b785b3f887
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user