mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-13 12:07:57 +01:00
Inline media-send v3.
This commit is contained in:
committed by
Cody Henthorne
parent
2d8bdec1e4
commit
40c06ea232
+59
-20
@@ -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() }
|
||||
|
||||
@@ -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"
|
||||
|
||||
-8
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -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 = ""
|
||||
)
|
||||
|
||||
-6
@@ -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
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
}
|
||||
}
|
||||
-160
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-101
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
-12
@@ -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
|
||||
}
|
||||
-192
@@ -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"
|
||||
}
|
||||
}
|
||||
-95
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-109
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
-63
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
-1
@@ -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
|
||||
|
||||
-151
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
-66
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-165
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-36
@@ -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
|
||||
}
|
||||
}
|
||||
-47
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-892
@@ -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)
|
||||
}
|
||||
}
|
||||
-73
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
-57
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
-50
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-54
@@ -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) {
|
||||
}
|
||||
}
|
||||
-49
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -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
|
||||
|
||||
-113
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user