Add audio removal to Signal Labs.

This commit is contained in:
Greyson Parrelli
2026-08-10 16:32:57 -04:00
parent 70d48745c0
commit ee59bb4080
41 changed files with 357 additions and 40 deletions
@@ -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 {
@@ -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()
@@ -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)
@@ -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
}
@@ -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)) }
)
}
}
}
}
@@ -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
)
@@ -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
)
}
}
@@ -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
@@ -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)
}
/**
@@ -2839,6 +2839,10 @@ class AttachmentTable(
return false
}
if (newProperties.videoMuted != potentialMatchProperties.videoMuted) {
return false
}
return true
}
@@ -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");
@@ -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<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}
@@ -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
)
}
@@ -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)
}
@@ -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]
}
@@ -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<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))
@@ -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)
}
@@ -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())
}
@@ -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) {
@@ -275,6 +275,27 @@
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"
@@ -287,7 +308,7 @@
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/add_a_message"
app:layout_constraintStart_toEndOf="@id/quality_selector"
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"
+2
View File
@@ -6417,6 +6417,8 @@
<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" translatable="false">Mute Video Audio (Labs)</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 -->
@@ -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));
}
@@ -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
)
}
@@ -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<TransformProperties>(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<TransformProperties>(Json.encodeToString(properties)).videoMuted)
}
}
@@ -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)),
@@ -159,7 +159,8 @@ class VideoTranscodeInstrumentationTest {
(qualityTier.videoBitrateMbps * VideoConstants.MB).toInt(),
qualityTier.audioBitrateKbps * VideoConstants.KB,
qualityTier.resolution,
true
true,
false
)
outputFile.outputStream().use { outputStream ->
@@ -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
)
}
}
@@ -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();
}
}
@@ -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 {
/**
@@ -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,
@@ -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
}
/**
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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()
)
}
}
@@ -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"
@@ -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)
@@ -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<TranscodingConfig.QualityTier> 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) {
@@ -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");