From 5c415139fd9e5a6c29a78effa73ac8cc6e9bc02f Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 5 Feb 2026 14:10:23 -0400 Subject: [PATCH] Reshape entry point for V3 media screens. --- app/src/main/AndroidManifest.xml | 2 +- .../thoughtcrime/securesms/MainActivity.kt | 3 +- .../securesms/dependencies/AppDependencies.kt | 4 + .../CoreUiDependenciesProvider.kt | 15 ++++ .../MediaSendDependenciesProvider.kt | 18 ++++ .../mediasend/v3/MediaSendV3Activity.kt | 87 ------------------- .../v3/MediaSendV3ActivityContract.kt | 13 --- ...k.kt => MediaSendV3PreUploadRepository.kt} | 4 +- .../mediasend/v3/MediaSendV3Repository.kt | 7 +- core/ui/build.gradle.kts | 7 ++ .../org/signal/core/ui/CoreUiDependencies.kt | 26 ++++++ .../core/ui/compose/theme/SignalTheme.kt | 3 +- .../signal/core/ui/CoreUiDependenciesRule.kt | 26 ++++++ .../sample/RegistrationApplication.kt | 5 ++ .../org/signal/mediasend/MediaSendActivity.kt | 73 +++------------- .../mediasend/MediaSendActivityContract.kt | 11 +-- .../signal/mediasend/MediaSendDependencies.kt | 41 +++++++++ .../org/signal/mediasend/MediaSendScreen.kt | 9 +- .../signal/mediasend/MediaSendViewModel.kt | 45 +++++----- ...ploadManager.kt => PreUploadController.kt} | 78 ++--------------- .../preupload/PreUploadRepository.kt | 78 +++++++++++++++++ feature/registration/build.gradle.kts | 1 + .../RegistrationNavigationTest.kt | 4 + .../phonenumber/PhoneNumberScreenTest.kt | 4 + .../VerificationCodeScreenTest.kt | 4 + .../screens/welcome/WelcomeScreenTest.kt | 4 + gradle.properties | 3 + .../signal/glide/SignalGlideDependencies.kt | 1 - 28 files changed, 291 insertions(+), 285 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/CoreUiDependenciesProvider.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ActivityContract.kt rename app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/{MediaSendV3PreUploadCallback.kt => MediaSendV3PreUploadRepository.kt} (94%) create mode 100644 core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt create mode 100644 core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt rename feature/media-send/src/main/java/org/signal/mediasend/preupload/{PreUploadManager.kt => PreUploadController.kt} (75%) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadRepository.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c9f2ba6395..531f62a12e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -475,7 +475,7 @@ android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" /> > { - error("Not yet implemented") - } - - override fun getMediaConstraints(): MediaConstraints { - return MediaConstraints.getPushMediaConstraints() - } - - override fun getMaxVideoDuration(): Int { - error("Not yet implemented") - } - // endregion -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ActivityContract.kt deleted file mode 100644 index 1cf245bba9..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ActivityContract.kt +++ /dev/null @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadRepository.kt similarity index 94% rename from app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadCallback.kt rename to app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadRepository.kt index c9584fdc82..b129288b02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadRepository.kt @@ -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? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index e92052098e..c6ac68de10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -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() diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 93b7218179..171e6c8d0e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -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) } diff --git a/core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt b/core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt new file mode 100644 index 0000000000..4d44e6f962 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt @@ -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 + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt b/core/ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt index dbbc8ea317..c6a7a49532 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/theme/SignalTheme.kt @@ -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 diff --git a/core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt b/core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt new file mode 100644 index 0000000000..61fdfe180d --- /dev/null +++ b/core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt @@ -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 + } +} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt index bf4744d5d6..78620dd487 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt @@ -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 { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt index 48103ed7be..86bfdb47f7 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt @@ -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(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 createIntent( + fun createIntent( context: Context, - activityClass: Class, args: MediaSendActivityContract.Args = MediaSendActivityContract.Args() ): Intent { - return Intent(context, activityClass).apply { + return Intent(context, MediaSendActivity::class.java).apply { putExtra(MediaSendActivityContract.EXTRA_ARGS, args) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt index fea61bc475..24616e9b8d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt @@ -30,9 +30,7 @@ import org.signal.core.models.media.Media * class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java) * ``` */ -open class MediaSendActivityContract( - private val activityClass: Class? = null -) : ActivityResultContract() { +class MediaSendActivityContract : ActivityResultContract() { /** * 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? { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt new file mode 100644 index 0000000000..9253ae4e0f --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt @@ -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 + } +} \ No newline at end of file diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index adb97b4e5e..58e45d67ba 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -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") } ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index f7c7a67a7c..076ffb4367 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -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, 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) { 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, - private val repository: MediaSendRepository, - private val preUploadCallback: PreUploadManager.Callback + private val isMeteredFlow: Flow ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, 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 } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadManager.kt b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadController.kt similarity index 75% rename from feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadManager.kt rename to feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadController.kt index 3ab59ed279..a64d4ce162 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadManager.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadController.kt @@ -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 = 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) - - /** - * 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) - - /** - * 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) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadRepository.kt new file mode 100644 index 0000000000..df220ed496 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadRepository.kt @@ -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) + + /** + * 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) + + /** + * 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 +} \ No newline at end of file diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index 4887a8999e..7fe7b25d94 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -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) diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt index 26c1f961c4..b75b9ae78c 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationNavigationTest.kt @@ -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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberScreenTest.kt index f1e55a5542..e5b8b4482c 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberScreenTest.kt @@ -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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt index 2b780192b7..364135293e 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/verificationcode/VerificationCodeScreenTest.kt @@ -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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt index bfee89f2f3..07491d7cf9 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenTest.kt @@ -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 diff --git a/gradle.properties b/gradle.properties index 52d812cc2f..ed60251991 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/lib/glide/src/main/java/org/signal/glide/SignalGlideDependencies.kt b/lib/glide/src/main/java/org/signal/glide/SignalGlideDependencies.kt index e97280abbc..8b5abe4f56 100644 --- a/lib/glide/src/main/java/org/signal/glide/SignalGlideDependencies.kt +++ b/lib/glide/src/main/java/org/signal/glide/SignalGlideDependencies.kt @@ -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) {