diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt index 6a85be3ae8..b88e1cf0eb 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt @@ -113,6 +113,33 @@ class AttachmentTableTest_deduping { assertDataFilesAreDifferent(id1, id2) assertDataHashStartMatches(id1, id2) } + + // Non-matching muted flag + test { + val id1 = insertWithData(DATA_A, TransformProperties()) + val id2 = insertWithData(DATA_A, TransformProperties(videoMuted = true)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } + + // Non-matching muted flag, otherwise-identical trims + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2)) + val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2, videoMuted = true)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } + + // Non-matching muted flag, otherwise-identical high quality + test { + val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code)) + val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code, videoMuted = true)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } } /** @@ -159,6 +186,16 @@ class AttachmentTableTest_deduping { assertSkipTransform(id1, false) assertSkipTransform(id2, false) } + + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoMuted = true)) + val id2 = insertWithData(DATA_A, TransformProperties(videoMuted = true)) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, false) + assertSkipTransform(id2, false) + } } /** @@ -325,6 +362,35 @@ class AttachmentTableTest_deduping { assertDoesNotHaveRemoteFields(id2) } + // This represents what would happen if you sent a video, then forwarded it, but *muted the forwarded video*. We should not dedupe. + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(videoMuted = true)) + + assertDataFilesAreDifferent(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, false) + assertDoesNotHaveRemoteFields(id2) + } + + // Muting a video means it must be transcoded, so the compressed output of a muted send must not be re-used by a + // later, unmuted send of the same source data. + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoMuted = true)) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A) + + assertDataFilesAreDifferent(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, false) + assertDoesNotHaveRemoteFields(id2) + } + // This represents what would happen if you sent an image using standard quality, then forwarded it using high quality. // Since you're forwarding, it doesn't matter if the new thing has a higher quality, we should still match and skip transform. test { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 9739b833eb..452187637b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -211,6 +211,8 @@ object BackupRepository { resetInitializedStateAndAuthCredentials() SignalStore.account.rotateAccountEntropyPool(stagedKeyRotations.aep) SignalStore.backup.mediaRootBackupKey = stagedKeyRotations.mediaRootBackupKey + resetSvrBChain() + refreshMasterKeyDependents() BackupMessagesJob.enqueue() } @@ -227,6 +229,15 @@ object BackupRepository { AppDependencies.jobManager.addAll(jobs) } + /** + * Discards our local SVRB state so that the next backup starts a brand new chain. + */ + fun resetSvrBChain() { + Log.i(TAG, "Resetting SVRB chain.", true) + SignalStore.backup.nextBackupSecretData = null + SignalStore.backup.backupSecretRestoreRequired = false + } + fun resetInitializedStateAndAuthCredentials() { SignalStore.backup.backupsInitialized = false SignalStore.backup.messageCredentials.clearAll() diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt index 9816068e40..89a227dfe8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt @@ -775,7 +775,7 @@ private enum class CallQualitySheetNavEntry { enum class CallQualityIssueCategory( @param:DrawableRes val icon: Int ) { - AUDIO(icon = R.drawable.symbol_speaker_24), + AUDIO(icon = CoreUiR.drawable.symbol_speaker_24), VIDEO(icon = R.drawable.symbol_video_24), CALL_DROPPED(icon = R.drawable.symbol_x_circle_24), SOMETHING_ELSE(icon = R.drawable.symbol_error_circle_24) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt index fa36d0f7b6..818fb8ae97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt @@ -14,4 +14,5 @@ sealed interface LabsSettingsEvents { data class ToggleStickerReplies(val enabled: Boolean) : LabsSettingsEvents data class ToggleMuteBreakthroughNotifications(val enabled: Boolean) : LabsSettingsEvents data class ToggleImprovedMessageDeletion(val enabled: Boolean) : LabsSettingsEvents + data class ToggleMuteVideoAudio(val enabled: Boolean) : LabsSettingsEvents } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt index 03ed443ad9..b825511fd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt @@ -160,6 +160,15 @@ private fun LabsSettingsContent( onCheckChanged = { onEvent(LabsSettingsEvents.ToggleImprovedMessageDeletion(it)) } ) } + + item { + Rows.ToggleRow( + checked = state.muteVideoAudio, + text = "Mute Video Audio", + label = "Adds a button to the video editor that removes the audio track from a video before sending it.", + onCheckChanged = { onEvent(LabsSettingsEvents.ToggleMuteVideoAudio(it)) } + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt index 03bbeb60d9..f584298ebf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt @@ -16,5 +16,6 @@ data class LabsSettingsState( val starredMessages: Boolean = false, val stickerReplies: Boolean = false, val muteBreakthroughNotifications: Boolean = false, - val improvedMessageDeletion: Boolean = false + val improvedMessageDeletion: Boolean = false, + val muteVideoAudio: Boolean = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt index 432de19dd8..eea0c32823 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt @@ -49,6 +49,10 @@ class LabsSettingsViewModel : ViewModel() { SignalStore.labs.improvedMessageDeletion = event.enabled _state.value = _state.value.copy(improvedMessageDeletion = event.enabled) } + is LabsSettingsEvents.ToggleMuteVideoAudio -> { + SignalStore.labs.muteVideoAudio = event.enabled + _state.value = _state.value.copy(muteVideoAudio = event.enabled) + } } } @@ -61,7 +65,8 @@ class LabsSettingsViewModel : ViewModel() { starredMessages = SignalStore.labs.starredMessages, stickerReplies = SignalStore.labs.stickerReplies, muteBreakthroughNotifications = SignalStore.labs.muteBreakthroughNotifications, - improvedMessageDeletion = SignalStore.labs.improvedMessageDeletion + improvedMessageDeletion = SignalStore.labs.improvedMessageDeletion, + muteVideoAudio = SignalStore.labs.muteVideoAudio ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CommonSettingsRows.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CommonSettingsRows.kt index 9755522bc0..6775913fc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CommonSettingsRows.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CommonSettingsRows.kt @@ -71,7 +71,7 @@ fun SoundsAndNotificationsRow( Rows.TextRow( text = if (isInternalUser) "$label (Internal Only)" else label, - icon = painterResource(R.drawable.symbol_speaker_24), + icon = painterResource(CoreUiR.drawable.symbol_speaker_24), enabled = enabled, onClick = onClick, modifier = modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt index 68ec1b051d..bf4a14af68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt @@ -42,8 +42,8 @@ enum class CallControlsChange( RINGING_DISABLED(null, R.string.CallStateUpdatePopupWindow__group_is_too_large), MIC_ON(R.drawable.symbol_mic_compact_16, R.string.CallStateUpdatePopupWindow__mic_on), MIC_OFF(R.drawable.symbol_mic_slash_compact_16, R.string.CallStateUpdatePopupWindow__mic_off), - SPEAKER_ON(R.drawable.symbol_speaker_24, R.string.CallStateUpdatePopupWindow__speaker_on), - SPEAKER_OFF(R.drawable.symbol_speaker_slash_24, R.string.CallStateUpdatePopupWindow__speaker_off) + SPEAKER_ON(CoreUiR.drawable.symbol_speaker_24, R.string.CallStateUpdatePopupWindow__speaker_on), + SPEAKER_OFF(CoreUiR.drawable.symbol_speaker_slash_24, R.string.CallStateUpdatePopupWindow__speaker_off) } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 0196be974d..3a39000797 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -2839,6 +2839,10 @@ class AttachmentTable( return false } + if (newProperties.videoMuted != potentialMatchProperties.videoMuted) { + return false + } + return true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index d63813544a..5db97d6a9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -252,15 +252,17 @@ public final class AttachmentCompressionJob extends BaseJob { throw new UndeliverableMessageException("Cannot get media data source for attachment."); } - TranscoderOptions options = null; + TranscoderOptions options = null; + boolean muteAudio = false; if (transformProperties != null) { allowSkipOnFailure = !transformProperties.getVideoEdited(); + muteAudio = transformProperties.videoMuted; if (transformProperties.videoTrim) { options = new TranscoderOptions(transformProperties.videoTrimStartTimeUs, transformProperties.videoTrimEndTimeUs); } } - StreamingTranscoder transcoder = new StreamingTranscoder(dataSource, options, constraints.getVideoTranscodingSettings(), AttachmentUploadJob.getMaxPlaintextSize(), RemoteConfig.allowAudioRemuxing()); + StreamingTranscoder transcoder = new StreamingTranscoder(dataSource, options, constraints.getVideoTranscodingSettings(), AttachmentUploadJob.getMaxPlaintextSize(), RemoteConfig.allowAudioRemuxing(), muteAudio); if (transcoder.isTranscodeRequired()) { Log.i(TAG, "Compressing with streaming muxer"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt index a05db3074c..8a40f0b016 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt @@ -12,6 +12,7 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues( const val STICKER_REPLIES: String = "labs.sticker_replies" const val MUTE_BREAKTHROUGH_NOTIFICATIONS: String = "labs.mute_breakthrough_notifications" const val IMPROVED_MESSAGE_DELETION: String = "labs.improved_message_deletion" + const val MUTE_VIDEO_AUDIO: String = "labs.mute_video_audio" } public override fun onFirstEverAppLaunch() = Unit @@ -34,6 +35,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues( var improvedMessageDeletion by booleanValue(IMPROVED_MESSAGE_DELETION, true).falseForExternalUsers() + var muteVideoAudio by booleanValue(MUTE_VIDEO_AUDIO, true).falseForExternalUsers() + private fun SignalStoreValueDelegate.falseForExternalUsers(): SignalStoreValueDelegate { return this.map { actualValue -> RemoteConfig.internalUser && actualValue } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt index a1f96604a6..d482cae82d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt @@ -22,7 +22,15 @@ class VideoTrimTransform(private val data: VideoTrimData) : MediaTransform { isVideoGif = media.isVideoGif, bucketId = media.bucketId, caption = media.caption, - transformProperties = TransformProperties(false, data.isDurationEdited, data.startTimeUs, data.endTimeUs, SentMediaQuality.STANDARD.code, false), + transformProperties = TransformProperties( + skipTransform = false, + videoTrim = data.isDurationEdited, + videoTrimStartTimeUs = if (data.isDurationEdited) data.startTimeUs else 0, + videoTrimEndTimeUs = if (data.isDurationEdited) data.endTimeUs else 0, + sentMediaQuality = SentMediaQuality.STANDARD.code, + mp4FastStart = false, + videoMuted = data.isMuted + ), fileName = media.fileName ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 153d1994a8..0aecb08f19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -264,7 +264,7 @@ class MediaSelectionRepository(context: Context) { } } - if (state is VideoTrimData && state.isDurationEdited) { + if (state is VideoTrimData && (state.isDurationEdited || state.isMuted)) { modelsToRender[it] = VideoTrimTransform(state) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index 24d25c9ef3..73b90f30b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -400,7 +400,7 @@ class MediaSelectionViewModel( val endMoved = !isEntireDuration && data.endTimeUs != endTimeUs val maxVideoDurationUs: Long = it.calculateMaxVideoDurationUs((endTimeUs - clampedStartTime).microseconds) val preserveStartTime = unedited || !endMoved - val videoTrimData = VideoTrimData(durationEdited, totalDurationUs, clampedStartTime, endTimeUs) + val videoTrimData = data.copy(isDurationEdited = durationEdited, totalInputDurationUs = totalDurationUs, startTimeUs = clampedStartTime, endTimeUs = endTimeUs) val updatedData = clampToMaxClipDuration(videoTrimData, maxVideoDurationUs, preserveStartTime) if (updatedData != videoTrimData) { @@ -425,6 +425,22 @@ class MediaSelectionViewModel( } } + /** + * 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] } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index 0174976d81..4cf60d77d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -96,6 +96,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul 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 @@ -164,6 +165,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul 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) @@ -215,6 +217,10 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul QualitySelectorBottomSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) } + muteVideoAudioButton.setOnClickListener { + sharedViewModel.toggleVideoMuted() + } + saveButton.setOnClickListener { sharedViewModel.sendCommand(HudCommand.SaveMedia) } @@ -380,6 +386,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul presentPager(state) presentAddMessageEntry(state.viewOnceToggleState, state.message) presentImageQualityToggle(state) + presentMuteVideoAudioToggle(state) if (state.quality != sentMediaQuality) { presentQualityToggleToast(state) } @@ -558,6 +565,13 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul ) } + 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) @@ -648,6 +662,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul 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)) @@ -789,6 +804,15 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul } } + private fun computeMuteVideoAudioButtonAnimators(state: MediaSelectionState): List { + 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 { return if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.contentType ?: "")) { listOf(MediaReviewAnimatorController.getFadeInAnimator(cropAndRotateButton)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index f0d9447dc2..2ae6342ce3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -329,6 +329,9 @@ object MediaSendV3Repository : MediaSendRepository { SignalStore.imageEditor.setBlurPercentage((value.blur * 100).roundToInt()) } + override val isMuteVideoAudioLabsEnabled: Boolean + get() = SignalStore.labs.muteVideoAudio + private fun PreUploadResult.toLegacyPreUploadResult(): MessageSender.PreUploadResult { return MessageSender.PreUploadResult(media, AttachmentId(attachmentId), jobIds) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt index cdcec647e1..6d7b029b98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt @@ -15,6 +15,7 @@ import org.signal.core.models.MasterKey import org.signal.core.util.Stopwatch import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob @@ -372,6 +373,7 @@ object SvrRepository { if (rotateAep) { SignalStore.account.rotateAccountEntropyPool(AccountEntropyPool.generate()) + BackupRepository.resetSvrBChain() AppDependencies.jobManager.add(MultiDeviceKeysUpdateJob()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt index 9d13eceb75..37caf51069 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt @@ -133,8 +133,8 @@ class RestoreLocalBackupActivityViewModel : ViewModel() { SignalStore.backup.localRestoreAccountEntropyPool = null SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed - SignalStore.backup.backupSecretRestoreRequired = false SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = -1L + SignalStore.backup.backupSecretRestoreRequired = true val backupIdMatchesCurrentAccount = actualBackupId?.value?.contentEquals(expectedBackupId.value) == true if (backupIdMatchesCurrentAccount) { diff --git a/app/src/main/res/layout/v2_media_review_fragment.xml b/app/src/main/res/layout/v2_media_review_fragment.xml index c30e04428f..3c9afb7967 100644 --- a/app/src/main/res/layout/v2_media_review_fragment.xml +++ b/app/src/main/res/layout/v2_media_review_fragment.xml @@ -275,6 +275,27 @@ tools:alpha="1" tools:visibility="visible" /> + + Crop and Rotate Change Media Quality + + Mute Video Audio (Labs) Save Media diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/AttachmentDatabaseTransformPropertiesTest.java b/app/src/test/java/org/thoughtcrime/securesms/database/AttachmentDatabaseTransformPropertiesTest.java index 8252a02673..5be8bfae30 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/AttachmentDatabaseTransformPropertiesTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/database/AttachmentDatabaseTransformPropertiesTest.java @@ -14,7 +14,7 @@ public class AttachmentDatabaseTransformPropertiesTest { public void transformProperties_verifyStructure() { TransformProperties properties = TransformProperties.empty(); assertEquals("Added transform property, need to confirm default behavior for pre-existing payloads in database", - "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}", + "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoMuted\":false,\"videoEdited\":false}", serialize(properties)); } diff --git a/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt b/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt index 51e3569e45..8c665cefac 100644 --- a/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt +++ b/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt @@ -55,7 +55,13 @@ data class TransformProperties( @JsonProperty("mp4Faststart") @SerialName("mp4Faststart") @JvmField - val mp4FastStart: Boolean = false + val mp4FastStart: Boolean = false, + + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + @JsonProperty("videoMuted") + @SerialName("videoMuted") + @JvmField + val videoMuted: Boolean = false ) : Parcelable { fun shouldSkipTransform(): Boolean { return skipTransform @@ -65,7 +71,7 @@ data class TransformProperties( @EncodeDefault(EncodeDefault.Mode.ALWAYS) @JsonProperty("videoEdited") @SerialName("videoEdited") - val videoEdited: Boolean = videoTrim + val videoEdited: Boolean = videoTrim || videoMuted fun withSkipTransform(): TransformProperties { return this.copy( @@ -89,7 +95,8 @@ data class TransformProperties( videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 0, sentMediaQuality = DEFAULT_MEDIA_QUALITY, - mp4FastStart = false + mp4FastStart = false, + videoMuted = false ) } @@ -100,7 +107,8 @@ data class TransformProperties( videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 0, sentMediaQuality = DEFAULT_MEDIA_QUALITY, - mp4FastStart = false + mp4FastStart = false, + videoMuted = false ) } @@ -111,7 +119,8 @@ data class TransformProperties( videoTrimStartTimeUs = videoTrimStartTimeUs, videoTrimEndTimeUs = videoTrimEndTimeUs, sentMediaQuality = DEFAULT_MEDIA_QUALITY, - mp4FastStart = false + mp4FastStart = false, + videoMuted = false ) } diff --git a/core/models/src/test/java/org/signal/core/models/media/TransformPropertiesTest.kt b/core/models/src/test/java/org/signal/core/models/media/TransformPropertiesTest.kt index 6c76fdca11..7b039ff436 100644 --- a/core/models/src/test/java/org/signal/core/models/media/TransformPropertiesTest.kt +++ b/core/models/src/test/java/org/signal/core/models/media/TransformPropertiesTest.kt @@ -18,7 +18,7 @@ class TransformPropertiesTest { val properties = TransformProperties.empty() Assert.assertEquals( - "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}", + "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoMuted\":false,\"videoEdited\":false}", Json.encodeToString(properties) ) } @@ -42,7 +42,7 @@ class TransformPropertiesTest { val encoded = objectMapper.writeValueAsString(properties) Assert.assertEquals( - "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}", + "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoMuted\":false,\"videoEdited\":false}", encoded ) } @@ -58,4 +58,21 @@ class TransformPropertiesTest { Assert.assertEquals(false, parsed.videoTrim) Assert.assertEquals(false, parsed.videoEdited) } + + @Test + fun `parsing json without videoMuted defaults to false`() { + val json = "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}" + + Assert.assertEquals(false, Json.decodeFromString(json).videoMuted) + Assert.assertEquals(false, ObjectMapper().registerKotlinModule().readValue(json, TransformProperties::class.java).videoMuted) + } + + @Test + fun `videoMuted implies videoEdited`() { + val properties = TransformProperties.empty().copy(videoMuted = true) + + Assert.assertEquals(true, properties.videoEdited) + Assert.assertEquals(false, properties.videoTrim) + Assert.assertEquals(true, Json.decodeFromString(Json.encodeToString(properties)).videoMuted) + } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index 536a65a8b3..1237f138c5 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -87,6 +87,8 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { Settings(icon(R.drawable.symbol_settings_android_24)), Share(icon(R.drawable.symbol_share_android_24)), SignalBackupsDisplay(icon(R.drawable.symbol_signal_backups_display_48)), + Speaker(icon(R.drawable.symbol_speaker_24)), + SpeakerSlash(icon(R.drawable.symbol_speaker_slash_24)), Sticker(icon(R.drawable.symbol_sticker_24)), Text(icon(R.drawable.symbol_text_24)), TextSquare(icon(R.drawable.symbol_text_square_24)), diff --git a/app/src/main/res/drawable/symbol_speaker_24.xml b/core/ui/src/main/res/drawable/symbol_speaker_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_speaker_24.xml rename to core/ui/src/main/res/drawable/symbol_speaker_24.xml diff --git a/app/src/main/res/drawable/symbol_speaker_slash_24.xml b/core/ui/src/main/res/drawable/symbol_speaker_slash_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_speaker_slash_24.xml rename to core/ui/src/main/res/drawable/symbol_speaker_slash_24.xml diff --git a/demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt b/demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt index b9c8759f2c..6fb7311431 100644 --- a/demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt +++ b/demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt @@ -159,7 +159,8 @@ class VideoTranscodeInstrumentationTest { (qualityTier.videoBitrateMbps * VideoConstants.MB).toInt(), qualityTier.audioBitrateKbps * VideoConstants.KB, qualityTier.resolution, - true + true, + false ) outputFile.outputStream().use { outputStream -> diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt index 617ebccd59..8a3b622d38 100644 --- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt +++ b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt @@ -46,7 +46,7 @@ class TranscodeTestRepository { ): TranscodeResult { return doTranscode(context, inputUri, enableFastStart, outputTag, onProgress) { inputFile -> val dataSource = FileMediaDataSource(inputFile) - StreamingTranscoder(dataSource, null, listOf(quality.qualityTier), DEFAULT_FILE_SIZE_LIMIT, enableAudioRemux) + StreamingTranscoder(dataSource, null, listOf(quality.qualityTier), DEFAULT_FILE_SIZE_LIMIT, enableAudioRemux, false) } } @@ -70,7 +70,8 @@ class TranscodeTestRepository { options.videoBitrate, options.audioBitrate, options.videoResolution.shortEdge, - options.enableAudioRemux + options.enableAudioRemux, + false ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaConstraints.java b/feature/media-send/src/main/java/org/signal/mediasend/MediaConstraints.java index cabc258d65..814ce8219b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaConstraints.java +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaConstraints.java @@ -100,4 +100,11 @@ public abstract class MediaConstraints { public static boolean isVideoTranscodeAvailable() { return Build.VERSION.SDK_INT >= 26; } + + /** + * Stripping the audio track means re-encoding the video, so the control is only offered where that is possible. + */ + public static boolean isMuteVideoAudioAvailable() { + return MediaSendDependencies.INSTANCE.getMediaSendRepository().isMuteVideoAudioLabsEnabled() && isVideoTranscodeAvailable(); + } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt index 0227569562..37f578b423 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt @@ -137,7 +137,12 @@ data class MediaSendFlowState( /** * The image editor's per-tool brush widths. Seeded from storage and written back as the user adjusts them. */ - val brushWidths: BrushWidths = MediaSendDependencies.mediaSendRepository.brushWidths + val brushWidths: BrushWidths = MediaSendDependencies.mediaSendRepository.brushWidths, + + /** + * Whether the labs-gated control for stripping a video's audio track is available. + */ + val isMuteVideoAudioEnabled: Boolean = MediaConstraints.isMuteVideoAudioAvailable() ) : Parcelable { /** diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt index 6b3fba1063..95b9ac7d6d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt @@ -345,6 +345,10 @@ class MediaSendFlowViewModel( toggleViewOnce() } + MediaEditScreenEvents.ToggleVideoMuted -> { + toggleVideoMuted() + } + MediaEditScreenEvents.SaveMedia -> { saveFocusedMediaToStorage() } @@ -889,6 +893,20 @@ class MediaSendFlowViewModel( preUploadController.cancelUpload(media) } + /** + * Toggles whether the focused video's audio track is stripped when it is sent. + */ + private fun toggleVideoMuted() { + val snapshot = state.value + val uri = snapshot.focusedMedia?.uri ?: return + val existing = snapshot.editorStateMap[uri] as? EditorState.VideoTrim ?: return + val updated = existing.copy(videoTrimData = existing.videoTrimData.copy(isMuted = !existing.videoTrimData.isMuted)) + + updateState { copy(editorStateMap = editorStateMap + (uri to updated)) } + + snapshot.selectedMedia.firstOrNull { it.uri == uri }?.let { preUploadController.cancelUpload(it) } + } + /** * Updates video trim duration. */ @@ -914,7 +932,7 @@ class MediaSendFlowViewModel( val maxVideoDurationUs = getMaxVideoDurationUs(existingData.videoTrimData.totalInputDurationUs.microseconds) val preserveStartTime = unedited || !endMoved - val newData = VideoTrimData( + val newData = existingData.videoTrimData.copy( isDurationEdited = durationEdited, totalInputDurationUs = totalDurationUs, startTimeUs = clampedStartTime, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index a6293769d6..2dea873f5b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -153,6 +153,12 @@ interface MediaSendRepository { * The image editor's per-tool brush widths, shared with the v2 editor. */ var brushWidths: BrushWidths + + /** + * Whether the labs flag for stripping a video's audio track before sending is on. Callers should ask + * [MediaConstraints.isMuteVideoAudioAvailable] instead, which also accounts for transcode support. + */ + val isMuteVideoAudioLabsEnabled: Boolean } /** diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt index 6b31124481..971a4fa5f6 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt @@ -27,4 +27,5 @@ sealed interface MediaEditScreenEvents { data object SaveMedia : MediaEditScreenEvents data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvents data class VideoSeek(val positionUs: Long, val editingComplete: Boolean) : MediaEditScreenEvents + data object ToggleVideoMuted : MediaEditScreenEvents } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt index 1b9eb8969a..cbaccbe2d0 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt @@ -121,6 +121,14 @@ internal fun MediaEditorToolbarSharedButtons( ) } + if (editorState is EditorState.VideoTrim && isMuteVisible(state, editorState)) { + MediaEditorToolbarButton( + imageVector = if (editorState.videoTrimData.isMuted) SignalIcons.SpeakerSlash.imageVector else SignalIcons.Speaker.imageVector, + onClick = { onEvent(MediaEditScreenEvents.ToggleVideoMuted) }, + modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_MUTE_BUTTON) + ) + } + if (isSaveVisible(editorState)) { MediaEditorToolbarButton( imageVector = SignalIcons.Save.imageVector, @@ -146,6 +154,10 @@ private fun isSaveVisible(editorState: EditorState): Boolean { return editorState is EditorState.Image || editorState is EditorState.Gif } +private fun isMuteVisible(state: MediaSendFlowState, editorState: EditorState): Boolean { + return state.isMuteVideoAudioEnabled && editorState is EditorState.VideoTrim +} + /** * Adding a second attachment would silently drop view-once, so the entry point -- and the selection rail it belongs to * -- goes away while it is on. @@ -159,5 +171,5 @@ internal fun isAddMediaVisible(state: MediaSendFlowState, editorState: EditorSta * toolbar rather than leave an empty one behind. */ internal fun hasSharedToolbarButtons(state: MediaSendFlowState, editorState: EditorState): Boolean { - return isQualityVisible(state, editorState) || isSaveVisible(editorState) || isAddMediaVisible(state, editorState) + return isQualityVisible(state, editorState) || isMuteVisible(state, editorState) || isSaveVisible(editorState) || isAddMediaVisible(state, editorState) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt index 33f22e3626..73739f4963 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt @@ -139,15 +139,23 @@ class VideoEditorFragment : Fragment() { fun onStateUpdate(focusedUri: Uri?, isTouchEnabled: Boolean, getOrCreateVideoTrimData: (Uri) -> VideoTrimData) { val currentlyFocused = focusedUri != null && focusedUri == uri + if (IS_VIDEO_TRANSCODE_AVAILABLE) { if (currentlyFocused) { if (isVideoGif) { player.play() } else { + val videoTrimData = getOrCreateVideoTrimData(uri) + + if (videoTrimData.isMuted) { + player.mute() + } else { + player.unmute() + } + if (!isFocused) { - bindVideoTimeline(getOrCreateVideoTrimData(uri)) + bindVideoTimeline(videoTrimData) } else { - val videoTrimData = getOrCreateVideoTrimData(focusedUri) hud.isVisible = isTouchEnabled && !isVideoGif onEditVideoDuration(videoTrimData, isTouchEnabled) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimData.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimData.kt index 106733a541..01c35dd871 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimData.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimData.kt @@ -19,7 +19,8 @@ data class VideoTrimData( val isDurationEdited: Boolean = false, val totalInputDurationUs: Long = 0, val startTimeUs: Long = 0, - val endTimeUs: Long = 0 + val endTimeUs: Long = 0, + val isMuted: Boolean = false ) : Parcelable { fun getDuration(): Duration = (endTimeUs - startTimeUs).microseconds @@ -30,6 +31,7 @@ data class VideoTrimData( putLong(KEY_TOTAL, totalInputDurationUs) putLong(KEY_START, startTimeUs) putLong(KEY_END, endTimeUs) + putByte(KEY_MUTED, (if (isMuted) 1 else 0).toByte()) } } @@ -38,13 +40,15 @@ data class VideoTrimData( private const val KEY_TOTAL = "TOTAL" private const val KEY_START = "START" private const val KEY_END = "END" + private const val KEY_MUTED = "MUTED" fun fromBundle(bundle: Bundle): VideoTrimData { return VideoTrimData( isDurationEdited = bundle.getByte(KEY_EDITED) == 1.toByte(), totalInputDurationUs = bundle.getLong(KEY_TOTAL), startTimeUs = bundle.getLong(KEY_START), - endTimeUs = bundle.getLong(KEY_END) + endTimeUs = bundle.getLong(KEY_END), + isMuted = bundle.getByte(KEY_MUTED) == 1.toByte() ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt index 7307a36a9b..67b643d8af 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt @@ -17,6 +17,7 @@ object TestTags { const val MEDIA_EDITOR_TOOLBAR_QUALITY_BUTTON = "media_editor_toolbar_quality_button" const val MEDIA_EDITOR_TOOLBAR_SAVE_BUTTON = "media_editor_toolbar_save_button" const val MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON = "media_editor_toolbar_add_media_button" + const val MEDIA_EDITOR_TOOLBAR_MUTE_BUTTON = "media_editor_toolbar_mute_button" // Media Select Screen const val MEDIA_SELECT_GRID = "media_select_grid" diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt index 145c31d591..b82022912f 100644 --- a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt @@ -66,7 +66,7 @@ class MediaEditorToolbarSharedButtonsTest { @Test fun `Given a video, when rendering the toolbar, then saving is not offered`() { - setContent(state(media = VIDEO), EditorState.VideoTrim.forVideo(durationUs = 1_000, maxDurationUs = 1_000)) + setContent(state(media = VIDEO), VIDEO_EDITOR_STATE) assertQuality(visible = true) assertSave(visible = false) @@ -109,6 +109,34 @@ class MediaEditorToolbarSharedButtonsTest { assertAddMedia(visible = false) } + @Test + fun `Given the mute labs flag is off, when rendering the toolbar for a video, then muting is not offered`() { + setContent(state(media = VIDEO), VIDEO_EDITOR_STATE) + + assertMute(visible = false) + } + + @Test + fun `Given the mute labs flag is on, when rendering the toolbar for a video, then muting is offered`() { + setContent(state(media = VIDEO, muteEnabled = true), VIDEO_EDITOR_STATE) + + assertMute(visible = true) + } + + @Test + fun `Given the mute labs flag is on, when rendering the toolbar for a video gif, then muting is not offered`() { + setContent(state(media = VIDEO_GIF, muteEnabled = true), EditorState.VideoGif) + + assertMute(visible = false) + } + + @Test + fun `Given the mute labs flag is on, when rendering the toolbar for an image, then muting is not offered`() { + setContent(state(media = IMAGE, muteEnabled = true), IMAGE_EDITOR_STATE) + + assertMute(visible = false) + } + /** * The selection rail is the multi-item form of the add media button, so it follows the same rule. */ @@ -135,6 +163,8 @@ class MediaEditorToolbarSharedButtonsTest { private fun assertAddMedia(visible: Boolean) = assertTag(TestTags.MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON, visible) + private fun assertMute(visible: Boolean) = assertTag(TestTags.MEDIA_EDITOR_TOOLBAR_MUTE_BUTTON, visible) + private fun assertTag(tag: String, visible: Boolean) { val node = composeTestRule.onNodeWithTag(tag) if (visible) { @@ -158,12 +188,13 @@ class MediaEditorToolbarSharedButtonsTest { } } - private fun state(media: Media, isStory: Boolean = false, viewOnce: Boolean = false): MediaSendFlowState { + private fun state(media: Media, isStory: Boolean = false, viewOnce: Boolean = false, muteEnabled: Boolean = false): MediaSendFlowState { return MediaSendFlowState( selectedMedia = listOf(media), focusedMedia = media, isStory = isStory, - viewOnceToggleState = if (viewOnce) MediaSendFlowState.ViewOnceToggleState.ONCE else MediaSendFlowState.ViewOnceToggleState.OFF + viewOnceToggleState = if (viewOnce) MediaSendFlowState.ViewOnceToggleState.ONCE else MediaSendFlowState.ViewOnceToggleState.OFF, + isMuteVideoAudioEnabled = muteEnabled ) } @@ -171,6 +202,7 @@ class MediaEditorToolbarSharedButtonsTest { /** The editor model is never read by the toolbar, and a real one cannot be built under Robolectric's legacy graphics. */ private val IMAGE_EDITOR_STATE = EditorState.Image(mockk(relaxed = true)) private val DOCUMENT_EDITOR_STATE = EditorState.Document(fileName = "report.pdf", fileSize = 1, extension = "pdf") + private val VIDEO_EDITOR_STATE = EditorState.VideoTrim.forVideo(durationUs = 1_000, maxDurationUs = 1_000) private val IMAGE = media(contentType = ContentTypeUtil.IMAGE_JPEG) private val GIF = media(contentType = ContentTypeUtil.IMAGE_GIF) diff --git a/lib/video/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java b/lib/video/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java index c69174267f..72b07f4f75 100644 --- a/lib/video/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java +++ b/lib/video/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java @@ -43,6 +43,7 @@ public final class StreamingTranscoder { private final long fileSizeEstimate; private final @Nullable TranscoderOptions options; private final boolean allowAudioRemux; + private final boolean muteAudio; /** * @param upperSizeLimit A upper size to transcode to. The actual output size can be up to 10% smaller. @@ -51,12 +52,14 @@ public final class StreamingTranscoder { @Nullable TranscoderOptions options, @NonNull List configs, long upperSizeLimit, - boolean allowAudioRemux) + boolean allowAudioRemux, + boolean muteAudio) throws IOException, VideoSourceException { this.dataSource = dataSource; this.options = options; this.allowAudioRemux = allowAudioRemux; + this.muteAudio = muteAudio; final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); try { @@ -77,7 +80,7 @@ public final class StreamingTranscoder { this.targetQuality = TranscodingQuality.createFromQualityTiers(configs, duration); this.upperSizeLimit = upperSizeLimit; - this.transcodeRequired = inputBitRate >= targetQuality.getTargetTotalBitRate() * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever) || options != null || !isH264(dataSource); + this.transcodeRequired = inputBitRate >= targetQuality.getTargetTotalBitRate() * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever) || options != null || muteAudio || !isH264(dataSource); if (!transcodeRequired) { Log.i(TAG, "Video is within 20% of target bitrate, below the size limit, contained no location metadata or custom options, and is already H.264."); } @@ -91,12 +94,14 @@ public final class StreamingTranscoder { int videoBitrate, int audioBitrate, int shortEdge, - boolean allowAudioRemux) + boolean allowAudioRemux, + boolean muteAudio) throws IOException, VideoSourceException { this.dataSource = dataSource; this.options = options; this.allowAudioRemux = allowAudioRemux; + this.muteAudio = muteAudio; final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); try { @@ -124,10 +129,11 @@ public final class StreamingTranscoder { int videoBitrate, int audioBitrate, int shortEdge, - boolean allowAudioRemux) + boolean allowAudioRemux, + boolean muteAudio) throws VideoSourceException, IOException { - return new StreamingTranscoder(dataSource, options, codec, videoBitrate, audioBitrate, shortEdge, allowAudioRemux); + return new StreamingTranscoder(dataSource, options, codec, videoBitrate, audioBitrate, shortEdge, allowAudioRemux, muteAudio); } /** @@ -184,6 +190,7 @@ public final class StreamingTranscoder { converter.setVideoBitrate(targetQuality.getTargetVideoBitRate()); converter.setAudioBitrate(targetQuality.getTargetAudioBitRate()); converter.setAllowAudioRemux(allowAudioRemux); + converter.setSkipAudio(muteAudio); if (options != null) { if (options.endTimeUs > 0) { diff --git a/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java b/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java index 6273bb2e6f..2fcbc074a1 100644 --- a/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java +++ b/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java @@ -71,6 +71,7 @@ public final class MediaConverter { private @VideoCodec String mVideoCodec = VIDEO_CODEC_H264; private int mAudioBitrate = 128000; // 128Kbps private boolean mAllowAudioRemux = false; + private boolean mSkipAudio = false; private Listener mListener; private boolean mCancelled; @@ -143,6 +144,13 @@ public final class MediaConverter { mAllowAudioRemux = allow; } + /** + * When set, the audio track of the input is dropped entirely and the output will be video-only. + */ + public void setSkipAudio(boolean skipAudio) { + mSkipAudio = skipAudio; + } + /** * @return The total content size of the MP4 mdat box. */ @@ -212,7 +220,7 @@ public final class MediaConverter { muxer = mOutput.createMuxer(); videoTrackConverter = VideoTrackConverter.create(mInput, mTimeFrom, mTimeTo, mVideoResolution, mVideoBitrate, mVideoCodec, excludedDecoders); - audioTrackConverter = AudioTrackConverter.create(mInput, mTimeFrom, mTimeTo, mAudioBitrate, mAllowAudioRemux && muxer.supportsAudioRemux()); + audioTrackConverter = mSkipAudio ? null : AudioTrackConverter.create(mInput, mTimeFrom, mTimeTo, mAudioBitrate, mAllowAudioRemux && muxer.supportsAudioRemux()); if (videoTrackConverter == null && audioTrackConverter == null) { throw new EncodingException("No video and audio tracks");