diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index c2f7bd1b3f..58b08a5eec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -107,7 +107,6 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ScreenLockActionBarActivity @@ -197,7 +196,6 @@ import org.thoughtcrime.securesms.util.isFullyScrolled import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.scrollAmount import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity @@ -825,7 +823,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType) ) { - val media = Media(mediaURI, filename, mimeType, 0, 0, 0, 0, Optional.absent(), Optional.absent()) + val media = Media(mediaURI, filename, mimeType, 0, 0, 0, 0, null, null) startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient!!, ""), PICK_FROM_LIBRARY) return } else { @@ -1908,7 +1906,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val recipient = viewModel.recipient ?: return val mimeType = MediaUtil.getMimeType(this, contentUri)!! val filename = FilenameUtils.getFilenameFromUri(this, contentUri, mimeType) - val media = Media(contentUri, filename, mimeType, 0, 0, 0, 0, Optional.absent(), Optional.absent()) + val media = Media(contentUri, filename, mimeType, 0, 0, 0, 0, null, null) startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) } @@ -2121,9 +2119,9 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, for (media in mediaList) { val mediaFilename: String? = media.filename when { - MediaUtil.isVideoType(media.mimeType) -> { slideDeck.addSlide(VideoSlide(this, media.uri, mediaFilename, 0, media.caption.orNull())) } - MediaUtil.isGif(media.mimeType) -> { slideDeck.addSlide(GifSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption.orNull())) } - MediaUtil.isImageType(media.mimeType) -> { slideDeck.addSlide(ImageSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption.orNull())) } + MediaUtil.isVideoType(media.mimeType) -> { slideDeck.addSlide(VideoSlide(this, media.uri, mediaFilename, 0, media.caption)) } + MediaUtil.isGif(media.mimeType) -> { slideDeck.addSlide(GifSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption)) } + MediaUtil.isImageType(media.mimeType) -> { slideDeck.addSlide(ImageSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption)) } else -> { Log.d(TAG, "Asked to send an unexpected media type: '" + media.mimeType + "'. Skipping.") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index 5299885f7a..4d474da047 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -562,7 +562,9 @@ public class AttachmentDatabase extends Database { throw new MmsException("No attachment data found!"); } - dataInfo = setAttachmentData(dataInfo.file, mediaStream.getStream()); + final File oldFile = dataInfo.file; + + dataInfo = setAttachmentData(mediaStream.getStream()); ContentValues contentValues = new ContentValues(); contentValues.put(SIZE, dataInfo.length); @@ -570,9 +572,18 @@ public class AttachmentDatabase extends Database { contentValues.put(WIDTH, mediaStream.getWidth()); contentValues.put(HEIGHT, mediaStream.getHeight()); contentValues.put(DATA_RANDOM, dataInfo.random); + contentValues.put(DATA, dataInfo.file.getAbsolutePath()); database.update(TABLE_NAME, contentValues, PART_ID_WHERE, databaseAttachment.getAttachmentId().toStrings()); + if (oldFile != null && oldFile.exists()) { + try { + oldFile.delete(); + } catch (Exception e) { + Log.w(TAG, "Error deleting an old attachment file", e); + } + } + return new DatabaseAttachment(databaseAttachment.getAttachmentId(), databaseAttachment.getMmsId(), databaseAttachment.hasData(), @@ -696,20 +707,12 @@ public class AttachmentDatabase extends Database { try { File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); File dataFile = File.createTempFile("part", ".mms", partsDirectory); - return setAttachmentData(dataFile, in); - } catch (IOException e) { - throw new MmsException(e); - } - } - private @NonNull DataInfo setAttachmentData(@NonNull File destination, @NonNull InputStream in) - throws MmsException - { - try { - Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, destination, false); + Log.d("AttachmentDatabase", "Writing attachment data to: " + dataFile.getAbsolutePath()); + Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false); long length = Util.copy(in, out.second); - return new DataInfo(destination, length, out.first); + return new DataInfo(dataFile, length, out.first); } catch (IOException e) { throw new MmsException(e); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java index 759a0b245c..1f13efd396 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java @@ -119,8 +119,8 @@ public class MediaPreviewViewModel extends ViewModel { mediaRecord.getAttachment().getWidth(), mediaRecord.getAttachment().getHeight(), mediaRecord.getAttachment().getSize(), - Optional.absent(), - Optional.fromNullable(mediaRecord.getAttachment().getCaption()) + null, + mediaRecord.getAttachment().getCaption() ); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java deleted file mode 100644 index bd1e71decb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import org.session.libsignal.utilities.guava.Optional; - -/** - * Represents a piece of media that the user has on their device. - */ -public class Media implements Parcelable { - - public static final String ALL_MEDIA_BUCKET_ID = "org.thoughtcrime.securesms.ALL_MEDIA"; - - private final Uri uri; - private final String filename; - private final String mimeType; - private final long date; - private final int width; - private final int height; - private final long size; - private final Optional bucketId; - private Optional caption; - - public Media(@NonNull Uri uri, @NonNull String filename, @NonNull String mimeType, long date, int width, int height, long size, Optional bucketId, Optional caption) { - this.uri = uri; - this.filename = filename; - this.mimeType = mimeType; - this.date = date; - this.width = width; - this.height = height; - this.size = size; - this.bucketId = bucketId; - this.caption = caption; - } - - protected Media(Parcel in) { - uri = in.readParcelable(Uri.class.getClassLoader()); - filename = in.readString(); - mimeType = in.readString(); - date = in.readLong(); - width = in.readInt(); - height = in.readInt(); - size = in.readLong(); - bucketId = Optional.fromNullable(in.readString()); - caption = Optional.fromNullable(in.readString()); - } - - public Uri getUri() { return uri; } - public String getFilename() { return filename; } - public String getMimeType() { return mimeType; } - public long getDate() { return date; } - public int getWidth() { return width; } - public int getHeight() { return height; } - public long getSize() { return size; } - public Optional getBucketId() { return bucketId; } - public Optional getCaption() { return caption; } - public void setCaption(String caption) { this.caption = Optional.fromNullable(caption); } - - @Override - public int describeContents() { return 0; } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeParcelable(uri, flags); - dest.writeString(filename); - dest.writeString(mimeType); - dest.writeLong(date); - dest.writeInt(width); - dest.writeInt(height); - dest.writeLong(size); - dest.writeString(bucketId.orNull()); - dest.writeString(caption.orNull()); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Media createFromParcel(Parcel in) { return new Media(in); } - - @Override - public Media[] newArray(int size) { return new Media[size]; } - }; - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Media media = (Media)o; - return uri.equals(media.uri); - } - - @Override - public int hashCode() { return uri.hashCode(); } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt new file mode 100644 index 0000000000..c72ced5375 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.mediasend + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents a piece of media that the user has on their device. + */ +@Parcelize +data class Media( + val uri: Uri, + val filename: String, + val mimeType: String, + val date: Long, + val width: Int, + val height: Int, + val size: Long, + val bucketId: String?, + val caption: String?, +) : Parcelable { + + // The equality check here is performed based only on the URI of the media. + // This behavior very opinionated and shouldn't really be in a generic equality check in the first place. + // However there are too much code working under this assumption and we can't simply change it to + // a generic solution. + // + // To later dev: once sufficient refactors are done, we can remove this equality + // check and rely on the data class default equality check instead. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Media) return false + + if (uri != other.uri) return false + + return true + } + + override fun hashCode(): Int { + return uri.hashCode() + } + + + companion object { + const val ALL_MEDIA_BUCKET_ID: String = "org.thoughtcrime.securesms.ALL_MEDIA" + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index bfe23f7d24..3b3c6ef811 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -194,7 +194,7 @@ class MediaRepository { long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); String filename = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)); - media.add(new Media(uri, filename, mimetype, date, width, height, size, Optional.of(bucketId), Optional.absent())); + media.add(new Media(uri, filename, mimetype, date, width, height, size, bucketId, null)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index c5ab98cb2a..2fc1876264 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -254,8 +254,8 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragme width, height, data.size.toLong(), - Optional.of(Media.ALL_MEDIA_BUCKET_ID), - Optional.absent() + Media.ALL_MEDIA_BUCKET_ID, + null ) } catch (e: Exception) { return@run null diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt index 56f64a8ed5..efd46bd7e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -1,11 +1,9 @@ package org.thoughtcrime.securesms.mediasend -import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Rect import android.net.Uri -import android.os.AsyncTask import android.os.Bundle import android.text.Editable import android.text.TextWatcher @@ -17,46 +15,39 @@ import android.view.ViewGroup import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.view.WindowManager import android.view.inputmethod.EditorInfo -import android.widget.ImageButton import android.widget.TextView -import android.widget.TextView.OnEditorActionListener -import androidx.core.os.BundleCompat +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import androidx.viewpager.widget.ViewPager.SimpleOnPageChangeListener import com.bumptech.glide.Glide import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import network.loki.messenger.R -import org.session.libsession.utilities.Address +import network.loki.messenger.databinding.MediasendFragmentBinding import org.session.libsession.utilities.MediaTypes import org.session.libsession.utilities.TextSecurePreferences.Companion.isEnterSendsEnabled -import org.session.libsession.utilities.Util.cancelRunnableOnMain -import org.session.libsession.utilities.Util.isEmpty -import org.session.libsession.utilities.Util.runOnMainDelayed import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.SettableFuture -import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.components.ComposeText -import org.thoughtcrime.securesms.components.ControllableViewPager -import org.thoughtcrime.securesms.components.InputAwareLayout import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardHiddenListener import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener -import org.thoughtcrime.securesms.imageeditor.model.EditorModel import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter.RailItemListener -import org.thoughtcrime.securesms.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.util.PushCharacterCalculator -import org.thoughtcrime.securesms.util.Stopwatch -import java.io.ByteArrayOutputStream -import java.io.IOException +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream import java.util.Locale -import java.util.concurrent.ExecutionException /** * Allows the user to edit and caption a set of media items before choosing to send them. @@ -64,35 +55,24 @@ import java.util.concurrent.ExecutionException @AndroidEntryPoint class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, OnKeyboardShownListener, OnKeyboardHiddenListener { - private var hud: InputAwareLayout? = null - private var captionAndRail: View? = null - private var sendButton: ImageButton? = null - private var composeText: ComposeText? = null - private var composeContainer: ViewGroup? = null - private var playbackControlsContainer: ViewGroup? = null - private var charactersLeft: TextView? = null - private var closeButton: View? = null - private var loader: View? = null - - private var fragmentPager: ControllableViewPager? = null + private var binding: MediasendFragmentBinding? = null + private var fragmentPagerAdapter: MediaSendFragmentPagerAdapter? = null - private var mediaRail: RecyclerView? = null private var mediaRailAdapter: MediaRailAdapter? = null private var visibleHeight = 0 private var viewModel: MediaSendViewModel? = null - private var controller: Controller? = null private val visibleBounds = Rect() private val characterCalculator = PushCharacterCalculator() + private val controller: Controller + get() = (parentFragment as? Controller) ?: requireActivity() as Controller + override fun onAttach(context: Context) { super.onAttach(context) - check(requireActivity() is Controller) { "Parent activity must implement controller interface." } - - controller = requireActivity() as Controller viewModel = ViewModelProvider(requireActivity()).get( MediaSendViewModel::class.java ) @@ -102,8 +82,8 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.mediasend_fragment, container, false) + ): View { + return MediasendFragmentBinding.inflate(inflater, container, false).root } override fun onCreate(savedInstanceState: Bundle?) { @@ -112,83 +92,76 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - hud = view.findViewById(R.id.mediasend_hud) - captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail) - sendButton = view.findViewById(R.id.mediasend_send_button) - composeText = view.findViewById(R.id.mediasend_compose_text) - composeContainer = view.findViewById(R.id.mediasend_compose_container) - fragmentPager = view.findViewById(R.id.mediasend_pager) - mediaRail = view.findViewById(R.id.mediasend_media_rail) - playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container) - charactersLeft = view.findViewById(R.id.mediasend_characters_left) - closeButton = view.findViewById(R.id.mediasend_close_button) - loader = view.findViewById(R.id.loader) - - val sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg) - - sendButton!!.setOnClickListener(View.OnClickListener { v: View? -> - if (hud!!.isKeyboardOpen()) { - hud!!.hideSoftkey(composeText, null) + val binding = MediasendFragmentBinding.bind(view).also { + this.binding = it + } + + binding.mediasendSendButton.setOnClickListener { v: View? -> + if (binding.mediasendHud.isKeyboardOpen) { + binding.mediasendHud.hideSoftkey(binding.mediasendComposeText, null) } - processMedia(fragmentPagerAdapter!!.allMedia, fragmentPagerAdapter!!.savedState) - }) + + fragmentPagerAdapter?.let { processMedia(it.allMedia, it.savedState) } + } val composeKeyPressedListener = ComposeKeyPressedListener() - composeText!!.setOnKeyListener(composeKeyPressedListener) - composeText!!.addTextChangedListener(composeKeyPressedListener) - composeText!!.setOnClickListener(composeKeyPressedListener) - composeText!!.setOnFocusChangeListener(composeKeyPressedListener) + binding.mediasendComposeText.setOnKeyListener(composeKeyPressedListener) + binding.mediasendComposeText.addTextChangedListener(composeKeyPressedListener) + binding.mediasendComposeText.setOnClickListener(composeKeyPressedListener) + binding.mediasendComposeText.setOnFocusChangeListener(composeKeyPressedListener) - composeText!!.requestFocus() + binding.mediasendComposeText.requestFocus() fragmentPagerAdapter = MediaSendFragmentPagerAdapter(childFragmentManager) - fragmentPager!!.setAdapter(fragmentPagerAdapter) + binding.mediasendPager.setAdapter(fragmentPagerAdapter) val pageChangeListener = FragmentPageChangeListener() - fragmentPager!!.addOnPageChangeListener(pageChangeListener) - fragmentPager!!.post(Runnable { pageChangeListener.onPageSelected(fragmentPager!!.currentItem) }) + binding.mediasendPager.addOnPageChangeListener(pageChangeListener) + binding.mediasendPager.post(Runnable { pageChangeListener.onPageSelected(binding.mediasendPager.currentItem) }) mediaRailAdapter = MediaRailAdapter(Glide.with(this), this, true) - mediaRail!!.setLayoutManager( + binding.mediasendMediaRail.setLayoutManager( LinearLayoutManager( requireContext(), LinearLayoutManager.HORIZONTAL, false ) ) - mediaRail!!.setAdapter(mediaRailAdapter) + binding.mediasendMediaRail.setAdapter(mediaRailAdapter) - hud!!.getRootView().viewTreeObserver.addOnGlobalLayoutListener(this) - hud!!.addOnKeyboardShownListener(this) - hud!!.addOnKeyboardHiddenListener(this) + binding.mediasendHud.getRootView().viewTreeObserver.addOnGlobalLayoutListener(this) + binding.mediasendHud.addOnKeyboardShownListener(this) + binding.mediasendHud.addOnKeyboardHiddenListener(this) - composeText!!.append(viewModel!!.body) + binding.mediasendComposeText.append(viewModel?.body) - val recipient = Recipient.from( - requireContext(), - arguments!!.getParcelable(KEY_ADDRESS)!!, false - ) - val displayName = Optional.fromNullable(recipient.name) - .or( - Optional.fromNullable(recipient.profileName) - .or(recipient.address.toString()) - ) - composeText!!.setHint(getString(R.string.message), null) - composeText!!.setOnEditorActionListener(OnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> + binding.mediasendComposeText.setHint(getString(R.string.message), null) + binding.mediasendComposeText.setOnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> val isSend = actionId == EditorInfo.IME_ACTION_SEND - if (isSend) sendButton!!.performClick() + if (isSend) binding.mediasendSendButton.performClick() isSend - }) + } - closeButton!!.setOnClickListener(View.OnClickListener { v: View? -> requireActivity().onBackPressed() }) + binding.mediasendCloseButton.setOnClickListener { requireActivity().onBackPressed() } + } + + override fun onDestroyView() { + super.onDestroyView() + + binding = null } override fun onStart() { super.onStart() - fragmentPagerAdapter!!.restoreState(viewModel!!.drawState) - viewModel!!.onImageEditorStarted() + val viewModel = viewModel + val adapter = fragmentPagerAdapter + + if (viewModel != null && adapter != null) { + adapter.restoreState(viewModel.drawState) + viewModel.onImageEditorStarted() + } requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) @@ -200,216 +173,262 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, override fun onStop() { super.onStop() - fragmentPagerAdapter!!.saveAllState() - viewModel!!.saveDrawState(fragmentPagerAdapter!!.savedState) + + val viewModel = viewModel + val adapter = fragmentPagerAdapter + + if (viewModel != null && adapter != null) { + adapter.saveAllState() + viewModel.saveDrawState(adapter.savedState) + } } override fun onGlobalLayout() { - hud!!.rootView.getWindowVisibleDisplayFrame(visibleBounds) + val hud = binding?.mediasendHud ?: return + + hud.rootView.getWindowVisibleDisplayFrame(visibleBounds) val currentVisibleHeight = visibleBounds.height() if (currentVisibleHeight != visibleHeight) { - hud!!.layoutParams.height = currentVisibleHeight - hud!!.layout( + hud.layoutParams.height = currentVisibleHeight + hud.layout( visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom ) - hud!!.requestLayout() + hud.requestLayout() visibleHeight = currentVisibleHeight } } override fun onRailItemClicked(distanceFromActive: Int) { - viewModel!!.onPageChanged(fragmentPager!!.currentItem + distanceFromActive) + val currentItem = binding?.mediasendPager?.currentItem ?: return + viewModel?.onPageChanged(currentItem + distanceFromActive) } override fun onRailItemDeleteClicked(distanceFromActive: Int) { - viewModel!!.onMediaItemRemoved( + val currentItem = binding?.mediasendPager?.currentItem ?: return + + viewModel?.onMediaItemRemoved( requireContext(), - fragmentPager!!.currentItem + distanceFromActive + currentItem + distanceFromActive ) } override fun onKeyboardShown() { - if (composeText!!.hasFocus()) { - mediaRail!!.visibility = View.VISIBLE - composeContainer!!.visibility = View.VISIBLE + val binding = binding ?: return + + if (binding.mediasendComposeText.hasFocus()) { + binding.mediasendMediaRail.visibility = View.VISIBLE + binding.mediasendComposeContainer.visibility = View.VISIBLE } else { - mediaRail!!.visibility = View.GONE - composeContainer!!.visibility = View.VISIBLE + binding.mediasendMediaRail.visibility = View.GONE + binding.mediasendComposeContainer.visibility = View.VISIBLE } } override fun onKeyboardHidden() { - composeContainer!!.visibility = View.VISIBLE - mediaRail!!.visibility = View.VISIBLE + binding?.apply { + mediasendComposeContainer.visibility = View.VISIBLE + mediasendMediaRail.visibility = View.VISIBLE + } } fun onTouchEventsNeeded(needed: Boolean) { - if (fragmentPager != null) { - fragmentPager!!.isEnabled = !needed - } + binding?.mediasendPager?.isEnabled = !needed } fun handleBackPress(): Boolean { - if (hud!!.isInputOpen) { - hud!!.hideCurrentInput(composeText) + val hud = binding?.mediasendHud ?: return false + val composeText = binding?.mediasendComposeText ?: return false + + if (hud.isInputOpen) { + hud.hideCurrentInput(composeText) return true } return false } private fun initViewModel() { - viewModel!!.getSelectedMedia().observe( + val viewModel = requireNotNull(viewModel) { + "ViewModel is not initialized" + } + + viewModel.getSelectedMedia().observe( this ) { media: List? -> - if (isEmpty(media)) { - controller!!.onNoMediaAvailable() + if (media.isNullOrEmpty()) { + controller.onNoMediaAvailable() return@observe } - fragmentPagerAdapter!!.setMedia(media!!) - mediaRail!!.visibility = View.VISIBLE - mediaRailAdapter!!.setMedia(media) + fragmentPagerAdapter?.setMedia(media) + + binding?.mediasendMediaRail?.visibility = View.VISIBLE + mediaRailAdapter?.setMedia(media) } - viewModel!!.getPosition().observe(this) { position: Int? -> + viewModel.getPosition().observe(this) { position: Int? -> if (position == null || position < 0) return@observe - fragmentPager!!.setCurrentItem(position, true) - mediaRailAdapter!!.setActivePosition(position) - mediaRail!!.smoothScrollToPosition(position) + binding?.mediasendPager?.setCurrentItem(position, true) + mediaRailAdapter?.setActivePosition(position) + binding?.mediasendMediaRail?.smoothScrollToPosition(position) - val playbackControls = fragmentPagerAdapter!!.getPlaybackControls(position) + val playbackControls = fragmentPagerAdapter?.getPlaybackControls(position) if (playbackControls != null) { val params = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) playbackControls.layoutParams = params - playbackControlsContainer!!.removeAllViews() - playbackControlsContainer!!.addView(playbackControls) + binding?.mediasendPlaybackControlsContainer?.removeAllViews() + binding?.mediasendPlaybackControlsContainer?.addView(playbackControls) } else { - playbackControlsContainer!!.removeAllViews() + binding?.mediasendPlaybackControlsContainer?.removeAllViews() } } - viewModel!!.getBucketId().observe(this) { bucketId: String? -> + viewModel.getBucketId().observe(this) { bucketId: String? -> if (bucketId == null) return@observe - mediaRailAdapter!!.setAddButtonListener { controller!!.onAddMediaClicked(bucketId) } + mediaRailAdapter!!.setAddButtonListener { controller.onAddMediaClicked(bucketId) } } } private fun presentCharactersRemaining() { - val messageBody = composeText!!.textTrimmed + val binding = binding ?: return + val messageBody = binding.mediasendComposeText.textTrimmed val characterState = characterCalculator.calculateCharacters(messageBody) if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { - charactersLeft!!.text = String.format( + binding.mediasendCharactersLeft.text = String.format( Locale.getDefault(), "%d/%d (%d)", characterState.charactersRemaining, characterState.maxTotalMessageSize, characterState.messagesSpent ) - charactersLeft!!.visibility = View.VISIBLE + binding.mediasendCharactersLeft.visibility = View.VISIBLE } else { - charactersLeft!!.visibility = View.GONE + binding.mediasendCharactersLeft.visibility = View.GONE } } - @SuppressLint("StaticFieldLeak") private fun processMedia(mediaList: List, savedState: Map) { - val futures: MutableMap> = HashMap() + val binding = binding ?: return // If the view is destroyed, this process should not continue - for (media in mediaList) { - val state = savedState[media.uri] + val context = requireContext().applicationContext - if (state is ImageEditorFragment.Data) { - val model = state.readModel() - if (model != null && model.isChanged) { - futures[media] = render(requireContext(), model) - } - } - } - - object : AsyncTask>() { - private var renderTimer: Stopwatch? = null - private var progressTimer: Runnable? = null - - override fun onPreExecute() { - renderTimer = Stopwatch("ProcessMedia") - progressTimer = Runnable { - loader!!.visibility = View.VISIBLE - } - runOnMainDelayed(progressTimer!!, 250) + lifecycleScope.launch { + val delayedShowLoader = launch { + delay(250) + binding.loader.isVisible = true } - override fun doInBackground(vararg params: Void?): List { - val context = requireContext() - val updatedMedia: MutableList = ArrayList(mediaList.size) - - for (media in mediaList) { - if (futures.containsKey(media)) { - try { - val bitmap = futures[media]!!.get() - val baos = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos) - - val uri = BlobProvider.getInstance() - .forData(baos.toByteArray()) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionOnDisk( - context - ) { e: IOException? -> - Log.w( - TAG, - "Failed to write to disk.", - e - ) + val updatedMedia = supervisorScope { + // For each media, render the image in the background if necessary + val renderingTasks = mediaList + .asSequence() + .map { media -> + media to (savedState[media.uri] as? ImageEditorFragment.Data) + ?.readModel() + ?.takeIf { it.isChanged } + } + .associate { (media, model) -> + media.uri to async { + runCatching { + if (model != null) { + // While we render the bitmap in the background, make sure + // we limit the number of parallel tasks to avoid overwhelming the memory, + // as bitmaps are memory intensive. + withContext(Dispatchers.Default.limitedParallelism(2)) { + val bitmap = model.render(context) + try { + // Compress the bitmap to JPEG + val jpegOut = requireNotNull( + File.createTempFile( + "media_preview", + ".jpg", + context.cacheDir + ) + ) { + "Unable to create temporary file" + } + + val (jpegSize, uri) = try { + FileOutputStream(jpegOut).use { out -> + bitmap.compress( + Bitmap.CompressFormat.JPEG, + 80, + out + ) + } + + // Once we have the JPEG file, save it as our blob + val jpegSize = jpegOut.length() + jpegSize to BlobProvider.getInstance() + .forData(FileInputStream(jpegOut), jpegSize) + .withMimeType(MediaTypes.IMAGE_JPEG) + .withFileName(media.filename) + .createForSingleSessionOnDisk(context, null) + .await() + } finally { + // Clean up the temporary file + jpegOut.delete() + } + + media.copy( + uri = uri, + mimeType = MediaTypes.IMAGE_JPEG, + width = bitmap.width, + height = bitmap.height, + size = jpegSize, + ) + } finally { + bitmap.recycle() + } + } + } else { + // No changes to the original media, copy and return as is + val newUri = BlobProvider.getInstance() + .forData(requireNotNull(context.contentResolver.openInputStream(media.uri)) { + "Invalid URI" + }, media.size) + .withMimeType(media.mimeType) + .withFileName(media.filename) + .createForSingleSessionOnDisk(context, null) + .await() + + media.copy(uri = newUri) } - .get() - - val updated = Media( - uri, - media.filename, - MediaTypes.IMAGE_JPEG, - media.date, - bitmap.width, - bitmap.height, - baos.size().toLong(), - media.bucketId, - media.caption - ) - - updatedMedia.add(updated) - renderTimer!!.split("item") - } catch (e: Exception) { - Log.w(TAG, "Failed to render image. Using base image.", e) - updatedMedia.add(media) + } } - } else { - updatedMedia.add(media) } + + // For each media, if there's a rendered version, use that or keep the original + mediaList.map { media -> + renderingTasks[media.uri]?.await()?.let { rendered -> + if (rendered.isFailure) { + Log.w(TAG, "Error rendering image", rendered.exceptionOrNull()) + media + } else { + rendered.getOrThrow() + } + } ?: media } - return updatedMedia } - override fun onPostExecute(media: List) { - controller!!.onSendClicked(media, composeText!!.textTrimmed) - cancelRunnableOnMain(progressTimer!!) - loader!!.visibility = View.GONE - renderTimer!!.stop(TAG) - } - }.execute() + controller.onSendClicked(updatedMedia, binding.mediasendComposeText.textTrimmed) + delayedShowLoader.cancel() + binding.loader.isVisible = false + } } fun onRequestFullScreen(fullScreen: Boolean) { - captionAndRail!!.visibility = + binding?.mediasendCaptionAndRail?.visibility = if (fullScreen) View.GONE else View.VISIBLE } @@ -427,13 +446,13 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, if (event.action == KeyEvent.ACTION_DOWN) { if (keyCode == KeyEvent.KEYCODE_ENTER) { if (isEnterSendsEnabled(requireContext())) { - sendButton!!.dispatchKeyEvent( + binding?.mediasendSendButton?.dispatchKeyEvent( KeyEvent( KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER ) ) - sendButton!!.dispatchKeyEvent( + binding?.mediasendSendButton?.dispatchKeyEvent( KeyEvent( KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER @@ -447,11 +466,12 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, } override fun onClick(v: View) { - hud!!.showSoftkey(composeText) + val binding = binding ?: return + binding.mediasendHud.showSoftkey(binding.mediasendComposeText) } override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { - beforeLength = composeText!!.textTrimmed.length + beforeLength = binding?.mediasendComposeText?.textTrimmed?.length ?: return } override fun afterTextChanged(s: Editable) { @@ -483,13 +503,5 @@ class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener, fragment.arguments = args return fragment } - - private fun render(context: Context, model: EditorModel): ListenableFuture { - val future = SettableFuture() - - AsyncTask.THREAD_POOL_EXECUTOR.execute { future.set(model.render(context)) } - - return future - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java index 4d6107044f..ea2ee1b403 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java @@ -17,6 +17,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import kotlin.collections.CollectionsKt; + class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { private final List media; @@ -83,7 +85,7 @@ class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { } List getAllMedia() { - return media; + return CollectionsKt.toList(media); } void setMedia(@NonNull List media) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt index c5150b2974..1831eb97aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -82,9 +82,9 @@ internal class MediaSendViewModel @Inject constructor( if (filteredMedia.size > 0) { val computedId: String = Stream.of(filteredMedia) .skip(1) - .reduce(filteredMedia.get(0).bucketId.or(Media.ALL_MEDIA_BUCKET_ID), + .reduce(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID, { id: String?, m: Media -> - if (equals(id, m.bucketId.or(Media.ALL_MEDIA_BUCKET_ID))) { + if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { return@reduce id } else { return@reduce Media.ALL_MEDIA_BUCKET_ID @@ -118,7 +118,7 @@ internal class MediaSendViewModel @Inject constructor( error.setValue(Error.ITEM_TOO_LARGE) bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) } else { - bucketId.setValue(filteredMedia.get(0).bucketId.or(Media.ALL_MEDIA_BUCKET_ID)) + bucketId.setValue(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID) } countButtonVisibility = CountButtonState.Visibility.FORCED_OFF diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index 15240e4d3c..8d05abd81e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -25,6 +25,7 @@ import java.io.OutputStream; import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import kotlin.Pair; @@ -178,7 +179,7 @@ public class BlobProvider { @WorkerThread @NonNull - private static Future writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { + private static CompletableFuture writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); String directory = getDirectory(blobSpec.getStorageType()); File outputFile = new File(getOrCreateCacheDirectory(context, directory), buildFileName(blobSpec.id)); @@ -186,7 +187,7 @@ public class BlobProvider { final Uri uri = buildUri(blobSpec); - return SignalExecutors.UNBOUNDED.submit(() -> { + return CompletableFuture.supplyAsync(() -> { try { Util.copy(blobSpec.getData(), outputStream); return uri; @@ -195,9 +196,9 @@ public class BlobProvider { errorListener.onError(e); } - throw e; + throw new RuntimeException(e); } - }); + }, SignalExecutors.UNBOUNDED); } private synchronized @NonNull Uri writeBlobSpecToMemory(@NonNull BlobSpec blobSpec, @NonNull byte[] data) { @@ -266,7 +267,7 @@ public class BlobProvider { * period from one {@link Application#onCreate()} to the next. */ @WorkerThread - public Future createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + public CompletableFuture createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.SINGLE_SESSION_DISK), errorListener); } @@ -275,7 +276,7 @@ public class BlobProvider { * eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use. */ @WorkerThread - public Future createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + public CompletableFuture createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), errorListener); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java index 93b21512ea..5e1a85df51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -118,7 +118,9 @@ public class BitmapUtil { do { totalAttempts++; ByteArrayOutputStream baos = new ByteArrayOutputStream(); - scaledBitmap.compress(format, quality, baos); + if (!scaledBitmap.compress(format, quality, baos)) { + Log.d(TAG, "Unable to compress image with quality " + quality); + } bytes = baos.toByteArray(); Log.d(TAG, "iteration with quality " + quality + " size " + bytes.length + " bytes."); @@ -144,7 +146,7 @@ public class BitmapUtil { } } - if (bytes.length <= 0) { + if (bytes.length == 0) { throw new BitmapDecodingException("Decoding failed. Bitmap has a length of " + bytes.length + " bytes."); }