Add large-screen media send toolbars for image editing.

This commit is contained in:
Alex Hart
2026-04-10 14:35:43 -03:00
committed by Cody Henthorne
parent b4bfb67a44
commit 773d6c36dc
41 changed files with 674 additions and 117 deletions
@@ -205,7 +205,7 @@ private fun FeatureBullet(text: String) {
modifier = Modifier.padding(vertical = 2.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
imageVector = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_check_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.core.util.next
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.next
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import kotlin.time.Duration.Companion.seconds
@@ -72,6 +72,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -413,7 +414,7 @@ private fun IssueChip(
leadingIcon = {
Icon(
imageVector = if (isSelected) {
ImageVector.vectorResource(R.drawable.symbol_check_24)
ImageVector.vectorResource(CoreUiR.drawable.symbol_check_24)
} else {
ImageVector.vectorResource(issue.category.icon)
},
@@ -704,7 +704,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
}
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(new ActionItem(R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
}
if (menuState.shouldShowCopyAction()) {
@@ -93,7 +93,7 @@ object PinnedContextMenu {
message.slideDeck.getStickerSlide() == null
) {
add(
ActionItem(R.drawable.symbol_save_android_24, context.getString(R.string.conversation_selection__menu_save)) {
ActionItem(CoreUiR.drawable.symbol_save_android_24, context.getString(R.string.conversation_selection__menu_save)) {
callbacks.onSave()
}
)
@@ -64,7 +64,7 @@ final class SafetyNumberChangeAdapter extends ListAdapter<ChangedRecipient, Safe
if (changedRecipient.isUnverified() || changedRecipient.isVerified()) {
subtitle.setText(R.string.safety_number_change_dialog__previous_verified);
Drawable check = DrawableUtil.tint(ContextUtil.requireDrawable(itemView.getContext(), R.drawable.symbol_check_24), ContextCompat.getColor(itemView.getContext(), R.color.signal_text_secondary));
Drawable check = DrawableUtil.tint(ContextUtil.requireDrawable(itemView.getContext(), org.signal.core.ui.R.drawable.symbol_check_24), ContextCompat.getColor(itemView.getContext(), R.color.signal_text_secondary));
check.setBounds(0, 0, ViewUtil.dpToPx(12), ViewUtil.dpToPx(12));
subtitle.setCompoundDrawables(check, null, null, null);
} else if (changedRecipient.getRecipient().hasAUserSetDisplayName(itemView.getContext())) {
@@ -2623,7 +2623,7 @@ class ConversationFragment :
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(
ActionItem(R.drawable.symbol_save_android_24, resources.getString(R.string.conversation_selection__menu_save)) {
ActionItem(CoreUiR.drawable.symbol_save_android_24, resources.getString(R.string.conversation_selection__menu_save)) {
handleSaveAttachment(getSelectedConversationMessage().messageRecord as MmsMessageRecord)
finishActionMode()
}
@@ -81,7 +81,7 @@ fun LearnMoreSheet() {
)
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp, start = 40.dp, end = 32.dp)) {
Icon(
painter = painterResource(R.drawable.symbol_save_android_24),
imageVector = SignalIcons.Save.imageVector,
contentDescription = stringResource(R.string.preferences__linked_devices),
modifier = Modifier.size(24.dp).padding(top = 4.dp)
)
@@ -53,7 +53,7 @@ class MediaOverviewContextMenu(
private fun getSaveActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem? {
if (mediaRecord.attachment == null) return null
return ActionItem(
iconRes = R.drawable.symbol_save_android_24,
iconRes = CoreUiR.drawable.symbol_save_android_24,
title = fragment.getString(R.string.save)
) {
callbacks.onSave(mediaRecord)
@@ -511,7 +511,7 @@ public final class MediaOverviewPageFragment extends LoggingFragment
int selectionCount = getListAdapter().getSectionCount();
bottomActionBar.setItems(Arrays.asList(
new ActionItem(R.drawable.symbol_save_android_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_save_plural, selectionCount), () -> {
new ActionItem(org.signal.core.ui.R.drawable.symbol_save_android_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_save_plural, selectionCount), () -> {
Collection<MediaTable.MediaRecord> selected = getListAdapter().getSelectedMedia();
if (SignalStore.backup().getOptimizeStorage()) {
@@ -419,8 +419,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
val icon = when (state.quality) {
SentMediaQuality.HIGH -> R.drawable.symbol_quality_high_24
else -> R.drawable.symbol_quality_high_slash_24
SentMediaQuality.HIGH -> CoreUiR.drawable.symbol_quality_high_24
else -> CoreUiR.drawable.symbol_quality_high_slash_24
}
MediaReviewToastPopupWindow.show(controls, icon, description)
@@ -494,8 +494,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
qualityButton.setImageResource(
when (state.quality) {
SentMediaQuality.STANDARD -> R.drawable.symbol_quality_high_slash_24
SentMediaQuality.HIGH -> R.drawable.symbol_quality_high_24
SentMediaQuality.STANDARD -> CoreUiR.drawable.symbol_quality_high_slash_24
SentMediaQuality.HIGH -> CoreUiR.drawable.symbol_quality_high_24
}
)
}
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import org.thoughtcrime.securesms.util.next
import org.signal.core.util.next
typealias OnTextAlignmentChanged = (TextAlignment) -> Unit
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import org.thoughtcrime.securesms.util.next
import org.signal.core.util.next
typealias OnTextColorStyleChanged = (TextColorStyle) -> Unit
@@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.mediasend.v2.text
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import org.signal.core.util.next
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.util.next
typealias OnTextFontChanged = (TextFont) -> Unit
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.util.Optional
import androidx.core.app.Person as PersonCompat
import org.signal.core.ui.R as CoreUiR
private const val BIG_PICTURE_DIMEN = 500
@@ -237,7 +238,7 @@ sealed class NotificationBuilder(protected val context: Context) {
val markAsRead: PendingIntent? = conversation.getMarkAsReadIntent(context)
if (markAsRead != null) {
val markAsReadAction: NotificationCompat.Action =
NotificationCompat.Action.Builder(R.drawable.symbol_check_24, context.getString(R.string.MessageNotifier_mark_read), markAsRead)
NotificationCompat.Action.Builder(CoreUiR.drawable.symbol_check_24, context.getString(R.string.MessageNotifier_mark_read), markAsRead)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
@@ -251,7 +252,7 @@ sealed class NotificationBuilder(protected val context: Context) {
val markAsRead: PendingIntent? = state.getMarkAsReadIntent(context)
if (markAsRead != null) {
val markAllAsReadAction = NotificationCompat.Action(R.drawable.symbol_check_24, context.getString(R.string.MessageNotifier_mark_all_as_read), markAsRead)
val markAllAsReadAction = NotificationCompat.Action(CoreUiR.drawable.symbol_check_24, context.getString(R.string.MessageNotifier_mark_all_as_read), markAsRead)
builder.addAction(markAllAsReadAction)
builder.extend(NotificationCompat.WearableExtender().addAction(markAllAsReadAction))
}
@@ -260,7 +261,7 @@ sealed class NotificationBuilder(protected val context: Context) {
override fun addTurnOffJoinedNotificationsAction(pendingIntent: PendingIntent?) {
if (pendingIntent != null) {
val turnOffTheseNotifications = NotificationCompat.Action(
R.drawable.symbol_check_24,
CoreUiR.drawable.symbol_check_24,
context.getString(R.string.MessageNotifier_turn_off_these_notifications),
pendingIntent
)
@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
class ImageEditorHudV2 @JvmOverloads constructor(
context: Context,
@@ -146,9 +147,9 @@ class ImageEditorHudV2 @JvmOverloads constructor(
cropAspectLockButton.setOnClickListener {
listener?.onCropAspectLock()
if (listener?.isCropAspectLocked == true) {
cropAspectLockButton.setImageResource(R.drawable.symbol_crop_lock_24)
cropAspectLockButton.setImageResource(CoreUiR.drawable.symbol_crop_lock_24)
} else {
cropAspectLockButton.setImageResource(R.drawable.symbol_crop_unlock_24)
cropAspectLockButton.setImageResource(CoreUiR.drawable.symbol_crop_unlock_24)
}
}
@@ -355,7 +356,7 @@ class ImageEditorHudV2 @JvmOverloads constructor(
private fun presentModeDraw() {
drawButton.isSelected = true
brushToggle.setImageResource(R.drawable.symbol_brush_pen_24)
brushToggle.setImageResource(CoreUiR.drawable.symbol_brush_pen_24)
widthSeekBar.progress = SignalStore.imageEditor.getMarkerPercentage()
listener?.onColorChange(getActiveColor())
updateColorIndicator()
@@ -368,7 +369,7 @@ class ImageEditorHudV2 @JvmOverloads constructor(
private fun presentModeHighlight() {
drawButton.isSelected = true
brushToggle.setImageResource(R.drawable.symbol_brush_highlighter_24)
brushToggle.setImageResource(CoreUiR.drawable.symbol_brush_highlighter_24)
widthSeekBar.progress = SignalStore.imageEditor.getHighlighterPercentage()
listener?.onColorChange(getActiveColor())
updateColorIndicator()
@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.components.transfercontrols.TransferProgressSt
import org.thoughtcrime.securesms.stickers.StickerPreviewDataFactory
import org.thoughtcrime.securesms.stickers.manage.AvailableStickerPack.DownloadStatus
import org.thoughtcrime.securesms.util.DeviceProperties
import org.signal.core.ui.R as CoreUiR
@Composable
fun StickerPackSectionHeader(
@@ -90,7 +91,7 @@ fun AvailableStickerPackRow(
)
val readyIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_circle_down_24)
val downloadedIcon = ImageVector.vectorResource(R.drawable.symbol_check_24)
val downloadedIcon = ImageVector.vectorResource(CoreUiR.drawable.symbol_check_24)
val startButtonContentDesc = stringResource(R.string.StickerManagement_accessibility_download)
val startButtonOnClickLabel = stringResource(R.string.StickerManagement_accessibility_download_pack, pack.record.title)
@@ -242,7 +242,7 @@ object StoryContextMenu {
}
)
add(
ActionItem(R.drawable.symbol_save_android_24, context.getString(R.string.save)) {
ActionItem(CoreUiR.drawable.symbol_save_android_24, context.getString(R.string.save)) {
callbacks.onSave()
}
)
@@ -35,18 +35,27 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
ArrowEnd(icon(R.drawable.symbol_arrow_end_24)),
At(icon(R.drawable.symbol_at_24)),
Backup(icon(R.drawable.symbol_backup_24)),
Blur(icon(R.drawable.symbol_blur_24)),
BrushPen(icon(R.drawable.symbol_brush_pen_24)),
BrushHighlighter(icon(R.drawable.symbol_brush_highlighter_24)),
Camera(icon(R.drawable.symbol_camera_24)),
CameraSwitch(icon(R.drawable.symbol_switch_24)),
Check(icon(R.drawable.symbol_check_24)),
CheckCircle(icon(R.drawable.symbol_check_circle_24)),
ChevronRight(icon(R.drawable.symbol_chevron_right_24)),
Copy(icon(R.drawable.symbol_copy_android_24)),
CropLock(icon(R.drawable.symbol_crop_lock_24)),
CropRotate(icon(R.drawable.symbol_crop_rotate_24)),
CropUnlock(icon(R.drawable.symbol_crop_unlock_24)),
Draw(icon(R.drawable.symbol_draw_24)),
Edit(icon(R.drawable.symbol_edit_24)),
Emoji(icon(R.drawable.symbol_emoji_24)),
ErrorCircle(icon(R.drawable.symbol_error_circle_fill_24)),
File(icon(R.drawable.symbol_file_24)),
FlashAuto(icon(R.drawable.symbol_flash_auto_24)),
FlashOff(icon(R.drawable.symbol_flash_slash_24)),
File(icon(R.drawable.symbol_file_24)),
FlashOn(icon(R.drawable.symbol_flash_24)),
Flip(icon(R.drawable.symbol_flip_24)),
Forward(icon(R.drawable.symbol_forward_24)),
Info(icon(R.drawable.symbol_info_24)),
Keyboard(icon(R.drawable.ic_keyboard_24)),
@@ -56,10 +65,15 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Phone(icon(R.drawable.symbol_phone_24)),
Plus(icon(R.drawable.symbol_plus_24)),
QrCode(icon(R.drawable.symbol_qrcode_24)),
QualityHigh(icon(R.drawable.symbol_quality_high_24)),
QualityHighSlash(icon(R.drawable.symbol_quality_high_slash_24)),
Recent(icon(R.drawable.symbol_recent_24)),
Save(icon(R.drawable.symbol_save_android_24)),
Search(icon(R.drawable.symbol_search_24)),
Settings(icon(R.drawable.symbol_settings_android_24)),
Share(icon(R.drawable.symbol_share_android_24)),
Sticker(icon(R.drawable.symbol_sticker_24)),
Text(icon(R.drawable.symbol_text_24)),
Trash(icon(R.drawable.symbol_trash_24)),
X(icon(R.drawable.symbol_x_24)),
XCircleFill(icon(R.drawable.symbol_x_circle_fill_24))
@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M11.13 22c0 0.48 0.39 0.88 0.87 0.88s0.88-0.4 0.88-0.88V2c0-0.48-0.4-0.88-0.88-0.88S11.12 1.52 11.12 2v20Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M8.13 3.13c-0.98 0-1.76 0-2.4 0.04C5.08 3.22 4.52 3.32 4 3.56 2.91 4.05 2.05 4.9 1.56 5.99 1.32 6.52 1.22 7.1 1.17 7.73c-0.05 0.64-0.05 1.42-0.04 2.4v3.74c0 0.98 0 1.76 0.04 2.4 0.05 0.65 0.15 1.21 0.39 1.74 0.49 1.08 1.35 1.94 2.43 2.43 0.53 0.24 1.1 0.34 1.74 0.39 0.64 0.05 1.42 0.05 2.4 0.05h0.12c0.48 0 0.88-0.4 0.88-0.88s-0.4-0.88-0.88-0.88H8.16c-1.02 0-1.74 0-2.3-0.04-0.55-0.04-0.89-0.11-1.15-0.23-0.69-0.32-1.24-0.87-1.56-1.56-0.12-0.26-0.2-0.6-0.23-1.15-0.04-0.56-0.04-1.28-0.04-2.3v-3.68c0-1.02 0-1.74 0.04-2.3 0.04-0.55 0.11-0.89 0.23-1.15 0.32-0.69 0.87-1.24 1.56-1.56 0.26-0.12 0.6-0.2 1.15-0.23 0.56-0.04 1.28-0.04 2.3-0.04h0.09c0.48 0 0.88-0.4 0.88-0.88s-0.4-0.88-0.88-0.88H8.13Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M14.88 4c0-0.48 0.39-0.88 0.87-0.88h0.5c0.48 0 0.88 0.4 0.88 0.88s-0.4 0.88-0.88 0.88h-0.5c-0.48 0-0.88-0.4-0.88-0.88Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M19.57 3.39c-0.44-0.18-0.95 0.04-1.13 0.48-0.18 0.45 0.04 0.96 0.48 1.14l0.5 0.2c0.45 0.18 0.96-0.04 1.14-0.49 0.18-0.44-0.04-0.95-0.48-1.13l-0.5-0.2Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M21.45 5.61c0.46-0.14 0.95 0.13 1.09 0.59l0.15 0.5c0.14 0.46-0.13 0.95-0.59 1.09-0.46 0.14-0.95-0.13-1.09-0.59l-0.15-0.5C20.72 6.24 21 5.75 21.45 5.61Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M22.88 10c0-0.48-0.4-0.88-0.88-0.88s-0.88 0.4-0.88 0.88v0.5c0 0.48 0.4 0.88 0.88 0.88s0.88-0.4 0.88-0.88V10Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M14.88 20c0 0.48 0.39 0.88 0.87 0.88h0.5c0.48 0 0.88-0.4 0.88-0.88s-0.4-0.88-0.88-0.88h-0.5c-0.48 0-0.88 0.4-0.88 0.88Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M19.57 20.61c-0.44 0.18-0.95-0.04-1.13-0.48-0.18-0.45 0.04-0.96 0.48-1.14l0.5-0.2c0.45-0.18 0.96 0.04 1.14 0.49 0.18 0.44-0.04 0.95-0.48 1.13l-0.5 0.2Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M21.45 18.39c0.46 0.14 0.95-0.13 1.09-0.59l0.15-0.5c0.14-0.46-0.13-0.95-0.59-1.09-0.46-0.14-0.95 0.13-1.09 0.59l-0.15 0.5c-0.14 0.46 0.13 0.95 0.59 1.09Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M22.88 14c0 0.48-0.4 0.88-0.88 0.88s-0.88-0.4-0.88-0.88v-0.5c0-0.48 0.4-0.88 0.88-0.88s0.88 0.4 0.88 0.88V14Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6.36 21.78c0.5 0.1 1.1 0.1 2.12 0.1h0.1l0.33-0.01c0.4-0.03 0.79-0.16 1.13-0.37l0.28-0.2 10.11-7.48c0.42-0.31 0.71-0.53 0.93-0.8 0.19-0.26 0.33-0.54 0.42-0.84 0.1-0.34 0.1-0.7 0.1-1.22V10.2c0-1.3 0-2.32-0.07-3.14-0.07-0.82-0.2-1.48-0.5-2.07C20.8 4 20 3.2 19 2.7c-0.6-0.3-1.25-0.43-2.07-0.5-0.82-0.06-1.83-0.06-3.14-0.06h-3.6c-1.3 0-2.32 0-3.14 0.06C6.24 2.26 5.58 2.4 5 2.7 4 3.2 3.2 4 2.7 5 2.4 5.6 2.27 6.24 2.2 7.06 2.13 7.88 2.13 8.9 2.13 10.2v5.32c0 1.03 0 1.62 0.1 2.12 0.4 2.09 2.04 3.72 4.13 4.14Zm5.3-3.4l-0.79 0.73c0-0.8 0-1.4 0.04-1.88 0.03-0.5 0.1-0.83 0.2-1.11 0.36-0.93 1.08-1.65 2-2.01 0.29-0.1 0.62-0.17 1.12-0.2 0.5-0.03 1.14-0.04 2.02-0.04l2-0.11-1 0.55-0.04 0.04-5.5 4c-0.02 0-0.03 0.02-0.05 0.03ZM8.5 20.12H8.14c-0.73 0-1.04 0-1.29-0.03-1.52-0.23-2.7-1.42-2.94-2.94-0.03-0.25-0.03-0.56-0.03-1.29V9.78c0-1.13 0-1.95 0.05-2.6 0.05-0.63 0.15-1.05 0.33-1.4 0.33-0.65 0.87-1.19 1.53-1.52 0.34-0.18 0.76-0.28 1.4-0.33 0.64-0.05 1.46-0.06 2.59-0.06h4.45c1.12 0 1.94 0 2.58 0.06 0.64 0.05 1.06 0.15 1.4 0.33 0.66 0.33 1.2 0.87 1.53 1.53 0.18 0.34 0.28 0.76 0.33 1.4 0.05 0.64 0.05 1.46 0.05 2.58v0.86l-0.01 0.78c-0.01 0.17-0.04 0.25-0.07 0.3-0.07 0.15-0.18 0.26-0.32 0.33-0.06 0.03-0.14 0.06-0.31 0.07l-0.79 0.02h-2.76c-1.38 0-2.18 0-2.84 0.17-1.82 0.49-3.23 1.9-3.72 3.72-0.18 0.66-0.18 1.46-0.18 2.84v0.28 0.35c-0.05 0.32-0.3 0.58-0.63 0.63Z"/>
</vector>
@@ -1,4 +1,9 @@
package org.thoughtcrime.securesms.util
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
/**
* Treating an Enum as a circular list, returns the "next"
@@ -6,8 +6,11 @@
package org.signal.mediasend
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.WriteWith
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
@@ -108,22 +111,38 @@ data class MediaSendState(
/**
* The [MediaFolder] list available on the system
*/
val mediaFolders: List<MediaFolder> = emptyList(),
val mediaFolders: @WriteWith<TransientMediaFolderListParceler> List<MediaFolder> = emptyList(),
/**
* The selected [MediaFolder] for which to display content in the Select screen
*/
val selectedMediaFolder: MediaFolder? = null,
val selectedMediaFolder: @WriteWith<TransientMediaFolderParceler> MediaFolder? = null,
/**
* The media content for a given selected [MediaFolder]
*/
val selectedMediaFolderItems: List<Media> = emptyList()
val selectedMediaFolderItems: @WriteWith<TransientMediaListParceler> List<Media> = emptyList()
) : Parcelable {
/**
* View-once toggle state.
* No-op parcelers for fields that are re-loaded on init and should not
* contribute to the saved-state bundle size.
*/
private object TransientMediaFolderListParceler : Parceler<List<MediaFolder>> {
override fun create(parcel: Parcel): List<MediaFolder> = emptyList()
override fun List<MediaFolder>.write(parcel: Parcel, flags: Int) = Unit
}
private object TransientMediaFolderParceler : Parceler<MediaFolder?> {
override fun create(parcel: Parcel): MediaFolder? = null
override fun MediaFolder?.write(parcel: Parcel, flags: Int) = Unit
}
private object TransientMediaListParceler : Parceler<List<Media>> {
override fun create(parcel: Parcel): List<Media> = emptyList()
override fun List<Media>.write(parcel: Parcel, flags: Int) = Unit
}
enum class ViewOnceToggleState(val code: Int) {
OFF(0),
ONCE(1);
@@ -0,0 +1,141 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import org.signal.imageeditor.core.model.EditorModel
@Stable
sealed interface EditorController {
@Stable
class Container @RememberInComposition constructor() {
val controllers = SnapshotStateMap<Uri, EditorController>()
fun getOrCreateImageController(uri: Uri, editorModel: EditorModel): Image {
return controllers.getOrPut(uri) { Image(editorModel) } as Image
}
}
@Stable
class Image @RememberInComposition constructor(val editorModel: EditorModel) : EditorController {
override val isUserInEdit: Boolean
get() = mode != Mode.NONE
var mode: Mode by mutableStateOf(Mode.NONE)
var isCropLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked)
private set
val isUserDrawing: Boolean
get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT
val isUserBlurring: Boolean
get() = mode == Mode.BLUR
val isUserEnteringText: Boolean
get() = mode == Mode.TEXT
val isUserInsertingSticker: Boolean
get() = mode == Mode.INSERT_STICKER
fun beginDrawEdit() {
enterDrawMode()
}
fun beginCropAndRotateEdit() {
enterCropMode()
}
fun cancelEdit() {
mode = Mode.NONE
}
fun commitEdit() {
mode = Mode.NONE
}
fun enterDrawMode() {
mode = Mode.DRAW
}
fun enterHighlightMode() {
mode = Mode.HIGHLIGHT
}
fun enterBlurMode() {
mode = Mode.BLUR
}
fun enterCropMode() {
mode = Mode.CROP
}
fun enterTextMode() {
mode = Mode.TEXT
}
fun enterStickerMode() {
mode = Mode.INSERT_STICKER
}
fun lockCrop() {
editorModel.setCropAspectLock(true)
isCropLocked = true
}
fun unlockCrop() {
editorModel.setCropAspectLock(false)
isCropLocked = false
}
fun flip() {
editorModel.flipHorizontal()
}
fun rotate() {
editorModel.rotate90anticlockwise()
}
fun toggleImageQuality() {
// TODO
}
fun saveToDisk() {
// TODO
}
fun addMedia() {
// TODO
}
enum class Mode {
NONE,
CROP,
TEXT,
DRAW,
HIGHLIGHT,
BLUR,
MOVE_STICKER,
MOVE_TEXT,
DELETE,
INSERT_STICKER
}
}
object VideoTrim : EditorController {
override val isUserInEdit: Boolean = false
}
val isUserInEdit: Boolean
}
@@ -13,7 +13,7 @@ import org.signal.imageeditor.core.ImageEditorView
@Composable
fun ImageEditor(
controller: ImageEditorController,
controller: EditorController.Image,
modifier: Modifier = Modifier
) {
AndroidView(
@@ -27,17 +27,17 @@ fun ImageEditor(
)
}
private fun mapMode(mode: ImageEditorController.Mode): ImageEditorView.Mode {
private fun mapMode(mode: EditorController.Image.Mode): ImageEditorView.Mode {
return when (mode) {
ImageEditorController.Mode.NONE -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.CROP -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.DRAW -> ImageEditorView.Mode.Draw
ImageEditorController.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw
ImageEditorController.Mode.BLUR -> ImageEditorView.Mode.Blur
ImageEditorController.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.NONE -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.CROP -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.DRAW -> ImageEditorView.Mode.Draw
EditorController.Image.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw
EditorController.Image.Mode.BLUR -> ImageEditorView.Mode.Blur
EditorController.Image.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize
}
}
@@ -1,34 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.signal.imageeditor.core.model.EditorModel
@Stable
class ImageEditorController @RememberInComposition constructor(
val editorModel: EditorModel
) {
var mode: Mode by mutableStateOf(Mode.NONE)
enum class Mode {
NONE,
CROP,
TEXT,
DRAW,
HIGHLIGHT,
BLUR,
MOVE_STICKER,
MOVE_TEXT,
DELETE,
INSERT_STICKER
}
}
@@ -0,0 +1,365 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.FoldablePortraitDayPreview
import org.signal.core.ui.compose.FoldablePortraitNightPreview
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.IconButtons.iconToggleButtonColors
import org.signal.core.ui.compose.PhonePortraitDayPreview
import org.signal.core.ui.compose.PhonePortraitNightPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.copied.androidx.compose.material3.IconButtonColors
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.core.util.next
import org.signal.imageeditor.core.model.EditorModel
import java.util.EnumMap
@Composable
fun ImageEditorToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
when (imageEditorController.mode) {
EditorController.Image.Mode.NONE -> {
ImageEditorNoneStateToolbar(imageEditorController, modifier)
}
EditorController.Image.Mode.CROP -> {
ImageEditorCropAndResizeToolbar(imageEditorController, modifier)
}
else -> {
ImageEditorDrawStateToolbar(imageEditorController, modifier)
}
}
}
/**
* Allows user to perform actions while viewing an editable image.
*/
@Composable
private fun ImageEditorNoneStateToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(modifier) {
ImageEditorButton(
imageVector = SignalIcons.BrushPen.imageVector,
onClick = imageEditorController::beginDrawEdit
)
ImageEditorButton(
imageVector = SignalIcons.CropRotate.imageVector,
onClick = imageEditorController::beginCropAndRotateEdit
)
ImageEditorButton(
imageVector = SignalIcons.QualityHigh.imageVector,
onClick = imageEditorController::toggleImageQuality
)
ImageEditorButton(
imageVector = SignalIcons.Save.imageVector,
onClick = imageEditorController::saveToDisk
)
ImageEditorButton(
imageVector = SignalIcons.Plus.imageVector, // TODO [alex] - wrong art asset
onClick = imageEditorController::addMedia
)
}
}
@Composable
private fun ImageEditorDrawStateToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(
modifier = modifier,
leading = {
CommitButton(imageEditorController)
},
trailing = {
DiscardButton(imageEditorController)
}
) {
ImageEditorToggleButton(
imageVector = SignalIcons.Draw.imageVector,
checked = imageEditorController.isUserDrawing,
onCheckChanged = {
if (!imageEditorController.isUserDrawing) {
imageEditorController.enterDrawMode()
}
}
)
ImageEditorToggleButton(
imageVector = SignalIcons.Text.imageVector,
checked = imageEditorController.isUserEnteringText,
onCheckChanged = {
if (!imageEditorController.isUserEnteringText) {
imageEditorController.enterTextMode()
}
}
)
ImageEditorToggleButton(
imageVector = SignalIcons.Sticker.imageVector,
checked = imageEditorController.isUserInsertingSticker,
onCheckChanged = {
if (!imageEditorController.isUserInsertingSticker) {
imageEditorController.enterStickerMode()
}
}
)
ImageEditorToggleButton(
imageVector = SignalIcons.Blur.imageVector,
checked = imageEditorController.isUserBlurring,
onCheckChanged = {
if (!imageEditorController.isUserBlurring) {
imageEditorController.enterBlurMode()
}
}
)
}
}
@Composable
private fun ImageEditorCropAndResizeToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(
modifier = modifier,
leading = {
CommitButton(imageEditorController)
},
trailing = {
DiscardButton(imageEditorController)
}
) {
ImageEditorButton(
imageVector = SignalIcons.CropRotate.imageVector,
onClick = imageEditorController::rotate
)
ImageEditorButton(
imageVector = SignalIcons.Flip.imageVector,
onClick = imageEditorController::flip
)
val cropLockImageVector = SignalIcons.CropLock.imageVector
val cropUnlockImageVector = SignalIcons.CropUnlock.imageVector
IconCrossfadeToggleButton(
target = if (imageEditorController.isCropLocked) CropLock.LOCKED else CropLock.UNLOCKED,
setTarget = { target ->
when (target) {
CropLock.LOCKED -> imageEditorController.lockCrop()
CropLock.UNLOCKED -> imageEditorController.unlockCrop()
}
},
targetToImageMap = remember(cropLockImageVector, cropUnlockImageVector) {
EnumMap<CropLock, ImageVector>(
CropLock::class.java
).apply {
put(CropLock.LOCKED, cropLockImageVector)
put(CropLock.UNLOCKED, cropUnlockImageVector)
}
}
)
}
}
@Composable
private fun CommitButton(imageEditorController: EditorController.Image) {
ImageEditorButton(
imageVector = SignalIcons.Check.imageVector,
onClick = imageEditorController::commitEdit,
colors = IconButtons.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
@Composable
private fun DiscardButton(imageEditorController: EditorController.Image) {
ImageEditorButton(
imageVector = SignalIcons.X.imageVector,
onClick = imageEditorController::cancelEdit,
colors = IconButtons.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
@Composable
private inline fun <reified E : Enum<E>> IconCrossfadeToggleButton(
target: E,
crossinline setTarget: (E) -> Unit,
targetToImageMap: EnumMap<E, ImageVector>
) {
IconButtons.IconButton(
onClick = { setTarget(target.next()) }
) {
Crossfade(target) { enumValue ->
Icon(
imageVector = targetToImageMap[enumValue]!!,
contentDescription = null, // TODO
modifier = Modifier.size(24.dp)
)
}
}
}
@Composable
private fun ImageEditorButton(
imageVector: ImageVector,
onClick: () -> Unit,
contentDescription: String? = null,
colors: IconButtonColors = IconButtons.iconButtonColors()
) {
IconButtons.IconButton(
onClick = onClick,
colors = colors
) {
Icon(imageVector = imageVector, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@Composable
private fun ImageEditorToggleButton(
imageVector: ImageVector,
checked: Boolean,
onCheckChanged: (Boolean) -> Unit,
contentDescription: String? = null
) {
IconButtons.IconToggleButton(
checked = checked,
onCheckedChange = onCheckChanged,
colors = iconToggleButtonColors(
checkedContentColor = MaterialTheme.colorScheme.onSurface,
checkedContainerColor = SignalTheme.colors.colorTransparentInverse2
)
) {
Icon(imageVector = imageVector, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@Composable
private fun OrientedImageEditorToolbar(
modifier: Modifier = Modifier,
leading: @Composable () -> Unit = {},
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit
) {
val windowBreakpoint = rememberWindowBreakpoint()
val isRow = windowBreakpoint == WindowBreakpoint.SMALL
if (isRow) {
Row(modifier = modifier.height(48.dp)) {
leading()
Row(
modifier = Modifier
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
) {
content()
}
trailing()
}
} else {
Column(modifier = modifier.width(48.dp)) {
leading()
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
) {
content()
}
trailing()
}
}
}
@PhonePortraitDayPreview
@PhonePortraitNightPreview
@FoldablePortraitDayPreview
@FoldablePortraitNightPreview
@Composable
private fun ImageEditorNoneStateToolbarPreview() {
Previews.Preview {
ImageEditorNoneStateToolbar(
imageEditorController = remember {
EditorController.Image(EditorModel.create(0))
}
)
}
}
@PhonePortraitDayPreview
@PhonePortraitNightPreview
@FoldablePortraitDayPreview
@FoldablePortraitNightPreview
@Composable
private fun ImageEditorDrawStateToolbarPreview() {
Previews.Preview {
ImageEditorDrawStateToolbar(
imageEditorController = remember {
EditorController.Image(EditorModel.create(0)).apply {
enterDrawMode()
}
}
)
}
}
@PhonePortraitDayPreview
@PhonePortraitNightPreview
@FoldablePortraitDayPreview
@FoldablePortraitNightPreview
@Composable
private fun ImageEditorCropAndResizeToolbarPreview() {
Previews.Preview {
ImageEditorCropAndResizeToolbar(
imageEditorController = remember {
EditorController.Image(EditorModel.create(0)).apply {
enterCropMode()
}
}
)
}
}
private enum class CropLock {
LOCKED, UNLOCKED
}
@@ -1,27 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.runtime.Composable
/**
* Allows user to perform actions while viewing an editable image.
*/
@Composable
fun ImageEditorTopLevelToolbar(
imageEditorController: ImageEditorController
) {
// Draw -- imageEditorController draw mode
// Crop&Rotate -- imageEditorController crop mode
// Quality -- callback toggle quality
// Save -- callback save to disk
// Add -- callback go to media select
}
interface ImageEditorToolbarsCallback {
object Empty : ImageEditorToolbarsCallback
}
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -28,12 +27,13 @@ import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.launch
import org.signal.core.models.media.Media
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.ContentTypeUtil
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.imageeditor.core.model.EditorModel
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendNavKey
import org.signal.mediasend.MediaSendState
@@ -46,7 +46,6 @@ fun MediaEditScreen(
backStack: NavBackStack<NavKey>,
videoEditorSlot: @Composable () -> Unit = {}
) {
val isFocusedMediaVideo = ContentTypeUtil.isVideoType(state.focusedMedia?.contentType)
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(
@@ -59,15 +58,28 @@ fun MediaEditScreen(
.fillMaxSize()
.navigationBarsPadding()
) {
val isSmallWindowBreakpoint = rememberWindowBreakpoint() == WindowBreakpoint.SMALL
val controllers = remember { EditorController.Container() }
val currentController = state.focusedMedia?.let {
when (val editorState = state.editorStateMap[it.uri]) {
is EditorState.Image -> controllers.getOrCreateImageController(it.uri, editorState.model)
is EditorState.VideoTrim -> EditorController.VideoTrim
null -> error("Invalid editor state.")
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
snapPosition = SnapPosition.Center
snapPosition = SnapPosition.Center,
userScrollEnabled = currentController?.isUserInEdit != true
) { index ->
when (val editorState = state.editorStateMap[state.selectedMedia[index].uri]) {
val uri = state.selectedMedia[index].uri
when (val editorState = state.editorStateMap[uri]) {
is EditorState.Image -> {
ImageEditor(
controller = remember { ImageEditorController(editorState.model) },
controller = controllers.getOrCreateImageController(uri, editorState.model),
modifier = Modifier.fillMaxSize()
)
}
@@ -104,10 +116,13 @@ fun MediaEditScreen(
)
}
if (isFocusedMediaVideo) {
// Video editor hud
} else if (!currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) {
// Image editor HU
when (currentController) {
is EditorController.Image -> {
if (isSmallWindowBreakpoint) {
ImageEditorToolbar(imageEditorController = currentController)
}
}
is EditorController.VideoTrim, null -> Unit
}
AddAMessageRow(
@@ -120,6 +135,13 @@ fun MediaEditScreen(
.padding(bottom = 16.dp)
)
}
if (!isSmallWindowBreakpoint && currentController is EditorController.Image) {
ImageEditorToolbar(
imageEditorController = currentController,
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 24.dp)
)
}
}
}
@@ -132,7 +154,10 @@ private fun MediaEditScreenPreview() {
MediaEditScreen(
state = MediaSendState(
selectedMedia = selectedMedia,
focusedMedia = selectedMedia.first()
focusedMedia = selectedMedia.first(),
editorStateMap = mutableMapOf(
selectedMedia.first().uri to EditorState.Image(EditorModel.create(0))
)
),
callback = MediaEditScreenCallback.Empty,
backStack = rememberNavBackStack(MediaSendNavKey.Edit),