Reshape entry point for V3 media screens.

This commit is contained in:
Alex Hart
2026-02-05 14:10:23 -04:00
committed by Greyson Parrelli
parent 6d944c0f8c
commit 5c415139fd
28 changed files with 291 additions and 285 deletions

View File

@@ -475,7 +475,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name=".mediasend.v3.MediaSendV3Activity"
android:name="org.signal.mediasend.MediaSendActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"
android:launchMode="singleTop"

View File

@@ -157,7 +157,6 @@ import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3ActivityContract
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -280,7 +279,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
mediaActivityLauncher = registerForActivityResult(MediaSendV3ActivityContract()) { }
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.app.Application
import io.reactivex.rxjava3.subjects.BehaviorSubject
import okhttp3.OkHttpClient
import org.signal.core.ui.CoreUiDependencies
import org.signal.core.util.billing.BillingApi
import org.signal.core.util.concurrent.DeadlockDetector
import org.signal.core.util.concurrent.LatestValueObservable
@@ -13,6 +14,7 @@ import org.signal.glide.SignalGlideDependencies
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.mediasend.MediaSendDependencies
import org.thoughtcrime.securesms.components.TypingStatusRepository
import org.thoughtcrime.securesms.components.TypingStatusSender
import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl
@@ -97,7 +99,9 @@ object AppDependencies {
_application = application
AppDependencies.provider = provider
CoreUiDependencies.init(CoreUiDependenciesProvider)
SignalGlideDependencies.init(application, SignalGlideDependenciesProvider)
MediaSendDependencies.init(application, MediaSendDependenciesProvider)
}
@JvmStatic

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.dependencies
import org.signal.core.ui.CoreUiDependencies
import org.thoughtcrime.securesms.util.TextSecurePreferences
object CoreUiDependenciesProvider : CoreUiDependencies.Provider {
override fun provideIsIncognitoKeyboardEnabled(): Boolean {
return TextSecurePreferences.isIncognitoKeyboardEnabled(AppDependencies.application)
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.dependencies
import org.signal.mediasend.MediaSendDependencies
import org.signal.mediasend.MediaSendRepository
import org.signal.mediasend.preupload.PreUploadRepository
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3PreUploadRepository
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Repository
object MediaSendDependenciesProvider : MediaSendDependencies.Provider {
override fun provideMediaSendRepository(): MediaSendRepository = MediaSendV3Repository
override fun providePreUploadRepository(): PreUploadRepository = MediaSendV3PreUploadRepository
}

View File

@@ -1,87 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import androidx.compose.runtime.Composable
import io.reactivex.rxjava3.core.Flowable
import org.signal.core.models.media.Media
import org.signal.mediasend.MediaSendActivity
import org.thoughtcrime.securesms.mediasend.CameraFragment
import org.thoughtcrime.securesms.mms.MediaConstraints
import java.io.FileDescriptor
import java.util.Optional
/**
* App-layer implementation of the feature module media send activity.
*/
class MediaSendV3Activity : MediaSendActivity(), CameraFragment.Controller {
override val preUploadCallback = MediaSendV3PreUploadCallback()
override val repository by lazy { MediaSendV3Repository(applicationContext) }
@Composable
override fun CameraSlot() {
MediaSendV3CameraSlot()
}
@Composable
override fun TextStoryEditorSlot() {
MediaSendV3PlaceholderScreen(text = "Text Story Editor")
}
@Composable
override fun VideoEditorSlot() {
MediaSendV3PlaceholderScreen(text = "Video Editor")
}
@Composable
override fun SendSlot() {
MediaSendV3PlaceholderScreen(text = "Send Review")
}
// region Camera Callbacks
override fun onCameraError() {
error("Not yet implemented")
}
override fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
error("Not yet implemented")
}
override fun onVideoCaptured(fd: FileDescriptor) {
error("Not yet implemented")
}
override fun onVideoCaptureError() {
error("Not yet implemented")
}
override fun onGalleryClicked() {
error("Not yet implemented")
}
override fun onCameraCountButtonClicked() {
error("Not yet implemented")
}
override fun onQrCodeFound(data: String) {
error("Not yet implemented")
}
override fun getMostRecentMediaItem(): Flowable<Optional<Media?>> {
error("Not yet implemented")
}
override fun getMediaConstraints(): MediaConstraints {
return MediaConstraints.getPushMediaConstraints()
}
override fun getMaxVideoDuration(): Int {
error("Not yet implemented")
}
// endregion
}

View File

@@ -1,13 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import org.signal.mediasend.MediaSendActivityContract
/**
* Activity result contract bound to [MediaSendV3Activity].
*/
class MediaSendV3ActivityContract : MediaSendActivityContract(MediaSendV3Activity::class.java)

View File

@@ -9,7 +9,7 @@ import android.content.Context
import androidx.annotation.WorkerThread
import org.signal.core.models.media.Media
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.preupload.PreUploadManager
import org.signal.mediasend.preupload.PreUploadRepository
import org.signal.mediasend.preupload.PreUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
class MediaSendV3PreUploadCallback : PreUploadManager.Callback {
object MediaSendV3PreUploadRepository : PreUploadRepository {
@WorkerThread
override fun preUpload(context: Context, media: Media, recipientId: MediaRecipientId?): PreUploadResult? {

View File

@@ -5,7 +5,6 @@
package org.thoughtcrime.securesms.mediasend.v3
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -48,11 +47,9 @@ import kotlin.time.Duration.Companion.seconds
/**
* App-layer implementation of [MediaSendRepository] that bridges to legacy v2 infrastructure.
*/
class MediaSendV3Repository(
context: Context
) : MediaSendRepository {
object MediaSendV3Repository : MediaSendRepository {
private val appContext = context.applicationContext
private val appContext = AppDependencies.application
private val legacyRepository = MediaSelectionRepository(appContext)
private val mediaRepository = MediaRepository()

View File

@@ -14,6 +14,10 @@ android {
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
testFixtures {
enable = true
}
}
dependencies {
@@ -38,4 +42,7 @@ dependencies {
api(libs.google.zxing.core)
api(libs.material.material)
api(libs.accompanist.permissions)
// JUnit is used by test fixtures
testFixturesImplementation(testLibs.junit.junit)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
object CoreUiDependencies {
private lateinit var _provider: Provider
fun init(provider: Provider) {
if (this::_provider.isInitialized) {
return
}
_provider = provider
}
val isIncognitoKeyboardEnabled: Boolean
get() = _provider.provideIsIncognitoKeyboardEnabled()
interface Provider {
fun provideIsIncognitoKeyboardEnabled(): Boolean
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import org.signal.core.ui.CoreUiDependencies
import org.signal.core.ui.compose.ProvideIncognitoKeyboard
private val typography = Typography().run {
@@ -190,7 +191,7 @@ private val darkSnackbarColors = SnackbarColors(
@Composable
fun SignalTheme(
isDarkMode: Boolean = LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES,
incognitoKeyboardEnabled: Boolean = false,
incognitoKeyboardEnabled: Boolean = CoreUiDependencies.isIncognitoKeyboardEnabled,
content: @Composable () -> Unit
) {
val extendedColors = if (isDarkMode) darkExtendedColors else lightExtendedColors

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import org.junit.rules.ExternalResource
/**
* Since tests that depend on [org.signal.core.ui.compose.theme.SignalTheme] need
* [CoreUiDependencies] to be initialized, this rule provides a convenient way to do so.
*/
class CoreUiDependenciesRule(
private val isIncognitoKeyboardEnabled: Boolean = false
) : ExternalResource() {
override fun before() {
CoreUiDependencies.init(Provider(isIncognitoKeyboardEnabled))
}
private class Provider(
val isIncognitoKeyboardEnabled: Boolean
): CoreUiDependencies.Provider {
override fun provideIsIncognitoKeyboardEnabled(): Boolean = isIncognitoKeyboardEnabled
}
}

View File

@@ -9,6 +9,7 @@ import android.app.Application
import android.os.Build
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.ui.CoreUiDependencies
import org.signal.core.util.Base64
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
@@ -56,6 +57,10 @@ class RegistrationApplication : Application() {
storageController = storageController
)
)
CoreUiDependencies.init(object : CoreUiDependencies.Provider {
override fun provideIsIncognitoKeyboardEnabled(): Boolean = false
})
}
private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket {

View File

@@ -15,28 +15,11 @@ 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.
* Activity for the media sending flow.
*/
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
@@ -49,11 +32,8 @@ abstract class MediaSendActivity : FragmentActivity() {
setContent {
val viewModel by viewModels<MediaSendViewModel>(factoryProducer = {
MediaSendViewModel.Factory(
context = applicationContext,
args = contractArgs,
isMeteredFlow = MeteredConnectivity.isMetered(applicationContext),
repository = repository,
preUploadCallback = preUploadCallback
isMeteredFlow = MeteredConnectivity.isMetered(applicationContext)
)
})
@@ -62,68 +42,35 @@ abstract class MediaSendActivity : FragmentActivity() {
if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select
)
Theme {
SignalTheme {
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() }
cameraSlot = { },
textStoryEditorSlot = { },
videoEditorSlot = { },
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.
* Creates an intent for [MediaSendActivity].
*
* @param context The context.
* @param activityClass The concrete activity class to launch.
* @param args The activity arguments.
*/
fun <T : MediaSendActivity> createIntent(
fun createIntent(
context: Context,
activityClass: Class<T>,
args: MediaSendActivityContract.Args = MediaSendActivityContract.Args()
): Intent {
return Intent(context, activityClass).apply {
return Intent(context, MediaSendActivity::class.java).apply {
putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
}
}

View File

@@ -30,9 +30,7 @@ import org.signal.core.models.media.Media
* class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java)
* ```
*/
open class MediaSendActivityContract(
private val activityClass: Class<out MediaSendActivity>? = null
) : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
class MediaSendActivityContract : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
/**
* Creates the intent to launch the media send activity.
@@ -40,12 +38,7 @@ open class MediaSendActivityContract(
* 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)
return MediaSendActivity.createIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): Result? {

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend
import android.app.Application
import org.signal.mediasend.preupload.PreUploadRepository
/**
* MediaSend Feature Module dependencies
*/
object MediaSendDependencies {
private lateinit var _application: Application
private lateinit var _provider: Provider
@Synchronized
fun init(application: Application, provider: Provider) {
if (this::_application.isInitialized || this::_provider.isInitialized) {
return
}
_application = application
_provider = provider
}
val application
get() = _application
val preUploadRepository: PreUploadRepository
get() = _provider.providePreUploadRepository()
val mediaSendRepository: MediaSendRepository
get() = _provider.provideMediaSendRepository()
interface Provider {
fun provideMediaSendRepository(): MediaSendRepository
fun providePreUploadRepository(): PreUploadRepository
}
}

View File

@@ -17,6 +17,7 @@ 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
import org.signal.mediasend.select.MediaSelectScreen
/**
* Enforces the following flow of:
@@ -32,7 +33,6 @@ fun MediaSendNavDisplay(
modifier: Modifier = Modifier,
cameraSlot: @Composable () -> Unit = {},
textStoryEditorSlot: @Composable () -> Unit = {},
mediaSelectSlot: @Composable () -> Unit = {},
videoEditorSlot: @Composable () -> Unit = {},
sendSlot: @Composable () -> Unit = {}
) {
@@ -50,7 +50,11 @@ fun MediaSendNavDisplay(
}
MediaSendNavKey.Select -> NavEntry(key) {
mediaSelectSlot()
MediaSelectScreen(
state = state,
backStack = backStack,
callback = callback
)
}
is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) {
@@ -82,7 +86,6 @@ private fun MediaSendNavDisplayPreview() {
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") }
)

View File

@@ -5,7 +5,6 @@
package org.signal.mediasend
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
@@ -38,7 +37,7 @@ 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 org.signal.mediasend.preupload.PreUploadController
import java.util.Collections
import kotlin.time.Duration.Companion.milliseconds
@@ -54,7 +53,7 @@ class MediaSendViewModel(
isMeteredFlow: Flow<Boolean>,
private val savedStateHandle: SavedStateHandle,
private val repository: MediaSendRepository,
private val preUploadManager: PreUploadManager
private val preUploadController: PreUploadController
) : ViewModel(), MediaSendCallback {
private val defaultState = MediaSendState(
@@ -309,8 +308,8 @@ class MediaSendViewModel(
viewModelScope.launch {
repository.deleteBlobs(media.toList())
}
preUploadManager.cancelUpload(media)
preUploadManager.updateDisplayOrder(newSelection)
preUploadController.cancelUpload(media)
preUploadController.updateDisplayOrder(newSelection)
}
/**
@@ -323,9 +322,9 @@ class MediaSendViewModel(
val updatedSelection = snapshot.selectedMedia.map { oldToNew[it] ?: it }
updateState { copy(selectedMedia = updatedSelection) }
preUploadManager.applyMediaUpdates(oldToNew, snapshot.recipientId)
preUploadManager.updateCaptions(updatedSelection)
preUploadManager.updateDisplayOrder(updatedSelection)
preUploadController.applyMediaUpdates(oldToNew, snapshot.recipientId)
preUploadController.updateCaptions(updatedSelection)
preUploadController.updateDisplayOrder(updatedSelection)
}
/**
@@ -333,7 +332,7 @@ class MediaSendViewModel(
*/
fun setDisplayOrder(mediaInOrder: List<Media>) {
updateState { copy(selectedMedia = mediaInOrder) }
preUploadManager.updateDisplayOrder(mediaInOrder)
preUploadController.updateDisplayOrder(mediaInOrder)
}
//endregion
@@ -350,9 +349,9 @@ class MediaSendViewModel(
media.filter { ContentTypeUtil.isStorySupportedType(it.contentType) }
}
preUploadManager.startUpload(filteredPreUploadMedia, snapshot.recipientId)
preUploadManager.updateCaptions(snapshot.selectedMedia)
preUploadManager.updateDisplayOrder(snapshot.selectedMedia)
preUploadController.startUpload(filteredPreUploadMedia, snapshot.recipientId)
preUploadController.updateCaptions(snapshot.selectedMedia)
preUploadController.updateDisplayOrder(snapshot.selectedMedia)
}
//endregion
@@ -369,7 +368,7 @@ class MediaSendViewModel(
if (snapshot.sentMediaQuality == sentMediaQuality) return
updateState { copy(sentMediaQuality = sentMediaQuality, isPreUploadEnabled = false) }
preUploadManager.cancelAllUploads()
preUploadController.cancelAllUploads()
// Re-clamp video durations based on new quality
val maxVideoDurationUs = getMaxVideoDurationUs()
@@ -404,7 +403,7 @@ class MediaSendViewModel(
savedStateHandle[KEY_EDITED_VIDEO_URIS] = ArrayList(editedVideoUris)
val media = state.value.selectedMedia.firstOrNull { it.uri == uri } ?: return
preUploadManager.cancelUpload(media)
preUploadController.cancelUpload(media)
}
/**
@@ -443,7 +442,7 @@ class MediaSendViewModel(
if (unedited && durationEdited) {
val media = snapshot.selectedMedia.firstOrNull { it.uri == uri }
if (media != null) {
preUploadManager.cancelUpload(media)
preUploadController.cancelUpload(media)
}
}
@@ -532,7 +531,7 @@ class MediaSendViewModel(
fun onMediaDragFinished() {
lastMediaDrag = Pair(0, 0)
preUploadManager.updateDisplayOrder(internalState.value.selectedMedia)
preUploadController.updateDisplayOrder(internalState.value.selectedMedia)
}
//endregion
@@ -704,8 +703,8 @@ class MediaSendViewModel(
//region Lifecycle
override fun onCleared() {
preUploadManager.cancelAllUploads()
preUploadManager.deleteAbandonedAttachments()
preUploadController.cancelAllUploads()
preUploadController.deleteAbandonedAttachments()
}
private fun shouldPreUpload(metered: Boolean): Boolean = !metered
@@ -715,26 +714,22 @@ class MediaSendViewModel(
//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
private val isMeteredFlow: Flow<Boolean>
) : 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
repository = MediaSendDependencies.mediaSendRepository,
preUploadController = PreUploadController()
) as T
}
}

View File

@@ -12,6 +12,7 @@ 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 org.signal.mediasend.MediaSendDependencies
import java.util.LinkedHashMap
import java.util.concurrent.Executor
@@ -27,12 +28,10 @@ import java.util.concurrent.Executor
*
* This class is stateful.
*/
class PreUploadManager(
context: Context,
private val callback: Callback
) {
class PreUploadController {
private val context: Context = context.applicationContext
private val callback: PreUploadRepository = MediaSendDependencies.preUploadRepository
private val context: Context = MediaSendDependencies.application
private val uploadResults: LinkedHashMap<Media, PreUploadResult> = LinkedHashMap()
private val executor: Executor =
SignalExecutors.newCachedSingleThreadExecutor("signal-PreUpload", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
@@ -234,74 +233,7 @@ class PreUploadManager(
}
}
/**
* 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)
private val TAG = Log.tag(PreUploadController::class.java)
}
}

View File

@@ -0,0 +1,78 @@
/*
* 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.mediasend.MediaRecipientId
/**
* 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 PreUploadRepository {
/**
* 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
}

View File

@@ -59,6 +59,7 @@ dependencies {
implementation(libs.google.libphonenumber)
// Testing
testImplementation(testFixtures(project(":core:ui")))
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.mockk)
testImplementation(testLibs.assertk)

View File

@@ -20,6 +20,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.screens.util.MockMultiplePermissionsState
import org.signal.registration.screens.util.MockPermissionsState
@@ -37,6 +38,9 @@ class RegistrationNavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule()
private lateinit var viewModel: RegistrationViewModel
private lateinit var mockRepository: RegistrationRepository

View File

@@ -16,6 +16,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.test.TestTags
@@ -29,6 +30,9 @@ class PhoneNumberScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule()
@Test
fun `Next button is disabled when fields are empty`() {
// Given

View File

@@ -17,6 +17,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.test.TestTags
@@ -31,6 +32,9 @@ class VerificationCodeScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule()
@Test
fun `screen displays title`() {
// Given

View File

@@ -16,6 +16,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.test.TestTags
@@ -30,6 +31,9 @@ class WelcomeScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule()
@Test
fun `when Get Started is clicked, Continue event is emitted`() {
// Given

View File

@@ -13,6 +13,9 @@ org.gradle.java.installations.auto-download=false
# Prevents lint crash when analyzing uncompiled kotlin gradle scripts
android.lint.useK2Uast=false
# Test fixtures support for Android modules
android.experimental.enableTestFixturesKotlinSupport=true
# Uncomment these to build libsignal from source.
# libsignalClientPath=../libsignal
# org.gradle.dependency.verification=lenient

View File

@@ -16,7 +16,6 @@ object SignalGlideDependencies {
private lateinit var _application: Application
private lateinit var _provider: Provider
@JvmStatic
@Synchronized
fun init(application: Application, provider: Provider) {
if (this::_application.isInitialized || this::_provider.isInitialized) {