Inline media-send v3.

This commit is contained in:
Alex Hart
2026-08-19 19:05:49 -04:00
committed by Cody Henthorne
parent 2d8bdec1e4
commit 40c06ea232
68 changed files with 130 additions and 5358 deletions
@@ -13,7 +13,12 @@ import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.ui.node.RootForTest
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getAllSemanticsNodes
import androidx.compose.ui.semantics.getOrNull
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
@@ -33,7 +38,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Activity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
@@ -42,6 +47,7 @@ import java.io.ByteArrayOutputStream
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.signal.mediasend.R as MediaSendR
/**
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
@@ -121,9 +127,9 @@ class MainNavigationLaunchTest {
/**
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
* that hops the user from the conversation into the media-send screen
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
* corner — i.e. it knows who the share is targeted at.
* ([MediaSendV3Activity]). Asserts that the secondary activity actually launches and that
* its edit screen's summary pill names both the recipient and the shared photo — i.e. it
* knows who the share is targeted at and what is being sent.
*/
@Test
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
@@ -131,13 +137,18 @@ class MainNavigationLaunchTest {
val intent = shareImageIntent(recipient = recipient, media = media)
launchSync(intent).use { launched ->
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
launched.awaitActivity(MediaSendV3Activity::class.java, timeoutMs = 20_000)
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
// await() already runs the predicate on the main thread; nesting another
// runOnMainSync here would throw "can not be called from the main application thread".
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
val expectedMedia = context.resources.getQuantityString(MediaSendR.plurals.MediaEditScreen__photo, 1, 1)
await(timeoutMs = 15_000, description = "media editor summary pill showing \"$expectedName\" / \"$expectedMedia\"") {
// Re-resolve each poll rather than closing over the awaitActivity instance: the flow
// toggles requestedOrientation as it settles, and a recreated activity would leave us
// reading a dead composition.
val mediaSend = launched.latestActivity(MediaSendV3Activity::class.java) ?: return@await false
val texts = mediaSend.composeTexts()
expectedName in texts && expectedMedia in texts
}
// Exactly one ConversationFragment should have been created — the share dispatch
@@ -580,10 +591,9 @@ class MainNavigationLaunchTest {
}
/**
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
* is populated.
* Build a [Media] backed by a real 1×1 JPEG. The media editor decodes each page to render
* it, so genuine JPEG bytes keep the screen in the state a real share would put it in
* rather than one recovering from a decode failure.
*/
private fun realJpegMedia(): Media {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
@@ -739,6 +749,32 @@ class MainNavigationLaunchTest {
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
}
/**
* Every string the activity's Compose hierarchy is currently rendering, read straight off the
* semantics tree. Media send is Compose end-to-end, so there is no view to findViewById; and the
* looper never goes idle here (see [launchSync]), which rules out the idle-synchronized matchers
* of a Compose test rule. Main-thread read — [await] already provides that.
*/
private fun Activity.composeTexts(): List<String> {
return window.decorView.composeRoots()
.flatMap { it.semanticsOwner.getAllSemanticsNodes(mergingEnabled = false) }
.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() }
.map { it.text }
}
private fun View.composeRoots(): List<RootForTest> {
val roots = mutableListOf<RootForTest>()
if (this is RootForTest) {
roots += this
}
if (this is ViewGroup) {
for (i in 0 until childCount) {
roots += getChildAt(i).composeRoots()
}
}
return roots
}
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
val expected = listFragmentClass(location)
try {
@@ -808,17 +844,20 @@ class MainNavigationLaunchTest {
*/
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
/** Most-recently-created, not-yet-destroyed activity of [clazz], or null. */
fun <T : Activity> latestActivity(clazz: Class<T>): T? = synchronized(allActivities) {
allActivities.lastOrNull { clazz.isInstance(it) }?.let { clazz.cast(it) }
}
/**
* Poll until an activity of [clazz] has been created, then return it. Used to assert
* the share-image flow's hop into MediaSelectionActivity.
* the share-image flow's hop into [MediaSendV3Activity].
*/
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val match = synchronized(allActivities) {
allActivities.firstOrNull { clazz.isInstance(it) }
}
if (match != null) return clazz.cast(match)!!
val match = latestActivity(clazz)
if (match != null) return match
Thread.sleep(50)
}
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
@@ -830,7 +869,7 @@ class MainNavigationLaunchTest {
}
override fun close() {
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
// Don't wait for looper idle — secondary activities (e.g. MediaSendV3Activity
// opened by share processing) can keep it busy indefinitely. Finish every tracked
// activity so subsequent tests start from a clean slate.
val toFinish = synchronized(allActivities) { allActivities.toList() }
-9
View File
@@ -466,15 +466,6 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden" />
<activity
android:name=".mediasend.v2.MediaSelectionActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:screenOrientation="portrait"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/TextSecure.DarkNoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name=".mediasend.v3.MediaSendV3Activity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
@@ -1082,14 +1082,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
viewModel.setUseConversationItemV2Media(!state.useConversationItemV2ForMedia)
}
)
switchPref(
title = DSLSettingsText.from("Use new media activity"),
isChecked = state.useNewMediaActivity,
onClick = {
viewModel.setUseNewMediaActivity(!state.useNewMediaActivity)
}
)
}
}
@@ -35,7 +35,6 @@ data class InternalSettingsState(
val hasPendingOneTimeDonation: Boolean,
val forceSplitPane: Boolean,
val forceSinglePane: Boolean,
val useNewMediaActivity: Boolean,
val disableInternalUser: Boolean,
val searchQuery: String = ""
)
@@ -172,11 +172,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setUseNewMediaActivity(enabled: Boolean) {
SignalStore.internal.useNewMediaActivity = enabled
refresh()
}
fun addSampleReleaseNote(callToAction: String = "action") {
repository.addSampleReleaseNote(callToAction)
}
@@ -269,7 +264,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
forceSplitPane = SignalStore.internal.forceSplitPane,
forceSinglePane = SignalStore.internal.forceSinglePane,
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
disableInternalUser = RemoteConfig.internalUserDisabled
)
@@ -121,7 +121,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
private object MediaSelection : ActivityResultContract<MediaSelectionInput, MediaSendActivityResult?>() {
override fun createIntent(context: Context, input: MediaSelectionInput): Intent {
val (media, recipientId, text) = input
return MediaSendLauncher.editor(context, MessageSendType.SignalMessageSendType, media, recipientId, text)
return MediaSendLauncher.editor(context, media, recipientId, text)
}
override fun parseResult(resultCode: Int, intent: Intent?): MediaSendActivityResult? {
@@ -132,7 +132,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
private object MediaCapture : ActivityResultContract<MediaSelectionInput, MediaSendActivityResult?>() {
override fun createIntent(context: Context, input: MediaSelectionInput): Intent {
val (_, recipientId, _, isReply) = input
return MediaSendLauncher.camera(context, MessageSendType.SignalMessageSendType, recipientId, isReply)
return MediaSendLauncher.camera(context, recipientId, isReply)
}
override fun parseResult(resultCode: Int, intent: Intent?): MediaSendActivityResult? {
@@ -143,7 +143,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
private object MediaGallery : ActivityResultContract<MediaSelectionInput, MediaSendActivityResult?>() {
override fun createIntent(context: Context, input: MediaSelectionInput): Intent {
val (media, recipientId, text, isReply) = input
return MediaSendLauncher.gallery(context, MessageSendType.SignalMessageSendType, media, recipientId, text, isReply)
return MediaSendLauncher.gallery(context, media, recipientId, text, isReply)
}
override fun parseResult(resultCode: Int, intent: Intent?): MediaSendActivityResult? {
@@ -131,7 +131,7 @@ public class GiphyActivity extends PassphraseRequiredActivity implements Keyboar
}
Media media = new Media(success.getBlobUri(), mimeType, 0, success.getWidth(), success.getHeight(), 0, 0, false, true, null, null, null, null);
startActivityForResult(MediaSendLauncher.editor(this, sendType, Collections.singletonList(media), recipientId, text), MEDIA_SENDER);
startActivityForResult(MediaSendLauncher.editor(this, Collections.singletonList(media), recipientId, text), MEDIA_SENDER);
}
private void handleGiphyMp4ErrorResult(@NonNull GiphyMp4SaveResult.Error error) {
@@ -41,7 +41,6 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup"
const val IMPORTED_BACKUP_DEBUG_INFO: String = "internal.imported_backup_debug_info"
const val USE_NEW_MEDIA_ACTIVITY: String = "internal.use_new_media_activity"
const val ANR_DETECTION_CRASH: String = "internal.anr_detection_crash"
const val ISSUE_NOTIFICATION_PRIORITY: String = "internal.issue_notification_priority"
const val ISSUE_NOTIFY_TIMES: String = "internal.issue_notify_times"
@@ -61,19 +60,6 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
*/
var forceSinglePane by booleanValue(FORCE_SINGLE_PANE_ON_ALL_DEVICES, false).falseForExternalUsers()
/**
* Whether to use the new media-send flow. Internal users can override the remote value.
*/
var useNewMediaActivity: Boolean
get() = if (RemoteConfig.internalUser) {
getBoolean(USE_NEW_MEDIA_ACTIVITY, RemoteConfig.useNewMediaSendFlow)
} else {
RemoteConfig.useNewMediaSendFlow
}
set(value) {
putBoolean(USE_NEW_MEDIA_ACTIVITY, value)
}
/**
* Members will not be added directly to a GV2 even if they could be.
*/
@@ -1,34 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import androidx.annotation.NonNull;
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
import java.security.MessageDigest;
public class FlipTransformation extends BitmapTransformation {
@Override
protected Bitmap transform(@NonNull BitmapPool pool, @NonNull Bitmap toTransform, int outWidth, int outHeight) {
Bitmap output = pool.get(toTransform.getWidth(), toTransform.getHeight(), toTransform.getConfig());
Canvas canvas = new Canvas(output);
Matrix matrix = new Matrix();
matrix.setScale(-1, 1);
matrix.postTranslate(toTransform.getWidth(), 0);
canvas.drawBitmap(toTransform, matrix, null);
return output;
}
@Override
public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
messageDigest.update(FlipTransformation.class.getSimpleName().getBytes());
}
}
@@ -1,6 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
public class MediaSendConstants {
public static final int MAX_PUSH = 32;
public static final int MAX_SMS = 1;
}
@@ -1,92 +0,0 @@
package org.thoughtcrime.securesms.mediasend
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.signal.core.models.media.Media
import org.signal.core.util.bytes
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.MediaSendDocumentViewModel.DocumentInfo
import org.signal.core.ui.R as CoreUiR
/**
* Fragment to show full screen document attachments
*/
class MediaSendDocumentFragment : Fragment(R.layout.mediasend_document_fragment), MediaSendPageFragment {
companion object {
private val TAG = Log.tag(MediaSendDocumentFragment::class.java)
private const val KEY_MEDIA = "media"
fun newInstance(media: Media): MediaSendDocumentFragment {
val args = Bundle()
args.putParcelable(KEY_MEDIA, media)
val fragment = MediaSendDocumentFragment()
fragment.arguments = args
fragment.uri = media.uri
return fragment
}
}
private lateinit var uri: Uri
private lateinit var media: Media
private val viewModel: MediaSendDocumentViewModel by viewModels {
MediaSendDocumentViewModel.Factory(media)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val name: TextView = view.findViewById(R.id.name)
val size: TextView = view.findViewById(R.id.size)
val extension: TextView = view.findViewById(R.id.extension)
this.media = requireNotNull(requireArguments().getParcelableCompat(KEY_MEDIA, Media::class.java))
viewModel.documentInfo.observe(viewLifecycleOwner) { documentInfoOptional ->
val documentInfo: DocumentInfo? = documentInfoOptional.orElse(null)
if (documentInfo != null) {
media.fileName = documentInfo.fileName
name.text = documentInfo.fileName ?: getString(R.string.DocumentView_unnamed_file)
size.text = documentInfo.fileSize.bytes.toUnitString()
if (documentInfo.extension.length <= 3) {
extension.text = documentInfo.extension
extension.setTextAppearance(requireContext(), CoreUiR.style.Signal_Text_BodySmall)
} else if (documentInfo.extension.length == 4) {
extension.text = documentInfo.extension
extension.setTextAppearance(requireContext(), CoreUiR.style.Signal_Text_Caption)
}
} else {
Toast.makeText(requireContext(), R.string.ConversationActivity_sorry_there_was_an_error_setting_your_attachment, Toast.LENGTH_SHORT).show()
requireActivity().finishAfterTransition()
}
}
viewModel.loadDocumentInfo()
}
override fun getUri(): Uri {
return uri
}
override fun setUri(uri: Uri) {
this.uri = uri
}
override fun saveState(): Any = Unit
override fun restoreState(state: Any) = Unit
override fun notifyHidden() = Unit
}
@@ -1,105 +0,0 @@
package org.thoughtcrime.securesms.mediasend
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.models.media.Media
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
import java.io.IOException
import java.util.Optional
class MediaSendDocumentViewModel(private val media: Media) : ViewModel() {
private val internalDocumentInfo = MutableLiveData<Optional<DocumentInfo>>()
val documentInfo: LiveData<Optional<DocumentInfo>> = internalDocumentInfo
fun loadDocumentInfo() {
viewModelScope.launch {
internalDocumentInfo.value = withContext(Dispatchers.IO) {
Optional.ofNullable(computeDocumentInfo())
}
}
}
private fun computeDocumentInfo(): DocumentInfo? {
val context = AppDependencies.application
val fileInfo: Pair<String?, Long> = getFileInfo(context) ?: return null
val extensionText: String = MediaUtil.getFileType(context, Optional.ofNullable(fileInfo.first), media.uri).orElse("")
return DocumentInfo(fileInfo.first, fileInfo.second, extensionText)
}
private fun getFileInfo(context: Context): Pair<String?, Long>? {
val uri = media.uri
return try {
if (PartAuthority.isLocalUri(uri)) {
getManuallyCalculatedFileInfo(context, uri)
} else {
getContentResolverFileInfo(context, uri) ?: getManuallyCalculatedFileInfo(context, uri)
}
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
@Throws(IOException::class)
private fun getManuallyCalculatedFileInfo(context: Context, uri: Uri): Pair<String?, Long> {
var fileName: String? = null
var fileSize: Long? = null
if (PartAuthority.isLocalUri(uri)) {
fileSize = PartAuthority.getAttachmentSize(context, uri)
fileName = PartAuthority.getAttachmentFileName(context, uri)
}
if (fileSize == null) {
fileSize = MediaUtil.getMediaSize(context, uri)
}
return Pair(fileName, fileSize)
}
private fun getContentResolverFileInfo(context: Context, uri: Uri): Pair<String, Long>? {
var cursor: Cursor? = null
try {
cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
return Pair(fileName, fileSize)
}
} finally {
cursor?.close()
}
return null
}
data class DocumentInfo(val fileName: String?, val fileSize: Long, val extension: String)
class Factory(private val media: Media) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaSendDocumentViewModel(media)))
}
}
companion object {
private val TAG = Log.tag(MediaSendDocumentViewModel::class.java)
}
}
@@ -1,69 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.bumptech.glide.Glide;
import org.thoughtcrime.securesms.R;
import org.signal.glide.decryptableuri.DecryptableUri;
public class MediaSendGifFragment extends Fragment implements MediaSendPageFragment {
private static final String KEY_URI = "uri";
private Uri uri;
public static MediaSendGifFragment newInstance(@NonNull Uri uri) {
Bundle args = new Bundle();
args.putParcelable(KEY_URI, uri);
MediaSendGifFragment fragment = new MediaSendGifFragment();
fragment.setArguments(args);
fragment.setUri(uri);
return fragment;
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.mediasend_image_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
uri = getArguments().getParcelable(KEY_URI);
Glide.with(this).load(new DecryptableUri(uri)).fitCenter().into((ImageView) view);
}
@Override
public void setUri(@NonNull Uri uri) {
this.uri = uri;
}
@Override
public @NonNull Uri getUri() {
return uri;
}
@Override
public @Nullable Object saveState() {
return null;
}
@Override
public void restoreState(@NonNull Object state) { }
@Override
public void notifyHidden() {
}
}
@@ -15,26 +15,15 @@ import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendRecipient
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Activity
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Single entry point for launching the media send flow.
*
* Callers describe the flow they want, and this decides whether to launch the v2 [MediaSelectionActivity] or the
* v3 [MediaSendV3Activity], translating the request into that implementation's arguments.
*
* Every flow is expressible by both implementations, so the choice is purely [useV3].
*/
object MediaSendLauncher {
private val useV3: Boolean
get() = SignalStore.internal.useNewMediaActivity
@JvmStatic
fun camera(context: Context): Intent {
return camera(context, isStory = false)
@@ -42,161 +31,125 @@ object MediaSendLauncher {
@JvmStatic
fun camera(context: Context, isStory: Boolean): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
isStory = isStory
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
isStory = isStory
)
} else {
MediaSelectionActivity.camera(context, isStory)
}
)
}
@JvmStatic
fun camera(
context: Context,
messageSendType: MessageSendType,
recipientId: RecipientId,
isReply: Boolean
): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
isReply = isReply
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
isReply = isReply
)
} else {
MediaSelectionActivity.camera(context, messageSendType, recipientId, isReply)
}
)
}
fun cameraForQuickRestore(context: Context): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
isForQuickRestore = true
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
isForQuickRestore = true
)
} else {
MediaSelectionActivity.cameraForQuickRestore(context)
}
)
}
fun addToGroupStory(context: Context, recipientId: RecipientId): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
isStory = true,
isAddToGroupStoryFlow = true
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
isCameraFirst = true,
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
isStory = true,
isAddToGroupStoryFlow = true
)
} else {
MediaSelectionActivity.addToGroupStory(context, recipientId)
}
)
}
@JvmStatic
fun gallery(
context: Context,
messageSendType: MessageSendType,
media: List<Media>,
recipientId: RecipientId,
message: CharSequence?,
isReply: Boolean
): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
initialMedia = media,
initialMessage = message,
isReply = isReply
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
initialMedia = media,
initialMessage = message,
isReply = isReply
)
} else {
MediaSelectionActivity.gallery(context, messageSendType, media, recipientId, message, isReply)
}
)
}
@JvmStatic
fun editor(
context: Context,
messageSendType: MessageSendType,
media: List<Media>,
recipientId: RecipientId,
message: CharSequence?
): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
initialMedia = media,
initialMessage = message
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
recipientId = recipientId.toMediaRecipientId(),
initialMedia = media,
initialMessage = message
)
} else {
MediaSelectionActivity.editor(context, messageSendType, media, recipientId, message)
}
)
}
@JvmStatic
fun editor(context: Context, media: List<Media>): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
initialMedia = media
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
initialMedia = media
)
} else {
MediaSelectionActivity.editor(context, media)
}
)
}
@JvmStatic
fun share(
context: Context,
messageSendType: MessageSendType,
media: List<Media>,
recipientSearchKeys: List<ContactSearchKey.RecipientSearchKey>,
message: CharSequence?,
asTextStory: Boolean
): Intent {
return if (useV3) {
v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.MultiRecipient,
additionalRecipients = recipientSearchKeys.map { MediaSendRecipient(it.recipientId.toMediaRecipientId(), it.isStory) },
initialMedia = media,
initialMessage = message,
isStory = recipientSearchKeys.any { it.isStory },
asTextStory = asTextStory
)
return v3Intent(
context,
MediaSendFlowActivityContract.Args(
mode = MediaSendFlowActivityContract.Mode.MultiRecipient,
additionalRecipients = recipientSearchKeys.map { MediaSendRecipient(it.recipientId.toMediaRecipientId(), it.isStory) },
initialMedia = media,
initialMessage = message,
isStory = recipientSearchKeys.any { it.isStory },
asTextStory = asTextStory
)
} else {
MediaSelectionActivity.share(context, messageSendType, media, recipientSearchKeys, message, asTextStory)
}
)
}
/**
@@ -1,23 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.net.Uri;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* A page that sits in the {@link MediaSendFragmentPagerAdapter}.
*/
public interface MediaSendPageFragment {
@NonNull Uri getUri();
void setUri(@NonNull Uri uri);
@Nullable Object saveState();
void restoreState(@NonNull Object state);
void notifyHidden();
}
@@ -1,91 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Stack;
@SuppressWarnings("ConstantConditions")
public class OrderEnforcer<E> {
private final Map<E, StageDetails> stages = new LinkedHashMap<>();
public OrderEnforcer(@NonNull E... stages) {
for (E stage : stages) {
this.stages.put(stage, new StageDetails());
}
}
public synchronized void run(@NonNull E stage, Runnable r) {
if (isCompletedThrough(stage)) {
r.run();
} else {
stages.get(stage).addAction(r);
}
}
public synchronized void markCompleted(@NonNull E stage) {
stages.get(stage).markCompleted();
for (E s : stages.keySet()) {
StageDetails details = stages.get(s);
if (details.isCompleted()) {
while (details.hasAction()) {
details.popAction().run();
}
} else {
break;
}
}
}
public synchronized void reset() {
for (StageDetails details : stages.values()) {
details.reset();
}
}
private boolean isCompletedThrough(@NonNull E stage) {
for (E s : stages.keySet()) {
if (s.equals(stage)) {
return stages.get(s).isCompleted();
} else if (!stages.get(s).isCompleted()) {
return false;
}
}
return false;
}
private static class StageDetails {
private boolean completed = false;
private Stack<Runnable> actions = new Stack<>();
boolean hasAction() {
return !actions.isEmpty();
}
@Nullable Runnable popAction() {
return actions.pop();
}
void addAction(@NonNull Runnable runnable) {
actions.push(runnable);
}
void reset() {
actions.clear();
completed = false;
}
boolean isCompleted() {
return completed;
}
void markCompleted() {
completed = true;
}
}
}
@@ -1,49 +0,0 @@
package org.thoughtcrime.securesms.mediasend
import android.content.Context
import android.view.OrientationEventListener
import android.view.Surface
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
/**
* Utilizes the OrientationEventListener to determine relative surface rotation.
*
* @param context A context, which will be held on to for the lifespan of the listener.
*/
class RotationListener(
context: Context
) : OrientationEventListener(context) {
private val subject: Subject<Rotation> = BehaviorSubject.create()
/**
* Observes the stream of orientation changes. This can emit a lot of data, as it does
* not perform any duplication.
*/
val observable = subject
.doOnSubscribe { enable() }
.doOnTerminate { disable() }
override fun onOrientationChanged(orientation: Int) {
subject.onNext(
when {
orientation == ORIENTATION_UNKNOWN -> Rotation.ROTATION_0
orientation > 315 || orientation < 45 -> Rotation.ROTATION_0
orientation < 135 -> Rotation.ROTATION_270
orientation < 225 -> Rotation.ROTATION_180
else -> Rotation.ROTATION_90
}
)
}
/**
* Expresses the rotation as a handy enum.
*/
enum class Rotation(val surfaceRotation: Int) {
ROTATION_0(Surface.ROTATION_0),
ROTATION_90(Surface.ROTATION_90),
ROTATION_180(Surface.ROTATION_180),
ROTATION_270(Surface.ROTATION_270)
}
}
@@ -1,21 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.view.animation.Animation;
/**
* Basic implementation of {@link android.view.animation.Animation.AnimationListener} with empty
* implementation so you don't have to override every method.
*/
public class SimpleAnimationListener implements Animation.AnimationListener {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
}
@Override
public void onAnimationRepeat(Animation animation) {
}
}
@@ -1,160 +0,0 @@
package org.thoughtcrime.securesms.mediasend.camerax;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.widget.AppCompatImageView;
import androidx.camera.core.ImageCapture;
import org.thoughtcrime.securesms.R;
import java.util.Arrays;
import java.util.List;
public final class CameraXFlashToggleView extends AppCompatImageView {
private static final String STATE_FLASH_INDEX = "flash.toggle.state.flash.index";
private static final String STATE_SUPPORT_AUTO = "flash.toggle.state.support.auto";
private static final String STATE_PARENT = "flash.toggle.state.parent";
private static final int[] FLASH_AUTO = { R.attr.state_flash_auto };
private static final int[] FLASH_OFF = { R.attr.state_flash_off };
private static final int[] FLASH_ON = { R.attr.state_flash_on };
private static final int[][] FLASH_ENUM = { FLASH_AUTO, FLASH_OFF, FLASH_ON };
private static final List<FlashMode> FLASH_MODES = Arrays.asList(FlashMode.AUTO, FlashMode.OFF, FlashMode.ON);
private static final FlashMode FLASH_FALLBACK = FlashMode.OFF;
private boolean supportsFlashModeAuto = true;
private int flashIndex;
private OnFlashModeChangedListener flashModeChangedListener;
public CameraXFlashToggleView(Context context) {
this(context, null);
}
public CameraXFlashToggleView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CameraXFlashToggleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setOnClickListener((v) -> setFlash(FLASH_MODES.get((flashIndex + 1) % FLASH_ENUM.length).getFlashMode()));
}
@Override
public int[] onCreateDrawableState(int extraSpace) {
final int[] extra = FLASH_ENUM[flashIndex];
final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length);
mergeDrawableStates(drawableState, extra);
return drawableState;
}
@Override
public void setOnClickListener(@Nullable OnClickListener l) {
throw new IllegalStateException("This View does not support custom click listeners.");
}
public void setAutoFlashEnabled(boolean isAutoEnabled) {
supportsFlashModeAuto = isAutoEnabled;
setFlash(FLASH_MODES.get(flashIndex).getFlashMode());
}
public void setFlash(@ImageCapture.FlashMode int mode) {
FlashMode flashMode = FlashMode.fromImageCaptureFlashMode(mode);
flashIndex = resolveFlashIndex(FLASH_MODES.indexOf(flashMode), supportsFlashModeAuto);
refreshDrawableState();
notifyListener();
}
public void setOnFlashModeChangedListener(@Nullable OnFlashModeChangedListener listener) {
this.flashModeChangedListener = listener;
}
@Override
protected Parcelable onSaveInstanceState() {
Parcelable parentState = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(STATE_PARENT, parentState);
bundle.putInt(STATE_FLASH_INDEX, flashIndex);
bundle.putBoolean(STATE_SUPPORT_AUTO, supportsFlashModeAuto);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle savedState = (Bundle) state;
supportsFlashModeAuto = savedState.getBoolean(STATE_SUPPORT_AUTO);
setFlash(FLASH_MODES.get(
resolveFlashIndex(savedState.getInt(STATE_FLASH_INDEX), supportsFlashModeAuto)).getFlashMode()
);
super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT));
} else {
super.onRestoreInstanceState(state);
}
}
private void notifyListener() {
if (flashModeChangedListener == null) return;
flashModeChangedListener.flashModeChanged(FLASH_MODES.get(flashIndex).getFlashMode());
}
private static int resolveFlashIndex(int desiredFlashIndex, boolean supportsFlashModeAuto) {
if (isIllegalFlashIndex(desiredFlashIndex)) {
throw new IllegalArgumentException("Unsupported index: " + desiredFlashIndex);
}
if (isUnsupportedFlashMode(desiredFlashIndex, supportsFlashModeAuto)) {
return FLASH_MODES.indexOf(FLASH_FALLBACK);
}
return desiredFlashIndex;
}
private static boolean isIllegalFlashIndex(int desiredFlashIndex) {
return desiredFlashIndex < 0 || desiredFlashIndex > FLASH_ENUM.length;
}
private static boolean isUnsupportedFlashMode(int desiredFlashIndex, boolean supportsFlashModeAuto) {
return FLASH_MODES.get(desiredFlashIndex) == FlashMode.AUTO && !supportsFlashModeAuto;
}
public interface OnFlashModeChangedListener {
void flashModeChanged(int flashMode);
}
private enum FlashMode {
AUTO(ImageCapture.FLASH_MODE_AUTO),
OFF(ImageCapture.FLASH_MODE_OFF),
ON(ImageCapture.FLASH_MODE_ON);
private final @ImageCapture.FlashMode int flashMode;
FlashMode(@ImageCapture.FlashMode int flashMode) {
this.flashMode = flashMode;
}
@ImageCapture.FlashMode int getFlashMode() {
return flashMode;
}
private static FlashMode fromImageCaptureFlashMode(@ImageCapture.FlashMode int flashMode) {
for (FlashMode mode : values()) {
if (mode.getFlashMode() == flashMode) {
return mode;
}
}
throw new AssertionError();
}
}
}
@@ -1,31 +0,0 @@
package org.thoughtcrime.securesms.mediasend.camerax;
import androidx.annotation.NonNull;
import java.util.HashSet;
import java.util.Set;
/**
* A set of {@link android.os.Build#MODEL} that are known to both benefit from
* {@link androidx.camera.core.ImageCapture.CaptureMode#CAPTURE_MODE_MAXIMIZE_QUALITY} and execute it quickly.
*
*/
public class FastCameraModels {
private static final Set<String> MODELS = new HashSet<String>() {{
add("Pixel 2");
add("Pixel 2 XL");
add("Pixel 3");
add("Pixel 3 XL");
add("Pixel 3a");
add("Pixel 3a XL");
add("SM-S911U1");
}};
/**
* @param model Should be a {@link android.os.Build#MODEL}.
*/
public static boolean contains(@NonNull String model) {
return MODELS.contains(model);
}
}
@@ -1,12 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2
sealed class HudCommand {
object StartDraw : HudCommand()
object StartCropAndRotate : HudCommand()
object SaveMedia : HudCommand()
object GoToText : HudCommand()
object GoToReview : HudCommand()
object ResumeEntryTransition : HudCommand()
}
@@ -1,524 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.models.media.Media
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.util.Debouncer
import org.signal.core.util.OVERRIDE_TRANSITION_CLOSE_COMPAT
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableArrayListExtraCompat
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.signal.core.util.overrideActivityTransitionCompat
import org.signal.emoji.EmojiEventListener
import org.signal.mediasend.MediaSendRoute
import org.signal.mediasend.MediaValidator
import org.signal.mediasend.screens.capture.MediaCaptureBottomBar
import org.signal.mediasend.screens.capture.MediaCaptureScreenEvents
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEvent
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEventViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationFragment
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class MediaSelectionActivity :
PassphraseRequiredActivity(),
MediaReviewFragment.Callback,
TextStoryPostCreationFragment.Callback,
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
EmojiSearchFragment.Callback {
private var selectedCaptureScreen: MediaSendRoute.Capture by mutableStateOf(MediaSendRoute.Capture.Camera)
private var isOnCaptureScreen: Boolean by mutableStateOf(false)
lateinit var viewModel: MediaSelectionViewModel
private val lifecycleDisposable = LifecycleDisposable()
private val addMessageCommandViewModel: EmojiKeyboardEventViewModel by viewModels()
private val destination: MediaSelectionDestination
get() = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
override val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
get() = (destination.getRecipientSearchKeyList() + destination.getRecipientSearchKey()).filterNotNull().toSet()
override val isAddToGroupStoryFlow: Boolean
get() = intent.getBooleanExtra(IS_ADD_TO_GROUP_STORY_FLOW, false)
override val textStoryDraftText: CharSequence?
get() = if (shareToTextStory) draftText else null
private val isStory: Boolean
get() = intent.getBooleanExtra(IS_STORY, false)
private val shareToTextStory: Boolean
get() = intent.getBooleanExtra(AS_TEXT_STORY, false)
private val draftText: CharSequence?
get() = intent.getCharSequenceExtra(MESSAGE)
private val debouncer = Debouncer(200)
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onPreCreate() {
val sendType: MessageSendType = requireNotNull(intent.getParcelableExtraCompat(MESSAGE_SEND_TYPE, MessageSendType::class.java))
val initialMedia: List<Media> = intent.getParcelableArrayListExtraCompat(MEDIA, Media::class.java) ?: listOf()
val message: CharSequence? = if (shareToTextStory) null else draftText
val isReply: Boolean = intent.getBooleanExtra(IS_REPLY, false)
val isAddToGroupStoryFlow: Boolean = intent.getBooleanExtra(IS_ADD_TO_GROUP_STORY_FLOW, false)
val factory = MediaSelectionViewModel.Factory(destination, sendType, initialMedia, message, isReply, isStory, isAddToGroupStoryFlow, MediaSelectionRepository(this))
viewModel = ViewModelProvider(this, factory)[MediaSelectionViewModel::class.java]
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContentView(R.layout.media_selection_activity)
if (resources.getWindowBreakpoint() !is WindowBreakpoint.Small) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
val toggleBar: ComposeView = findViewById(R.id.toggle_bar)
toggleBar.setContent {
val state by viewModel.state.observeAsState()
val canDisplayStorySwitch = remember(state?.selectedMedia) {
state?.selectedMedia?.let { canDisplayStorySwitch(it) } ?: false
}
val canDisplayMediaPreview = remember(state?.selectedMedia) {
state?.selectedMedia?.let { canDisplayMediaPreview(it) } ?: false
}
SignalTheme {
if (isOnCaptureScreen) {
MediaCaptureBottomBar(
canDisplayToggleSwitch = canDisplayStorySwitch,
canDisplayMediaBar = canDisplayMediaPreview,
selectedCaptureScreen = selectedCaptureScreen,
selectedMedia = state?.selectedMedia ?: emptyList(),
onEvent = { event ->
when (event) {
MediaCaptureScreenEvents.ShowCamera -> debouncer.publish { popTextStoryPostCreationFragment() }
MediaCaptureScreenEvents.ShowTextStory -> viewModel.sendCommand(HudCommand.GoToText)
MediaCaptureScreenEvents.NextClicked -> viewModel.sendCommand(HudCommand.GoToReview)
is MediaCaptureScreenEvents.Camera,
is MediaCaptureScreenEvents.ParentStateChanged,
is MediaCaptureScreenEvents.SelectedCaptureScreenChanged -> Unit
}
},
modifier = Modifier.navigationBarsPadding()
)
}
}
}
if (savedInstanceState == null) {
val navHostFragment = NavHostFragment.create(R.navigation.media)
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, navHostFragment, NAV_HOST_TAG)
.commitNowAllowingStateLoss()
navigateToStartDestination()
} else {
viewModel.onRestoreState(this, savedInstanceState)
}
(supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment).navController.addOnDestinationChangedListener { _, d, _ ->
when (d.id) {
R.id.mediaCaptureFragment -> {
selectedCaptureScreen = MediaSendRoute.Capture.Camera
isOnCaptureScreen = true
requestedOrientation = if (resources.getWindowBreakpoint() is WindowBreakpoint.Small) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
R.id.textStoryPostCreationFragment -> {
selectedCaptureScreen = MediaSendRoute.Capture.TextStory
isOnCaptureScreen = true
requestedOrientation = if (resources.getWindowBreakpoint() is WindowBreakpoint.Small) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
else -> {
isOnCaptureScreen = false
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
// Hard-cut rotation while capturing (like Pixel Camera) instead of the system's smooth rotate.
window.attributes = window.attributes.apply {
rotationAnimation = if (isOnCaptureScreen) {
WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
} else {
WindowManager.LayoutParams.ROTATION_ANIMATION_ROTATE
}
}
}
lifecycleDisposable.bindTo(this)
lifecycleDisposable += viewModel.mediaErrors
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::handleError)
lifecycleDisposable += viewModel.videoTrimmedEvents
.observeOn(AndroidSchedulers.mainThread())
.subscribe { Toast.makeText(this, R.string.MediaReviewFragment__video_trimmed_to_fit, Toast.LENGTH_SHORT).show() }
onBackPressedDispatcher.addCallback(OnBackPressed())
if (savedInstanceState == null && intent.getBooleanExtra(IS_FOR_QUICK_RESTORE, false)) {
QuickRestoreInfoDialog.show(supportFragmentManager)
}
}
private fun handleError(error: MediaValidator.FilterError) {
when (error) {
MediaValidator.FilterError.None -> return
is MediaValidator.FilterError.ItemTooLarge -> Toast.makeText(this, R.string.MediaReviewFragment__one_or_more_items_were_too_large, Toast.LENGTH_SHORT).show()
is MediaValidator.FilterError.ItemInvalidType -> Toast.makeText(this, R.string.MediaReviewFragment__one_or_more_items_were_invalid, Toast.LENGTH_SHORT).show()
MediaValidator.FilterError.TooManyItems -> Toast.makeText(this, R.string.MediaReviewFragment__too_many_items_selected, Toast.LENGTH_SHORT).show()
is MediaValidator.FilterError.NoItems -> {
error.cause?.let { handleError(it) }
onNoMediaSelected()
}
}
viewModel.clearMediaErrors()
}
private fun popTextStoryPostCreationFragment() {
val navController = findNavController(R.id.fragment_container)
if (navController.currentDestination?.id == R.id.textStoryPostCreationFragment) {
navController.popBackStack()
}
}
private fun canDisplayMediaPreview(selectedMedia: List<Media>): Boolean {
return Stories.isFeatureEnabled() &&
isCameraFirst() &&
selectedMedia.isNotEmpty() &&
(destination == MediaSelectionDestination.ChooseAfterMediaSelection || destination is MediaSelectionDestination.SingleStory)
}
private fun canDisplayStorySwitch(selectedMedia: List<Media>): Boolean {
return Stories.isFeatureEnabled() &&
isCameraFirst() &&
selectedMedia.isEmpty() &&
(destination == MediaSelectionDestination.ChooseAfterMediaSelection || destination is MediaSelectionDestination.SingleStory)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewModel.onSaveState(outState)
}
override fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) {
setResult(
RESULT_OK,
Intent().apply {
putExtra(MediaSendActivityResult.EXTRA_RESULT, mediaSendActivityResult)
}
)
finish()
overrideActivityTransitionCompat(OVERRIDE_TRANSITION_CLOSE_COMPAT, R.anim.stationary, R.anim.camera_slide_to_bottom)
}
override fun onSentWithoutResult() {
val intent = Intent()
setResult(RESULT_OK, intent)
finish()
overrideActivityTransitionCompat(OVERRIDE_TRANSITION_CLOSE_COMPAT, R.anim.stationary, R.anim.camera_slide_to_bottom)
}
override fun onSendError(error: Throwable) {
setResult(RESULT_CANCELED)
// TODO [alex] - Toast
Log.w(TAG, "Failed to send message.", error)
finish()
overrideActivityTransitionCompat(OVERRIDE_TRANSITION_CLOSE_COMPAT, R.anim.stationary, R.anim.camera_slide_to_bottom)
}
override fun onNoMediaSelected() {
Log.w(TAG, "No media selected. Exiting.")
setResult(RESULT_CANCELED)
finish()
overrideActivityTransitionCompat(OVERRIDE_TRANSITION_CLOSE_COMPAT, R.anim.stationary, R.anim.camera_slide_to_bottom)
}
override fun onPopFromReview() {
if (isCameraFirst()) {
viewModel.removeCameraFirstCapture()
}
if (!navigateToStartDestination()) {
finish()
}
}
private fun navigateToStartDestination(navHostFragment: NavHostFragment? = null): Boolean {
val hostFragment: NavHostFragment = navHostFragment ?: supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment
val startDestination: Int = intent.getIntExtra(START_ACTION, -1)
return if (startDestination > 0) {
hostFragment.navController.safeNavigate(
startDestination,
Bundle().apply {
putBoolean("first", true)
}
)
true
} else {
false
}
}
private fun isCameraFirst(): Boolean = intent.getIntExtra(START_ACTION, -1) == R.id.action_directly_to_mediaCaptureFragment
override fun openEmojiSearch() {
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.OpenEmojiSearch)
}
override fun onEmojiSelected(emoji: String?) {
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.EmojiInsert(emoji))
}
override fun onKeyEvent(keyEvent: KeyEvent?) {
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.EmojiKeyEvent(keyEvent))
}
override fun closeEmojiSearch() {
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.CloseEmojiSearch)
}
private inner class OnBackPressed : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val navController = this@MediaSelectionActivity.findNavController(R.id.fragment_container)
if (shareToTextStory && navController.currentDestination?.id == R.id.textStoryPostCreationFragment) {
finish()
}
if (!navController.popBackStack()) {
finish()
}
}
}
companion object {
private val TAG = Log.tag(MediaSelectionActivity::class.java)
private const val NAV_HOST_TAG = "NAV_HOST"
private const val START_ACTION = "start.action"
private const val MESSAGE_SEND_TYPE = "message.send.type"
private const val MEDIA = "media"
private const val MESSAGE = "message"
private const val DESTINATION = "destination"
private const val IS_REPLY = "is_reply"
private const val IS_STORY = "is_story"
private const val AS_TEXT_STORY = "as_text_story"
private const val IS_ADD_TO_GROUP_STORY_FLOW = "is_add_to_group_story_flow"
private const val IS_FOR_QUICK_RESTORE = "is_for_quick_restore"
@JvmStatic
fun camera(context: Context): Intent {
return camera(context, false)
}
@JvmStatic
fun camera(context: Context, isStory: Boolean): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaCaptureFragment,
isStory = isStory
)
}
fun cameraForQuickRestore(context: Context): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaCaptureFragment,
isForQuickRestore = true
)
}
fun addToGroupStory(
context: Context,
recipientId: RecipientId
): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaCaptureFragment,
isStory = true,
isAddToGroupStoryFlow = true,
destination = MediaSelectionDestination.SingleStory(recipientId)
)
}
@JvmStatic
fun camera(
context: Context,
messageSendType: MessageSendType,
recipientId: RecipientId,
isReply: Boolean
): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaCaptureFragment,
messageSendType = messageSendType,
destination = MediaSelectionDestination.SingleRecipient(recipientId),
isReply = isReply
)
}
@JvmStatic
fun gallery(
context: Context,
messageSendType: MessageSendType,
media: List<Media>,
recipientId: RecipientId,
message: CharSequence?,
isReply: Boolean
): Intent {
return buildIntent(
context = context,
startAction = R.id.action_directly_to_mediaGalleryFragment,
messageSendType = messageSendType,
media = media,
destination = MediaSelectionDestination.SingleRecipient(recipientId),
message = message,
isReply = isReply
)
}
@JvmStatic
fun editor(
context: Context,
messageSendType: MessageSendType,
media: List<Media>,
recipientId: RecipientId,
message: CharSequence?
): Intent {
return buildIntent(
context = context,
messageSendType = messageSendType,
media = media,
destination = MediaSelectionDestination.SingleRecipient(recipientId),
message = message
)
}
@JvmStatic
fun editor(
context: Context,
media: List<Media>
): Intent {
return buildIntent(
context = context,
media = media
)
}
@JvmStatic
fun share(
context: Context,
messageSendType: MessageSendType,
media: List<Media>,
recipientSearchKeys: List<ContactSearchKey.RecipientSearchKey>,
message: CharSequence?,
asTextStory: Boolean
): Intent {
return buildIntent(
context = context,
messageSendType = messageSendType,
media = media,
destination = MediaSelectionDestination.MultipleRecipients(recipientSearchKeys),
message = message,
asTextStory = asTextStory,
startAction = if (asTextStory) R.id.action_directly_to_textPostCreationFragment else -1,
isStory = recipientSearchKeys.any { it.isStory }
)
}
private fun buildIntent(
context: Context,
startAction: Int = -1,
messageSendType: MessageSendType = MessageSendType.SignalMessageSendType,
media: List<Media> = listOf(),
destination: MediaSelectionDestination = MediaSelectionDestination.ChooseAfterMediaSelection,
message: CharSequence? = null,
isReply: Boolean = false,
isStory: Boolean = false,
asTextStory: Boolean = false,
isAddToGroupStoryFlow: Boolean = false,
isForQuickRestore: Boolean = false
): Intent {
return Intent(context, MediaSelectionActivity::class.java).apply {
putExtra(START_ACTION, startAction)
putExtra(MESSAGE_SEND_TYPE, messageSendType)
putParcelableArrayListExtra(MEDIA, ArrayList(media))
putExtra(MESSAGE, message)
putExtra(DESTINATION, destination.toBundle())
putExtra(IS_REPLY, isReply)
putExtra(IS_STORY, isStory)
putExtra(AS_TEXT_STORY, asTextStory)
putExtra(IS_ADD_TO_GROUP_STORY_FLOW, isAddToGroupStoryFlow)
putExtra(IS_FOR_QUICK_RESTORE, isForQuickRestore)
}
}
}
}
@@ -1,101 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.os.Bundle
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.recipients.RecipientId
sealed class MediaSelectionDestination {
object Wallpaper : MediaSelectionDestination() {
override fun toBundle(): Bundle {
return Bundle().apply {
putBoolean(WALLPAPER, true)
}
}
}
object Avatar : MediaSelectionDestination() {
override fun toBundle(): Bundle {
return Bundle().apply {
putBoolean(AVATAR, true)
}
}
}
object ChooseAfterMediaSelection : MediaSelectionDestination() {
override fun toBundle(): Bundle {
return Bundle.EMPTY
}
}
class SingleRecipient(private val id: RecipientId) : MediaSelectionDestination() {
override fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey = ContactSearchKey.RecipientSearchKey(id, false)
override fun toBundle(): Bundle {
return Bundle().apply {
putParcelable(RECIPIENT, id)
}
}
}
class SingleStory(private val id: RecipientId) : MediaSelectionDestination() {
override fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey = ContactSearchKey.RecipientSearchKey(id, true)
override fun toBundle(): Bundle {
return Bundle().apply {
putParcelable(STORY, id)
}
}
}
class MultipleRecipients(val recipientSearchKeys: List<ContactSearchKey.RecipientSearchKey>) : MediaSelectionDestination() {
companion object {
fun fromParcel(parcelables: List<ContactSearchKey.RecipientSearchKey>): MultipleRecipients {
return MultipleRecipients(parcelables)
}
}
override fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey? {
return if (recipientSearchKeys.size == 1) {
recipientSearchKeys[0]
} else {
super.getRecipientSearchKey()
}
}
override fun getRecipientSearchKeyList(): List<ContactSearchKey.RecipientSearchKey> = recipientSearchKeys
override fun toBundle(): Bundle {
return Bundle().apply {
putParcelableArrayList(RECIPIENT_LIST, ArrayList(recipientSearchKeys.map { it.requireRecipientSearchKey() }))
}
}
}
open fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey? = null
open fun getRecipientSearchKeyList(): List<ContactSearchKey.RecipientSearchKey> = emptyList()
abstract fun toBundle(): Bundle
companion object {
private const val WALLPAPER = "wallpaper"
private const val AVATAR = "avatar"
private const val RECIPIENT = "recipient"
private const val STORY = "story"
private const val RECIPIENT_LIST = "recipient_list"
fun fromBundle(bundle: Bundle): MediaSelectionDestination {
return when {
bundle.containsKey(WALLPAPER) -> Wallpaper
bundle.containsKey(AVATAR) -> Avatar
bundle.containsKey(RECIPIENT) -> SingleRecipient(requireNotNull(bundle.getParcelableCompat(RECIPIENT, RecipientId::class.java)))
bundle.containsKey(STORY) -> SingleStory(requireNotNull(bundle.getParcelableCompat(STORY, RecipientId::class.java)))
bundle.containsKey(RECIPIENT_LIST) -> MultipleRecipients.fromParcel(requireNotNull(bundle.getParcelableArrayListCompat(RECIPIENT_LIST, ContactSearchKey.RecipientSearchKey::class.java)))
else -> ChooseAfterMediaSelection
}
}
}
}
@@ -1,30 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2
import androidx.navigation.NavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class MediaSelectionNavigator(
private val toCamera: Int = -1,
private val toGallery: Int = -1
) {
fun goToReview(navController: NavController) {
navController.popBackStack(R.id.mediaReviewFragment, false)
}
fun goToCamera(navController: NavController) {
if (toCamera == -1) return
navController.safeNavigate(toCamera)
}
fun goToGallery(navController: NavController) {
if (toGallery == -1) return
navController.safeNavigate(toGallery)
}
fun isPreviousScreenMediaReview(navController: NavController): Boolean {
return navController.previousBackStackEntry?.destination?.id == R.id.mediaReviewFragment
}
}
@@ -1,79 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.net.Uri
import org.signal.core.models.media.Media
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.SentMediaQuality
import org.signal.mediasend.screens.edit.video.VideoTrimData
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.TranscodingConfigProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.TranscodingConfig
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class MediaSelectionState(
val sendType: MessageSendType,
val selectedMedia: List<Media> = listOf(),
val focusedMedia: Media? = null,
val recipient: Recipient? = null,
val quality: SentMediaQuality = SignalStore.settings.sentMediaQuality,
val message: CharSequence? = null,
val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.default,
val isTouchEnabled: Boolean = true,
val isSent: Boolean = false,
val isPreUploadEnabled: Boolean = false,
val isMeteredConnection: Boolean = false,
val editorStateMap: Map<Uri, Any> = mapOf(),
val cameraFirstCapture: Media? = null,
val isStory: Boolean,
val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND,
val suppressEmptyError: Boolean = true,
val transcodingConfigs: List<TranscodingConfig.QualityTier> = TranscodingConfigProvider.getConfigsForMediaQuality(SentMediaQuality.fromCode(quality.code))
) {
val isVideoTrimmingVisible: Boolean = focusedMedia != null && MediaUtil.isVideoType(focusedMedia.contentType) && MediaConstraints.isVideoTranscodeAvailable() && !focusedMedia.isVideoGif
val maxSelection = RemoteConfig.maxAttachmentCount
val canSend = !isSent && selectedMedia.isNotEmpty()
fun getOrCreateVideoTrimData(uri: Uri): VideoTrimData {
return editorStateMap[uri] as? VideoTrimData ?: VideoTrimData()
}
fun calculateMaxVideoDurationUs(videoDuration: Duration): Long {
return if (isStory && !MediaConstraints.isVideoTranscodeAvailable()) {
Stories.MAX_VIDEO_DURATION_MILLIS
} else {
TranscodingConfig.calculateMaxVideoUploadDurationInSeconds(transcodingConfigs, videoDuration).seconds.inWholeMicroseconds
}
}
enum class ViewOnceToggleState(val code: Int) {
INFINITE(0),
ONCE(1);
fun next(): ViewOnceToggleState {
return when (this) {
INFINITE -> ONCE
ONCE -> INFINITE
}
}
companion object {
val default = INFINITE
fun fromCode(code: Int): ViewOnceToggleState {
return when (code) {
1 -> ONCE
else -> INFINITE
}
}
}
}
}
@@ -1,673 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.os.Parcel
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.common.io.ByteStreams
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.models.media.Media
import org.signal.core.util.Util
import org.signal.core.util.contentproviders.BlobProvider
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.MediaValidator
import org.signal.mediasend.SentMediaQuality
import org.signal.mediasend.screens.edit.video.VideoTrimData
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.MessageStyler
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mms.PushMediaConstraints
import org.thoughtcrime.securesms.mms.TranscodingConfigProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Collections
import kotlin.math.max
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* ViewModel which maintains the list of selected media and other shared values.
*/
class MediaSelectionViewModel(
val destination: MediaSelectionDestination,
sendType: MessageSendType,
initialMedia: List<Media>,
initialMessage: CharSequence?,
val isReply: Boolean,
isStory: Boolean,
val isAddToGroupStoryFlow: Boolean,
private val repository: MediaSelectionRepository,
private val identityChangesSince: Long = System.currentTimeMillis()
) : ViewModel() {
private val TAG = Log.tag(MediaSelectionViewModel::class.java)
private val selectedMediaSubject: Subject<List<Media>> = BehaviorSubject.create()
private val store: Store<MediaSelectionState> = Store(
MediaSelectionState(
sendType = sendType,
message = initialMessage,
isStory = isStory
)
)
val isContactSelectionRequired = destination == MediaSelectionDestination.ChooseAfterMediaSelection
val state: LiveData<MediaSelectionState> = store.stateLiveData
private val internalHudCommands = PublishSubject.create<HudCommand>()
val mediaErrors: BehaviorSubject<MediaValidator.FilterError> = BehaviorSubject.createDefault(MediaValidator.FilterError.None)
val hudCommands: Observable<HudCommand> = internalHudCommands
private val _videoTrimmedEvents = PublishSubject.create<Unit>()
val videoTrimmedEvents: Observable<Unit> = _videoTrimmedEvents
private val disposables = CompositeDisposable()
private val isMeteredDisposable: Disposable = repository.isMetered.subscribe { metered ->
store.update {
it.copy(
isMeteredConnection = metered,
isPreUploadEnabled = shouldPreUpload(metered)
)
}
}
private var lastMediaDrag: Pair<Int, Int> = Pair(0, 0)
init {
val recipientSearchKey = destination.getRecipientSearchKey()
if (recipientSearchKey != null) {
store.update(Recipient.live(recipientSearchKey.recipientId).liveData) { r, s ->
s.copy(
recipient = r,
isPreUploadEnabled = shouldPreUpload(s.isMeteredConnection)
)
}
}
if (initialMedia.isNotEmpty()) {
addMedia(initialMedia.toSet())
}
disposables += selectedMediaSubject
.flatMapSingle { media ->
Single.fromCallable {
Stories.MediaTransform.getSendRequirements(media)
}.subscribeOn(Schedulers.io())
}
.subscribeBy { requirements ->
store.update {
it.copy(storySendRequirements = requirements)
}
}
}
override fun onCleared() {
isMeteredDisposable.dispose()
disposables.clear()
}
fun kick() {
store.update { it }
}
fun sendCommand(hudCommand: HudCommand) {
internalHudCommands.onNext(hudCommand)
}
fun setTouchEnabled(isEnabled: Boolean) {
store.update { it.copy(isTouchEnabled = isEnabled) }
}
fun setSuppressEmptyError(isSuppressed: Boolean) {
store.update { it.copy(suppressEmptyError = isSuppressed) }
}
fun addMedia(media: Media) {
addMedia(setOf(media))
}
fun isStory(): Boolean {
return store.state.isStory
}
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements {
return store.state.storySendRequirements
}
fun addMedia(media: Set<Media>) {
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
addAll(store.state.selectedMedia)
addAll(media)
}.toList()
disposables.add(
repository
.populateAndFilterMedia(newSelectionList, getMediaConstraints(), store.state.maxSelection, store.state.isStory)
.subscribe { filterResult ->
if (filterResult.filteredMedia.isNotEmpty()) {
val existingState = store.state
val initializedVideoEditorStates = filterResult.filteredMedia.filterNot { media -> existingState.editorStateMap.containsKey(media.uri) }
.filter { media -> MediaUtil.isNonGifVideo(media) }
.associate { video: Media ->
val duration = video.duration.milliseconds.inWholeMicroseconds
val maxDuration = existingState.calculateMaxVideoDurationUs(video.duration.milliseconds)
if (MediaConstraints.isVideoTranscodeAvailable() && duration >= maxDuration) {
video.uri to VideoTrimData(true, duration, 0, maxDuration)
} else {
video.uri to VideoTrimData(false, duration, 0, duration)
}
}
store.update {
val updatedCameraFirstCapture = if (it.cameraFirstCapture != null) {
filterResult.filteredMedia.find { filtered -> filtered.uri == it.cameraFirstCapture.uri }
} else {
null
}
it.copy(
selectedMedia = filterResult.filteredMedia,
focusedMedia = it.focusedMedia ?: filterResult.filteredMedia.first(),
editorStateMap = it.editorStateMap + initializedVideoEditorStates,
cameraFirstCapture = if (filterResult.filteredMedia.size > 1) null else updatedCameraFirstCapture ?: it.cameraFirstCapture
)
}
if (initializedVideoEditorStates.any { (_, data) -> data.isDurationEdited }) {
_videoTrimmedEvents.onNext(Unit)
}
selectedMediaSubject.onNext(filterResult.filteredMedia)
val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList()
startUpload(newMedia)
}
if (filterResult.filterError != null) {
mediaErrors.onNext(filterResult.filterError)
}
}
)
}
fun swapMedia(originalStart: Int, end: Int): Boolean {
var start: Int = originalStart
if (lastMediaDrag.first == start && lastMediaDrag.second == end) {
return true
} else if (lastMediaDrag.first == start) {
start = lastMediaDrag.second
}
val snapshot = store.state
if (end >= snapshot.selectedMedia.size || end < 0 || start >= snapshot.selectedMedia.size || start < 0) {
return false
}
lastMediaDrag = Pair(originalStart, end)
val newMediaList = snapshot.selectedMedia.toMutableList()
if (start < end) {
for (i in start until end) {
Collections.swap(newMediaList, i, i + 1)
}
} else {
for (i in start downTo end + 1) {
Collections.swap(newMediaList, i, i - 1)
}
}
store.update {
it.copy(
selectedMedia = newMediaList
)
}
return true
}
fun isValidMediaDragPosition(position: Int): Boolean {
return position >= 0 && position < store.state.selectedMedia.size
}
fun onMediaDragFinished() {
lastMediaDrag = Pair(0, 0)
}
fun isSelectedMediaEmpty(): Boolean {
return store.state.selectedMedia.isEmpty()
}
fun removeMedia(media: Media, suppressEmptyError: Boolean = store.state.suppressEmptyError) {
removeMedia(setOf(media), suppressEmptyError)
}
fun removeMedia(media: Set<Media>, suppressEmptyError: Boolean = store.state.suppressEmptyError) {
val snapshot = store.state
val newMediaList = snapshot.selectedMedia - media
val newFocus = when {
newMediaList.isEmpty() -> null
snapshot.focusedMedia in media -> {
val oldFocusIndex = snapshot.selectedMedia.indexOf(snapshot.focusedMedia)
newMediaList[Util.clamp(oldFocusIndex, 0, newMediaList.size - 1)]
}
else -> snapshot.focusedMedia
}
store.update {
it.copy(
selectedMedia = newMediaList,
focusedMedia = newFocus,
editorStateMap = it.editorStateMap - media.map { it.uri },
cameraFirstCapture = if (it.cameraFirstCapture in media) null else it.cameraFirstCapture
)
}
if (newMediaList.isEmpty() && !store.state.suppressEmptyError) {
mediaErrors.onNext(MediaValidator.FilterError.NoItems())
}
selectedMediaSubject.onNext(newMediaList)
repository.deleteBlobs(media.toList())
Log.d(TAG, "User removed ${media.forEach { it.uri }} from message.")
cancelUpload(media)
}
fun addCameraFirstCapture(media: Media) {
store.update { state ->
state.copy(cameraFirstCapture = media)
}
addMedia(media)
}
fun removeCameraFirstCapture() {
val cameraFirstCapture: Media? = store.state.cameraFirstCapture
if (cameraFirstCapture != null) {
setSuppressEmptyError(true)
removeMedia(cameraFirstCapture, suppressEmptyError = true)
}
}
fun onPageChanged(media: Media) {
store.update { it.copy(focusedMedia = media) }
}
fun onPageChanged(position: Int) {
store.update {
if (position >= it.selectedMedia.size) {
it.copy(focusedMedia = null)
} else {
val focusedMedia: Media = it.selectedMedia[position]
it.copy(focusedMedia = focusedMedia)
}
}
}
fun getMediaConstraints(): MediaConstraints {
return PushMediaConstraints(null)
}
/**
* A recording is assumed to be wanted in its entirety, so if it is longer than high quality allows we fall back to
* standard quality rather than have the editor truncate it to fit.
*/
fun onVideoRecorded(duration: Duration) {
if (store.state.quality != SentMediaQuality.HIGH) {
return
}
val maxDuration = TranscodingConfigProvider.getMaxVideoDurationSeconds(SentMediaQuality.HIGH, duration).seconds
if (duration > maxDuration) {
Log.i(TAG, "Recording of $duration exceeds the $maxDuration allowed at high quality. Falling back to standard quality.")
setSentMediaQuality(SentMediaQuality.STANDARD)
}
}
fun setSentMediaQuality(sentMediaQuality: SentMediaQuality) {
if (sentMediaQuality == store.state.quality) {
return
}
store.update { it.copy(quality = sentMediaQuality, isPreUploadEnabled = false, transcodingConfigs = TranscodingConfigProvider.getConfigsForMediaQuality(sentMediaQuality)) }
repository.uploadRepository.cancelAllUploads()
var videoTrimmed = false
store.state.selectedMedia.forEach { mediaItem ->
if (MediaUtil.isVideoType(mediaItem.contentType) && MediaConstraints.isVideoTranscodeAvailable()) {
val uri = mediaItem.uri
val before = store.state.getOrCreateVideoTrimData(uri)
onEditVideoDuration(totalDurationUs = before.totalInputDurationUs, startTimeUs = before.startTimeUs, endTimeUs = before.endTimeUs, touchEnabled = true, uri = uri)
val after = store.state.getOrCreateVideoTrimData(uri)
if (after.getDuration() < before.getDuration()) {
videoTrimmed = true
}
}
}
if (videoTrimmed) {
_videoTrimmedEvents.onNext(Unit)
}
}
fun setMessage(text: CharSequence?) {
store.update { it.copy(message = text) }
}
fun incrementViewOnceState() {
store.update { it.copy(viewOnceToggleState = it.viewOnceToggleState.next()) }
}
fun onEditVideoDuration(totalDurationUs: Long, startTimeUs: Long, endTimeUs: Long, touchEnabled: Boolean, uri: Uri? = store.state.focusedMedia?.uri) {
if (uri == null) return
store.update {
val data = it.getOrCreateVideoTrimData(uri)
val clampedStartTime = max(startTimeUs, 0)
val unedited = !data.isDurationEdited
val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs
val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs
val endMoved = !isEntireDuration && data.endTimeUs != endTimeUs
val maxVideoDurationUs: Long = it.calculateMaxVideoDurationUs((endTimeUs - clampedStartTime).microseconds)
val preserveStartTime = unedited || !endMoved
val videoTrimData = data.copy(isDurationEdited = durationEdited, totalInputDurationUs = totalDurationUs, startTimeUs = clampedStartTime, endTimeUs = endTimeUs)
val updatedData = clampToMaxClipDuration(videoTrimData, maxVideoDurationUs, preserveStartTime)
if (updatedData != videoTrimData) {
Log.d(TAG, "Video attachment trim clamped from ${videoTrimData.startTimeUs}, ${videoTrimData.endTimeUs} to ${updatedData.startTimeUs}, ${updatedData.endTimeUs}")
}
if (unedited && durationEdited) {
Log.d(TAG, "Canceling attachment upload because the duration has been edited for the first time..")
cancelUpload(MediaBuilder.buildMedia(uri))
}
if (updatedData != data) {
Log.d(TAG, "Updating video attachment trim data for $uri")
it.copy(
isTouchEnabled = touchEnabled,
editorStateMap = it.editorStateMap + (uri to updatedData)
)
} else {
Log.d(TAG, "Preserving video attachment trim data for $uri")
it.copy(isTouchEnabled = touchEnabled)
}
}
}
/**
* Toggles whether the focused video's audio track is stripped when it is sent.
*/
fun toggleVideoMuted() {
val uri = store.state.focusedMedia?.uri ?: return
val data = store.state.getOrCreateVideoTrimData(uri)
val updatedData = data.copy(isMuted = !data.isMuted)
store.update {
it.copy(editorStateMap = it.editorStateMap + (uri to updatedData))
}
Log.d(TAG, "Canceling attachment upload because the audio was muted/unmuted.")
cancelUpload(MediaBuilder.buildMedia(uri))
}
fun getEditorState(uri: Uri): Any? {
return store.state.editorStateMap[uri]
}
fun setEditorState(uri: Uri, state: Any) {
store.update {
it.copy(editorStateMap = it.editorStateMap + (uri to state))
}
}
fun send(
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList(),
scheduledDate: Long? = null
): Maybe<MediaSendActivityResult> = send(selectedContacts, scheduledDate ?: -1)
fun send(
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList(),
scheduledDate: Long
): Maybe<MediaSendActivityResult> {
return UntrustedRecords.checkForBadIdentityRecords(selectedContacts.toSet(), identityChangesSince).andThen(
repository.send(
selectedMedia = store.state.selectedMedia,
stateMap = store.state.editorStateMap,
quality = store.state.quality,
message = store.state.message,
isViewOnce = isViewOnceEnabled(),
singleContact = destination.getRecipientSearchKey(),
contacts = selectedContacts.ifEmpty { destination.getRecipientSearchKeyList() },
mentions = MentionAnnotation.getMentionsFromAnnotations(store.state.message),
bodyRanges = MessageStyler.getStyling(store.state.message),
sendType = store.state.sendType,
scheduledTime = scheduledDate
)
)
}
private fun isViewOnceEnabled(): Boolean {
return store.state.selectedMedia.size == 1 &&
store.state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE
}
private fun startUpload(media: List<Media>) {
if (!store.state.isPreUploadEnabled) {
return
}
val filteredPreUploadMedia = if (destination is MediaSelectionDestination.SingleRecipient || !Stories.isFeatureEnabled()) {
media.filter { !MediaUtil.isDocumentType(it.contentType) }
} else {
media.filter { Stories.MediaTransform.canPreUploadMedia(it) }
}
repository.uploadRepository.startUpload(filteredPreUploadMedia, store.state.recipient)
}
private fun cancelUpload(media: Media) {
cancelUpload(setOf(media))
}
private fun cancelUpload(media: Set<Media>) {
repository.uploadRepository.cancelUpload(media)
}
private fun shouldPreUpload(metered: Boolean): Boolean {
return !metered && !isContactSelectionRequired
}
fun onSaveState(outState: Bundle) {
val snapshot = store.state
outState.putParcelableArrayList(STATE_SELECTION, ArrayList(snapshot.selectedMedia))
outState.putParcelable(STATE_FOCUSED, snapshot.focusedMedia)
outState.putInt(STATE_QUALITY, snapshot.quality.code)
outState.putCharSequence(STATE_MESSAGE, snapshot.message)
outState.putInt(STATE_VIEW_ONCE, snapshot.viewOnceToggleState.code)
outState.putBoolean(STATE_TOUCH_ENABLED, snapshot.isTouchEnabled)
outState.putBoolean(STATE_SENT, snapshot.isSent)
outState.putParcelable(STATE_CAMERA_FIRST_CAPTURE, snapshot.cameraFirstCapture)
val editorStates: List<Bundle> = store.state.editorStateMap.entries.map { it.toBundleStateEntry() }
outState.putInt(STATE_EDITOR_COUNT, editorStates.size)
if (editorStates.isNotEmpty()) {
val parcel = Parcel.obtain()
editorStates.forEach { it.writeToParcel(parcel, 0) }
val serializedEditorState: ByteArray = parcel.marshall()
parcel.recycle()
val blobUri = AppDependencies.blobs.forData(serializedEditorState).createForSingleUseInMemory()
outState.putParcelable(STATE_EDITORS, blobUri)
}
}
fun hasSelectedMedia(): Boolean {
return store.state.selectedMedia.isNotEmpty()
}
fun clearMediaErrors() {
mediaErrors.onNext(MediaValidator.FilterError.None)
}
fun onRestoreState(context: Context, savedInstanceState: Bundle) {
val selection: List<Media> = savedInstanceState.getParcelableArrayListCompat(STATE_SELECTION, Media::class.java) ?: emptyList()
val focused: Media? = savedInstanceState.getParcelableCompat(STATE_FOCUSED, Media::class.java)
val quality: SentMediaQuality = SentMediaQuality.fromCode(savedInstanceState.getInt(STATE_QUALITY))
val message: CharSequence? = savedInstanceState.getCharSequence(STATE_MESSAGE)
val viewOnce: MediaSelectionState.ViewOnceToggleState = MediaSelectionState.ViewOnceToggleState.fromCode(savedInstanceState.getInt(STATE_VIEW_ONCE))
val touchEnabled: Boolean = savedInstanceState.getBoolean(STATE_TOUCH_ENABLED)
val sent: Boolean = savedInstanceState.getBoolean(STATE_SENT)
val cameraFirstCapture: Media? = savedInstanceState.getParcelableCompat(STATE_CAMERA_FIRST_CAPTURE, Media::class.java)
val editorCount: Int = savedInstanceState.getInt(STATE_EDITOR_COUNT, 0)
val blobUri: Uri? = savedInstanceState.getParcelableCompat(STATE_EDITORS, Uri::class.java)
val blobProvider: BlobProvider = AppDependencies.blobs
val editorStates: List<Bundle> = if (editorCount > 0 && blobUri != null && blobProvider.hasStream(context, blobUri)) {
val accumulator: MutableList<Bundle> = mutableListOf()
val blob: ByteArray = ByteStreams.toByteArray(blobProvider.getStream(context, blobUri))
val parcel: Parcel = Parcel.obtain()
parcel.unmarshall(blob, 0, blob.size)
parcel.setDataPosition(0)
for (index in 0 until editorCount) {
val bundle = parcel.readBundle(this::class.java.classLoader)
if (bundle != null && bundle != Bundle.EMPTY) {
accumulator.add(bundle)
}
}
parcel.recycle()
accumulator
} else {
emptyList()
}
val editorStateMap = editorStates.associate { it.toAssociation() }
selectedMediaSubject.onNext(selection)
store.update { state ->
state.copy(
selectedMedia = selection,
focusedMedia = focused,
quality = quality,
message = message,
viewOnceToggleState = viewOnce,
isTouchEnabled = touchEnabled,
isSent = sent,
cameraFirstCapture = cameraFirstCapture,
editorStateMap = editorStateMap
)
}
}
private fun Bundle.toAssociation(): Pair<Uri, Any> {
val key: Uri = requireNotNull(getParcelableCompat(BUNDLE_URI, Uri::class.java))
val value: Any = if (getBoolean(BUNDLE_IS_IMAGE)) {
ImageEditorFragment.Data(this)
} else {
VideoTrimData.fromBundle(this)
}
return key to value
}
private fun Map.Entry<Uri, Any>.toBundleStateEntry(): Bundle {
return when (val value = this.value) {
is ImageEditorFragment.Data -> {
value.bundle.apply {
putParcelable(BUNDLE_URI, key)
putBoolean(BUNDLE_IS_IMAGE, true)
}
}
is VideoTrimData -> {
value.toBundle().apply {
putParcelable(BUNDLE_URI, key)
putBoolean(BUNDLE_IS_IMAGE, false)
}
}
else -> {
throw IllegalStateException()
}
}
}
companion object {
private const val STATE_PREFIX = "selection.view.model"
private const val BUNDLE_URI = "$STATE_PREFIX.uri"
private const val BUNDLE_IS_IMAGE = "$STATE_PREFIX.is_image"
private const val STATE_SELECTION = "$STATE_PREFIX.selection"
private const val STATE_FOCUSED = "$STATE_PREFIX.focused"
private const val STATE_QUALITY = "$STATE_PREFIX.quality"
private const val STATE_MESSAGE = "$STATE_PREFIX.message"
private const val STATE_VIEW_ONCE = "$STATE_PREFIX.viewOnce"
private const val STATE_TOUCH_ENABLED = "$STATE_PREFIX.touchEnabled"
private const val STATE_SENT = "$STATE_PREFIX.sent"
private const val STATE_CAMERA_FIRST_CAPTURE = "$STATE_PREFIX.camera_first_capture"
private const val STATE_EDITORS = "$STATE_PREFIX.editors"
private const val STATE_EDITOR_COUNT = "$STATE_PREFIX.editor_count"
@JvmStatic
fun clampToMaxClipDuration(data: VideoTrimData, maxVideoDurationUs: Long, preserveStartTime: Boolean): VideoTrimData {
if (!MediaConstraints.isVideoTranscodeAvailable()) {
return data
}
if ((data.endTimeUs - data.startTimeUs) <= maxVideoDurationUs) {
return data
}
return data.copy(
isDurationEdited = true,
startTimeUs = if (!preserveStartTime) data.endTimeUs - maxVideoDurationUs else data.startTimeUs,
endTimeUs = if (preserveStartTime) data.startTimeUs + maxVideoDurationUs else data.endTimeUs
)
}
}
class Factory(
private val destination: MediaSelectionDestination,
private val sendType: MessageSendType,
private val initialMedia: List<Media>,
private val initialMessage: CharSequence?,
private val isReply: Boolean,
private val isStory: Boolean,
private val isAddToGroupStoryFlow: Boolean,
private val repository: MediaSelectionRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaSelectionViewModel(destination, sendType, initialMedia, initialMessage, isReply, isStory, isAddToGroupStoryFlow, repository)))
}
}
}
@@ -1,12 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.recipients.Recipient
sealed interface MediaCaptureEvent {
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent
data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent
data object DeviceLinkScannedFromQrCode : MediaCaptureEvent
data object MediaCaptureRenderFailed : MediaCaptureEvent
data class ReregistrationScannedFromQrCode(val data: String) : MediaCaptureEvent
}
@@ -1,192 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.SeekableFileDescriptor
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.screens.capture.CameraFragment
import org.signal.mediasend.screens.capture.CameraXFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
private val TAG = Log.tag(MediaCaptureFragment::class.java)
/**
* Fragment which displays the proper camera fragment.
*/
class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragment.Controller {
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val viewModel: MediaCaptureViewModel by viewModels(
factoryProducer = { MediaCaptureViewModel.Factory(MediaCaptureRepository(requireContext())) }
)
private lateinit var captureChildFragment: CameraFragment
private lateinit var navigator: MediaSelectionNavigator
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
captureChildFragment = CameraXFragment.newInstance(sharedViewModel.isContactSelectionRequired) as CameraFragment
navigator = MediaSelectionNavigator(
toGallery = R.id.action_mediaCaptureFragment_to_mediaGalleryFragment
)
childFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, captureChildFragment as Fragment)
.commitNowAllowingStateLoss()
lifecycleDisposable += viewModel.events.subscribe { event ->
when (event) {
MediaCaptureEvent.MediaCaptureRenderFailed -> {
Log.w(TAG, "Failed to render captured media.")
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
}
is MediaCaptureEvent.MediaCaptureRendered -> {
if (isFirst()) {
sharedViewModel.addCameraFirstCapture(event.media)
} else {
sharedViewModel.addMedia(event.media)
}
navigator.goToReview(findNavController())
}
is MediaCaptureEvent.UsernameScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.MediaCaptureFragment_username_dialog_title, event.username))
.setMessage(getString(R.string.MediaCaptureFragment_username_dialog_body, event.username))
.setPositiveButton(R.string.MediaCaptureFragment_username_dialog_go_to_chat_button) { d, _ ->
CommunicationActions.startConversation(requireContext(), event.recipient, "")
requireActivity().finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
is MediaCaptureEvent.DeviceLinkScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.MediaCaptureFragment_device_link_dialog_title)
.setMessage(R.string.MediaCaptureFragment_it_looks_like_youre_trying)
.setPositiveButton(R.string.MediaCaptureFragment_device_link_dialog_continue) { d, _ ->
startActivity(AppSettingsActivity.linkedDevices(requireContext()))
requireActivity().finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
is MediaCaptureEvent.ReregistrationScannedFromQrCode -> {
startActivity(QuickTransferOldDeviceActivity.intent(requireContext(), event.data))
requireActivity().finish()
}
}
}
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
captureChildFragment.presentHud(state.selectedMedia.size)
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += sharedViewModel.hudCommands.subscribe { command ->
if (command == HudCommand.GoToText) {
findNavController().safeNavigate(R.id.action_mediaCaptureFragment_to_textStoryPostCreationFragment)
} else if (command == HudCommand.GoToReview) {
navigator.goToReview(findNavController())
}
}
if (isFirst() || sharedViewModel.isSelectedMediaEmpty()) {
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
requireActivity().finish()
}
}
)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun onResume() {
super.onResume()
captureChildFragment.fadeInControls()
}
override fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
viewModel.onImageCaptured(data, width, height)
}
override fun onVideoCaptured(fd: SeekableFileDescriptor, durationMs: Long) {
sharedViewModel.onVideoRecorded(durationMs.milliseconds)
viewModel.onVideoCaptured(fd)
}
override fun onVideoCaptureError() {
Log.w(TAG, "Video capture error.")
context?.let { context ->
Toast.makeText(context, R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
}
}
override fun onGalleryClicked() {
val controller = findNavController()
captureChildFragment.fadeOutControls {
navigator.goToGallery(controller)
}
}
override fun onCameraCloseClicked() {
// TODO [media-send] - Alex R to supply dialog.
requireActivity().finish()
}
override fun onQrCodeFound(data: String) {
viewModel.onQrCodeFound(data)
}
override fun getMediaConstraints(): MediaConstraints {
return sharedViewModel.getMediaConstraints()
}
override fun getMaxVideoDuration(): Int {
return if (sharedViewModel.isStory()) TimeUnit.MILLISECONDS.toSeconds(Stories.MAX_VIDEO_DURATION_MILLIS).toInt() else -1
}
private fun isFirst(): Boolean {
return arguments?.getBoolean("first") == true
}
companion object {
const val CAPTURE_RESULT = "capture_result"
const val CAPTURE_RESULT_OK = "capture_result_ok"
}
}
@@ -1,95 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import android.content.Context
import android.net.Uri
import org.signal.core.models.media.Media
import org.signal.core.util.ContentTypeUtil
import org.signal.core.util.SeekableFileDescriptor
import org.signal.core.util.closeQuietly
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.contentproviders.BlobProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import java.io.FileInputStream
import java.io.IOException
class MediaCaptureRepository(context: Context) {
private val context: Context = context.applicationContext
fun renderImageToMedia(data: ByteArray, width: Int, height: Int, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
SignalExecutors.BOUNDED.execute {
val media: Media? = renderCaptureToMedia(
dataSupplier = { data },
getLength = { data.size.toLong() },
createBlobBuilder = { blobProvider, bytes, _ -> blobProvider.forData(bytes) },
mimeType = ContentTypeUtil.IMAGE_JPEG,
width = width,
height = height
)
if (media != null) {
onMediaRendered(media)
} else {
onFailedToRender()
}
}
}
/** Takes ownership of [fileDescriptor], closing it once the recording has been copied. */
fun renderVideoToMedia(fileDescriptor: SeekableFileDescriptor, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
SignalExecutors.BOUNDED.execute {
val media: Media? = renderCaptureToMedia(
dataSupplier = { FileInputStream(fileDescriptor.fileDescriptor) },
getLength = { it.channel.size() },
createBlobBuilder = BlobProvider::forData,
mimeType = VideoConstants.RECORDED_VIDEO_CONTENT_TYPE,
width = 0,
height = 0
)
fileDescriptor.closeQuietly()
if (media != null) {
onMediaRendered(media)
} else {
onFailedToRender()
}
}
}
private fun <T> renderCaptureToMedia(
dataSupplier: () -> T,
getLength: (T) -> Long,
createBlobBuilder: (BlobProvider, T, Long) -> BlobProvider.BlobBuilder,
mimeType: String,
width: Int,
height: Int
): Media? {
return try {
val data: T = dataSupplier()
val length: Long = getLength(data)
val uri: Uri = createBlobBuilder(AppDependencies.blobs, data, length)
.withMimeType(mimeType)
.createForSingleSessionOnDisk(context)
Media(
uri = uri,
contentType = mimeType,
date = System.currentTimeMillis(),
width = width,
height = height,
size = length,
duration = 0,
isBorderless = false,
isVideoGif = false,
bucketId = Media.ALL_MEDIA_BUCKET_ID,
caption = null,
transformProperties = null,
fileName = null
)
} catch (e: IOException) {
return null
}
}
}
@@ -1,109 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.models.media.Media
import org.signal.core.util.SeekableFileDescriptor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import java.util.concurrent.TimeUnit
class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : ViewModel() {
companion object {
private val TAG = Log.tag(MediaCaptureViewModel::class.java)
}
private val internalEvents: Subject<MediaCaptureEvent> = PublishSubject.create()
private val qrData: Subject<String> = PublishSubject.create()
val events: Observable<MediaCaptureEvent> = internalEvents.observeOn(AndroidSchedulers.mainThread())
val disposables = CompositeDisposable()
init {
disposables += qrData
.throttleFirst(5, TimeUnit.SECONDS)
.filter { UsernameRepository.isValidLink(it) }
.subscribeOn(Schedulers.io())
.flatMapSingle { url ->
UsernameRepository.fetchUsernameAndAciFromLink(url)
.map { result ->
when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(result.username.toString(), Recipient.externalUsername(result.aci, result.username.toString()))
is UsernameRepository.UsernameLinkConversionResult.Invalid,
is UsernameRepository.UsernameLinkConversionResult.NotFound,
is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.Failure
}
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data ->
if (data is QrScanResult.Success) {
internalEvents.onNext(MediaCaptureEvent.UsernameScannedFromQrCode(data.recipient, data.username))
} else {
Log.w(TAG, "Failed to scan QR code.")
}
}
disposables += qrData
.throttleFirst(5, TimeUnit.SECONDS)
.filter { it.startsWith("sgnl://linkdevice") && SignalStore.account.isPrimaryDevice }
.subscribe { data ->
internalEvents.onNext(MediaCaptureEvent.DeviceLinkScannedFromQrCode)
}
if (SignalStore.account.isRegistered) {
disposables += qrData
.throttleFirst(5, TimeUnit.SECONDS)
.filter { it.startsWith("sgnl://rereg") && QuickRegistrationRepository.isValidReRegistrationQr(it) && SignalStore.account.isPrimaryDevice }
.subscribe { data ->
internalEvents.onNext(MediaCaptureEvent.ReregistrationScannedFromQrCode(data))
}
}
}
override fun onCleared() {
disposables.dispose()
}
fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
repository.renderImageToMedia(data, width, height, this::onMediaRendered, this::onMediaRenderFailed)
}
fun onVideoCaptured(fd: SeekableFileDescriptor) {
repository.renderVideoToMedia(fd, this::onMediaRendered, this::onMediaRenderFailed)
}
fun onQrCodeFound(data: String) {
qrData.onNext(data)
}
private fun onMediaRendered(media: Media) {
internalEvents.onNext(MediaCaptureEvent.MediaCaptureRendered(media))
}
private fun onMediaRenderFailed() {
internalEvents.onNext(MediaCaptureEvent.MediaCaptureRenderFailed)
}
private sealed class QrScanResult {
data class Success(val username: String, val recipient: Recipient) : QrScanResult()
object Failure : QrScanResult()
}
class Factory(private val repository: MediaCaptureRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaCaptureViewModel(repository)))
}
}
}
@@ -1,63 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.documents
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.signal.core.models.media.Media
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.MediaSendDocumentFragment
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
private const val DOCUMENT_TAG = "media.send.document.fragment"
/**
* Fragment which ensures we fire off ResumeEntryTransition when viewing a document.
*/
class MediaReviewDocumentPageFragment : Fragment(R.layout.fragment_container) {
private lateinit var mediaSendDocumentFragment: MediaSendDocumentFragment
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mediaSendDocumentFragment = ensureFragment()
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
private fun ensureFragment(): MediaSendDocumentFragment {
val fragmentInManager: MediaSendDocumentFragment? = childFragmentManager.findFragmentByTag(DOCUMENT_TAG) as? MediaSendDocumentFragment
return if (fragmentInManager != null) {
fragmentInManager
} else {
val mediaSendDocumentFragment = MediaSendDocumentFragment.newInstance(requireMedia())
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
mediaSendDocumentFragment,
DOCUMENT_TAG
)
.commitAllowingStateLoss()
mediaSendDocumentFragment
}
}
private fun requireMedia(): Media = requireNotNull(requireArguments().getParcelableCompat(ARG_MEDIA, Media::class.java))
companion object {
private const val ARG_MEDIA = "arg.media"
fun newInstance(media: Media): Fragment {
return MediaReviewDocumentPageFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_MEDIA, media)
}
}
}
}
}
@@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.conversation.ManageContextMenu
import org.thoughtcrime.securesms.databinding.V2MediaGalleryFragmentBinding
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -1,9 +1,9 @@
/*
* Copyright 2025 Signal Messenger, LLC
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v2.review
package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.content.Context
import android.content.res.Resources
@@ -23,7 +23,6 @@ import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.glide.decryptableuri.DecryptableUri
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@@ -1,151 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.models.media.Media
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.review.MediaSelectionItemTouchHelper
import org.signal.core.ui.R as CoreUiR
private const val MEDIA_GALLERY_TAG = "MEDIA_GALLERY"
class MediaSelectionGalleryFragment : Fragment(R.layout.fragment_container), MediaGalleryFragment.Callbacks {
private lateinit var mediaGalleryFragment: MediaGalleryFragment
private val navigator = MediaSelectionNavigator(
toCamera = R.id.action_mediaGalleryFragment_to_mediaCaptureFragment
)
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = arguments
val isFirst = when {
args == null -> false
args.containsKey("suppressEmptyError") -> args.getBoolean("suppressEmptyError")
args.containsKey("first") -> args.getBoolean("first")
else -> false
}
lifecycleDisposable.bindTo(this)
sharedViewModel.setSuppressEmptyError(isFirst)
mediaGalleryFragment = ensureMediaGalleryFragment()
mediaGalleryFragment.bindSelectedMediaItemDragHelper(ItemTouchHelper(MediaSelectionItemTouchHelper(sharedViewModel)))
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
mediaGalleryFragment.onViewStateUpdated(
MediaGalleryFragment.ViewState(
selectedMedia = state.selectedMedia,
chatColor = state.recipient?.chatColors?.asSingleColor() ?: ContextCompat.getColor(requireContext(), CoreUiR.color.signal_light_colorPrimary)
)
)
}
lifecycleDisposable += sharedViewModel.mediaErrors
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
mediaGalleryFragment.onMediaErrorOccurred()
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onBackPressed()
}
}
)
}
private fun ensureMediaGalleryFragment(): MediaGalleryFragment {
val fragmentInManager: MediaGalleryFragment? = childFragmentManager.findFragmentByTag(MEDIA_GALLERY_TAG) as? MediaGalleryFragment
return if (fragmentInManager != null) {
fragmentInManager
} else {
val mediaGalleryFragment = MediaGalleryFragment()
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
mediaGalleryFragment,
MEDIA_GALLERY_TAG
)
.commitNowAllowingStateLoss()
mediaGalleryFragment
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun isMultiselectEnabled(): Boolean {
return true
}
override fun onMediaSelected(media: Media) {
sharedViewModel.addMedia(media)
}
override fun onMediaUnselected(media: Media) {
sharedViewModel.removeMedia(media)
}
override fun onMediaSelected(media: Set<Media>) {
sharedViewModel.addMedia(media)
}
override fun onMediaUnselected(media: Set<Media>) {
sharedViewModel.removeMedia(media)
}
override fun onSelectedMediaClicked(media: Media) {
sharedViewModel.onPageChanged(media)
navigator.goToReview(findNavController())
}
override fun onNavigateToCamera() {
val controller = findNavController()
navigator.goToCamera(controller)
}
override fun onSubmit() {
navigator.goToReview(findNavController())
}
override fun onToolbarNavigationClicked() {
onBackPressed()
}
fun onBackPressed() {
if (arguments?.containsKey("first") == true) {
requireActivity().finish()
return
}
if (navigator.isPreviousScreenMediaReview(findNavController()) && sharedViewModel.isSelectedMediaEmpty()) {
requireActivity().finish()
} else {
findNavController().popBackStack()
}
}
}
@@ -1,66 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.gif
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.MediaSendGifFragment
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
private const val GIF_TAG = "media.send.gif.fragment"
/**
* Fragment which ensures we fire off ResumeEntryTransition when viewing a non-video gif.
*/
class MediaReviewGifPageFragment : Fragment(R.layout.fragment_container) {
private lateinit var mediaSendGifFragment: MediaSendGifFragment
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mediaSendGifFragment = ensureGifFragment()
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
private fun ensureGifFragment(): MediaSendGifFragment {
val fragmentInManager: MediaSendGifFragment? = childFragmentManager.findFragmentByTag(GIF_TAG) as? MediaSendGifFragment
return if (fragmentInManager != null) {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
fragmentInManager
} else {
val mediaSendGifFragment = MediaSendGifFragment.newInstance(
requireUri()
)
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
mediaSendGifFragment,
GIF_TAG
)
.commitAllowingStateLoss()
mediaSendGifFragment
}
}
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelableCompat(ARG_URI, Uri::class.java))
companion object {
private const val ARG_URI = "arg.uri"
fun newInstance(uri: Uri): Fragment {
return MediaReviewGifPageFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_URI, uri)
}
}
}
}
}
@@ -1,165 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.images
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.scribbles.ImageEditorHudV2
import java.util.concurrent.TimeUnit
private const val IMAGE_EDITOR_TAG = "image.editor.fragment"
private val MODE_DELAY = TimeUnit.MILLISECONDS.toMillis(300)
/**
* Displays the chosen image within the image editor. Also manages the "touch enabled" state of the shared
* view model. We utilize delays here to help with Animation choreography.
*/
class MediaReviewImagePageFragment : Fragment(R.layout.fragment_container), ImageEditorFragment.Controller {
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
private var imageEditorFragment: ImageEditorFragment? = null
private var hudCommandDisposable: Disposable = Disposable.disposed()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
imageEditorFragment = ensureImageEditorFragment()
}
override fun onPause() {
super.onPause()
hudCommandDisposable.dispose()
}
override fun onResume() {
super.onResume()
hudCommandDisposable = sharedViewModel.hudCommands.subscribe { command ->
if (isResumed) {
when (command) {
HudCommand.StartDraw -> {
sharedViewModel.setTouchEnabled(false)
requireView().postDelayed(
{
imageEditorFragment?.setMode(ImageEditorHudV2.Mode.DRAW)
},
MODE_DELAY
)
}
HudCommand.StartCropAndRotate -> {
sharedViewModel.setTouchEnabled(false)
requireView().postDelayed(
{
imageEditorFragment?.setMode(ImageEditorHudV2.Mode.CROP)
},
MODE_DELAY
)
}
HudCommand.SaveMedia -> imageEditorFragment?.onSave()
else -> Unit
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
imageEditorFragment?.let {
sharedViewModel.setEditorState(requireUri(), requireNotNull(it.saveState()))
}
}
private fun ensureImageEditorFragment(): ImageEditorFragment {
val fragmentInManager: ImageEditorFragment? = childFragmentManager.findFragmentByTag(IMAGE_EDITOR_TAG) as? ImageEditorFragment
return if (fragmentInManager != null) {
fragmentInManager
} else {
val imageEditorFragment = ImageEditorFragment.newInstance(
requireUri()
)
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
imageEditorFragment,
IMAGE_EDITOR_TAG
)
.commitAllowingStateLoss()
imageEditorFragment
}
}
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelableCompat(ARG_URI, Uri::class.java))
override fun onTouchEventsNeeded(needed: Boolean) {
if (isResumed) {
if (!needed) {
requireView().postDelayed(
{
sharedViewModel.setTouchEnabled(true)
},
MODE_DELAY
)
} else {
sharedViewModel.setTouchEnabled(false)
}
}
}
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) = Unit
override fun onDoneEditing() {
imageEditorFragment?.setMode(ImageEditorHudV2.Mode.NONE)
if (isResumed) {
imageEditorFragment?.let {
sharedViewModel.setEditorState(requireUri(), requireNotNull(it.saveState()))
}
}
}
override fun onCancelEditing() {
restoreState()
}
override fun onMainImageLoaded() {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
override fun onMainImageFailedToLoad() {
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
}
override fun restoreState() {
val data = sharedViewModel.getEditorState(requireUri()) as? ImageEditorFragment.Data
if (data != null) {
imageEditorFragment?.restoreState(data)
} else {
imageEditorFragment?.onClearAll()
}
}
companion object {
private const val ARG_URI = "arg.uri"
fun newInstance(uri: Uri): Fragment {
return MediaReviewImagePageFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_URI, uri)
}
}
}
}
}
@@ -1,36 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
typealias OnAddMediaItemClicked = () -> Unit
object MediaReviewAddItem {
fun register(mappingAdapter: MappingAdapter, onAddMediaItemClicked: OnAddMediaItemClicked) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAddMediaItemClicked) }, R.layout.v2_media_review_add_media_item))
}
object Model : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return true
}
}
class ViewHolder(itemView: View, onAddMediaItemClicked: OnAddMediaItemClicked) : MappingViewHolder<Model>(itemView) {
init {
itemView.setOnClickListener { onAddMediaItemClicked() }
}
override fun bind(model: Model) = Unit
}
}
@@ -1,47 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.animation.Animator
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.view.View
import android.view.animation.Interpolator
import androidx.core.animation.doOnEnd
import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations
import org.thoughtcrime.securesms.util.visible
object MediaReviewAnimatorController {
fun getFadeInAnimator(view: View, isEnabled: Boolean = true): Animator {
view.visible = true
view.isEnabled = isEnabled
return ObjectAnimator.ofFloat(view, "alpha", view.alpha, 1f).apply {
interpolator = MediaAnimations.interpolator
}
}
fun getFadeOutAnimator(view: View, isEnabled: Boolean = false): Animator {
view.isEnabled = isEnabled
val animator = ObjectAnimator.ofFloat(view, "alpha", view.alpha, 0f).apply {
interpolator = MediaAnimations.interpolator
}
animator.doOnEnd { view.visible = false }
return animator
}
fun getHeightAnimator(view: View, start: Int, end: Int, interpolator: Interpolator = MediaAnimations.interpolator): Animator {
return ValueAnimator.ofInt(start, end).apply {
setInterpolator(interpolator)
addUpdateListener {
val animatedValue = it.animatedValue as Int
val layoutParams = view.layoutParams
layoutParams.height = animatedValue
view.layoutParams = layoutParams
}
duration = 120
}
}
}
@@ -1,892 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.animation.Animator
import android.animation.AnimatorSet
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.text.SpannableString
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.ViewSwitcher
import androidx.activity.OnBackPressedCallback
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.imageview.ShapeableImageView
import org.signal.core.models.media.Media
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.SentMediaQuality
import org.signal.mediasend.screens.edit.video.VideoThumbnailsRangeSelectorView
import org.signal.mediasend.screens.edit.video.VideoTrimData
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.ReenableScheduledMessagesDialogFragment
import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu
import org.thoughtcrime.securesms.conversation.ScheduleMessageDialogCallback
import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSheet
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardActivity
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords
import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.video.TranscodingConfig
import org.thoughtcrime.securesms.video.TranscodingQuality
import java.io.IOException
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.microseconds
import org.signal.core.ui.R as CoreUiR
import org.signal.mediasend.R as MediaSendR
/**
* Allows the user to view and edit selected media.
*/
class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), ScheduleMessageTimePickerBottomSheet.ScheduleCallback, ScheduleMessageDialogCallback, VideoThumbnailsRangeSelectorView.RangeDragListener, SafetyNumberBottomSheet.Callbacks {
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private lateinit var callback: Callback
private lateinit var drawToolButton: View
private lateinit var cropAndRotateButton: View
private lateinit var qualityButton: ImageView
private lateinit var muteVideoAudioButton: ImageView
private lateinit var saveButton: View
private lateinit var sendButton: ImageView
private lateinit var addMediaButton: View
private lateinit var viewOnceButton: ViewSwitcher
private lateinit var emojiButton: ShapeableImageView
private lateinit var addMessageButton: TextView
private lateinit var recipientDisplay: TextView
private lateinit var pager: ViewPager2
private lateinit var controls: ConstraintLayout
private lateinit var selectionRecycler: RecyclerView
private lateinit var controlsShade: View
private lateinit var videoTimeLine: VideoThumbnailsRangeSelectorView
private lateinit var videoSizeHint: TextView
private lateinit var videoTimelinePlaceholder: View
private lateinit var progress: ProgressBar
private lateinit var progressWrapper: TouchInterceptingFrameLayout
private val exclusionZone = listOf(Rect())
private val navigator = MediaSelectionNavigator(
toGallery = R.id.action_mediaReviewFragment_to_mediaGalleryFragment
)
private var animatorSet: AnimatorSet? = null
private var disposables: LifecycleDisposable = LifecycleDisposable()
private var sentMediaQuality: SentMediaQuality = SignalStore.settings.sentMediaQuality
private var viewOnceToggleState: MediaSelectionState.ViewOnceToggleState = MediaSelectionState.ViewOnceToggleState.default
private var scheduledSendTime: Long? = null
private var readyToSend = true
private val multiselectLauncher = registerForActivityResult(MultiselectForwardActivity.SelectionContract()) { keys ->
if (keys.isNotEmpty()) {
Log.d(TAG, "Performing send from multi-select activity result.")
performSend(keys)
} else {
readyToSend = true
}
}
private val storiesLauncher = registerForActivityResult(StoriesMultiselectForwardActivity.SelectionContract()) { keys ->
if (keys.isNotEmpty()) {
Log.d(TAG, "Performing send from stories activity result.")
performSend(keys)
} else {
readyToSend = true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
SystemWindowInsetsSetter.attach(view, viewLifecycleOwner, WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
disposables.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(AddMessageDialogFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
if (bundle.getBoolean(AddMessageDialogFragment.RESULT_INCREMENT_VIEW_ONCE_STATE)) {
sharedViewModel.setMessage(null)
sharedViewModel.incrementViewOnceState()
} else {
sharedViewModel.setMessage(bundle.getCharSequence(AddMessageDialogFragment.RESULT_MESSAGE, null))
}
}
callback = requireListener()
drawToolButton = view.findViewById(R.id.draw_tool)
cropAndRotateButton = view.findViewById(R.id.crop_and_rotate_tool)
qualityButton = view.findViewById(R.id.quality_selector)
muteVideoAudioButton = view.findViewById(R.id.mute_video_audio)
saveButton = view.findViewById(R.id.save_to_media)
sendButton = view.findViewById(R.id.send)
addMediaButton = view.findViewById(R.id.add_media)
viewOnceButton = view.findViewById(R.id.view_once_toggle)
emojiButton = view.findViewById(R.id.emoji_button)
addMessageButton = view.findViewById(R.id.add_a_message)
recipientDisplay = view.findViewById(R.id.recipient)
pager = view.findViewById(R.id.media_pager)
controls = view.findViewById(R.id.controls)
selectionRecycler = view.findViewById(R.id.selection_recycler)
controlsShade = view.findViewById(R.id.controls_shade)
progress = view.findViewById(R.id.progress)
progressWrapper = view.findViewById(R.id.progress_wrapper)
videoTimeLine = view.findViewById(R.id.video_timeline)
videoSizeHint = view.findViewById(R.id.video_size_hint)
videoTimelinePlaceholder = view.findViewById(R.id.timeline_placeholder)
DrawableCompat.setTint(progress.indeterminateDrawable, Color.WHITE)
progressWrapper.setOnInterceptTouchEventListener { true }
val pagerAdapter = MediaReviewFragmentPagerAdapter(this)
disposables += sharedViewModel.hudCommands.subscribe {
when (it) {
HudCommand.ResumeEntryTransition -> startPostponedEnterTransition()
else -> Unit
}
}
pager.adapter = pagerAdapter
controls.addOnLayoutChangeListener { v, left, _, right, _, _, _, _, _ ->
val outRect: Rect = exclusionZone[0]
videoTimeLine.getHitRect(outRect)
outRect.left = left
outRect.right = right
ViewCompat.setSystemGestureExclusionRects(v, exclusionZone)
}
drawToolButton.setOnClickListener {
sharedViewModel.sendCommand(HudCommand.StartDraw)
}
cropAndRotateButton.setOnClickListener {
sharedViewModel.sendCommand(HudCommand.StartCropAndRotate)
}
qualityButton.setOnClickListener {
QualitySelectorBottomSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
muteVideoAudioButton.setOnClickListener {
sharedViewModel.toggleVideoMuted()
}
saveButton.setOnClickListener {
sharedViewModel.sendCommand(HudCommand.SaveMedia)
}
sendButton.setOnClickListener {
if (!readyToSend) {
Log.d(TAG, "Attachment send button not currently enabled. Ignoring click event.")
return@setOnClickListener
} else {
Log.d(TAG, "Attachment send button enabled. Processing click event.")
readyToSend = false
}
val viewOnce: Boolean = sharedViewModel.state.value?.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE
if (sharedViewModel.isContactSelectionRequired) {
val args = MultiselectForwardFragmentArgs(
title = R.string.MediaReviewFragment__send_to,
storySendRequirements = sharedViewModel.getStorySendRequirements(),
isSearchEnabled = !sharedViewModel.isStory(),
isViewOnce = viewOnce
)
if (sharedViewModel.isStory()) {
val snapshot = sharedViewModel.state.value
if (snapshot != null) {
readyToSend = false
SimpleTask.run(viewLifecycleOwner.lifecycle, {
snapshot.selectedMedia.take(2).map { media ->
val editorData = snapshot.editorStateMap[media.uri]
if (MediaUtil.isImageType(media.contentType) && editorData != null && editorData is ImageEditorFragment.Data) {
val model = editorData.readModel()
if (model != null) {
ImageEditorFragment.renderToSingleSessionBlob(requireContext(), model)
} else {
media.uri
}
} else {
media.uri
}
}
}, {
storiesLauncher.launch(StoriesMultiselectForwardActivity.Args(args, it))
})
} else {
storiesLauncher.launch(StoriesMultiselectForwardActivity.Args(args, emptyList()))
}
scheduledSendTime = null
} else {
multiselectLauncher.launch(args)
}
} else if (sharedViewModel.isAddToGroupStoryFlow) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.MediaReviewFragment__add_to_the_group_story, sharedViewModel.state.value!!.recipient!!.getDisplayName(requireContext())))
.setPositiveButton(R.string.MediaReviewFragment__add_to_story) { _, _ ->
Log.d(TAG, "Performing send add to group story dialog.")
performSend()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
readyToSend = true
}
.setOnCancelListener {
readyToSend = true
}
.setOnDismissListener {
readyToSend = true
}
.show()
scheduledSendTime = null
} else {
Log.d(TAG, "Performing send from send button.")
performSend()
}
}
if (!sharedViewModel.isStory()) {
sendButton.setOnLongClickListener {
ScheduleMessageContextMenu.show(it, (requireView() as ViewGroup)) { time: Long ->
if (time == -1L) {
scheduledSendTime = null
ScheduleMessageTimePickerBottomSheet.showSchedule(childFragmentManager)
} else {
startScheduledSend(time)
}
}
true
}
}
addMediaButton.setOnClickListener {
launchGallery()
}
viewOnceButton.setOnClickListener {
sharedViewModel.incrementViewOnceState()
}
emojiButton.setOnClickListener {
sharedViewModel.state.value?.let { state ->
AddMessageDialogFragment.show(
parentFragmentManager,
state.message,
true,
state.selectedMedia.size == 1 && !state.isStory && !MediaUtil.isDocumentType(state.focusedMedia?.contentType),
sharedViewModel.destination.getRecipientSearchKey()?.recipientId
)
}
}
addMessageButton.setOnClickListener {
sharedViewModel.state.value?.let { state ->
AddMessageDialogFragment.show(
parentFragmentManager,
state.message,
false,
state.selectedMedia.size == 1 && !state.isStory && !MediaUtil.isDocumentType(state.focusedMedia?.contentType),
sharedViewModel.destination.getRecipientSearchKey()?.recipientId
)
}
}
if (sharedViewModel.isReply) {
addMessageButton.setText(R.string.MediaReviewFragment__add_a_reply)
}
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
qualityButton.alpha = 0f
saveButton.alpha = 0f
sharedViewModel.onPageChanged(position)
}
})
if (MediaConstraints.isVideoTranscodeAvailable()) {
videoTimeLine.registerEditorOnRangeChangeListener(this)
}
val selectionAdapter = MappingAdapter(false)
MediaReviewAddItem.register(selectionAdapter) {
launchGallery()
}
MediaReviewSelectedItem.register(selectionAdapter) { media, isSelected ->
if (isSelected) {
sharedViewModel.removeMedia(media)
} else {
sharedViewModel.onPageChanged(media)
}
}
selectionRecycler.adapter = selectionAdapter
ItemTouchHelper(MediaSelectionItemTouchHelper(sharedViewModel)).attachToRecyclerView(selectionRecycler)
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
pagerAdapter.submitMedia(state.selectedMedia)
selectionAdapter.submitList(
state.selectedMedia.map {
val trimStartTimeUs = (state.editorStateMap[it.uri] as? VideoTrimData)?.startTimeUs ?: 0L
MediaReviewSelectedItem.Model(it, state.focusedMedia == it, trimStartTimeUs)
} + MediaReviewAddItem.Model
)
presentSendButton(readyToSend, state.sendType, state.recipient)
presentPager(state)
presentAddMessageEntry(state.viewOnceToggleState, state.message)
presentImageQualityToggle(state)
presentMuteVideoAudioToggle(state)
if (state.quality != sentMediaQuality) {
presentQualityToggleToast(state)
}
sentMediaQuality = state.quality
viewOnceButton.displayedChild = if (state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE) 1 else 0
if (state.viewOnceToggleState != viewOnceToggleState &&
state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE &&
state.selectedMedia.size == 1
) {
presentViewOnceToggleToast(MediaUtil.isNonGifVideo(state.selectedMedia[0]))
}
viewOnceToggleState = state.viewOnceToggleState
presentVideoTimeline(state)
presentVideoSizeHint(state)
computeViewStateAndAnimate(state)
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
callback.onPopFromReview()
}
}
)
}
private fun presentViewOnceToggleToast(isVideo: Boolean) {
val description = if (isVideo) {
getString(R.string.MediaReviewFragment__video_set_to_view_once)
} else {
getString(R.string.MediaReviewFragment__photo_set_to_view_once)
}
MediaReviewToastPopupWindow.show(controls, CoreUiR.drawable.symbol_view_once_24, description)
}
private fun presentQualityToggleToast(state: MediaSelectionState) {
val mediaList = state.selectedMedia
if (mediaList.isEmpty()) {
return
}
val description = if (mediaList.size == 1) {
val media: Media = mediaList[0]
if (MediaUtil.isNonGifVideo(media)) {
if (state.quality == SentMediaQuality.HIGH) {
getString(MediaSendR.string.MediaReviewFragment__video_set_to_high_quality)
} else {
getString(MediaSendR.string.MediaReviewFragment__video_set_to_standard_quality)
}
} else if (MediaUtil.isImageType(media.contentType)) {
if (state.quality == SentMediaQuality.HIGH) {
getString(MediaSendR.string.MediaReviewFragment__photo_set_to_high_quality)
} else {
getString(MediaSendR.string.MediaReviewFragment__photo_set_to_standard_quality)
}
} else {
Log.i(TAG, "Could not display quality toggle toast for attachment of type: ${media.contentType}")
return
}
} else {
if (state.quality == SentMediaQuality.HIGH) {
resources.getQuantityString(MediaSendR.plurals.MediaReviewFragment__items_set_to_high_quality, mediaList.size, mediaList.size)
} else {
resources.getQuantityString(MediaSendR.plurals.MediaReviewFragment__items_set_to_standard_quality, mediaList.size, mediaList.size)
}
}
val icon = when (state.quality) {
SentMediaQuality.HIGH -> CoreUiR.drawable.symbol_quality_high_24
else -> CoreUiR.drawable.symbol_quality_high_slash_24
}
MediaReviewToastPopupWindow.show(controls, icon, description)
}
override fun onResume() {
super.onResume()
sharedViewModel.kick()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
private fun launchGallery() {
val controller = findNavController()
navigator.goToGallery(controller)
}
private fun performSend(selection: List<ContactSearchKey> = listOf()) {
Log.d(TAG, "Performing attachment send.")
readyToSend = false
progressWrapper.visible = true
progressWrapper.animate()
.setStartDelay(300)
.setInterpolator(MediaAnimations.interpolator)
.alpha(1f)
sharedViewModel
.send(selection.filterIsInstance<ContactSearchKey.RecipientSearchKey>(), scheduledSendTime)
.subscribe(
{ result ->
callback.onSentWithResult(result)
readyToSend = true
},
{ error ->
if (error is UntrustedRecords.UntrustedRecordsException) {
Log.w(TAG, "Send failed due to untrusted identities.")
hideSendProgress()
SafetyNumberBottomSheet
.forIdentityRecordsAndDestinations(error.untrustedRecords, error.destinations.toList())
.show(childFragmentManager)
} else {
callback.onSendError(error)
}
readyToSend = true
},
{
callback.onSentWithoutResult()
readyToSend = true
}
)
}
private fun hideSendProgress() {
progressWrapper.animate().cancel()
progressWrapper.alpha = 0f
progressWrapper.visible = false
}
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
performSend(destinations)
}
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() {
error("Unsupported, we do not hand in a message id.")
}
override fun onCanceled() = Unit
private fun presentAddMessageEntry(viewOnceState: MediaSelectionState.ViewOnceToggleState, message: CharSequence?) {
when (viewOnceState) {
MediaSelectionState.ViewOnceToggleState.INFINITE -> {
addMessageButton.gravity = Gravity.CENTER_VERTICAL
addMessageButton.text = SpannableString(message.takeIf { it.isNotNullOrBlank() } ?: getString(R.string.MediaReviewFragment__add_a_message))
addMessageButton.isClickable = true
}
MediaSelectionState.ViewOnceToggleState.ONCE -> {
addMessageButton.gravity = Gravity.CENTER
addMessageButton.setText(R.string.MediaReviewFragment__view_once_message)
addMessageButton.isClickable = false
}
}
}
private fun presentImageQualityToggle(state: MediaSelectionState) {
qualityButton.updateLayoutParams<ConstraintLayout.LayoutParams> {
if (MediaUtil.isImageAndNotGif(state.focusedMedia?.contentType ?: "")) {
startToStart = ConstraintLayout.LayoutParams.UNSET
startToEnd = cropAndRotateButton.id
} else {
startToStart = ConstraintLayout.LayoutParams.PARENT_ID
startToEnd = ConstraintLayout.LayoutParams.UNSET
}
}
qualityButton.setImageResource(
when (state.quality) {
SentMediaQuality.STANDARD -> CoreUiR.drawable.symbol_quality_high_slash_24
SentMediaQuality.HIGH -> CoreUiR.drawable.symbol_quality_high_24
}
)
}
private fun presentMuteVideoAudioToggle(state: MediaSelectionState) {
val muted = state.focusedMedia?.uri?.let { state.getOrCreateVideoTrimData(it).isMuted } == true
muteVideoAudioButton.setImageResource(
if (muted) CoreUiR.drawable.symbol_speaker_slash_24 else CoreUiR.drawable.symbol_speaker_24
)
}
private fun presentSendButton(enabled: Boolean, sendType: MessageSendType, recipient: Recipient?) {
val sendButtonBackgroundTint = when {
!enabled -> ContextCompat.getColor(requireContext(), R.color.core_grey_50)
recipient != null -> recipient.chatColors.asSingleColor()
sendType.usesSignalTransport -> ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorOnSecondaryContainer)
else -> ContextCompat.getColor(requireContext(), R.color.core_grey_50)
}
val sendButtonForegroundDrawable = when {
recipient != null -> ContextCompat.getDrawable(requireContext(), R.drawable.symbol_send_fill_24)
else -> ContextCompat.getDrawable(requireContext(), R.drawable.symbol_arrow_end_24)
}
val sendButtonForegroundTint = when {
!enabled -> ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorSecondaryContainer)
recipient != null -> ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorOnCustom)
else -> ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorSecondaryContainer)
}
sendButton.setImageDrawable(sendButtonForegroundDrawable)
sendButton.setColorFilter(sendButtonForegroundTint)
ViewCompat.setBackgroundTintList(sendButton, ColorStateList.valueOf(sendButtonBackgroundTint))
}
private fun presentPager(state: MediaSelectionState) {
pager.isUserInputEnabled = state.isTouchEnabled
val indexOfSelectedItem = state.selectedMedia.indexOf(state.focusedMedia)
if (pager.currentItem == indexOfSelectedItem) {
return
}
if (indexOfSelectedItem != -1) {
pager.setCurrentItem(indexOfSelectedItem, false)
} else {
pager.setCurrentItem(0, false)
}
}
private fun presentVideoTimeline(state: MediaSelectionState) {
val mediaItem = state.focusedMedia ?: return
if (!MediaUtil.isVideoType(mediaItem.contentType) || !MediaConstraints.isVideoTranscodeAvailable()) {
return
}
val uri = mediaItem.uri
val updatedInputInTimeline = videoTimeLine.setInput(uri, DecryptableUriMediaInput)
if (updatedInputInTimeline) {
videoTimeLine.unregisterDragListener()
}
val size: Long = tryGetUriSize(requireContext(), uri, Long.MAX_VALUE)
val maxSend = sharedViewModel.getMediaConstraints().editorVideoMaxSize
if (size > maxSend) {
videoTimeLine.setTimeLimit(TranscodingConfig.calculateMaxVideoUploadDurationInSeconds(state.transcodingConfigs, state.getOrCreateVideoTrimData(uri).totalInputDurationUs.microseconds), TimeUnit.SECONDS)
}
if (state.isTouchEnabled) {
val data = state.getOrCreateVideoTrimData(uri)
if (data.totalInputDurationUs > 0) {
videoTimeLine.setRange(data.startTimeUs, data.endTimeUs)
}
}
}
private fun presentVideoSizeHint(state: MediaSelectionState) {
val focusedMedia = state.focusedMedia ?: return
val trimData = state.getOrCreateVideoTrimData(focusedMedia.uri)
videoSizeHint.text = if (state.isVideoTrimmingVisible) {
val seconds = trimData.getDuration().inWholeSeconds
val bytes = TranscodingQuality.createFromQualityTiers(state.transcodingConfigs, trimData.getDuration().inWholeMilliseconds).byteCountEstimate
String.format(Locale.getDefault(), "%d:%02d • %s", seconds / 60, seconds % 60, bytes.bytes.toUnitString())
} else {
null
}
}
private fun computeViewStateAndAnimate(state: MediaSelectionState) {
this.animatorSet?.cancel()
val animators = mutableListOf<Animator>()
animators.addAll(computeAddMessageAnimators(state))
animators.addAll(computeEmojiButtonAnimators(state))
animators.addAll(computeViewOnceButtonAnimators(state))
animators.addAll(computeAddMediaButtonsAnimators(state))
animators.addAll(computeSendButtonAnimators(state))
animators.addAll(computeSaveButtonAnimators(state))
animators.addAll(computeQualityButtonAnimators(state))
animators.addAll(computeMuteVideoAudioButtonAnimators(state))
animators.addAll(computeCropAndRotateButtonAnimators(state))
animators.addAll(computeDrawToolButtonAnimators(state))
animators.addAll(computeRecipientDisplayAnimators(state))
animators.addAll(computeControlsShadeAnimators(state))
animators.addAll(computeVideoTimelineAnimator(state))
val animatorSet = AnimatorSet()
animatorSet.playTogether(animators)
animatorSet.start()
this.animatorSet = animatorSet
}
private fun computeControlsShadeAnimators(state: MediaSelectionState): List<Animator> {
val animators = mutableListOf<Animator>()
animators += if (state.isTouchEnabled) {
MediaReviewAnimatorController.getFadeInAnimator(controlsShade)
} else {
MediaReviewAnimatorController.getFadeOutAnimator(controlsShade)
}
animators += if (state.isVideoTrimmingVisible) {
MediaReviewAnimatorController.getHeightAnimator(videoTimelinePlaceholder, videoTimelinePlaceholder.height, resources.getDimension(R.dimen.video_timeline_height_expanded).roundToInt())
} else {
MediaReviewAnimatorController.getHeightAnimator(videoTimelinePlaceholder, videoTimelinePlaceholder.height, resources.getDimension(R.dimen.video_timeline_height_collapsed).roundToInt())
}
return animators
}
private fun computeVideoTimelineAnimator(state: MediaSelectionState): List<Animator> {
val animators = mutableListOf<Animator>()
if (state.isVideoTrimmingVisible) {
animators += MediaReviewAnimatorController.getFadeInAnimator(videoTimeLine).apply {
startDelay = 100
duration = 500
}
} else {
animators += MediaReviewAnimatorController.getFadeOutAnimator(videoTimeLine).apply {
duration = 400
}
}
animators += if (state.isVideoTrimmingVisible && state.isTouchEnabled) {
MediaReviewAnimatorController.getFadeInAnimator(videoSizeHint).apply {
startDelay = 100
duration = 500
}
} else {
MediaReviewAnimatorController.getFadeOutAnimator(videoSizeHint).apply {
duration = 400
}
}
return animators
}
private fun computeAddMessageAnimators(state: MediaSelectionState): List<Animator> {
return if (!state.isTouchEnabled) {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton)
)
} else {
listOf(
MediaReviewAnimatorController.getFadeInAnimator(addMessageButton)
)
}
}
private fun computeViewOnceButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && state.selectedMedia.size == 1 && !state.isStory && !MediaUtil.isDocumentType(state.focusedMedia?.contentType)) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(viewOnceButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(viewOnceButton))
}
}
private fun computeEmojiButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && state.viewOnceToggleState != MediaSelectionState.ViewOnceToggleState.ONCE) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(emojiButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(emojiButton))
}
}
private fun computeAddMediaButtonsAnimators(state: MediaSelectionState): List<Animator> {
return when {
!state.isTouchEnabled || state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE || MediaUtil.isDocumentType(state.focusedMedia?.contentType) -> {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(addMediaButton),
MediaReviewAnimatorController.getFadeOutAnimator(selectionRecycler)
)
}
state.selectedMedia.size > 1 -> {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(addMediaButton),
MediaReviewAnimatorController.getFadeInAnimator(selectionRecycler)
)
}
else -> {
listOf(
MediaReviewAnimatorController.getFadeInAnimator(addMediaButton),
MediaReviewAnimatorController.getFadeOutAnimator(selectionRecycler)
)
}
}
}
private fun computeSendButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled) {
listOf(
MediaReviewAnimatorController.getFadeInAnimator(sendButton, isEnabled = state.canSend)
)
} else {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(sendButton, isEnabled = state.canSend)
)
}
}
private fun computeSaveButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && !MediaUtil.isVideo(state.focusedMedia?.contentType) && !MediaUtil.isDocumentType(state.focusedMedia?.contentType)) {
listOf(
MediaReviewAnimatorController.getFadeInAnimator(saveButton)
)
} else {
listOf(
MediaReviewAnimatorController.getFadeOutAnimator(saveButton)
)
}
}
private fun computeQualityButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && !state.isStory && !MediaUtil.isDocumentType(state.focusedMedia?.contentType)) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(qualityButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(qualityButton))
}
}
private fun computeMuteVideoAudioButtonAnimators(state: MediaSelectionState): List<Animator> {
val focusedMedia = state.focusedMedia
return if (state.isTouchEnabled && MediaConstraints.isMuteVideoAudioAvailable() && focusedMedia != null && MediaUtil.isNonGifVideo(focusedMedia)) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(muteVideoAudioButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(muteVideoAudioButton))
}
}
private fun computeCropAndRotateButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.contentType ?: "")) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(cropAndRotateButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(cropAndRotateButton))
}
}
private fun computeDrawToolButtonAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.contentType ?: "")) {
listOf(MediaReviewAnimatorController.getFadeInAnimator(drawToolButton))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(drawToolButton))
}
}
private fun computeRecipientDisplayAnimators(state: MediaSelectionState): List<Animator> {
return if (state.isTouchEnabled && state.recipient != null) {
recipientDisplay.text = if (state.recipient.isSelf) requireContext().getString(R.string.note_to_self) else state.recipient.getDisplayName(requireContext())
listOf(MediaReviewAnimatorController.getFadeInAnimator(recipientDisplay))
} else {
listOf(MediaReviewAnimatorController.getFadeOutAnimator(recipientDisplay))
}
}
companion object {
private val TAG = Log.tag(MediaReviewFragment::class.java)
@JvmStatic
private fun tryGetUriSize(context: Context, uri: Uri, defaultValue: Long): Long {
return try {
var size: Long = 0
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) {
size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
}
}
if (size <= 0) {
size = MediaUtil.getMediaSize(context, uri)
}
size
} catch (e: IOException) {
Log.w(TAG, e)
defaultValue
}
}
}
interface Callback {
fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult)
fun onSentWithoutResult()
fun onSendError(error: Throwable)
fun onNoMediaSelected()
fun onPopFromReview()
}
override fun onScheduleSend(scheduledTime: Long) {
startScheduledSend(scheduledTime)
}
override fun onSchedulePermissionsGranted(metricId: String?, scheduledDate: Long) {
scheduledSendTime = scheduledDate
sendButton.performClick()
}
private fun startScheduledSend(scheduledTime: Long) {
if (ReenableScheduledMessagesDialogFragment.showIfNeeded(requireContext(), childFragmentManager, null, scheduledTime)) {
return
}
scheduledSendTime = scheduledTime
sendButton.performClick()
}
override fun onRangeDrag(minValue: Long, maxValue: Long, duration: Long, end: Boolean) {
sharedViewModel.onEditVideoDuration(totalDurationUs = duration, startTimeUs = minValue, endTimeUs = maxValue, touchEnabled = end)
}
}
@@ -1,73 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.mediasend.v2.documents.MediaReviewDocumentPageFragment
import org.thoughtcrime.securesms.mediasend.v2.gif.MediaReviewGifPageFragment
import org.thoughtcrime.securesms.mediasend.v2.images.MediaReviewImagePageFragment
import org.thoughtcrime.securesms.mediasend.v2.videos.MediaReviewVideoPageFragment
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.LinkedList
class MediaReviewFragmentPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
private val mediaList: MutableList<Media> = mutableListOf()
fun submitMedia(media: List<Media>) {
val oldMedia: List<Media> = LinkedList(mediaList)
mediaList.clear()
mediaList.addAll(media)
DiffUtil
.calculateDiff(Callback(oldMedia, mediaList))
.dispatchUpdatesTo(this)
}
override fun getItemId(position: Int): Long {
if (position > mediaList.size || position < 0) {
return RecyclerView.NO_ID
}
return mediaList[position].uri.hashCode().toLong()
}
override fun containsItem(itemId: Long): Boolean {
return mediaList.any { it.uri.hashCode().toLong() == itemId }
}
override fun getItemCount(): Int = mediaList.size
override fun createFragment(position: Int): Fragment {
val mediaItem: Media = mediaList[position]
return when {
MediaUtil.isGif(mediaItem.contentType) -> MediaReviewGifPageFragment.newInstance(mediaItem.uri)
MediaUtil.isImageType(mediaItem.contentType) -> MediaReviewImagePageFragment.newInstance(mediaItem.uri)
MediaUtil.isVideoType(mediaItem.contentType) -> MediaReviewVideoPageFragment.newInstance(mediaItem.uri, mediaItem.isVideoGif)
MediaUtil.isDocumentType(mediaItem.contentType) -> MediaReviewDocumentPageFragment.newInstance(mediaItem)
else -> {
throw UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.contentType + "'")
}
}
}
private class Callback(
private val oldList: List<Media>,
private val newList: List<Media>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition].uri == newList[newItemPosition].uri
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition] == newList[newItemPosition]
}
}
}
@@ -1,57 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.review
import android.view.View
import android.widget.ImageView
import com.bumptech.glide.Glide
import org.signal.core.models.media.Media
import org.signal.glide.decryptableuri.DecryptableUri
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
typealias OnSelectedMediaClicked = (Media, Boolean) -> Unit
object MediaReviewSelectedItem {
fun register(mappingAdapter: MappingAdapter, onSelectedMediaClicked: OnSelectedMediaClicked) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onSelectedMediaClicked) }, R.layout.v2_media_review_selected_item))
}
class Model(val media: Media, val isSelected: Boolean, val videoTrimStartTimeUs: Long = 0) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return media == newItem.media
}
override fun areContentsTheSame(newItem: Model): Boolean {
return media == newItem.media && isSelected == newItem.isSelected && videoTrimStartTimeUs == newItem.videoTrimStartTimeUs
}
}
class ViewHolder(itemView: View, private val onSelectedMediaClicked: OnSelectedMediaClicked) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.media_review_selected_image)
private val playOverlay: ImageView = itemView.findViewById(R.id.media_review_play_overlay)
private val trashOverlay: ImageView = itemView.findViewById(R.id.media_review_trash_overlay)
override fun bind(model: Model) {
Glide.with(imageView)
.load(DecryptableUri(model.media.uri, model.videoTrimStartTimeUs))
.centerCrop()
.into(imageView)
playOverlay.visible = MediaUtil.isNonGifVideo(model.media) && !model.isSelected
trashOverlay.visible = model.isSelected
itemView.contentDescription = if (model.isSelected) {
context.getString(R.string.MediaReviewSelectedItem__tap_to_remove)
} else {
context.getString(R.string.MediaReviewSelectedItem__tap_to_select)
}
itemView.setOnClickListener { onSelectedMediaClicked(model.media, model.isSelected) }
}
}
}
@@ -1,50 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v2.review
import android.view.Gravity
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.TextView
import org.thoughtcrime.securesms.R
import kotlin.time.Duration.Companion.seconds
/**
* Toast-style notification used in the media review flow. This exists so we can specify the location and animation of how it appears.
*/
class MediaReviewToastPopupWindow private constructor(parent: ViewGroup, iconResource: Int, descriptionText: String) : PopupWindow(
LayoutInflater.from(parent.context).inflate(R.layout.v2_media_review_quality_popup_window, parent, false),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
) {
private val icon: ImageView = contentView.findViewById(R.id.media_review_toast_popup_icon)
private val description: TextView = contentView.findViewById(R.id.media_review_toast_popup_description)
init {
animationStyle = R.style.StickerPopupAnimation
icon.setImageResource(iconResource)
description.text = descriptionText
}
private fun show(parent: ViewGroup) {
showAtLocation(parent, Gravity.CENTER, 0, 0)
contentView.postDelayed({ dismiss() }, DURATION)
}
companion object {
private val DURATION = 3.seconds.inWholeMilliseconds
@JvmStatic
fun show(parent: ViewGroup, icon: Int, description: String): MediaReviewToastPopupWindow {
val qualityToast = MediaReviewToastPopupWindow(parent, icon, description)
qualityToast.show(parent)
return qualityToast
}
}
}
@@ -1,54 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.review;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel;
/**
* A touch helper for handling drag + drop on the media rail in the media send flow.
*/
public class MediaSelectionItemTouchHelper extends ItemTouchHelper.Callback {
private final MediaSelectionViewModel viewModel;
public MediaSelectionItemTouchHelper(MediaSelectionViewModel viewModel) {
this.viewModel = viewModel;
}
@Override
public boolean isLongPressDragEnabled() {
return true;
}
@Override
public boolean isItemViewSwipeEnabled() {
return false;
}
@Override
public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (viewModel.isValidMediaDragPosition(viewHolder.getAdapterPosition())) {
int dragFlags = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
return makeMovementFlags(dragFlags, 0);
} else {
return 0;
}
}
@Override
public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
return viewModel.swapMedia(viewHolder.getAdapterPosition(), target.getAdapterPosition());
}
@Override
public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
super.clearView(recyclerView, viewHolder);
viewModel.onMediaDragFinished();
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
}
}
@@ -1,49 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v2.review
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.mediasend.screens.edit.QualitySelectorSheetContent
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
/**
* Bottom sheet dialog to select the media quality (Standard vs. High) when sending media.
*/
class QualitySelectorBottomSheet : ComposeBottomSheetDialogFragment() {
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
override val forceDarkTheme = true
@Composable
override fun SheetContent() {
val state by sharedViewModel.state.observeAsState()
val quality = state?.quality
if (quality != null) {
Column {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) {
BottomSheets.Handle(modifier = Modifier.padding(top = 6.dp))
}
QualitySelectorSheetContent(quality = quality, onQualitySelected = {
sharedViewModel.setSentMediaQuality(it)
dismiss()
})
}
}
}
}
@@ -44,7 +44,6 @@ import org.thoughtcrime.securesms.databinding.StoriesTextPostCreationFragmentBin
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewState
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult
import org.thoughtcrime.securesms.recipients.Recipient
@@ -1,113 +0,0 @@
package org.thoughtcrime.securesms.mediasend.v2.videos
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import org.signal.core.util.getParcelableCompat
import org.signal.mediasend.screens.edit.video.VideoEditorFragment
import org.signal.mediasend.screens.edit.video.VideoEditorViewModel
import org.signal.mediasend.screens.edit.video.VideoThumbnailsRangeSelectorView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
private const val VIDEO_EDITOR_TAG = "video.editor.fragment"
/**
* Page fragment which displays a single editable video (non-gif) to the user. Has an embedded MediaSendVideoFragment
* and adds some extra support for saving and restoring state, as well as saving a video to disk.
*/
class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container) {
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
private val videoEditorViewModel: VideoEditorViewModel by viewModels(ownerProducer = { requireActivity() })
private lateinit var videoEditorFragment: VideoEditorFragment
private lateinit var videoTimeLine: VideoThumbnailsRangeSelectorView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
videoTimeLine = requireActivity().findViewById(R.id.video_timeline)
videoEditorFragment = ensureVideoEditorFragment()
videoTimeLine.registerPlayerDragListener(object : VideoThumbnailsRangeSelectorView.PositionDragListener {
override fun onPositionDrag(position: Long) {
focusedUri()?.let { videoEditorViewModel.sendCommand(it, VideoEditorViewModel.Command.PositionDrag(position)) }
}
override fun onEndPositionDrag(position: Long) {
focusedUri()?.let { videoEditorViewModel.sendCommand(it, VideoEditorViewModel.Command.EndPositionDrag(position)) }
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
videoEditorViewModel.events(requireUri()).collect { event ->
when (event) {
VideoEditorViewModel.Event.PlayerReady, VideoEditorViewModel.Event.PlayerError -> sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
is VideoEditorViewModel.Event.TouchEventsNeeded -> sharedViewModel.setTouchEnabled(!event.needed)
is VideoEditorViewModel.Event.ActualPositionChanged -> videoTimeLine.setActualPosition(event.positionUs)
}
}
}
}
sharedViewModel.state.observe(viewLifecycleOwner) { incomingState ->
videoEditorFragment.onStateUpdate(
incomingState.focusedMedia?.uri,
incomingState.isTouchEnabled,
incomingState::getOrCreateVideoTrimData
)
}
}
private fun focusedUri(): Uri? = sharedViewModel.state.value?.focusedMedia?.uri
private fun ensureVideoEditorFragment(): VideoEditorFragment {
val fragmentInManager: VideoEditorFragment? = childFragmentManager.findFragmentByTag(VIDEO_EDITOR_TAG) as? VideoEditorFragment
return if (fragmentInManager != null) {
fragmentInManager
} else {
val videoEditorFragment = VideoEditorFragment.newInstance(
requireUri(),
requireMaxAttachmentSize(),
requireIsVideoGif()
)
childFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
videoEditorFragment,
VIDEO_EDITOR_TAG
)
.commitAllowingStateLoss()
videoEditorFragment
}
}
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelableCompat(ARG_URI, Uri::class.java))
private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize()
private fun requireIsVideoGif(): Boolean = requireNotNull(requireArguments().getBoolean(ARG_IS_VIDEO_GIF))
companion object {
private const val ARG_URI = "arg.uri"
private const val ARG_IS_VIDEO_GIF = "arg.is.video.gif"
fun newInstance(uri: Uri, isVideoGif: Boolean): Fragment {
return MediaReviewVideoPageFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_URI, uri)
putBoolean(ARG_IS_VIDEO_GIF, isVideoGif)
}
}
}
}
}
@@ -265,7 +265,7 @@ public class AttachmentManager {
.request(PermissionCompat.forImagesAndVideos())
.ifNecessary()
.withPermanentDenialDialog(fragment.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.onAllGranted(() -> fragment.startActivityForResult(MediaSendLauncher.gallery(fragment.requireContext(), messageSendType, Collections.emptyList(), recipient.getId(), body, hasQuote), requestCode))
.onAllGranted(() -> fragment.startActivityForResult(MediaSendLauncher.gallery(fragment.requireContext(), Collections.emptyList(), recipient.getId(), body, hasQuote), requestCode))
.execute();
}
@@ -59,7 +59,6 @@ import org.thoughtcrime.securesms.attachments.AttachmentSaver;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.fonts.FontTypefaceProvider;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment;
import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations;
import org.thoughtcrime.securesms.mms.PushMediaConstraints;
import org.thoughtcrime.securesms.scribbles.stickers.AnalogClockStickerRenderer;
@@ -85,7 +84,6 @@ import kotlin.Pair;
import static android.app.Activity.RESULT_OK;
public final class ImageEditorFragment extends Fragment implements ImageEditorHudV2.EventListener,
MediaSendPageFragment,
TextEntryDialogFragment.Controller
{
@@ -313,25 +311,21 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressedCallback);
}
@Override
public void setUri(@NonNull Uri uri) {
this.imageUri = uri;
}
@NonNull
@Override
public Uri getUri() {
return imageUri;
}
@Override
public Object saveState() {
Data data = new Data();
data.writeModel(imageEditorView.getModel());
return data;
}
@Override
public void restoreState(@NonNull Object state) {
if (state instanceof Data) {
@@ -351,10 +345,6 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
}
}
@Override
public void notifyHidden() {
}
private void changeEntityColor(int selectedColor) {
if (currentSelection != null) {
Renderer renderer = currentSelection.getRenderer();
@@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.mediasend.MediaSendLauncher.share
@@ -326,7 +325,6 @@ class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.C
val intent = share(
this,
MessageSendType.SignalMessageSendType,
media,
multiShareArgs.recipientSearchKeys.toList(),
multiShareArgs.draftText,
@@ -1413,15 +1413,6 @@ object RemoteConfig {
hotSwappable = true
)
/** Whether to utilize the new media-send feature module */
@JvmStatic
@get:JvmName("useNewMediaSendFlow")
val useNewMediaSendFlow: Boolean by remoteBoolean(
key = "android.useNewMediaSendFlow.2",
defaultValue = false,
hotSwappable = true
)
/** Whether to enable Jetpack telecom integration for 1:1 calls */
@JvmStatic
@get:JvmName("useJetPackTelecom")
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
<translate
android:interpolator="@android:anim/decelerate_interpolator"
android:duration="250"
android:fromYDelta="0%"
android:toYDelta="100%" />
</set>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<layer-list>
<item android:drawable="@color/signal_dark_colorSurfaceVariant" />
<item>
<ripple android:color="@color/transparent_white_10" />
</item>
</layer-list>
</item>
<item android:drawable="@color/signal_dark_colorSurfaceVariant" android:state_pressed="false" />
</selector>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item app:state_flash_auto="true" android:drawable="@drawable/symbol_flash_auto_24" />
<item app:state_flash_off="true" android:drawable="@drawable/symbol_flash_slash_24" />
<item app:state_flash_on="true" android:drawable="@drawable/symbol_flash_24" />
</selector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M20.634,1.37C19.5,0.245 17.927,0 15.719,0H6.195C4.073,0 2.5,0.245 1.366,1.383C0.244,2.508 0,4.075 0,6.216V15.711C0,17.926 0.232,19.492 1.354,20.617C2.5,21.755 4.061,22 6.268,22H15.719C17.927,22 19.512,21.755 20.634,20.617C21.768,19.492 22,17.926 22,15.711V6.289C22,4.062 21.768,2.496 20.634,1.37ZM20.488,5.959V16.029C20.488,17.485 20.268,18.757 19.524,19.504C18.781,20.263 17.488,20.483 16.049,20.483H5.939C4.512,20.483 3.22,20.25 2.463,19.504C1.72,18.757 1.512,17.485 1.512,16.029V6.032C1.512,4.527 1.72,3.23 2.463,2.484C3.207,1.725 4.524,1.505 6.024,1.505H16.049C17.488,1.505 18.781,1.737 19.524,2.484C20.268,3.242 20.488,4.515 20.488,5.959ZM5.695,10.988C5.695,11.428 6.037,11.759 6.5,11.759H10.219V15.491C10.219,15.955 10.549,16.286 10.988,16.286C11.463,16.286 11.793,15.968 11.793,15.491V11.759H15.512C15.976,11.759 16.305,11.428 16.305,10.988C16.305,10.511 15.988,10.18 15.512,10.18H11.793V6.448C11.793,5.971 11.463,5.641 10.988,5.641C10.549,5.641 10.219,5.983 10.219,6.448V10.18H6.5C6.024,10.18 5.695,10.511 5.695,10.988Z"
android:fillColor="@color/signal_dark_colorOnSurface"/>
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<layer-list>
<item android:drawable="@color/signal_dark_colorSurfaceVariant" />
<item android:drawable="@color/transparent_white_10" />
</layer-list>
</item>
<item android:drawable="@color/signal_dark_colorSurfaceVariant" android:state_pressed="false" />
</selector>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/toggle_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal" />
</FrameLayout>
@@ -1,56 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="70dp"
android:minHeight="94dp"
android:src="@drawable/ic_document_large" />
<TextView
android:id="@+id/extension"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="@color/signal_light_colorOnSurface"
style="@style/Signal.Text.Caption"
tools:text="pdf" />
</FrameLayout>
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="4dp"
android:layout_marginHorizontal="16dp"
android:singleLine="true"
android:ellipsize="middle"
android:textColor="@color/signal_colorOnSurface"
android:gravity="center"
style="@style/Signal.Text.BodyLarge"
android:letterSpacing="0"
tools:text="thoughts.pdf" />
<TextView
android:id="@+id/size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/signal_colorOnSurfaceVariant"
style="@style/Signal.Text.BodyLarge"
tools:text="12 KB" />
</LinearLayout>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ImageView
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter">
</ImageView>
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.imageview.ShapeableImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/add_media"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:background="@drawable/media_gallery_button_background"
android:padding="4dp"
android:scaleType="centerInside"
app:layout_constraintBottom_toTopOf="@+id/controls_shade"
app:layout_constraintStart_toStartOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/ic_add_media_22" />
@@ -1,361 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/media_pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/controls"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false">
<View
android:id="@+id/controls_shade"
android:layout_width="match_parent"
android:layout_height="0dp"
android:alpha="0"
android:background="@color/signal_dark_colorSurface"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="@id/timeline_placeholder"
tools:alpha="1"
tools:background="@color/black"
tools:visibility="visible" />
<View
android:id="@+id/timeline_placeholder"
android:layout_width="match_parent"
android:layout_height="@dimen/video_timeline_height_collapsed"
android:background="@color/transparent"
app:layout_constraintBottom_toBottomOf="@id/timeline_guideline" />
<org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView
android:id="@+id/recipient"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:layout_marginEnd="16dp"
android:alpha="0"
android:background="@drawable/image_editor_hud_clear_all_background"
android:drawablePadding="4dp"
android:maxLines="1"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_dark_colorOnSurface"
android:visibility="gone"
app:drawableStartCompat="@drawable/symbol_arrow_right_24"
app:drawableTint="@color/signal_dark_colorOnSurface"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:alpha="1"
tools:text="Sam"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/selection_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:alpha="0"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@id/controls_shade"
tools:alpha="1"
tools:listitem="@layout/v2_media_review_selected_item"
tools:visibility="visible" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/add_media"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="10dp"
android:layout_marginBottom="15dp"
android:alpha="0"
android:background="@drawable/media_gallery_button_background"
android:contentDescription="@string/MediaReviewFragment__add_media_accessibility_label"
android:padding="4dp"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/controls_shade"
app:layout_constraintStart_toStartOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/ic_add_media_22"
tools:alpha="1"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/add_a_message"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:alpha="0"
android:background="@drawable/rounded_rectangle_surface_variant_32"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:minHeight="48dp"
android:paddingStart="48dp"
android:paddingEnd="48dp"
android:text="@string/MediaReviewFragment__add_a_message"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/send"
app:layout_constraintStart_toStartOf="parent"
tools:alpha="1" />
<ViewSwitcher
android:id="@+id/view_once_toggle"
android:layout_width="48dp"
android:layout_height="48dp"
android:alpha="0"
android:animateFirstView="false"
android:contentDescription="@string/MediaReviewFragment__view_once_toggle_accessibility_label"
android:inAnimation="@anim/fade_in"
android:outAnimation="@anim/fade_out"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/add_a_message"
app:layout_constraintEnd_toEndOf="@id/add_a_message"
app:layout_constraintTop_toTopOf="@id/add_a_message"
tools:alpha="1"
tools:visibility="visible">
<com.google.android.material.imageview.ShapeableImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:scaleType="centerInside"
app:srcCompat="@drawable/symbol_view_once_infinite_24"
app:tint="@color/signal_colorOnSurface" />
<com.google.android.material.imageview.ShapeableImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:scaleType="centerInside"
app:srcCompat="@drawable/symbol_view_once_24"
app:tint="@color/signal_colorOnSurface" />
</ViewSwitcher>
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/emoji_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:background="@color/transparent"
android:contentDescription="@string/MediaReviewFragment__emoji_toggle_accessibility_label"
android:scaleType="centerInside"
android:src="@drawable/symbol_emoji_24"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/add_a_message"
app:layout_constraintStart_toStartOf="@id/add_a_message"
app:layout_constraintTop_toTopOf="@id/add_a_message"
app:tint="@color/signal_colorOnSurface"
tools:visibility="visible" />
<org.signal.mediasend.screens.edit.video.VideoThumbnailsRangeSelectorView
android:id="@+id/video_timeline"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:alpha="0"
android:paddingTop="8dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/timeline_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:thumbColor="@color/signal_light_colorOnPrimary"
app:thumbColorEdited="#ff0"
app:thumbHintBackgroundColor="@color/signal_dark_colorSurfaceVariant"
app:thumbHintTextColor="@color/signal_light_colorOnPrimary"
app:thumbHintTextSize="14sp"
app:thumbTouchRadius="24dp"
app:thumbWidth="6dp"
tools:targetApi="23"
tools:visibility="visible" />
<TextView
android:id="@+id/video_size_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/Signal.Text.BodySmall"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintEnd_toEndOf="@id/video_timeline"
app:layout_constraintTop_toBottomOf="@id/video_timeline"
tools:text="0:04 · 399 KB" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/timeline_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_end="148dp" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/draw_tool"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="10dp"
android:layout_marginBottom="16dp"
android:alpha="0"
android:background="@drawable/media_gallery_button_background"
android:contentDescription="@string/MediaReviewFragment__brush_pen_accessibility_label"
android:padding="6dp"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/add_a_message"
app:layout_constraintStart_toStartOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_brush_pen_24"
app:tint="@color/signal_dark_colorOnSurface"
tools:alpha="1"
tools:visibility="visible" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/crop_and_rotate_tool"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_marginBottom="16dp"
android:alpha="0"
android:background="@drawable/media_gallery_button_background"
android:contentDescription="@string/MediaReviewFragment__crop_rotate_accessibility_label"
android:padding="4dp"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/add_a_message"
app:layout_constraintStart_toEndOf="@id/draw_tool"
app:layout_goneMarginStart="10dp"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_crop_rotate_24"
app:tint="@color/signal_dark_colorOnSurface"
tools:alpha="1"
tools:visibility="visible" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/quality_selector"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_marginBottom="16dp"
android:alpha="0"
android:background="@drawable/media_gallery_button_background"
android:contentDescription="@string/MediaReviewFragment__change_media_quality_accessibility_label"
android:padding="4dp"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/add_a_message"
app:layout_constraintStart_toEndOf="@id/crop_and_rotate_tool"
app:layout_goneMarginStart="10dp"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_quality_high_slash_24"
app:tint="@color/signal_dark_colorOnSurface"
tools:alpha="1"
tools:visibility="visible" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/mute_video_audio"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_marginBottom="16dp"
android:alpha="0"
android:background="@drawable/media_gallery_button_background"
android:contentDescription="@string/MediaReviewFragment__mute_video_audio_accessibility_label"
android:padding="4dp"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/add_a_message"
app:layout_constraintStart_toEndOf="@id/quality_selector"
app:layout_goneMarginStart="10dp"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_speaker_24"
app:tint="@color/signal_dark_colorOnSurface"
tools:alpha="1"
tools:visibility="visible" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/save_to_media"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_marginBottom="16dp"
android:background="@drawable/media_gallery_button_background"
android:contentDescription="@string/MediaReviewFragment__save_media_accessibility_label"
android:padding="4dp"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/add_a_message"
app:layout_constraintStart_toEndOf="@id/mute_video_audio"
app:layout_goneMarginStart="10dp"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_save_android_24"
app:tint="@color/signal_dark_colorOnSurface"
tools:visibility="visible" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/send"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="10dp"
android:layout_marginBottom="16dp"
android:background="@color/signal_light_colorPrimary"
android:contentDescription="@string/MediaReviewFragment__send_media_accessibility_label"
android:padding="4dp"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toEndOf="@id/save_to_media"
app:layout_goneMarginStart="10dp"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_send_fill_24"
app:tint="@color/signal_colorOnSurface"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout
android:id="@+id/progress_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0"
android:background="@color/transparent_black_60"
android:visibility="gone"
tools:alpha="0"
tools:visibility="gone">
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:indeterminateBehavior="cycle" />
</org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout>
</FrameLayout>
@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2024 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_rectangle_surface_2_18"
android:elevation="8dp"
android:minHeight="44dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/media_review_toast_popup_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="21dp"
android:paddingBottom="1dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/media_review_toast_popup_description"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/symbol_quality_high_24"
app:tint="@color/signal_colorOnSurface" />
<TextView
android:id="@+id/media_review_toast_popup_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="21dp"
android:gravity="center_vertical"
android:paddingBottom="2dp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_colorOnSurface"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/media_review_toast_popup_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/first_names" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
tools:background="@color/signal_dark_colorSurface">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/media_review_selected_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:padding="1dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.MediaSelection.Selected"
app:strokeColor="@color/core_white"
app:strokeWidth="2dp"
tools:background="@drawable/test_gradient" />
<ImageView
android:id="@+id/media_review_play_overlay"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:background="@drawable/circle_tintable"
android:importantForAccessibility="no"
app:srcCompat="@drawable/exo_icon_play_ultramarine" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/media_review_trash_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:background="@color/signal_dark_colorTransparentInverse4"
android:padding="1dp"
android:scaleType="centerInside"
android:visibility="gone"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.MediaSelection.Selected"
app:srcCompat="@drawable/ic_trash_24"
app:strokeColor="@color/signal_light_colorPrimary"
app:strokeWidth="2dp"
app:tint="@color/core_white"
tools:visibility="visible" />
</FrameLayout>
-73
View File
@@ -1,73 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/media"
app:startDestination="@id/mediaReviewFragment">
<fragment
android:id="@+id/mediaCaptureFragment"
android:name="org.thoughtcrime.securesms.mediasend.v2.capture.MediaCaptureFragment"
android:label="media_capture_fragment"
tools:layout="@layout/fragment_container">
<action
android:id="@+id/action_mediaCaptureFragment_to_mediaGalleryFragment"
app:destination="@id/mediaGalleryFragment">
<argument
android:name="suppressEmptyError"
android:defaultValue="true"
app:argType="boolean" />
</action>
<action
android:id="@+id/action_mediaCaptureFragment_to_textStoryPostCreationFragment"
app:destination="@id/textStoryPostCreationFragment"
app:enterAnim="@anim/slide_from_end"
app:exitAnim="@null"
app:popEnterAnim="@null"
app:popExitAnim="@anim/slide_to_end" />
</fragment>
<fragment
android:id="@+id/mediaReviewFragment"
android:name="org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment"
android:label="media_review_fragment"
tools:layout="@layout/v2_media_review_fragment">
<action
android:id="@+id/action_mediaReviewFragment_to_mediaGalleryFragment"
app:destination="@id/mediaGalleryFragment" />
</fragment>
<fragment
android:id="@+id/mediaGalleryFragment"
android:name="org.thoughtcrime.securesms.mediasend.v2.gallery.MediaSelectionGalleryFragment"
android:label="media_review_fragment"
tools:layout="@layout/v2_media_gallery_fragment">
<action
android:id="@+id/action_mediaGalleryFragment_to_mediaCaptureFragment"
app:destination="@id/mediaCaptureFragment" />
</fragment>
<fragment
android:id="@+id/textStoryPostCreationFragment"
android:name="org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationFragment"
android:label="text_story_post_creation_fragment"
tools:layout="@layout/stories_text_post_creation_fragment" />
<action
android:id="@+id/action_directly_to_mediaCaptureFragment"
app:destination="@id/mediaCaptureFragment" />
<action
android:id="@+id/action_directly_to_mediaGalleryFragment"
app:destination="@id/mediaGalleryFragment" />
<action
android:id="@+id/action_directly_to_mediaReviewFragment"
app:destination="@id/mediaReviewFragment" />
<action
android:id="@+id/action_directly_to_textPostCreationFragment"
app:destination="@id/textStoryPostCreationFragment" />
</navigation>
-6
View File
@@ -233,12 +233,6 @@
<attr name="media_keyboard_theme" format="reference" />
</declare-styleable>
<declare-styleable name="CameraXFlashState">
<attr name="state_flash_auto" format="boolean" />
<attr name="state_flash_off" format="boolean" />
<attr name="state_flash_on" format="boolean" />
</declare-styleable>
<declare-styleable name="ContactFilterView">
<attr name="searchTextStyle" format="reference" />
<attr name="showDialpad" format="boolean" />
-2
View File
@@ -226,8 +226,6 @@
<dimen name="safety_number_qr_peek">24dp</dimen>
<dimen name="video_timeline_height_expanded">44dp</dimen>
<dimen name="video_timeline_height_collapsed">1dp</dimen>
<dimen name="image_editor_hud_tool_filled_circle_diameter">40dp</dimen>
<dimen name="image_editor_hud_tool_filled_circle_padding">6dp</dimen>
<dimen name="image_editor_hud_tool_invisible_circle_diameter">40dp</dimen>
-49
View File
@@ -432,7 +432,6 @@
<!-- Accessibility text associated with image button to send an edited message. -->
<string name="ConversationActivity_send_edit">Send edit</string>
<string name="ConversationActivity_compose_message">Compose message</string>
<string name="ConversationActivity_sorry_there_was_an_error_setting_your_attachment">Sorry, there was an error setting your attachment.</string>
<!-- Toast shown when user is unable to find the recipient when sending a message -->
<string name="ConversationActivity_recipient_is_not_a_valid_sms_or_email_address_exclamation">Recipient is not a valid SMS or email address!</string>
<string name="ConversationActivity_message_is_empty_exclamation">Message is empty!</string>
@@ -1885,9 +1884,6 @@
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity__menu_open_camera">Open camera</string>
<!-- MediaSendActivity -->
<string name="MediaSendActivity_camera_unavailable">Camera unavailable.</string>
<!-- MediaRepository -->
<string name="MediaRepository_all_media">All media</string>
<string name="MediaRepository__camera">Camera</string>
@@ -6345,36 +6341,9 @@
<!-- Positive dialog action when sending a story via an add to group story button -->
<string name="MediaReviewFragment__add_to_story">Add to story</string>
<string name="MediaReviewFragment__add_a_message">Add a message</string>
<!-- Hint text inside of a compose box that is shown when the user is adding media while quoting a message. -->
<string name="MediaReviewFragment__add_a_reply">Add a reply</string>
<string name="MediaReviewFragment__send_to">Send to</string>
<string name="MediaReviewFragment__view_once_message">View once media</string>
<string name="MediaReviewFragment__one_or_more_items_were_too_large">One or more items were too large</string>
<string name="MediaReviewFragment__one_or_more_items_were_invalid">One or more items were invalid</string>
<string name="MediaReviewFragment__too_many_items_selected">Too many items selected</string>
<string name="MediaReviewFragment__video_trimmed_to_fit">Your video was trimmed to fit within the size limit</string>
<!-- Small notification presented to the user when they set their video to view-once mode -->
<string name="MediaReviewFragment__video_set_to_view_once">Video set to view once</string>
<!-- Small notification presented to the user when they set their photo to view-once mode -->
<string name="MediaReviewFragment__photo_set_to_view_once">Photo set to view once</string>
<!-- Accessibility label describing the add media button on the Media review screen -->
<string name="MediaReviewFragment__add_media_accessibility_label">Add a Media</string>
<!-- Accessibility label describing the brush and pen button on the Media review screen -->
<string name="MediaReviewFragment__brush_pen_accessibility_label">Brush and Pen</string>
<!-- Accessibility label describing the crop and rotate button on the Media review screen -->
<string name="MediaReviewFragment__crop_rotate_accessibility_label">Crop and Rotate</string>
<!-- Accessibility label describing the change media quality button on the Media review screen -->
<string name="MediaReviewFragment__change_media_quality_accessibility_label">Change Media Quality</string>
<!-- Accessibility label describing the mute video audio button on the Media review screen -->
<string name="MediaReviewFragment__mute_video_audio_accessibility_label">Mute Video Audio</string>
<!-- Accessibility label describing the save media button on the Media review screen -->
<string name="MediaReviewFragment__save_media_accessibility_label">Save Media</string>
<!-- Accessibility label describing the toggle emoji keyboard button on the Media review screen -->
<string name="MediaReviewFragment__emoji_toggle_accessibility_label">Toggle emoji keyboard</string>
<!-- Accessibility label describing the toggle view once button on the Media review screen -->
<string name="MediaReviewFragment__view_once_toggle_accessibility_label">Toggle View Once</string>
<!-- Accessibility label describing the send media button on the Media review screen -->
<string name="MediaReviewFragment__send_media_accessibility_label">Send Media</string>
<!-- Accessibility label describing the finish adding a message button on the Media review screen dialog -->
<string name="MediaReviewFragment__finish_adding_a_message_accessibility_label">Finish adding a Message</string>
@@ -6394,28 +6363,10 @@
<string name="MediaCountIndicatorButton__send">Send</string>
<string name="MediaReviewSelectedItem__tap_to_remove">Tap to remove</string>
<string name="MediaReviewSelectedItem__tap_to_select">Tap to select</string>
<string name="MediaReviewImagePageFragment__discard">Discard</string>
<string name="MediaReviewImagePageFragment__discard_changes">Discard changes?</string>
<string name="MediaReviewImagePageFragment__youll_lose_any_changes">You\'ll lose any changes you\'ve made to this photo.</string>
<!-- The title of a dialog notifying that a user was found matching a scanned QR code. The placeholder is a username. Usernames are always latin characters. -->
<string name="MediaCaptureFragment_username_dialog_title">Found %1$s</string>
<!-- The body of a dialog notifying that a user was found matching a scanned QR code, prompting the user to start a chat with them. The placeholder is a username. Usernames are always latin characters. -->
<string name="MediaCaptureFragment_username_dialog_body">Start a chat with \"%1$s\"</string>
<!-- The label of a dialog asking the user if they would like to start a chat with a specific user. -->
<string name="MediaCaptureFragment_username_dialog_go_to_chat_button">Go to chat</string>
<!-- The title of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="MediaCaptureFragment_device_link_dialog_title">Link device?</string>
<!-- The body of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="MediaCaptureFragment_it_looks_like_youre_trying">It looks like you\'re trying to link a Signal device. Tap continue and then tap \"Link a New Device\" and scan the QR code again.</string>
<!-- The label of a dialog asking the user if they would like to continue to the linked device settings screen. -->
<string name="MediaCaptureFragment_device_link_dialog_continue">Continue</string>
<string name="BadgesOverviewFragment__my_badges">My badges</string>
<string name="BadgesOverviewFragment__featured_badge">Featured badge</string>
<string name="BadgesOverviewFragment__display_badges_on_profile">Display badges on profile</string>
@@ -1,105 +0,0 @@
package org.thoughtcrime.securesms.camera;
import org.junit.Test;
import org.thoughtcrime.securesms.mediasend.OrderEnforcer;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
public class OrderEnforcerTest {
@Test
public void markCompleted_singleEntry() {
AtomicInteger counter = new AtomicInteger(0);
OrderEnforcer<Stage> enforcer = new OrderEnforcer<>(Stage.A, Stage.B, Stage.C, Stage.D);
enforcer.run(Stage.A, new CountRunnable(counter));
assertEquals(0, counter.get());
enforcer.markCompleted(Stage.A);
assertEquals(1, counter.get());
}
@Test
public void markCompleted_singleEntry_waterfall() {
AtomicInteger counter = new AtomicInteger(0);
OrderEnforcer<Stage> enforcer = new OrderEnforcer<>(Stage.A, Stage.B, Stage.C, Stage.D);
enforcer.run(Stage.C, new CountRunnable(counter));
assertEquals(0, counter.get());
enforcer.markCompleted(Stage.A);
assertEquals(0, counter.get());
enforcer.markCompleted(Stage.C);
assertEquals(0, counter.get());
enforcer.markCompleted(Stage.B);
assertEquals(1, counter.get());
}
@Test
public void markCompleted_multipleEntriesPerStage_waterfall() {
AtomicInteger counter = new AtomicInteger(0);
OrderEnforcer<Stage> enforcer = new OrderEnforcer<>(Stage.A, Stage.B, Stage.C, Stage.D);
enforcer.run(Stage.A, new CountRunnable(counter));
enforcer.run(Stage.A, new CountRunnable(counter));
assertEquals(0, counter.get());
enforcer.run(Stage.B, new CountRunnable(counter));
enforcer.run(Stage.B, new CountRunnable(counter));
assertEquals(0, counter.get());
enforcer.run(Stage.C, new CountRunnable(counter));
enforcer.run(Stage.C, new CountRunnable(counter));
assertEquals(0, counter.get());
enforcer.run(Stage.D, new CountRunnable(counter));
enforcer.run(Stage.D, new CountRunnable(counter));
assertEquals(0, counter.get());
enforcer.markCompleted(Stage.A);
assertEquals(counter.get(), 2);
enforcer.markCompleted(Stage.D);
assertEquals(counter.get(), 2);
enforcer.markCompleted(Stage.B);
assertEquals(counter.get(), 4);
enforcer.markCompleted(Stage.C);
assertEquals(counter.get(), 8);
}
@Test
public void run_alreadyCompleted() {
AtomicInteger counter = new AtomicInteger(0);
OrderEnforcer<Stage> enforcer = new OrderEnforcer<>(Stage.A, Stage.B, Stage.C, Stage.D);
enforcer.markCompleted(Stage.A);
enforcer.markCompleted(Stage.B);
enforcer.run(Stage.B, new CountRunnable(counter));
assertEquals(1, counter.get());
}
private static class CountRunnable implements Runnable {
private final AtomicInteger counter;
public CountRunnable(AtomicInteger counter) {
this.counter = counter;
}
@Override
public void run() {
counter.incrementAndGet();
}
}
private enum Stage {
A, B, C, D
}
}