mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Add media send feature module.
This commit is contained in:
77
feature/media-send/build.gradle.kts
Normal file
77
feature/media-send/build.gradle.kts
Normal file
@@ -0,0 +1,77 @@
|
||||
plugins {
|
||||
id("signal-library")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version.set("1.5.0")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.mediasend"
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks(project(":lintchecks"))
|
||||
ktlintRuleset(libs.ktlint.twitter.compose)
|
||||
|
||||
// Project dependencies
|
||||
implementation(libs.androidx.ui.test.junit4)
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":core:models"))
|
||||
implementation(project(":lib:image-editor"))
|
||||
implementation(project(":lib:glide"))
|
||||
|
||||
// Compose BOM
|
||||
platform(libs.androidx.compose.bom).let { composeBom ->
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
}
|
||||
|
||||
// Compose dependencies
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling.core)
|
||||
|
||||
// Navigation 3
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
|
||||
// Kotlinx Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
|
||||
// Permissions
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Testing
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testLibs.assertk)
|
||||
testImplementation(testLibs.kotlinx.coroutines.test)
|
||||
testImplementation(testLibs.robolectric.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
implementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
10
feature/media-send/src/main/AndroidManifest.xml
Normal file
10
feature/media-send/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!--
|
||||
MediaSendActivity is abstract. Concrete implementations should be
|
||||
declared in the app module's manifest.
|
||||
-->
|
||||
</manifest>
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.core.os.bundleOf
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
|
||||
/**
|
||||
* Sealed interface for per-media editor state. All subtypes are [Parcelable] so the
|
||||
* entire editor state map can be persisted in [SavedStateHandle].
|
||||
*/
|
||||
sealed interface EditorState : Parcelable {
|
||||
|
||||
/**
|
||||
* Video trim/duration editing state.
|
||||
*/
|
||||
@Parcelize
|
||||
data class VideoTrim(
|
||||
val isDurationEdited: Boolean = false,
|
||||
val totalInputDurationUs: Long = 0,
|
||||
val startTimeUs: Long = 0,
|
||||
val endTimeUs: Long = 0
|
||||
) : EditorState {
|
||||
|
||||
val clipDurationUs: Long get() = endTimeUs - startTimeUs
|
||||
|
||||
/**
|
||||
* Clamps this trim data to the maximum allowed clip duration.
|
||||
*
|
||||
* @param maxDurationUs Maximum allowed duration in microseconds.
|
||||
* @param preserveStartTime If true, keeps start time and adjusts end; otherwise adjusts start.
|
||||
* @return Clamped VideoTrim, or this if already within limits.
|
||||
*/
|
||||
fun clampToMaxDuration(maxDurationUs: Long, preserveStartTime: Boolean): VideoTrim {
|
||||
if (clipDurationUs <= maxDurationUs) {
|
||||
return this
|
||||
}
|
||||
|
||||
return copy(
|
||||
isDurationEdited = true,
|
||||
startTimeUs = if (!preserveStartTime) endTimeUs - maxDurationUs else startTimeUs,
|
||||
endTimeUs = if (preserveStartTime) startTimeUs + maxDurationUs else endTimeUs
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_IS_DURATION_EDITED = "isDurationEdited"
|
||||
private const val KEY_TOTAL_INPUT_DURATION_US = "totalInputDurationUs"
|
||||
private const val KEY_START_TIME_US = "startTimeUs"
|
||||
private const val KEY_END_TIME_US = "endTimeUs"
|
||||
|
||||
fun fromBundle(bundle: Bundle): VideoTrim {
|
||||
return VideoTrim(
|
||||
isDurationEdited = bundle.getBoolean(KEY_IS_DURATION_EDITED, false),
|
||||
totalInputDurationUs = bundle.getLong(KEY_TOTAL_INPUT_DURATION_US, 0),
|
||||
startTimeUs = bundle.getLong(KEY_START_TIME_US, 0),
|
||||
endTimeUs = bundle.getLong(KEY_END_TIME_US, 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates initial trim data for a video, clamping to max duration if needed.
|
||||
*/
|
||||
fun forVideo(durationUs: Long, maxDurationUs: Long): VideoTrim {
|
||||
return if (durationUs <= maxDurationUs) {
|
||||
VideoTrim(
|
||||
isDurationEdited = false,
|
||||
totalInputDurationUs = durationUs,
|
||||
startTimeUs = 0,
|
||||
endTimeUs = durationUs
|
||||
)
|
||||
} else {
|
||||
VideoTrim(
|
||||
isDurationEdited = true,
|
||||
totalInputDurationUs = durationUs,
|
||||
startTimeUs = 0,
|
||||
endTimeUs = maxDurationUs
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toBundle(): Bundle = Bundle().apply {
|
||||
putBoolean(KEY_IS_DURATION_EDITED, isDurationEdited)
|
||||
putLong(KEY_TOTAL_INPUT_DURATION_US, totalInputDurationUs)
|
||||
putLong(KEY_START_TIME_US, startTimeUs)
|
||||
putLong(KEY_END_TIME_US, endTimeUs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image editor state.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Image(
|
||||
val model: EditorModel
|
||||
) : EditorState {
|
||||
companion object {
|
||||
private const val KEY_MODEL = "model"
|
||||
|
||||
fun fromBundle(bundle: Bundle): Image = Image(bundle.getParcelableCompat(KEY_MODEL, EditorModel::class.java)!!)
|
||||
}
|
||||
|
||||
fun toBundle(): Bundle = bundleOf(KEY_MODEL to model)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
|
||||
/**
|
||||
* Screen that allows user to capture the media they will send using a camera or text story
|
||||
*/
|
||||
@Composable
|
||||
fun MediaCaptureScreen(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
cameraSlot: @Composable () -> Unit,
|
||||
textStoryEditorSlot: @Composable () -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val top = backStack.last()
|
||||
|
||||
when (top) {
|
||||
is MediaSendNavKey.Capture.Camera -> cameraSlot()
|
||||
is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
Buttons.Small(onClick = {
|
||||
if (top == MediaSendNavKey.Capture.TextStory) {
|
||||
backStack.remove(top)
|
||||
}
|
||||
}) {
|
||||
Text(text = "Camera")
|
||||
}
|
||||
|
||||
Buttons.Small(onClick = {
|
||||
if (top == MediaSendNavKey.Capture.Camera) {
|
||||
backStack.add(MediaSendNavKey.Capture.TextStory)
|
||||
}
|
||||
}) {
|
||||
Text(text = "Text Story")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Feature-module value type representing a recipient identifier.
|
||||
*
|
||||
* This avoids coupling to the app-layer `RecipientId` while making it clear we're passing
|
||||
* a recipient reference rather than an arbitrary Long.
|
||||
*/
|
||||
@Parcelize
|
||||
@JvmInline
|
||||
value class MediaRecipientId(val id: Long) : Parcelable {
|
||||
override fun toString(): String = "MediaRecipientId($id)"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.mediasend.preupload.PreUploadManager
|
||||
import org.signal.mediasend.select.MediaSelectScreen
|
||||
|
||||
/**
|
||||
* Abstract base activity for the media sending flow.
|
||||
*
|
||||
* App-layer implementations must extend this class and provide:
|
||||
* - [preUploadCallback] — For pre-upload job management
|
||||
* - [repository] — For media validation, sending, and other app-layer operations
|
||||
* - UI slots: [CameraSlot], [TextStoryEditorSlot], [MediaSelectSlot], [ImageEditorSlot], [VideoEditorSlot], [SendSlot]
|
||||
*
|
||||
* The concrete implementation should be registered in the app's manifest.
|
||||
*/
|
||||
abstract class MediaSendActivity : FragmentActivity() {
|
||||
|
||||
/** Pre-upload callback implementation for job management. */
|
||||
protected abstract val preUploadCallback: PreUploadManager.Callback
|
||||
|
||||
/** Repository implementation for app-layer operations. */
|
||||
protected abstract val repository: MediaSendRepository
|
||||
|
||||
/** Contract args extracted from intent. Available after super.onCreate(). */
|
||||
protected lateinit var contractArgs: MediaSendActivityContract.Args
|
||||
private set
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
contractArgs = MediaSendActivityContract.Args.fromIntent(intent)
|
||||
|
||||
setContent {
|
||||
val viewModel by viewModels<MediaSendViewModel>(factoryProducer = {
|
||||
MediaSendViewModel.Factory(
|
||||
context = applicationContext,
|
||||
args = contractArgs,
|
||||
isMeteredFlow = MeteredConnectivity.isMetered(applicationContext),
|
||||
repository = repository,
|
||||
preUploadCallback = preUploadCallback
|
||||
)
|
||||
})
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val backStack = rememberNavBackStack(
|
||||
if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select
|
||||
)
|
||||
|
||||
Theme {
|
||||
Surface {
|
||||
MediaSendNavDisplay(
|
||||
state = state,
|
||||
backStack = backStack,
|
||||
callback = viewModel,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cameraSlot = { CameraSlot() },
|
||||
textStoryEditorSlot = { TextStoryEditorSlot() },
|
||||
mediaSelectSlot = {
|
||||
MediaSelectScreen(
|
||||
state = state,
|
||||
backStack = backStack,
|
||||
callback = viewModel
|
||||
)
|
||||
},
|
||||
videoEditorSlot = { VideoEditorSlot() },
|
||||
sendSlot = { SendSlot() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Theme wrapper */
|
||||
@Composable
|
||||
protected open fun Theme(content: @Composable () -> Unit) {
|
||||
SignalTheme(incognitoKeyboardEnabled = false) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Camera capture UI slot. */
|
||||
@Composable
|
||||
protected abstract fun CameraSlot()
|
||||
|
||||
/** Text story editor UI slot. */
|
||||
@Composable
|
||||
protected abstract fun TextStoryEditorSlot()
|
||||
|
||||
/** Video editor UI slot. */
|
||||
@Composable
|
||||
protected abstract fun VideoEditorSlot()
|
||||
|
||||
/** Send/review UI slot. */
|
||||
@Composable
|
||||
protected abstract fun SendSlot()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates an intent for a concrete [MediaSendActivity] subclass.
|
||||
*
|
||||
* @param context The context.
|
||||
* @param activityClass The concrete activity class to launch.
|
||||
* @param args The activity arguments.
|
||||
*/
|
||||
fun <T : MediaSendActivity> createIntent(
|
||||
context: Context,
|
||||
activityClass: Class<T>,
|
||||
args: MediaSendActivityContract.Args = MediaSendActivityContract.Args()
|
||||
): Intent {
|
||||
return Intent(context, activityClass).apply {
|
||||
putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.models.media.Media
|
||||
|
||||
/**
|
||||
* Well-defined entry/exit contract for the media sending flow.
|
||||
*
|
||||
* This intentionally supports a "multi-tier" outcome:
|
||||
* - Return a payload to be sent by the caller (single-recipient / conversation-owned send pipeline).
|
||||
* - Or send immediately in the flow and return an acknowledgement (multi-recipient / broadcast paths).
|
||||
*
|
||||
* Since [MediaSendActivity] is abstract, app-layer implementations should either:
|
||||
* 1. Extend this contract and override [createIntent] to use their concrete activity class
|
||||
* 2. Use the constructor that takes an activity class parameter
|
||||
*
|
||||
* Example:
|
||||
* ```kotlin
|
||||
* class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java)
|
||||
* ```
|
||||
*/
|
||||
open class MediaSendActivityContract(
|
||||
private val activityClass: Class<out MediaSendActivity>? = null
|
||||
) : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
|
||||
|
||||
/**
|
||||
* Creates the intent to launch the media send activity.
|
||||
*
|
||||
* Subclasses should override this if not using the constructor parameter.
|
||||
*/
|
||||
override fun createIntent(context: Context, input: Args): Intent {
|
||||
val clazz = activityClass
|
||||
?: throw IllegalStateException(
|
||||
"MediaSendActivityContract requires either a concrete activity class in the constructor " +
|
||||
"or an overridden createIntent() method. MediaSendActivity is abstract and cannot be launched directly."
|
||||
)
|
||||
return MediaSendActivity.createIntent(context, clazz, input)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Result? {
|
||||
if (resultCode != Activity.RESULT_OK || intent == null) return null
|
||||
return intent.getParcelableExtra(EXTRA_RESULT)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Args(
|
||||
val isCameraFirst: Boolean = false,
|
||||
/**
|
||||
* Optional recipient identifier for single-recipient flows.
|
||||
*/
|
||||
val recipientId: MediaRecipientId? = null,
|
||||
val mode: Mode = Mode.SingleRecipient,
|
||||
/**
|
||||
* Initial media to populate the selection.
|
||||
* For gallery/editor flows, this is the pre-selected media.
|
||||
* For camera-first flows, this is typically empty.
|
||||
*/
|
||||
val initialMedia: List<Media> = emptyList(),
|
||||
/**
|
||||
* Initial message/caption text.
|
||||
*/
|
||||
val initialMessage: String? = null,
|
||||
/**
|
||||
* Whether this is a reply flow (affects UI/constraints).
|
||||
*/
|
||||
val isReply: Boolean = false,
|
||||
/**
|
||||
* Whether this is a story send flow.
|
||||
*/
|
||||
val isStory: Boolean = false,
|
||||
/**
|
||||
* Whether this is specifically the "add to group story" flow.
|
||||
*/
|
||||
val isAddToGroupStoryFlow: Boolean = false,
|
||||
/**
|
||||
* Maximum number of media items that can be selected.
|
||||
*/
|
||||
val maxSelection: Int = 32,
|
||||
/**
|
||||
* Send type identifier (app-layer enum ordinal).
|
||||
*/
|
||||
val sendType: Int = 0
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
fun fromIntent(intent: Intent): Args {
|
||||
return intent.getParcelableExtra(EXTRA_ARGS) ?: Args()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level mode of operation for the flow.
|
||||
*
|
||||
* Note: We keep this free of app/database types. Callers can wrap their own identifiers as needed.
|
||||
*/
|
||||
sealed interface Mode : Parcelable {
|
||||
/** Single known recipient — returns result for caller to send. */
|
||||
@Parcelize
|
||||
data object SingleRecipient : Mode
|
||||
|
||||
/** Multiple known recipients — sends immediately, returns [Result.Sent]. */
|
||||
@Parcelize
|
||||
data object MultiRecipient : Mode
|
||||
|
||||
/** User will select contacts during the flow — sends immediately, returns [Result.Sent]. */
|
||||
@Parcelize
|
||||
data object ChooseAfterMediaSelection : Mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when the flow completes successfully.
|
||||
*
|
||||
* - [ReadyToSend] mirrors the historical "return a result to Conversation to send" pattern.
|
||||
* - [Sent] mirrors the historical "broadcast paths send immediately" behavior.
|
||||
*/
|
||||
sealed interface Result : Parcelable {
|
||||
|
||||
/**
|
||||
* Caller should send via its canonical pipeline (e.g., conversation-owned send path).
|
||||
*/
|
||||
@Parcelize
|
||||
data class ReadyToSend(
|
||||
val recipientId: MediaRecipientId,
|
||||
val body: String,
|
||||
val isViewOnce: Boolean,
|
||||
val scheduledTime: Long = -1,
|
||||
val payload: Payload
|
||||
) : Result
|
||||
|
||||
/**
|
||||
* The flow already performed the send (e.g., multi-recipient broadcast), so there is no payload.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Sent : Result
|
||||
}
|
||||
|
||||
sealed interface Payload : Parcelable {
|
||||
/**
|
||||
* Pre-upload handles + dependencies. This is intentionally "handle-only" so the feature module
|
||||
* does not need to understand DB details; the app layer can interpret these primitives.
|
||||
*/
|
||||
@Parcelize
|
||||
data class PreUploaded(
|
||||
val items: List<PreUploadHandle>
|
||||
) : Payload
|
||||
|
||||
/**
|
||||
* Local media that the caller should upload/send as part of the normal pipeline.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LocalMedia(
|
||||
val items: List<SelectedMedia>
|
||||
) : Payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the minimum data the legacy send pipeline needs for pre-upload sends:
|
||||
* - an attachment row identifier
|
||||
* - job dependency ids that the send jobs should wait on
|
||||
*
|
||||
* The feature module does not create or interpret these; it only transports them.
|
||||
*/
|
||||
@Parcelize
|
||||
data class PreUploadHandle(
|
||||
val attachmentId: Long,
|
||||
val jobIds: List<String>,
|
||||
val mediaUri: Uri
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Feature-level representation of selected media when the caller is responsible for upload/send.
|
||||
* This intentionally avoids coupling to app-layer `Media` / `TransformProperties`.
|
||||
*/
|
||||
@Parcelize
|
||||
data class SelectedMedia(
|
||||
val uri: Uri,
|
||||
val contentType: String?,
|
||||
val width: Int = 0,
|
||||
val height: Int = 0,
|
||||
val size: Long = 0,
|
||||
val duration: Long = 0,
|
||||
val isBorderless: Boolean = false,
|
||||
val isVideoGif: Boolean = false,
|
||||
val caption: String? = null,
|
||||
val fileName: String? = null,
|
||||
val transform: Transform? = null
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Feature-level transform model for media edits/quality.
|
||||
* Kept Parcelable for the Activity result boundary; a pure JVM equivalent can live in `core-models`.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Transform(
|
||||
val skipTransform: Boolean = false,
|
||||
val videoTrim: Boolean = false,
|
||||
val videoTrimStartTimeUs: Long = 0,
|
||||
val videoTrimEndTimeUs: Long = 0,
|
||||
val sentMediaQuality: Int = 0,
|
||||
val mp4FastStart: Boolean = false
|
||||
) : Parcelable
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ARGS = "org.signal.mediasend.args"
|
||||
const val EXTRA_RESULT = "result"
|
||||
|
||||
fun toResultIntent(result: Result): Intent {
|
||||
return Intent().apply {
|
||||
putExtra(EXTRA_RESULT, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.net.Uri
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.mediasend.edit.MediaEditScreenCallback
|
||||
import org.signal.mediasend.select.MediaSelectScreenCallback
|
||||
|
||||
/**
|
||||
* Interface for communicating user intent back up to the view-model.
|
||||
*/
|
||||
interface MediaSendCallback : MediaEditScreenCallback, MediaSelectScreenCallback {
|
||||
|
||||
/** Called when the user navigates to a different position. */
|
||||
fun onPageChanged(position: Int) {}
|
||||
|
||||
/** Called when the user edits video trim data. */
|
||||
fun onVideoEdited(uri: Uri, isEdited: Boolean) {}
|
||||
|
||||
/** Called when message text changes. */
|
||||
fun onMessageChanged(text: CharSequence?) {}
|
||||
|
||||
object Empty : MediaSendCallback, MediaEditScreenCallback by MediaEditScreenCallback.Empty, MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
|
||||
override fun setFocusedMedia(media: Media) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands sent from the ViewModel to the UI layer (HUD).
|
||||
*
|
||||
* These are one-shot events that don't belong in persistent state.
|
||||
*/
|
||||
sealed interface HudCommand {
|
||||
/** Start camera capture flow. */
|
||||
data object StartCamera : HudCommand
|
||||
|
||||
/** Open the media selector/gallery. */
|
||||
data object OpenGallery : HudCommand
|
||||
|
||||
/** Resume previously paused video. */
|
||||
data object ResumeVideo : HudCommand
|
||||
|
||||
/** Show a transient error message. */
|
||||
data class ShowError(val message: String) : HudCommand
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object MediaSendMetrics {
|
||||
val SelectedMediaPreviewSize = DpSize(44.dp, 44.dp)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Nav3 keys
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface MediaSendNavKey : NavKey {
|
||||
@Serializable
|
||||
data object Select : MediaSendNavKey
|
||||
|
||||
@Serializable
|
||||
sealed interface Capture : MediaSendNavKey {
|
||||
@Serializable
|
||||
data object Chrome : Capture
|
||||
|
||||
@Serializable
|
||||
data object Camera : Capture
|
||||
|
||||
@Serializable
|
||||
data object TextStory : Capture
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object Edit : MediaSendNavKey
|
||||
|
||||
@Serializable
|
||||
data object Send : MediaSendNavKey
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
|
||||
/**
|
||||
* Repository interface for media send operations that require app-layer implementation.
|
||||
*
|
||||
* This allows the feature module to remain decoupled from database, recipient,
|
||||
* and constraint implementations while still supporting full functionality.
|
||||
*/
|
||||
interface MediaSendRepository {
|
||||
|
||||
/**
|
||||
* Retrieves the top-level folders which contain media
|
||||
*/
|
||||
suspend fun getFolders(): List<MediaFolder>
|
||||
|
||||
/**
|
||||
* Retrieves media for a given bucketId (folder)
|
||||
*/
|
||||
suspend fun getMedia(bucketId: String): List<Media>
|
||||
|
||||
/**
|
||||
* Validates and filters media against constraints.
|
||||
*
|
||||
* @param media The media items to validate.
|
||||
* @param maxSelection Maximum number of items allowed.
|
||||
* @param isStory Whether this is for a story (may have different constraints).
|
||||
* @return Result containing filtered media and any validation errors.
|
||||
*/
|
||||
suspend fun validateAndFilterMedia(
|
||||
media: List<Media>,
|
||||
maxSelection: Int,
|
||||
isStory: Boolean
|
||||
): MediaFilterResult
|
||||
|
||||
/**
|
||||
* Deletes temporary blob files for the given media.
|
||||
*/
|
||||
suspend fun deleteBlobs(media: List<Media>)
|
||||
|
||||
/**
|
||||
* Sends the media with the given parameters.
|
||||
*
|
||||
* @return Result indicating success or containing a send result.
|
||||
*/
|
||||
suspend fun send(request: SendRequest): SendResult
|
||||
|
||||
/**
|
||||
* Gets the maximum video duration in microseconds based on quality and file size limits.
|
||||
*
|
||||
* @param quality The sent media quality code.
|
||||
* @param maxFileSizeBytes Maximum file size in bytes.
|
||||
* @return Maximum duration in microseconds.
|
||||
*/
|
||||
fun getMaxVideoDurationUs(quality: Int, maxFileSizeBytes: Long): Long
|
||||
|
||||
/**
|
||||
* Gets the maximum video file size in bytes.
|
||||
*/
|
||||
fun getVideoMaxSizeBytes(): Long
|
||||
|
||||
/**
|
||||
* Checks if video transcoding is available on this device.
|
||||
*/
|
||||
fun isVideoTranscodeAvailable(): Boolean
|
||||
|
||||
/**
|
||||
* Gets story send requirements for the given media.
|
||||
*/
|
||||
suspend fun getStorySendRequirements(media: List<Media>): StorySendRequirements
|
||||
|
||||
/**
|
||||
* Checks for untrusted identity records among the given contacts.
|
||||
*
|
||||
* @param contactIds Contact identifiers to check.
|
||||
* @param since Timestamp to check identity changes since.
|
||||
* @return List of contacts with bad identity records, empty if all trusted.
|
||||
*/
|
||||
suspend fun checkUntrustedIdentities(
|
||||
contactIds: Set<Long>,
|
||||
since: Long
|
||||
): List<Long>
|
||||
|
||||
/**
|
||||
* Provides a flow of recipient "exists" state for determining pre-upload eligibility.
|
||||
* Emits true if the recipient is valid and can receive pre-uploads.
|
||||
*
|
||||
* @param recipientId The recipient to observe.
|
||||
* @return Flow that emits whenever recipient validity changes.
|
||||
*/
|
||||
fun observeRecipientValid(recipientId: MediaRecipientId): Flow<Boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of media validation/filtering.
|
||||
*/
|
||||
data class MediaFilterResult(
|
||||
val filteredMedia: List<Media>,
|
||||
val error: MediaFilterError?
|
||||
)
|
||||
|
||||
/**
|
||||
* Errors that can occur during media filtering.
|
||||
*/
|
||||
sealed interface MediaFilterError {
|
||||
data object NoItems : MediaFilterError
|
||||
data class ItemTooLarge(val media: Media) : MediaFilterError
|
||||
data class ItemInvalidType(val media: Media) : MediaFilterError
|
||||
data class TooManyItems(val max: Int) : MediaFilterError
|
||||
data class CannotMixMediaTypes(val message: String) : MediaFilterError
|
||||
data class Other(val message: String) : MediaFilterError
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for sending media.
|
||||
*/
|
||||
data class SendRequest(
|
||||
val selectedMedia: List<Media>,
|
||||
val editorStateMap: Map<Uri, EditorState>,
|
||||
val quality: Int,
|
||||
val message: String?,
|
||||
val isViewOnce: Boolean,
|
||||
val singleRecipientId: MediaRecipientId?,
|
||||
val recipientIds: List<MediaRecipientId>,
|
||||
val scheduledTime: Long,
|
||||
val sendType: Int,
|
||||
val isStory: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of a send operation.
|
||||
*/
|
||||
sealed interface SendResult {
|
||||
data object Success : SendResult
|
||||
data class Error(val message: String) : SendResult
|
||||
data class UntrustedIdentity(val recipientIds: List<Long>) : SendResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Story send requirements based on media content.
|
||||
*/
|
||||
enum class StorySendRequirements {
|
||||
/** Can send to stories. */
|
||||
CAN_SEND,
|
||||
|
||||
/** Cannot send to stories. */
|
||||
CAN_NOT_SEND,
|
||||
|
||||
/** Requires cropping before sending to stories. */
|
||||
REQUIRES_CROP
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
|
||||
import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.mediasend.edit.MediaEditScreen
|
||||
|
||||
/**
|
||||
* Enforces the following flow of:
|
||||
*
|
||||
* Capture -> Edit -> Send
|
||||
* Select -> Edit -> Send
|
||||
*/
|
||||
@Composable
|
||||
fun MediaSendNavDisplay(
|
||||
state: MediaSendState,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
callback: MediaSendCallback,
|
||||
modifier: Modifier = Modifier,
|
||||
cameraSlot: @Composable () -> Unit = {},
|
||||
textStoryEditorSlot: @Composable () -> Unit = {},
|
||||
mediaSelectSlot: @Composable () -> Unit = {},
|
||||
videoEditorSlot: @Composable () -> Unit = {},
|
||||
sendSlot: @Composable () -> Unit = {}
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = modifier.fillMaxSize()
|
||||
) { key ->
|
||||
when (key) {
|
||||
is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) {
|
||||
MediaCaptureScreen(
|
||||
backStack = backStack,
|
||||
cameraSlot = cameraSlot,
|
||||
textStoryEditorSlot = textStoryEditorSlot
|
||||
)
|
||||
}
|
||||
|
||||
MediaSendNavKey.Select -> NavEntry(key) {
|
||||
mediaSelectSlot()
|
||||
}
|
||||
|
||||
is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) {
|
||||
MediaEditScreen(
|
||||
state = state,
|
||||
backStack = backStack,
|
||||
videoEditorSlot = videoEditorSlot,
|
||||
callback = callback
|
||||
)
|
||||
}
|
||||
|
||||
is MediaSendNavKey.Send -> NavEntry(key) {
|
||||
sendSlot()
|
||||
}
|
||||
|
||||
else -> error("Unknown key: $key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaSendNavDisplayPreview() {
|
||||
Previews.Preview {
|
||||
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides rememberNavigationEventDispatcherOwner(parent = null)) {
|
||||
MediaSendNavDisplay(
|
||||
state = MediaSendState(isCameraFirst = true),
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
callback = MediaSendCallback.Empty,
|
||||
cameraSlot = { BoxWithText("Camera Slot") },
|
||||
textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") },
|
||||
mediaSelectSlot = { BoxWithText("Media Select Slot") },
|
||||
videoEditorSlot = { BoxWithText("Video Editor Slot") },
|
||||
sendSlot = { BoxWithText("Send Slot") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxWithText(text: String, modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
|
||||
/**
|
||||
* The collective state of the media send flow.
|
||||
*
|
||||
* Fully [Parcelable] for [SavedStateHandle] persistence — no separate serialization needed.
|
||||
*/
|
||||
@Parcelize
|
||||
data class MediaSendState(
|
||||
val isCameraFirst: Boolean = false,
|
||||
/**
|
||||
* Optional recipient identifier for single-recipient flows.
|
||||
*/
|
||||
val recipientId: MediaRecipientId? = null,
|
||||
/**
|
||||
* Mode of operation — determines whether we return a result or send immediately.
|
||||
*/
|
||||
val mode: MediaSendActivityContract.Mode = MediaSendActivityContract.Mode.SingleRecipient,
|
||||
val selectedMedia: List<Media> = emptyList(),
|
||||
/**
|
||||
* The currently focused/visible media item in the pager.
|
||||
*/
|
||||
val focusedMedia: Media? = null,
|
||||
val isMeteredConnection: Boolean = false,
|
||||
val isPreUploadEnabled: Boolean = false,
|
||||
/**
|
||||
* Int code to avoid depending on app-layer enums. Conventionally 0 == STANDARD.
|
||||
*/
|
||||
val sentMediaQuality: Int = 0,
|
||||
/**
|
||||
* Per-media editor state keyed by URI (video trim data, image editor data, etc.).
|
||||
*/
|
||||
val editorStateMap: Map<Uri, EditorState> = emptyMap(),
|
||||
/**
|
||||
* View-once toggle state. Cycles: OFF -> ONCE -> OFF.
|
||||
*/
|
||||
val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.OFF,
|
||||
/**
|
||||
* Optional message/caption text to accompany the media.
|
||||
*/
|
||||
val message: String? = null,
|
||||
/**
|
||||
* If non-null, this media was the first capture from the camera and may be
|
||||
* removed if the user backs out of camera-first flow.
|
||||
*/
|
||||
val cameraFirstCapture: Media? = null,
|
||||
/**
|
||||
* Whether touch interactions are enabled (disabled during animations/transitions).
|
||||
*/
|
||||
val isTouchEnabled: Boolean = true,
|
||||
/**
|
||||
* When true, suppresses the "no items" error when selection becomes empty.
|
||||
* Used during camera-first flow exit.
|
||||
*/
|
||||
val suppressEmptyError: Boolean = false,
|
||||
/**
|
||||
* Whether the media has been sent (prevents duplicate sends).
|
||||
*/
|
||||
val isSent: Boolean = false,
|
||||
/**
|
||||
* Whether this is a story send flow.
|
||||
*/
|
||||
val isStory: Boolean = false,
|
||||
/**
|
||||
* Send type code (SMS vs Signal). Conventionally 0 == SignalMessageSendType.
|
||||
*/
|
||||
val sendType: Int = 0,
|
||||
/**
|
||||
* Story send requirements based on current media selection.
|
||||
*/
|
||||
val storySendRequirements: StorySendRequirements = StorySendRequirements.CAN_NOT_SEND,
|
||||
/**
|
||||
* Maximum number of media items that can be selected.
|
||||
*/
|
||||
val maxSelection: Int = 32,
|
||||
/**
|
||||
* Whether contact selection is required (for choose-after-media flows).
|
||||
*/
|
||||
val isContactSelectionRequired: Boolean = false,
|
||||
/**
|
||||
* Whether this is a reply to an existing message.
|
||||
*/
|
||||
val isReply: Boolean = false,
|
||||
/**
|
||||
* Whether this is the "add to group story" flow.
|
||||
*/
|
||||
val isAddToGroupStoryFlow: Boolean = false,
|
||||
/**
|
||||
* Additional recipient IDs for multi-recipient sends.
|
||||
*/
|
||||
val additionalRecipientIds: List<MediaRecipientId> = emptyList(),
|
||||
/**
|
||||
* Scheduled send time (-1 for immediate).
|
||||
*/
|
||||
val scheduledTime: Long = -1,
|
||||
|
||||
/**
|
||||
* The [MediaFolder] list available on the system
|
||||
*/
|
||||
val mediaFolders: List<MediaFolder> = emptyList(),
|
||||
|
||||
/**
|
||||
* The selected [MediaFolder] for which to display content in the Select screen
|
||||
*/
|
||||
val selectedMediaFolder: MediaFolder? = null,
|
||||
|
||||
/**
|
||||
* The media content for a given selected [MediaFolder]
|
||||
*/
|
||||
val selectedMediaFolderItems: List<Media> = emptyList()
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* View-once toggle state.
|
||||
*/
|
||||
enum class ViewOnceToggleState(val code: Int) {
|
||||
OFF(0),
|
||||
ONCE(1);
|
||||
|
||||
fun next(): ViewOnceToggleState = when (this) {
|
||||
OFF -> ONCE
|
||||
ONCE -> OFF
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int): ViewOnceToggleState = entries.firstOrNull { it.code == code } ?: OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Screen allowing the user to select who they wish to send a piece of content to.
|
||||
*/
|
||||
@Composable
|
||||
fun MediaSendToScreen() {
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.imageeditor.core.model.EditorElement
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import org.signal.imageeditor.core.renderers.UriGlideRenderer
|
||||
import org.signal.mediasend.preupload.PreUploadManager
|
||||
import java.util.Collections
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Configuration-survivable state manager for the media send flow.
|
||||
*
|
||||
* Uses [SavedStateHandle] for automatic state persistence across process death.
|
||||
* [MediaSendState] is fully [Parcelable] and saved directly as a single key.
|
||||
*/
|
||||
class MediaSendViewModel(
|
||||
private val args: MediaSendActivityContract.Args,
|
||||
private val identityChangesSince: Long,
|
||||
isMeteredFlow: Flow<Boolean>,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val repository: MediaSendRepository,
|
||||
private val preUploadManager: PreUploadManager
|
||||
) : ViewModel(), MediaSendCallback {
|
||||
|
||||
private val defaultState = MediaSendState(
|
||||
isCameraFirst = args.isCameraFirst,
|
||||
recipientId = args.recipientId,
|
||||
mode = args.mode,
|
||||
isStory = args.isStory,
|
||||
isReply = args.isReply,
|
||||
isAddToGroupStoryFlow = args.isAddToGroupStoryFlow,
|
||||
maxSelection = args.maxSelection,
|
||||
message = args.initialMessage,
|
||||
isContactSelectionRequired = args.mode == MediaSendActivityContract.Mode.ChooseAfterMediaSelection,
|
||||
sendType = args.sendType
|
||||
)
|
||||
|
||||
/**
|
||||
* Main UI state. Backed by [SavedStateHandle] for automatic process death survival.
|
||||
* Writes to this flow are automatically persisted.
|
||||
*/
|
||||
private val internalState: MutableStateFlow<MediaSendState> = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState)
|
||||
val state: StateFlow<MediaSendState> = internalState.asStateFlow()
|
||||
|
||||
private val editedVideoUris: MutableSet<Uri> = mutableSetOf<Uri>().apply {
|
||||
addAll(savedStateHandle[KEY_EDITED_VIDEO_URIS] ?: emptyList())
|
||||
}
|
||||
|
||||
/** One-shot HUD commands exposed as a Flow. */
|
||||
private val hudCommandChannel = Channel<HudCommand>(Channel.BUFFERED)
|
||||
val hudCommands: Flow<HudCommand> = hudCommandChannel.receiveAsFlow()
|
||||
|
||||
/** Media filter errors. */
|
||||
private val _mediaErrors = MutableSharedFlow<MediaFilterError>(replay = 1)
|
||||
val mediaErrors: SharedFlow<MediaFilterError> = _mediaErrors.asSharedFlow()
|
||||
|
||||
/** Character count for the message field. */
|
||||
val messageCharacterCount: Flow<Int> = state
|
||||
.map { it.message?.let { msg -> StringUtil.getGraphemeCount(msg) } ?: 0 }
|
||||
.distinctUntilChanged()
|
||||
|
||||
/** Tracks drag state for media reordering. */
|
||||
private var lastMediaDrag: Pair<Int, Int> = Pair(0, 0)
|
||||
|
||||
init {
|
||||
// Matches legacy behavior: VM subscribes to connectivity updates and derives
|
||||
// isPreUploadEnabled from metered state.
|
||||
viewModelScope.launch {
|
||||
isMeteredFlow.collect { metered ->
|
||||
updateState { copy(isMeteredConnection = metered, isPreUploadEnabled = shouldPreUpload(metered)) }
|
||||
}
|
||||
}
|
||||
|
||||
// Observe recipient validity for pre-upload eligibility
|
||||
args.recipientId?.let { recipientId ->
|
||||
viewModelScope.launch {
|
||||
repository.observeRecipientValid(recipientId).collect { isValid ->
|
||||
if (isValid) {
|
||||
updateState { copy(isPreUploadEnabled = shouldPreUpload(isMeteredConnection)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add initial media if provided
|
||||
if (args.initialMedia.isNotEmpty()) {
|
||||
addMedia(args.initialMedia.toSet())
|
||||
}
|
||||
|
||||
refreshMediaFolders()
|
||||
}
|
||||
|
||||
/** Updates state atomically — automatically persisted via SavedStateHandle-backed MutableStateFlow. */
|
||||
private inline fun updateState(crossinline transform: MediaSendState.() -> MediaSendState) {
|
||||
internalState.update { it.transform() }
|
||||
}
|
||||
|
||||
//region Media Selection
|
||||
|
||||
fun refreshMediaFolders() {
|
||||
viewModelScope.launch {
|
||||
val folders = repository.getFolders()
|
||||
internalState.update {
|
||||
it.copy(
|
||||
mediaFolders = folders,
|
||||
selectedMediaFolder = if (it.selectedMediaFolder in folders) it.selectedMediaFolder else null,
|
||||
selectedMedia = if (it.selectedMediaFolder in folders) it.selectedMediaFolderItems else emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFolderClick(mediaFolder: MediaFolder?) {
|
||||
viewModelScope.launch {
|
||||
if (mediaFolder != null) {
|
||||
val media = repository.getMedia(mediaFolder.bucketId)
|
||||
internalState.update { it.copy(selectedMediaFolder = mediaFolder, selectedMediaFolderItems = media) }
|
||||
} else {
|
||||
internalState.update { it.copy(selectedMediaFolder = null, selectedMediaFolderItems = emptyList()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaClick(media: Media) {
|
||||
if (media.uri in internalState.value.selectedMedia.map { it.uri }) {
|
||||
removeMedia(media)
|
||||
} else {
|
||||
addMedia(media)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [media] to the selection, preserving insertion order and uniqueness by equality.
|
||||
*
|
||||
* Validates against constraints and starts pre-uploads for newly added items.
|
||||
*
|
||||
* @param media Media items to add.
|
||||
*/
|
||||
fun addMedia(media: Set<Media>) {
|
||||
viewModelScope.launch {
|
||||
val snapshot = state.value
|
||||
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
|
||||
addAll(snapshot.selectedMedia)
|
||||
addAll(media)
|
||||
}.toList()
|
||||
|
||||
// Validate and filter through repository
|
||||
val filterResult = repository.validateAndFilterMedia(
|
||||
media = newSelectionList,
|
||||
maxSelection = snapshot.maxSelection,
|
||||
isStory = snapshot.isStory
|
||||
)
|
||||
|
||||
if (filterResult.filteredMedia.isNotEmpty()) {
|
||||
// Initialize video trim states for new videos
|
||||
val maxVideoDurationUs = getMaxVideoDurationUs()
|
||||
val initializedVideoEditorStates = filterResult.filteredMedia
|
||||
.filterNot { snapshot.editorStateMap.containsKey(it.uri) }
|
||||
.filter { isNonGifVideo(it) }
|
||||
.associate { video ->
|
||||
val durationUs = video.duration.milliseconds.inWholeMicroseconds
|
||||
video.uri to EditorState.VideoTrim.forVideo(durationUs, maxVideoDurationUs)
|
||||
}
|
||||
|
||||
val initializedImageEditorStates = filterResult.filteredMedia
|
||||
.filterNot { snapshot.editorStateMap.containsKey(it.uri) }
|
||||
.filter { ContentTypeUtil.isImageType(it.contentType) }
|
||||
.associate { image ->
|
||||
// TODO - this should likely be in a repository?
|
||||
val editorModel = EditorModel.create(0x0)
|
||||
val element = EditorElement(
|
||||
UriGlideRenderer(
|
||||
image.uri,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
UriGlideRenderer.STRONG_BLUR,
|
||||
object : RequestListener<Bitmap> {
|
||||
override fun onResourceReady(resource: Bitmap?, model: Any?, target: Target<Bitmap?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Bitmap?>?, isFirstResource: Boolean): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
element.flags.setSelectable(false).persist()
|
||||
editorModel.addElement(element)
|
||||
image.uri to EditorState.Image(editorModel)
|
||||
}
|
||||
|
||||
updateState {
|
||||
copy(
|
||||
selectedMedia = filterResult.filteredMedia,
|
||||
focusedMedia = focusedMedia ?: filterResult.filteredMedia.firstOrNull(),
|
||||
editorStateMap = editorStateMap + initializedVideoEditorStates + initializedImageEditorStates
|
||||
)
|
||||
}
|
||||
|
||||
// Update story requirements
|
||||
updateStorySendRequirements(filterResult.filteredMedia)
|
||||
|
||||
// Start pre-uploads for new media
|
||||
val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList()
|
||||
startUpload(newMedia)
|
||||
}
|
||||
|
||||
if (filterResult.error != null) {
|
||||
_mediaErrors.emit(filterResult.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single [media] item to the selection.
|
||||
*/
|
||||
fun addMedia(media: Media) {
|
||||
addMedia(setOf(media))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single [media] item from the selection.
|
||||
*/
|
||||
fun removeMedia(media: Media) {
|
||||
removeMedia(setOf(media))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes [media] from the selection.
|
||||
*
|
||||
* Cancels any pre-uploads for the removed items.
|
||||
*
|
||||
* @param media Media items to remove.
|
||||
*/
|
||||
fun removeMedia(media: Set<Media>) {
|
||||
val snapshot = state.value
|
||||
val newSelection = snapshot.selectedMedia - media
|
||||
|
||||
val newFocus = when {
|
||||
newSelection.isEmpty() -> null
|
||||
snapshot.focusedMedia in media -> {
|
||||
val oldFocusIndex = snapshot.selectedMedia.indexOf(snapshot.focusedMedia)
|
||||
newSelection[oldFocusIndex.coerceIn(0, newSelection.size - 1)]
|
||||
}
|
||||
|
||||
else -> snapshot.focusedMedia
|
||||
}
|
||||
|
||||
val newCameraFirstCapture = if (snapshot.cameraFirstCapture in media) null else snapshot.cameraFirstCapture
|
||||
|
||||
updateState {
|
||||
copy(
|
||||
selectedMedia = newSelection,
|
||||
focusedMedia = newFocus,
|
||||
editorStateMap = editorStateMap - media.map { it.uri }.toSet(),
|
||||
cameraFirstCapture = newCameraFirstCapture
|
||||
)
|
||||
}
|
||||
|
||||
if (newSelection.isEmpty() && !snapshot.suppressEmptyError) {
|
||||
viewModelScope.launch {
|
||||
_mediaErrors.emit(MediaFilterError.NoItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Update story requirements
|
||||
viewModelScope.launch {
|
||||
updateStorySendRequirements(newSelection)
|
||||
}
|
||||
|
||||
// Delete blobs and cancel uploads
|
||||
viewModelScope.launch {
|
||||
repository.deleteBlobs(media.toList())
|
||||
}
|
||||
preUploadManager.cancelUpload(media)
|
||||
preUploadManager.updateDisplayOrder(newSelection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies updates to selected media (old -> new).
|
||||
*/
|
||||
fun applyMediaUpdates(oldToNew: Map<Media, Media>) {
|
||||
if (oldToNew.isEmpty()) return
|
||||
|
||||
val snapshot = state.value
|
||||
val updatedSelection = snapshot.selectedMedia.map { oldToNew[it] ?: it }
|
||||
updateState { copy(selectedMedia = updatedSelection) }
|
||||
|
||||
preUploadManager.applyMediaUpdates(oldToNew, snapshot.recipientId)
|
||||
preUploadManager.updateCaptions(updatedSelection)
|
||||
preUploadManager.updateDisplayOrder(updatedSelection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current ordering of selected media.
|
||||
*/
|
||||
fun setDisplayOrder(mediaInOrder: List<Media>) {
|
||||
updateState { copy(selectedMedia = mediaInOrder) }
|
||||
preUploadManager.updateDisplayOrder(mediaInOrder)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Pre-Upload Management
|
||||
|
||||
private fun startUpload(media: List<Media>) {
|
||||
val snapshot = state.value
|
||||
if (!snapshot.isPreUploadEnabled) return
|
||||
|
||||
val filteredPreUploadMedia = if (snapshot.mode is MediaSendActivityContract.Mode.SingleRecipient) {
|
||||
media.filter { !ContentTypeUtil.isDocumentType(it.contentType) }
|
||||
} else {
|
||||
media.filter { ContentTypeUtil.isStorySupportedType(it.contentType) }
|
||||
}
|
||||
|
||||
preUploadManager.startUpload(filteredPreUploadMedia, snapshot.recipientId)
|
||||
preUploadManager.updateCaptions(snapshot.selectedMedia)
|
||||
preUploadManager.updateDisplayOrder(snapshot.selectedMedia)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Quality
|
||||
|
||||
/**
|
||||
* Sets the sent media quality.
|
||||
*
|
||||
* Cancels all pre-uploads and re-initializes video trim data.
|
||||
*/
|
||||
fun setSentMediaQuality(sentMediaQuality: Int) {
|
||||
val snapshot = state.value
|
||||
if (snapshot.sentMediaQuality == sentMediaQuality) return
|
||||
|
||||
updateState { copy(sentMediaQuality = sentMediaQuality, isPreUploadEnabled = false) }
|
||||
preUploadManager.cancelAllUploads()
|
||||
|
||||
// Re-clamp video durations based on new quality
|
||||
val maxVideoDurationUs = getMaxVideoDurationUs()
|
||||
snapshot.selectedMedia.forEach { mediaItem ->
|
||||
if (isNonGifVideo(mediaItem) && repository.isVideoTranscodeAvailable()) {
|
||||
val existingData = snapshot.editorStateMap[mediaItem.uri] as? EditorState.VideoTrim
|
||||
if (existingData != null) {
|
||||
onEditVideoDuration(
|
||||
totalDurationUs = existingData.totalInputDurationUs,
|
||||
startTimeUs = existingData.startTimeUs,
|
||||
endTimeUs = existingData.endTimeUs,
|
||||
touchEnabled = true,
|
||||
uri = mediaItem.uri
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Video Editing
|
||||
|
||||
/**
|
||||
* Notifies the view-model that a video's trim/duration has been edited.
|
||||
*/
|
||||
override fun onVideoEdited(uri: Uri, isEdited: Boolean) {
|
||||
if (!isEdited) return
|
||||
if (!editedVideoUris.add(uri)) return
|
||||
|
||||
// Persist the updated set
|
||||
savedStateHandle[KEY_EDITED_VIDEO_URIS] = ArrayList(editedVideoUris)
|
||||
|
||||
val media = state.value.selectedMedia.firstOrNull { it.uri == uri } ?: return
|
||||
preUploadManager.cancelUpload(media)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates video trim duration.
|
||||
*/
|
||||
fun onEditVideoDuration(
|
||||
totalDurationUs: Long,
|
||||
startTimeUs: Long,
|
||||
endTimeUs: Long,
|
||||
touchEnabled: Boolean,
|
||||
uri: Uri? = state.value.focusedMedia?.uri
|
||||
) {
|
||||
if (uri == null) return
|
||||
if (!repository.isVideoTranscodeAvailable()) return
|
||||
|
||||
val snapshot = state.value
|
||||
val existingData = snapshot.editorStateMap[uri] as? EditorState.VideoTrim
|
||||
?: EditorState.VideoTrim(totalInputDurationUs = totalDurationUs)
|
||||
|
||||
val clampedStartTime = maxOf(startTimeUs, 0)
|
||||
val unedited = !existingData.isDurationEdited
|
||||
val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs
|
||||
val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs
|
||||
val endMoved = !isEntireDuration && existingData.endTimeUs != endTimeUs
|
||||
val maxVideoDurationUs = getMaxVideoDurationUs()
|
||||
val preserveStartTime = unedited || !endMoved
|
||||
|
||||
val newData = EditorState.VideoTrim(
|
||||
isDurationEdited = durationEdited,
|
||||
totalInputDurationUs = totalDurationUs,
|
||||
startTimeUs = clampedStartTime,
|
||||
endTimeUs = endTimeUs
|
||||
).clampToMaxDuration(maxVideoDurationUs, preserveStartTime)
|
||||
|
||||
// Cancel upload on first edit
|
||||
if (unedited && durationEdited) {
|
||||
val media = snapshot.selectedMedia.firstOrNull { it.uri == uri }
|
||||
if (media != null) {
|
||||
preUploadManager.cancelUpload(media)
|
||||
}
|
||||
}
|
||||
|
||||
if (newData != existingData) {
|
||||
updateState {
|
||||
copy(
|
||||
isTouchEnabled = touchEnabled,
|
||||
editorStateMap = editorStateMap + (uri to newData)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
updateState { copy(isTouchEnabled = touchEnabled) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMaxVideoDurationUs(): Long {
|
||||
val snapshot = state.value
|
||||
return repository.getMaxVideoDurationUs(
|
||||
quality = snapshot.sentMediaQuality,
|
||||
maxFileSizeBytes = repository.getVideoMaxSizeBytes()
|
||||
)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Page/Focus Management
|
||||
|
||||
override fun setFocusedMedia(media: Media) {
|
||||
updateState { copy(focusedMedia = media) }
|
||||
}
|
||||
|
||||
override fun onPageChanged(position: Int) {
|
||||
val snapshot = state.value
|
||||
val focused = if (position >= snapshot.selectedMedia.size) null else snapshot.selectedMedia[position]
|
||||
updateState { copy(focusedMedia = focused) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Drag/Reordering
|
||||
|
||||
fun swapMedia(originalStart: Int, end: Int): Boolean {
|
||||
var start = originalStart
|
||||
|
||||
if (lastMediaDrag.first == start && lastMediaDrag.second == end) {
|
||||
return true
|
||||
} else if (lastMediaDrag.first == start) {
|
||||
start = lastMediaDrag.second
|
||||
}
|
||||
|
||||
val snapshot = state.value
|
||||
|
||||
if (end >= snapshot.selectedMedia.size ||
|
||||
end < 0 ||
|
||||
start >= snapshot.selectedMedia.size ||
|
||||
start < 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
lastMediaDrag = Pair(originalStart, end)
|
||||
|
||||
val newMediaList = snapshot.selectedMedia.toMutableList()
|
||||
|
||||
if (start < end) {
|
||||
for (i in start until end) {
|
||||
Collections.swap(newMediaList, i, i + 1)
|
||||
}
|
||||
} else {
|
||||
for (i in start downTo end + 1) {
|
||||
Collections.swap(newMediaList, i, i - 1)
|
||||
}
|
||||
}
|
||||
|
||||
updateState { copy(selectedMedia = newMediaList) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun isValidMediaDragPosition(position: Int): Boolean {
|
||||
return position >= 0 && position < internalState.value.selectedMedia.size
|
||||
}
|
||||
|
||||
private fun isNonGifVideo(media: Media): Boolean {
|
||||
return ContentTypeUtil.isVideo(media.contentType) && !media.isVideoGif
|
||||
}
|
||||
|
||||
fun onMediaDragFinished() {
|
||||
lastMediaDrag = Pair(0, 0)
|
||||
preUploadManager.updateDisplayOrder(internalState.value.selectedMedia)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Editor State
|
||||
|
||||
fun getEditorState(uri: Uri): EditorState? {
|
||||
return internalState.value.editorStateMap[uri]
|
||||
}
|
||||
|
||||
fun setEditorState(uri: Uri, state: EditorState) {
|
||||
updateState { copy(editorStateMap = editorStateMap + (uri to state)) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region View Once
|
||||
|
||||
fun incrementViewOnceState() {
|
||||
updateState { copy(viewOnceToggleState = viewOnceToggleState.next()) }
|
||||
}
|
||||
|
||||
fun isViewOnceEnabled(): Boolean {
|
||||
val snapshot = internalState.value
|
||||
return snapshot.selectedMedia.size == 1 &&
|
||||
snapshot.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Message
|
||||
|
||||
fun setMessage(text: String?) {
|
||||
updateState { copy(message = text) }
|
||||
}
|
||||
|
||||
override fun onMessageChanged(text: CharSequence?) {
|
||||
setMessage(text?.toString())
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Story
|
||||
|
||||
fun isStory(): Boolean = state.value.isStory
|
||||
|
||||
fun getStorySendRequirements(): StorySendRequirements = state.value.storySendRequirements
|
||||
|
||||
private suspend fun updateStorySendRequirements(media: List<Media>) {
|
||||
if (!state.value.isStory) return
|
||||
val requirements = repository.getStorySendRequirements(media)
|
||||
updateState { copy(storySendRequirements = requirements) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Recipients
|
||||
|
||||
fun setAdditionalRecipients(recipientIds: List<MediaRecipientId>) {
|
||||
updateState { copy(additionalRecipientIds = recipientIds) }
|
||||
}
|
||||
|
||||
fun setScheduledTime(time: Long) {
|
||||
updateState { copy(scheduledTime = time) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Camera First Capture
|
||||
|
||||
fun addCameraFirstCapture(media: Media) {
|
||||
internalState.update { it.copy(cameraFirstCapture = media) }
|
||||
addMedia(media)
|
||||
}
|
||||
|
||||
fun removeCameraFirstCapture() {
|
||||
val capture = internalState.value.cameraFirstCapture ?: return
|
||||
setSuppressEmptyError(true)
|
||||
removeMedia(capture)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Touch & Error Suppression
|
||||
|
||||
fun setTouchEnabled(isEnabled: Boolean) {
|
||||
updateState { copy(isTouchEnabled = isEnabled) }
|
||||
}
|
||||
|
||||
fun setSuppressEmptyError(isSuppressed: Boolean) {
|
||||
updateState { copy(suppressEmptyError = isSuppressed) }
|
||||
}
|
||||
|
||||
fun clearMediaErrors() {
|
||||
viewModelScope.launch {
|
||||
_mediaErrors.resetReplayCache()
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Send
|
||||
|
||||
/**
|
||||
* Sends the media with current state.
|
||||
*
|
||||
* @return Result of the send operation.
|
||||
*/
|
||||
suspend fun send(): SendResult {
|
||||
val snapshot = state.value
|
||||
|
||||
// Check for untrusted identities
|
||||
val allRecipientIds = buildSet {
|
||||
snapshot.recipientId?.let { add(it.id) }
|
||||
addAll(snapshot.additionalRecipientIds.map { it.id })
|
||||
}
|
||||
|
||||
if (allRecipientIds.isNotEmpty()) {
|
||||
val untrusted = repository.checkUntrustedIdentities(allRecipientIds, identityChangesSince)
|
||||
if (untrusted.isNotEmpty()) {
|
||||
return SendResult.UntrustedIdentity(untrusted)
|
||||
}
|
||||
}
|
||||
|
||||
val request = SendRequest(
|
||||
selectedMedia = snapshot.selectedMedia,
|
||||
editorStateMap = snapshot.editorStateMap,
|
||||
quality = snapshot.sentMediaQuality,
|
||||
message = snapshot.message,
|
||||
isViewOnce = isViewOnceEnabled(),
|
||||
singleRecipientId = snapshot.recipientId,
|
||||
recipientIds = snapshot.additionalRecipientIds,
|
||||
scheduledTime = snapshot.scheduledTime,
|
||||
sendType = snapshot.sendType,
|
||||
isStory = snapshot.isStory
|
||||
)
|
||||
|
||||
val result = repository.send(request)
|
||||
|
||||
if (result is SendResult.Success) {
|
||||
updateState { copy(isSent = true) }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region HUD Commands
|
||||
|
||||
fun sendCommand(command: HudCommand) {
|
||||
hudCommandChannel.trySend(command)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Query Methods
|
||||
|
||||
fun hasSelectedMedia(): Boolean = internalState.value.selectedMedia.isNotEmpty()
|
||||
|
||||
fun isSelectedMediaEmpty(): Boolean = internalState.value.selectedMedia.isEmpty()
|
||||
|
||||
fun kick() {
|
||||
internalState.update { it }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Lifecycle
|
||||
|
||||
override fun onCleared() {
|
||||
preUploadManager.cancelAllUploads()
|
||||
preUploadManager.deleteAbandonedAttachments()
|
||||
}
|
||||
|
||||
private fun shouldPreUpload(metered: Boolean): Boolean = !metered
|
||||
|
||||
//endregion
|
||||
|
||||
//region Factory
|
||||
|
||||
class Factory(
|
||||
private val context: Context,
|
||||
private val args: MediaSendActivityContract.Args,
|
||||
private val identityChangesSince: Long = System.currentTimeMillis(),
|
||||
private val isMeteredFlow: Flow<Boolean>,
|
||||
private val repository: MediaSendRepository,
|
||||
private val preUploadCallback: PreUploadManager.Callback
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
||||
val savedStateHandle = extras.createSavedStateHandle()
|
||||
val manager = PreUploadManager(context.applicationContext, preUploadCallback)
|
||||
|
||||
return MediaSendViewModel(
|
||||
args = args,
|
||||
identityChangesSince = identityChangesSince,
|
||||
isMeteredFlow = isMeteredFlow,
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = repository,
|
||||
preUploadManager = manager
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private const val KEY_STATE = "media_send_vm_state"
|
||||
private const val KEY_EDITED_VIDEO_URIS = "media_send_vm_edited_video_uris"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
object MeteredConnectivity {
|
||||
|
||||
/**
|
||||
* @return A cold [Flow] that emits `true` when the active network is metered.
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
|
||||
fun isMetered(context: Context): Flow<Boolean> {
|
||||
val appContext = context.applicationContext
|
||||
|
||||
return callbackFlow {
|
||||
val cm: ConnectivityManager = requireNotNull(ContextCompat.getSystemService(appContext, ConnectivityManager::class.java))
|
||||
|
||||
fun currentMetered(): Boolean = cm.isActiveNetworkMetered
|
||||
|
||||
trySend(currentMetered())
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(currentMetered())
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(currentMetered())
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
val metered = !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
trySend(metered)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
|
||||
awaitClose {
|
||||
cm.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
internal fun NavBackStack<NavKey>.goToEdit() {
|
||||
if (contains(MediaSendNavKey.Edit)) {
|
||||
popTo(MediaSendNavKey.Edit)
|
||||
} else {
|
||||
add(MediaSendNavKey.Edit)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NavBackStack<NavKey>.pop() {
|
||||
if (isNotEmpty()) {
|
||||
removeAt(size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavBackStack<NavKey>.popTo(key: NavKey) {
|
||||
while (size > 1 && get(size - 1) != key) {
|
||||
removeAt(size - 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.IconButtons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
|
||||
@Composable
|
||||
internal fun AddAMessageRow(
|
||||
message: String?,
|
||||
callback: AddAMessageRowCallback,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
|
||||
.weight(1f)
|
||||
.heightIn(min = 40.dp)
|
||||
) {
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onEmojiKeyboardClick
|
||||
) {
|
||||
Icon(
|
||||
painter = SignalIcons.Emoji.painter,
|
||||
contentDescription = "Open emoji keyboard"
|
||||
)
|
||||
}
|
||||
|
||||
Crossfade(
|
||||
targetState = message.isNotNullOrBlank(),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { isNotEmpty ->
|
||||
if (isNotEmpty) {
|
||||
BasicTextField(
|
||||
value = message ?: "",
|
||||
onValueChange = callback::onMessageChange
|
||||
)
|
||||
} else
|
||||
Text(
|
||||
text = "Message",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onNextClick,
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = SignalIcons.ArrowEnd.painter,
|
||||
contentDescription = "Open emoji keyboard",
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AddAMessageRowPreview() {
|
||||
Previews.Preview {
|
||||
AddAMessageRow(
|
||||
message = null,
|
||||
callback = AddAMessageRowCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal interface AddAMessageRowCallback {
|
||||
fun onMessageChange(message: String)
|
||||
fun onEmojiKeyboardClick()
|
||||
fun onNextClick()
|
||||
|
||||
object Empty : AddAMessageRowCallback {
|
||||
override fun onMessageChange(message: String) = Unit
|
||||
override fun onEmojiKeyboardClick() = Unit
|
||||
override fun onNextClick() = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.signal.imageeditor.core.ImageEditorView
|
||||
|
||||
@Composable
|
||||
fun ImageEditor(
|
||||
controller: ImageEditorController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context -> ImageEditorView(context) },
|
||||
update = { view ->
|
||||
view.model = controller.editorModel
|
||||
view.mode = mapMode(controller.mode)
|
||||
},
|
||||
onReset = { },
|
||||
modifier = modifier.clipToBounds()
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapMode(mode: ImageEditorController.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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,27 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.snapping.SnapPosition
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
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
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
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.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaSendNavKey
|
||||
import org.signal.mediasend.MediaSendState
|
||||
|
||||
@Composable
|
||||
fun MediaEditScreen(
|
||||
state: MediaSendState,
|
||||
callback: MediaEditScreenCallback,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
videoEditorSlot: @Composable () -> Unit = {}
|
||||
) {
|
||||
val isFocusedMediaVideo = ContentTypeUtil.isVideoType(state.focusedMedia?.contentType)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = state.focusedMedia?.let { state.selectedMedia.indexOf(it) } ?: 0,
|
||||
pageCount = { state.selectedMedia.size }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snapPosition = SnapPosition.Center
|
||||
) { index ->
|
||||
when (val editorState = state.editorStateMap[state.selectedMedia[index].uri]) {
|
||||
is EditorState.Image -> {
|
||||
ImageEditor(
|
||||
controller = remember { ImageEditorController(editorState.model) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
is EditorState.VideoTrim -> {
|
||||
videoEditorSlot()
|
||||
}
|
||||
|
||||
null -> {
|
||||
if (!LocalInspectionMode.current) {
|
||||
error("Invalid editor state.")
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize().background(color = Previews.rememberRandomColor()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
) {
|
||||
if (state.selectedMedia.isNotEmpty()) {
|
||||
ThumbnailRow(
|
||||
selectedMedia = state.selectedMedia,
|
||||
pagerState = pagerState,
|
||||
onFocusedMediaChange = callback::setFocusedMedia,
|
||||
onThumbnailClick = { index ->
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isFocusedMediaVideo) {
|
||||
// Video editor hud
|
||||
} else if (!currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) {
|
||||
// Image editor HU
|
||||
}
|
||||
|
||||
AddAMessageRow(
|
||||
message = state.message,
|
||||
callback = AddAMessageRowCallback.Empty,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 624.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaEditScreenPreview() {
|
||||
val selectedMedia = rememberPreviewMedia(10)
|
||||
|
||||
Previews.Preview {
|
||||
MediaEditScreen(
|
||||
state = MediaSendState(
|
||||
selectedMedia = selectedMedia,
|
||||
focusedMedia = selectedMedia.first()
|
||||
),
|
||||
callback = MediaEditScreenCallback.Empty,
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
videoEditorSlot = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Red)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaEditScreenCallback {
|
||||
fun setFocusedMedia(media: Media)
|
||||
|
||||
object Empty : MediaEditScreenCallback {
|
||||
override fun setFocusedMedia(media: Media) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.signal.mediasend.MediaSendMetrics
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val BASE_SPACING = 4.dp
|
||||
private val MIN_PADDING = 0.dp
|
||||
private val MAX_PADDING = 8.dp
|
||||
|
||||
/**
|
||||
* Horizontally scrollable thumbnail strip that syncs with [pagerState].
|
||||
* Features fish-eye padding effect where the centered item has more padding.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ThumbnailRow(
|
||||
selectedMedia: List<Media>,
|
||||
pagerState: PagerState,
|
||||
onFocusedMediaChange: (Media) -> Unit,
|
||||
onThumbnailClick: (Int) -> Unit = {}
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val itemWidthPx = with(density) { MediaSendMetrics.SelectedMediaPreviewSize.width.toPx() }
|
||||
val baseSpacingPx = with(density) { BASE_SPACING.toPx() }
|
||||
val itemStride = itemWidthPx + baseSpacingPx
|
||||
val pagerPageSize = pagerState.layoutInfo.pageSize.takeIf { it > 0 } ?: 1
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val draggableState = rememberDraggableState { delta ->
|
||||
val scaledDelta = delta * (pagerPageSize.toFloat() / itemStride)
|
||||
pagerState.dispatchRawDelta(-scaledDelta)
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.isScrollInProgress }
|
||||
.filter { !it }
|
||||
.drop(1)
|
||||
.collectLatest {
|
||||
val settledPage = pagerState.currentPage
|
||||
if (settledPage in selectedMedia.indices) {
|
||||
onFocusedMediaChange(selectedMedia[settledPage])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState, itemStride, selectedMedia.size) {
|
||||
if (selectedMedia.isEmpty()) return@LaunchedEffect
|
||||
|
||||
snapshotFlow { pagerState.currentPage + pagerState.currentPageOffsetFraction }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { position ->
|
||||
val clampedPosition = position.coerceIn(0f, selectedMedia.lastIndex.toFloat())
|
||||
val baseIndex = floor(clampedPosition.toDouble()).toInt()
|
||||
val fraction = (clampedPosition - baseIndex).coerceIn(0f, 1f)
|
||||
val scrollOffsetPx = (fraction * itemStride).roundToInt()
|
||||
|
||||
listState.scrollToItem(baseIndex, scrollOffsetPx)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxWidth().draggable(
|
||||
state = draggableState,
|
||||
orientation = Orientation.Horizontal,
|
||||
onDragStopped = { velocity ->
|
||||
scope.launch {
|
||||
val currentOffset = pagerState.currentPageOffsetFraction
|
||||
val targetPage = when {
|
||||
velocity > 500f -> (pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
velocity < -500f -> (pagerState.currentPage + 1).coerceAtMost(selectedMedia.lastIndex)
|
||||
currentOffset > 0.5f -> (pagerState.currentPage + 1).coerceAtMost(selectedMedia.lastIndex)
|
||||
currentOffset < -0.5f -> (pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
else -> pagerState.currentPage
|
||||
}
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
val itemWidth = MediaSendMetrics.SelectedMediaPreviewSize.width
|
||||
|
||||
val baseEdgePadding = ((maxWidth - itemWidth) / 2).coerceAtLeast(0.dp)
|
||||
val startPadding = (baseEdgePadding - MAX_PADDING).coerceAtLeast(0.dp)
|
||||
val endPadding = baseEdgePadding + MAX_PADDING
|
||||
|
||||
LazyRow(
|
||||
horizontalArrangement = spacedBy(BASE_SPACING),
|
||||
contentPadding = PaddingValues(start = startPadding, end = endPadding),
|
||||
state = listState,
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
itemsIndexed(selectedMedia, key = { _, media -> media.uri }) { index, media ->
|
||||
val padding by remember(index) {
|
||||
derivedStateOf {
|
||||
val currentPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||
val distanceFromCenter = abs(index - currentPosition).coerceIn(0f, 1f)
|
||||
lerp(MAX_PADDING, MIN_PADDING, distanceFromCenter)
|
||||
}
|
||||
}
|
||||
|
||||
Thumbnail(
|
||||
media = media,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = padding)
|
||||
.clickable { onThumbnailClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(start: Dp, stop: Dp, fraction: Float): Dp {
|
||||
return start + (stop - start) * fraction
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Thumbnail(media: Media, modifier: Modifier = Modifier) {
|
||||
if (!LocalInspectionMode.current) {
|
||||
GlideImage(
|
||||
model = media.uri,
|
||||
imageSize = MediaSendMetrics.SelectedMediaPreviewSize,
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.background(color = Color.Gray, shape = RoundedCornerShape(8.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ThumbnailRowPreview() {
|
||||
val media = rememberPreviewMedia(10)
|
||||
val pagerState = rememberPagerState(pageCount = { media.size })
|
||||
|
||||
Previews.Preview {
|
||||
ThumbnailRow(
|
||||
selectedMedia = media,
|
||||
pagerState = pagerState,
|
||||
onFocusedMediaChange = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ThumbnailPreview() {
|
||||
Previews.Preview {
|
||||
Thumbnail(
|
||||
media = rememberPreviewMedia(1).first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberPreviewMedia(count: Int): List<Media> {
|
||||
return remember(count) {
|
||||
(0 until count).map {
|
||||
Media(
|
||||
uri = "https://example.com/image$it.png".toUri(),
|
||||
contentType = ContentTypeUtil.IMAGE_PNG,
|
||||
width = 100,
|
||||
height = 100,
|
||||
duration = 0,
|
||||
date = 0,
|
||||
size = 0,
|
||||
isBorderless = false,
|
||||
isVideoGif = false,
|
||||
bucketId = null,
|
||||
caption = null,
|
||||
transformProperties = null,
|
||||
fileName = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.preupload
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Manages proactive upload of media during the selection process.
|
||||
*
|
||||
* Upload/cancel operations are serialized, because they're asynchronous operations that depend on
|
||||
* ordered completion.
|
||||
*
|
||||
* For example, if we begin upload of a [Media] but then immediately cancel it (before it was fully
|
||||
* enqueued), we need to wait until we have the job ids to cancel. This class manages everything by
|
||||
* using a single-thread executor.
|
||||
*
|
||||
* This class is stateful.
|
||||
*/
|
||||
class PreUploadManager(
|
||||
context: Context,
|
||||
private val callback: Callback
|
||||
) {
|
||||
|
||||
private val context: Context = context.applicationContext
|
||||
private val uploadResults: LinkedHashMap<Media, PreUploadResult> = LinkedHashMap()
|
||||
private val executor: Executor =
|
||||
SignalExecutors.newCachedSingleThreadExecutor("signal-PreUpload", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
|
||||
|
||||
/**
|
||||
* Starts a pre-upload for [media].
|
||||
*
|
||||
* @param media The media item to pre-upload.
|
||||
* @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
|
||||
*/
|
||||
fun startUpload(media: Media, recipientId: MediaRecipientId?) {
|
||||
executor.execute { uploadMediaInternal(media, recipientId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) pre-uploads for [mediaItems].
|
||||
*
|
||||
* This cancels any existing pre-upload for each item before starting a new one.
|
||||
*
|
||||
* @param mediaItems The media items to pre-upload.
|
||||
* @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
|
||||
*/
|
||||
fun startUpload(mediaItems: Collection<Media>, recipientId: MediaRecipientId?) {
|
||||
executor.execute {
|
||||
for (media in mediaItems) {
|
||||
Log.d(TAG, "Canceling existing preuploads.")
|
||||
cancelUploadInternal(media)
|
||||
Log.d(TAG, "Re-uploading media with recipient.")
|
||||
uploadMediaInternal(media, recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a map of old->new, cancel medias that were changed and upload their replacements. Will
|
||||
* also upload any media in the map that wasn't yet uploaded.
|
||||
*
|
||||
* @param oldToNew A mapping of prior media objects to their updated equivalents.
|
||||
* @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
|
||||
*/
|
||||
fun applyMediaUpdates(oldToNew: Map<Media, Media>, recipientId: MediaRecipientId?) {
|
||||
executor.execute {
|
||||
for ((oldMedia, newMedia) in oldToNew) {
|
||||
val same = oldMedia == newMedia && hasSameTransformProperties(oldMedia, newMedia)
|
||||
|
||||
if (!same || !uploadResults.containsKey(newMedia)) {
|
||||
Log.d(TAG, "Canceling existing preuploads.")
|
||||
cancelUploadInternal(oldMedia)
|
||||
Log.d(TAG, "Applying media updates.")
|
||||
uploadMediaInternal(newMedia, recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSameTransformProperties(oldMedia: Media, newMedia: Media): Boolean {
|
||||
val oldProperties = oldMedia.transformProperties
|
||||
val newProperties = newMedia.transformProperties
|
||||
|
||||
if (oldProperties == null || newProperties == null) {
|
||||
return oldProperties == newProperties
|
||||
}
|
||||
|
||||
// Matches legacy behavior: if the new media is "video edited", we treat it as different.
|
||||
// Otherwise, we treat it as the same if only the sent quality matches.
|
||||
return !newProperties.videoEdited && oldProperties.sentMediaQuality == newProperties.sentMediaQuality
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the pre-upload (if present) for [media] and deletes any associated attachment state.
|
||||
*
|
||||
* @param media The media item to cancel.
|
||||
*/
|
||||
fun cancelUpload(media: Media) {
|
||||
Log.d(TAG, "User canceling media upload.")
|
||||
executor.execute { cancelUploadInternal(media) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels pre-uploads (if present) for all [mediaItems].
|
||||
*
|
||||
* @param mediaItems Media items to cancel.
|
||||
*/
|
||||
fun cancelUpload(mediaItems: Collection<Media>) {
|
||||
Log.d(TAG, "Canceling uploads.")
|
||||
executor.execute {
|
||||
for (media in mediaItems) {
|
||||
cancelUploadInternal(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all current pre-uploads and clears internal state.
|
||||
*/
|
||||
fun cancelAllUploads() {
|
||||
Log.d(TAG, "Canceling all uploads.")
|
||||
executor.execute {
|
||||
val keysSnapshot = uploadResults.keys.toList()
|
||||
for (media in keysSnapshot) {
|
||||
cancelUploadInternal(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current pre-upload results snapshot.
|
||||
*
|
||||
* @param callback Invoked with the current set of results (in display/order insertion order).
|
||||
*/
|
||||
fun getPreUploadResults(callback: (Collection<PreUploadResult>) -> Unit) {
|
||||
executor.execute { callback(uploadResults.values) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates captions for any pre-uploaded items in [updatedMedia].
|
||||
*
|
||||
* @param updatedMedia Media items containing the latest caption values.
|
||||
*/
|
||||
fun updateCaptions(updatedMedia: List<Media>) {
|
||||
executor.execute { updateCaptionsInternal(updatedMedia) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates display order for pre-uploaded items, using [mediaInOrder] list order.
|
||||
*
|
||||
* @param mediaInOrder Media items in the desired display order.
|
||||
*/
|
||||
fun updateDisplayOrder(mediaInOrder: List<Media>) {
|
||||
executor.execute { updateDisplayOrderInternal(mediaInOrder) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes abandoned pre-upload attachments via the callback.
|
||||
*
|
||||
* @return Nothing. The callback controls deletion and returns a count for logging.
|
||||
*/
|
||||
fun deleteAbandonedAttachments() {
|
||||
executor.execute {
|
||||
val deleted = this.callback.deleteAbandonedPreuploadedAttachments(context)
|
||||
Log.i(TAG, "Deleted $deleted abandoned attachments.")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun uploadMediaInternal(media: Media, recipientId: MediaRecipientId?) {
|
||||
val result = callback.preUpload(context, media, recipientId)
|
||||
|
||||
if (result != null) {
|
||||
uploadResults[media] = result
|
||||
} else {
|
||||
Log.w(TAG, "Failed to upload media with URI: ${media.uri}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelUploadInternal(media: Media) {
|
||||
val result = uploadResults[media] ?: return
|
||||
|
||||
Log.d(TAG, "Canceling attachment upload jobs for ${result.attachmentId}")
|
||||
callback.cancelJobs(context, result.jobIds)
|
||||
uploadResults.remove(media)
|
||||
callback.deleteAttachment(context, result.attachmentId)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateCaptionsInternal(updatedMedia: List<Media>) {
|
||||
for (updated in updatedMedia) {
|
||||
val result = uploadResults[updated]
|
||||
|
||||
if (result != null) {
|
||||
callback.updateAttachmentCaption(context, result.attachmentId, updated.caption)
|
||||
} else {
|
||||
Log.w(TAG, "When updating captions, no pre-upload result could be found for media with URI: ${updated.uri}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateDisplayOrderInternal(mediaInOrder: List<Media>) {
|
||||
val orderMap: MutableMap<Long, Int> = LinkedHashMap()
|
||||
val orderedUploadResults: LinkedHashMap<Media, PreUploadResult> = LinkedHashMap()
|
||||
|
||||
for ((index, media) in mediaInOrder.withIndex()) {
|
||||
val result = uploadResults[media]
|
||||
|
||||
if (result != null) {
|
||||
orderMap[result.attachmentId] = index
|
||||
orderedUploadResults[media] = result
|
||||
} else {
|
||||
Log.w(TAG, "When updating display order, no pre-upload result could be found for media with URI: ${media.uri}")
|
||||
}
|
||||
}
|
||||
|
||||
callback.updateDisplayOrder(context, orderMap)
|
||||
|
||||
if (orderedUploadResults.size == uploadResults.size) {
|
||||
uploadResults.clear()
|
||||
uploadResults.putAll(orderedUploadResults)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks that perform the real side-effects (DB ops, job scheduling/cancelation, etc).
|
||||
*
|
||||
* This keeps `feature/media-send` free of direct dependencies on app-specific systems.
|
||||
*
|
||||
* Threading: all callback methods are invoked on this manager's serialized background executor
|
||||
* thread (i.e., not the main thread).
|
||||
*/
|
||||
interface Callback {
|
||||
/**
|
||||
* Performs pre-upload side-effects (e.g., create attachment state + enqueue jobs).
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param media The media item being pre-uploaded.
|
||||
* @param recipientId Optional recipient identifier, if known.
|
||||
* @return A [PreUploadResult] if enqueued, or `null` if it failed or should not pre-upload.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun preUpload(context: Context, media: Media, recipientId: MediaRecipientId?): PreUploadResult?
|
||||
|
||||
/**
|
||||
* Cancels any scheduled/running work for the provided job ids.
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param jobIds Job identifiers to cancel.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun cancelJobs(context: Context, jobIds: List<String>)
|
||||
|
||||
/**
|
||||
* Deletes any persisted attachment state for [attachmentId].
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param attachmentId Attachment identifier to delete.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun deleteAttachment(context: Context, attachmentId: Long)
|
||||
|
||||
/**
|
||||
* Updates the caption for [attachmentId].
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param attachmentId Attachment identifier.
|
||||
* @param caption New caption (or `null` to clear).
|
||||
*/
|
||||
@WorkerThread
|
||||
fun updateAttachmentCaption(context: Context, attachmentId: Long, caption: String?)
|
||||
|
||||
/**
|
||||
* Updates display order for attachments.
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param orderMap Map of attachment id -> display order index.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun updateDisplayOrder(context: Context, orderMap: Map<Long, Int>)
|
||||
|
||||
/**
|
||||
* Deletes any pre-uploaded attachments that are no longer referenced.
|
||||
*
|
||||
* @param context Application context.
|
||||
* @return The number of attachments deleted.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun deleteAbandonedPreuploadedAttachments(context: Context): Int
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val TAG = Log.tag(PreUploadManager::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.preupload
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.models.media.Media
|
||||
|
||||
/**
|
||||
* Handle returned from a successful pre-upload.
|
||||
*
|
||||
* This mirrors the legacy concept of:
|
||||
* - an attachment row identifier
|
||||
* - the upload dependency job ids
|
||||
* - the original media
|
||||
*/
|
||||
@Parcelize
|
||||
data class PreUploadResult(
|
||||
val media: Media,
|
||||
val attachmentId: Long,
|
||||
val jobIds: List<String>
|
||||
) : Parcelable {
|
||||
val uri: Uri
|
||||
get() = media.uri
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.select
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.signal.mediasend.MediaSendMetrics
|
||||
import org.signal.mediasend.MediaSendNavKey
|
||||
import org.signal.mediasend.MediaSendState
|
||||
import org.signal.mediasend.edit.rememberPreviewMedia
|
||||
import org.signal.mediasend.goToEdit
|
||||
import org.signal.mediasend.pop
|
||||
|
||||
/**
|
||||
* Allows user to select one or more pieces of content to add to the
|
||||
* current media selection.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MediaSelectScreen(
|
||||
state: MediaSendState,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
callback: MediaSelectScreenCallback
|
||||
) {
|
||||
val gridConfiguration = rememberGridConfiguration(state.selectedMediaFolder == null)
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = state.selectedMediaFolder?.title ?: "Gallery",
|
||||
navigationIcon = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_arrow_start_24),
|
||||
onNavigationClick = {
|
||||
if (state.selectedMediaFolder != null) {
|
||||
callback.onFolderClick(null)
|
||||
} else {
|
||||
backStack.pop()
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = gridConfiguration.gridCells,
|
||||
horizontalArrangement = spacedBy(gridConfiguration.horizontalSpacing),
|
||||
verticalArrangement = spacedBy(gridConfiguration.verticalSpacing),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = gridConfiguration.horizontalMargin)
|
||||
.weight(1f)
|
||||
) {
|
||||
if (state.selectedMediaFolder == null) {
|
||||
items(state.mediaFolders, key = { it.bucketId }) {
|
||||
MediaFolderTile(it, callback)
|
||||
}
|
||||
} else {
|
||||
items(state.selectedMediaFolderItems, key = { it.uri }) { media ->
|
||||
MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, callback = callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.selectedMedia.isNotEmpty(),
|
||||
enter = expandVertically(
|
||||
expandFrom = Alignment.Top,
|
||||
animationSpec = spring()
|
||||
) + fadeIn(animationSpec = spring()),
|
||||
exit = shrinkVertically(
|
||||
shrinkTowards = Alignment.Top,
|
||||
animationSpec = spring()
|
||||
) + fadeOut(animationSpec = spring()),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.padding(vertical = gridConfiguration.bottomBarVerticalPadding, horizontal = gridConfiguration.bottomBarHorizontalPadding)
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 16.dp),
|
||||
horizontalArrangement = spacedBy(space = 12.dp, alignment = gridConfiguration.bottomBarAlignment)
|
||||
) {
|
||||
items(state.selectedMedia, key = { it.uri }) { media ->
|
||||
MediaThumbnail(media, modifier = Modifier.animateItem()) {
|
||||
callback.setFocusedMedia(media)
|
||||
backStack.goToEdit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NextButton(state.selectedMedia.size) {
|
||||
backStack.goToEdit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberGridConfiguration(isRootGrid: Boolean): GridConfiguration {
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
|
||||
return remember(windowSizeClass, isRootGrid) {
|
||||
GridConfiguration(
|
||||
gridCells = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = GridCells.Fixed(6),
|
||||
medium = GridCells.Fixed(4),
|
||||
compact = GridCells.Fixed(2)
|
||||
)
|
||||
} else {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = GridCells.Fixed(8),
|
||||
medium = GridCells.Fixed(6),
|
||||
compact = GridCells.Fixed(4)
|
||||
)
|
||||
},
|
||||
horizontalMargin = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 38.dp,
|
||||
medium = 35.dp,
|
||||
compact = 24.dp
|
||||
)
|
||||
} else {
|
||||
0.dp
|
||||
},
|
||||
horizontalSpacing = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 32.dp,
|
||||
medium = 28.dp,
|
||||
compact = 16.dp
|
||||
)
|
||||
} else {
|
||||
4.dp
|
||||
},
|
||||
verticalSpacing = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 32.dp,
|
||||
medium = 32.dp,
|
||||
compact = 24.dp
|
||||
)
|
||||
} else {
|
||||
4.dp
|
||||
},
|
||||
bottomBarVerticalPadding = windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 16.dp,
|
||||
medium = 16.dp,
|
||||
compact = 8.dp
|
||||
),
|
||||
bottomBarHorizontalPadding = windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 24.dp,
|
||||
medium = 24.dp,
|
||||
compact = 16.dp
|
||||
),
|
||||
bottomBarAlignment = windowSizeClass.forWidthBreakpoint(
|
||||
expanded = Alignment.End,
|
||||
medium = Alignment.End,
|
||||
compact = Alignment.Start
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> WindowSizeClass.forWidthBreakpoint(
|
||||
expanded: T,
|
||||
medium: T,
|
||||
compact: T
|
||||
): T {
|
||||
return when {
|
||||
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> expanded
|
||||
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> medium
|
||||
else -> compact
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaFolderTile(
|
||||
mediaFolder: MediaFolder,
|
||||
callback: MediaSelectScreenCallback
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = { callback.onFolderClick(mediaFolder) },
|
||||
onClickLabel = mediaFolder.title,
|
||||
role = Role.Button
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(26.dp))
|
||||
)
|
||||
} else {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(26.dp))
|
||||
) {
|
||||
val width = maxWidth
|
||||
val height = maxHeight
|
||||
|
||||
GlideImage(
|
||||
model = mediaFolder.thumbnailUri,
|
||||
imageSize = DpSize(width, height),
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = mediaFolder.title, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaTile(
|
||||
media: Media,
|
||||
selectionIndex: Int,
|
||||
callback: MediaSelectScreenCallback
|
||||
) {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (selectionIndex >= 0) {
|
||||
0.8f
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
)
|
||||
|
||||
val cornerClip by animateDpAsState(
|
||||
if (selectionIndex >= 0) 12.dp else 0.dp
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(
|
||||
onClick = { callback.onMediaClick(media) },
|
||||
onClickLabel = media.fileName,
|
||||
role = Role.Button
|
||||
)
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.scale(scale)
|
||||
.background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(cornerClip)).fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
} else {
|
||||
val width = maxWidth
|
||||
val height = maxHeight
|
||||
|
||||
GlideImage(
|
||||
model = media.uri,
|
||||
imageSize = DpSize(width, height),
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.scale(scale)
|
||||
.clip(RoundedCornerShape(cornerClip))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectionIndex >= 0) {
|
||||
Box(
|
||||
modifier = Modifier.padding(3.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.border(width = 3.dp, color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
|
||||
.padding(1.dp)
|
||||
.background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(percent = 50))
|
||||
.ensureWidthIsAtLeastHeight()
|
||||
.padding(horizontal = 5.5.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(text = "${selectionIndex + 1}", color = MaterialTheme.colorScheme.onPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextButton(mediaSelectionCount: Int, onClick: () -> Unit) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(percent = 50))
|
||||
.ensureWidthIsAtLeastHeight()
|
||||
) {
|
||||
Text(
|
||||
text = "$mediaSelectionCount",
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_chevron_right_24),
|
||||
contentDescription = "Next"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaThumbnail(
|
||||
media: Media,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(8.dp))
|
||||
)
|
||||
} else {
|
||||
GlideImage(
|
||||
model = media.uri,
|
||||
imageSize = MediaSendMetrics.SelectedMediaPreviewSize,
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaSelectScreenFolderPreview() {
|
||||
Previews.Preview {
|
||||
MediaSelectScreen(
|
||||
state = MediaSendState(
|
||||
mediaFolders = rememberPreviewMediaFolders(20)
|
||||
),
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
callback = MediaSelectScreenCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaSelectScreenMediaPreview() {
|
||||
val folders = rememberPreviewMediaFolders(20)
|
||||
val media = rememberPreviewMedia(100)
|
||||
val selectedMedia: MutableList<Media> = remember { mutableStateListOf() }
|
||||
val callback = remember {
|
||||
object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
|
||||
override fun onMediaClick(media: Media) {
|
||||
if (media in selectedMedia) {
|
||||
selectedMedia.remove(media)
|
||||
} else {
|
||||
selectedMedia.add(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Previews.Preview {
|
||||
MediaSelectScreen(
|
||||
state = MediaSendState(
|
||||
mediaFolders = folders,
|
||||
selectedMediaFolder = folders.first(),
|
||||
selectedMediaFolderItems = media,
|
||||
selectedMedia = selectedMedia
|
||||
),
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
callback = callback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaFolderTilePreview() {
|
||||
Previews.Preview {
|
||||
Box(modifier = Modifier.width(174.dp)) {
|
||||
MediaFolderTile(
|
||||
mediaFolder = rememberPreviewMediaFolders(1).first(),
|
||||
callback = MediaSelectScreenCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaTilePreview() {
|
||||
Previews.Preview {
|
||||
MediaTile(
|
||||
media = rememberPreviewMedia(1).first(),
|
||||
selectionIndex = -1,
|
||||
callback = MediaSelectScreenCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaTileSelectedPreview() {
|
||||
var isSelected by remember { mutableStateOf(true) }
|
||||
|
||||
Previews.Preview {
|
||||
MediaTile(
|
||||
media = rememberPreviewMedia(1).first(),
|
||||
selectionIndex = if (isSelected) 0 else -1,
|
||||
callback = object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
|
||||
override fun onMediaClick(media: Media) {
|
||||
isSelected = !isSelected
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NextButtonPreview() {
|
||||
Previews.Preview {
|
||||
NextButton(
|
||||
mediaSelectionCount = 3,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPreviewMediaFolders(count: Int): List<MediaFolder> {
|
||||
return remember(count) {
|
||||
(0 until count).map { index ->
|
||||
MediaFolder(
|
||||
thumbnailUri = "https://example.com/folder$index.jpg".toUri(),
|
||||
title = "Folder $index",
|
||||
itemCount = (index + 1) * 10,
|
||||
bucketId = "bucket_$index",
|
||||
folderType = if (index == 0) MediaFolder.FolderType.CAMERA else MediaFolder.FolderType.NORMAL
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class GridConfiguration(
|
||||
val gridCells: GridCells,
|
||||
val horizontalMargin: Dp,
|
||||
val horizontalSpacing: Dp,
|
||||
val verticalSpacing: Dp,
|
||||
val bottomBarVerticalPadding: Dp,
|
||||
val bottomBarHorizontalPadding: Dp,
|
||||
val bottomBarAlignment: Alignment.Horizontal
|
||||
)
|
||||
|
||||
interface MediaSelectScreenCallback {
|
||||
fun onFolderClick(mediaFolder: MediaFolder?)
|
||||
fun onMediaClick(media: Media)
|
||||
fun setFocusedMedia(media: Media)
|
||||
|
||||
object Empty : MediaSelectScreenCallback {
|
||||
override fun onFolderClick(mediaFolder: MediaFolder?) {}
|
||||
override fun onMediaClick(media: Media) {}
|
||||
override fun setFocusedMedia(media: Media) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user