Signal-Android/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt

362 wiersze
15 KiB
Kotlin

package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
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.BreakIteratorCompat
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.signal.imageeditor.core.model.EditorModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.mediasend.CompositeMediaTransform
import org.thoughtcrime.securesms.mediasend.ImageEditorModelRenderMediaTransform
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.MediaTransform
import org.thoughtcrime.securesms.mediasend.MediaUploadRepository
import org.thoughtcrime.securesms.mediasend.SentMediaQualityTransform
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
import org.thoughtcrime.securesms.mediasend.VideoTrimTransform
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MessageUtil
import java.util.Collections
import java.util.Optional
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(MediaSelectionRepository::class.java)
class MediaSelectionRepository(context: Context) {
private val context: Context = context.applicationContext
private val mediaRepository = MediaRepository()
val uploadRepository = MediaUploadRepository(this.context)
val isMetered: Observable<Boolean> = MeteredConnectivity.isMetered(this.context)
fun populateAndFilterMedia(media: List<Media>, mediaConstraints: MediaConstraints, maxSelection: Int, isStory: Boolean): Single<MediaValidator.FilterResult> {
return Single.fromCallable {
val populatedMedia = mediaRepository.getPopulatedMedia(context, media)
MediaValidator.filterMedia(context, populatedMedia, mediaConstraints, maxSelection, isStory)
}.subscribeOn(Schedulers.io())
}
/**
* Tries to send the selected media, performing proper transformations for edited images and videos.
*/
fun send(
selectedMedia: List<Media>,
stateMap: Map<Uri, Any>,
quality: SentMediaQuality,
message: CharSequence?,
isSms: Boolean,
isViewOnce: Boolean,
singleContact: ContactSearchKey.RecipientSearchKey?,
contacts: List<ContactSearchKey.RecipientSearchKey>,
mentions: List<Mention>,
sendType: MessageSendType
): Maybe<MediaSendActivityResult> {
if (isSms && contacts.isNotEmpty()) {
throw IllegalStateException("Provided recipients to send to, but this is SMS!")
}
if (selectedMedia.isEmpty()) {
throw IllegalStateException("No selected media!")
}
val isSendingToStories = singleContact?.isStory == true || contacts.any { it.isStory }
val sentMediaQuality = if (isSendingToStories) SentMediaQuality.STANDARD else quality
return Maybe.create<MediaSendActivityResult> { emitter ->
val trimmedBody: String = if (isViewOnce) "" else getTruncatedBody(message?.toString()?.trim()) ?: ""
val trimmedMentions: List<Mention> = if (isViewOnce) emptyList() else mentions
val modelsToTransform: Map<Media, MediaTransform> = buildModelsToTransform(selectedMedia, stateMap, sentMediaQuality)
val oldToNewMediaMap: Map<Media, Media> = MediaRepository.transformMediaSync(context, selectedMedia, modelsToTransform)
val updatedMedia = oldToNewMediaMap.values.toList()
for (media in updatedMedia) {
Log.w(TAG, media.uri.toString() + " : " + media.transformProperties.map { t: TransformProperties -> "" + t.isVideoTrim }.orElse("null"))
}
val singleRecipient: Recipient? = singleContact?.let { Recipient.resolved(it.recipientId) }
val storyType: StoryType = if (singleRecipient?.isDistributionList == true) {
SignalDatabase.distributionLists.getStoryType(singleRecipient.requireDistributionListId())
} else if (singleRecipient?.isGroup == true && singleContact.isStory) {
StoryType.STORY_WITH_REPLIES
} else {
StoryType.NONE
}
if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, isSms)) {
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.")
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(singleRecipient!!.id, updatedMedia, trimmedBody, sendType, isViewOnce, trimmedMentions, StoryType.NONE))
} else {
val splitMessage = MessageUtil.getSplitMessage(context, trimmedBody, sendType.calculateCharacters(trimmedBody).maxPrimaryMessageSize)
val splitBody = splitMessage.body
if (splitMessage.textSlide.isPresent) {
val slide: Slide = splitMessage.textSlide.get()
uploadRepository.startUpload(
MediaBuilder.buildMedia(
uri = requireNotNull(slide.uri),
mimeType = slide.contentType,
date = System.currentTimeMillis(),
size = slide.fileSize,
borderless = slide.isBorderless,
videoGif = slide.isVideoGif
),
singleRecipient
)
}
val clippedVideosForStories: List<Media> = if (isSendingToStories) {
updatedMedia.filter {
Stories.MediaTransform.getSendRequirements(it) == Stories.MediaTransform.SendRequirements.REQUIRES_CLIP
}.map { media ->
Stories.MediaTransform.clipMediaToStoryDuration(media)
}.flatten()
} else emptyList()
uploadRepository.applyMediaUpdates(oldToNewMediaMap, singleRecipient)
uploadRepository.updateCaptions(updatedMedia)
uploadRepository.updateDisplayOrder(updatedMedia)
uploadRepository.getPreUploadResults { uploadResults ->
if (contacts.isNotEmpty()) {
sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce, clippedVideosForStories)
uploadRepository.deleteAbandonedAttachments()
emitter.onComplete()
} else if (uploadResults.isNotEmpty()) {
emitter.onSuccess(MediaSendActivityResult.forPreUpload(singleRecipient!!.id, uploadResults, splitBody, sendType, isViewOnce, trimmedMentions, storyType))
} else {
Log.w(TAG, "Got empty upload results! isSms: $isSms, updatedMedia.size(): ${updatedMedia.size}, isViewOnce: $isViewOnce, target: $singleContact")
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(singleRecipient!!.id, updatedMedia, trimmedBody, sendType, isViewOnce, trimmedMentions, storyType))
}
}
}
}.subscribeOn(Schedulers.io()).cast(MediaSendActivityResult::class.java)
}
private fun getTruncatedBody(body: String?): String? {
return if (!Stories.isFeatureEnabled() || body.isNullOrEmpty()) {
body
} else {
val iterator = BreakIteratorCompat.getInstance()
iterator.setText(body)
iterator.take(Stories.MAX_CAPTION_SIZE).toString()
}
}
fun deleteBlobs(media: List<Media>) {
media
.map(Media::getUri)
.filter(BlobProvider::isAuthority)
.forEach { BlobProvider.getInstance().delete(context, it) }
}
fun cleanUp(selectedMedia: List<Media>) {
deleteBlobs(selectedMedia)
uploadRepository.cancelAllUploads()
uploadRepository.deleteAbandonedAttachments()
}
fun isLocalSelfSend(recipient: Recipient?, isSms: Boolean): Boolean {
return MessageSender.isLocalSelfSend(context, recipient, isSms)
}
@WorkerThread
private fun buildModelsToTransform(
selectedMedia: List<Media>,
stateMap: Map<Uri, Any>,
quality: SentMediaQuality
): Map<Media, MediaTransform> {
val modelsToRender: MutableMap<Media, MediaTransform> = mutableMapOf()
selectedMedia.forEach {
val state = stateMap[it.uri]
if (state is ImageEditorFragment.Data) {
val model: EditorModel? = state.readModel()
if (model != null && model.isChanged) {
modelsToRender[it] = ImageEditorModelRenderMediaTransform(model)
}
}
if (state is VideoEditorFragment.Data && state.isDurationEdited) {
modelsToRender[it] = VideoTrimTransform(state)
}
if (quality == SentMediaQuality.HIGH) {
val existingTransform: MediaTransform? = modelsToRender[it]
modelsToRender[it] = if (existingTransform == null) {
SentMediaQualityTransform(quality)
} else {
CompositeMediaTransform(existingTransform, SentMediaQualityTransform(quality))
}
}
}
return modelsToRender
}
@WorkerThread
private fun sendMessages(
contacts: List<ContactSearchKey.RecipientSearchKey>,
body: String,
preUploadResults: Collection<PreUploadResult>,
mentions: List<Mention>,
isViewOnce: Boolean,
storyClips: List<Media>
) {
val nonStoryMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList(contacts.size)
val storyPreUploadMessages: MutableMap<PreUploadResult, MutableList<OutgoingSecureMediaMessage>> = mutableMapOf()
val storyClipMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList()
val distributionListPreUploadSentTimestamps: MutableMap<PreUploadResult, Long> = mutableMapOf()
val distributionListStoryClipsSentTimestamps: MutableMap<MediaKey, Long> = mutableMapOf()
for (contact in contacts) {
val recipient = Recipient.resolved(contact.recipientId)
val isStory = contact.isStory || recipient.isDistributionList
if (isStory && !recipient.isMyStory) {
SignalStore.storyValues().setLatestStorySend(StorySend.newSend(recipient))
}
val storyType: StoryType = when {
recipient.isDistributionList -> SignalDatabase.distributionLists.getStoryType(recipient.requireDistributionListId())
isStory -> StoryType.STORY_WITH_REPLIES
else -> StoryType.NONE
}
val message = OutgoingMediaMessage(
recipient,
body,
emptyList(),
if (recipient.isDistributionList) distributionListPreUploadSentTimestamps.getOrPut(preUploadResults.first()) { System.currentTimeMillis() } else System.currentTimeMillis(),
-1,
TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
isViewOnce,
ThreadTable.DistributionTypes.DEFAULT,
storyType,
null,
false,
null,
emptyList(),
emptyList(),
mentions,
mutableSetOf(),
mutableSetOf(),
null
)
if (isStory) {
preUploadResults.filterNot { result -> storyClips.any { it.uri == result.media.uri } }.forEach {
val list = storyPreUploadMessages[it] ?: mutableListOf()
list.add(
OutgoingSecureMediaMessage(message).withSentTimestamp(
if (recipient.isDistributionList) {
distributionListPreUploadSentTimestamps.getOrPut(it) { System.currentTimeMillis() }
} else {
System.currentTimeMillis()
}
)
)
storyPreUploadMessages[it] = list
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5)
}
storyClips.forEach {
storyClipMessages.add(
OutgoingSecureMediaMessage(
OutgoingMediaMessage(
recipient,
body,
listOf(MediaUploadRepository.asAttachment(context, it)),
if (recipient.isDistributionList) distributionListStoryClipsSentTimestamps.getOrPut(it.asKey()) { System.currentTimeMillis() } else System.currentTimeMillis(),
-1,
TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
isViewOnce,
ThreadTable.DistributionTypes.DEFAULT,
storyType,
null,
false,
null,
emptyList(),
emptyList(),
mentions,
mutableSetOf(),
mutableSetOf(),
null
)
)
)
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5)
}
} else {
nonStoryMessages.add(OutgoingSecureMediaMessage(message))
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5)
}
}
if (nonStoryMessages.isNotEmpty()) {
Log.d(TAG, "Sending ${nonStoryMessages.size} preupload messages to chats")
MessageSender.sendMediaBroadcast(
context,
nonStoryMessages,
preUploadResults,
true
)
}
if (storyPreUploadMessages.isNotEmpty()) {
Log.d(TAG, "Sending ${storyPreUploadMessages.size} preload messages to stories")
storyPreUploadMessages.forEach { (preUploadResult, messages) ->
MessageSender.sendMediaBroadcast(context, messages, Collections.singleton(preUploadResult), nonStoryMessages.isEmpty())
}
}
if (storyClipMessages.isNotEmpty()) {
Log.d(TAG, "Sending ${storyClipMessages.size} video clip messages to stories")
MessageSender.sendStories(context, storyClipMessages, null, null)
}
}
private fun Media.asKey(): MediaKey {
return MediaKey(this, this.transformProperties)
}
data class MediaKey(val media: Media, val mediaTransform: Optional<TransformProperties>)
}