mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-14 23:18:43 +00:00
Reshape entry point for V3 media screens.
This commit is contained in:
committed by
Greyson Parrelli
parent
6d944c0f8c
commit
5c415139fd
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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? {
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user