diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c04179745f5..d240aed87da 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -85,18 +85,26 @@ android:name=".ui.AppLockActivity" android:exported="true" android:hardwareAccelerated="true" - android:launchMode="singleTask" + android:launchMode="singleTop" android:screenOrientation="portrait" android:theme="@style/AppTheme" /> + + + diff --git a/app/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt b/app/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt index 5399d72263c..facbccec657 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt @@ -27,13 +27,3 @@ object SlideNavigationAnimation : WireDestinationStyleAnimated { object PopUpNavigationAnimation : WireDestinationStyleAnimated { override fun animationType(): TransitionAnimationType = TransitionAnimationType.POP_UP } - -object WakeUpScreenPopUpNavigationAnimation : WireDestinationStyleAnimated, ScreenModeStyle { - override fun animationType(): TransitionAnimationType = TransitionAnimationType.POP_UP - override fun screenMode(): ScreenMode = ScreenMode.WAKE_UP -} - -object KeepOnScreenPopUpNavigationAnimation : WireDestinationStyleAnimated, ScreenModeStyle { - override fun animationType(): TransitionAnimationType = TransitionAnimationType.POP_UP - override fun screenMode(): ScreenMode = ScreenMode.KEEP_ON -} diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt index d5d4077830a..c187e94eeac 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt @@ -51,7 +51,7 @@ fun getActionReply( fun getOpenIncomingCallAction(context: Context, conversationId: String, userId: String) = getAction( context.getString(R.string.notification_action_open_call), - openIncomingCallPendingIntent(context, conversationId, userId) + fullScreenIncomingCallPendingIntent(context, conversationId, userId) ) fun getDeclineCallAction(context: Context, conversationId: String, userId: String) = getAction( diff --git a/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt b/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt index 7c0c8e91130..2e207272458 100644 --- a/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt +++ b/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt @@ -28,6 +28,8 @@ import com.wire.android.notification.broadcastreceivers.CallNotificationDismissR import com.wire.android.notification.broadcastreceivers.EndOngoingCallReceiver import com.wire.android.notification.broadcastreceivers.NotificationReplyReceiver import com.wire.android.ui.WireActivity +import com.wire.android.ui.calling.CallActivity +import com.wire.android.ui.calling.CallScreenType import com.wire.android.util.deeplink.DeepLinkProcessor fun messagePendingIntent(context: Context, conversationId: String, userId: String?): PendingIntent { @@ -81,17 +83,6 @@ fun replyMessagePendingIntent(context: Context, conversationId: String, userId: PendingIntent.FLAG_MUTABLE ) -fun openIncomingCallPendingIntent(context: Context, conversationId: String, userId: String): PendingIntent { - val intent = openIncomingCallIntent(context, conversationId, userId) - - return PendingIntent.getActivity( - context.applicationContext, - OPEN_INCOMING_CALL_REQUEST_CODE, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) -} - fun openOngoingCallPendingIntent(context: Context, conversationId: String): PendingIntent { val intent = openOngoingCallIntent(context, conversationId) @@ -132,27 +123,21 @@ fun fullScreenIncomingCallPendingIntent(context: Context, conversationId: String context, FULL_SCREEN_REQUEST_CODE, intent, - PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) } private fun openIncomingCallIntent(context: Context, conversationId: String, userId: String) = - Intent(context.applicationContext, WireActivity::class.java).apply { - data = Uri.Builder() - .scheme(DeepLinkProcessor.DEEP_LINK_SCHEME) - .authority(DeepLinkProcessor.INCOMING_CALL_DEEPLINK_HOST) - .appendPath(conversationId) - .appendQueryParameter(DeepLinkProcessor.USER_TO_USE_QUERY_PARAM, userId) - .build() + Intent(context.applicationContext, CallActivity::class.java).apply { + putExtra(CallActivity.EXTRA_CONVERSATION_ID, conversationId) + putExtra(CallActivity.EXTRA_USER_ID, userId) + putExtra(CallActivity.EXTRA_SCREEN_TYPE, CallScreenType.Incoming.name) } private fun openOngoingCallIntent(context: Context, conversationId: String) = - Intent(context.applicationContext, WireActivity::class.java).apply { - data = Uri.Builder() - .scheme(DeepLinkProcessor.DEEP_LINK_SCHEME) - .authority(DeepLinkProcessor.ONGOING_CALL_DEEPLINK_HOST) - .appendPath(conversationId) - .build() + Intent(context.applicationContext, CallActivity::class.java).apply { + putExtra(CallActivity.EXTRA_CONVERSATION_ID, conversationId) + putExtra(CallActivity.EXTRA_SCREEN_TYPE, CallScreenType.Ongoing.name) } private fun openMigrationLoginIntent(context: Context, userHandle: String) = @@ -188,14 +173,12 @@ fun openAppPendingIntent(context: Context): PendingIntent { private const val MESSAGE_NOTIFICATIONS_SUMMARY_REQUEST_CODE = 0 private const val DECLINE_CALL_REQUEST_CODE = "decline_call_" -private const val OPEN_INCOMING_CALL_REQUEST_CODE = 2 private const val FULL_SCREEN_REQUEST_CODE = 3 private const val OPEN_ONGOING_CALL_REQUEST_CODE = 4 private const val OPEN_MIGRATION_LOGIN_REQUEST_CODE = 5 private const val END_ONGOING_CALL_REQUEST_CODE = "hang_up_call_" private const val OPEN_MESSAGE_REQUEST_CODE_PREFIX = "open_message_" private const val OPEN_OTHER_USER_PROFILE_CODE_PREFIX = "open_other_user_profile_" -private const val CALL_REQUEST_CODE_PREFIX = "call_" private const val REPLY_MESSAGE_REQUEST_CODE_PREFIX = "reply_" private fun getRequestCode(conversationId: String, prefix: String): Int = (prefix + conversationId).hashCode() diff --git a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt index 7bf369a96ff..69f29465d36 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -305,7 +305,6 @@ class WireNotificationManager @Inject constructor( when (screens) { is CurrentScreen.Conversation -> messagesNotificationManager.hideNotification(screens.id, userId) is CurrentScreen.OtherUserProfile -> messagesNotificationManager.hideNotification(screens.id, userId) - is CurrentScreen.IncomingCallScreen -> callNotificationManager.hideIncomingCallNotification() else -> {} } } diff --git a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt index 47769edce5f..7add69f70fb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt @@ -74,6 +74,7 @@ class AppLockActivity : AppCompatActivity() { } } } + companion object { const val SET_TEAM_APP_LOCK = "set_team_app_lock" } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index a8fe5eba5c6..a1e5dae3b7f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -62,7 +62,7 @@ import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.NavigationGraph import com.wire.android.navigation.navigateToItem import com.wire.android.navigation.rememberNavigator -import com.wire.android.ui.calling.ProximitySensorManager +import com.wire.android.ui.calling.getOngoingCallIntent import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.CommonTopAppBar import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel @@ -72,10 +72,8 @@ import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.ImportMediaScreenDestination -import com.wire.android.ui.destinations.IncomingCallScreenDestination import com.wire.android.ui.destinations.LoginScreenDestination import com.wire.android.ui.destinations.MigrationScreenDestination -import com.wire.android.ui.destinations.OngoingCallScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SelfDevicesScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination @@ -103,7 +101,6 @@ import com.wire.android.util.SyncStateObserver import com.wire.android.util.debug.FeatureVisibilityFlags import com.wire.android.util.debug.LocalFeatureVisibilityFlags import com.wire.android.util.deeplink.DeepLinkResult -import com.wire.android.util.ui.updateScreenSettings import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -122,9 +119,6 @@ class WireActivity : AppCompatActivity() { @Inject lateinit var currentScreenManager: CurrentScreenManager - @Inject - lateinit var proximitySensorManager: ProximitySensorManager - @Inject lateinit var lockCodeTimeManager: Lazy @@ -154,9 +148,6 @@ class WireActivity : AppCompatActivity() { lifecycle.addObserver(currentScreenManager) WindowCompat.setDecorFitsSystemWindows(window, false) - appLogger.i("$TAG proximity sensor") - proximitySensorManager.initialize() - lifecycleScope.launch(Dispatchers.Default) { appLogger.i("$TAG persistent connection status") @@ -219,7 +210,9 @@ class WireActivity : AppCompatActivity() { CommonTopAppBar( commonTopAppBarState = commonTopAppBarViewModel.state, onReturnToCallClick = { establishedCall -> - navigator.navigate(NavigationCommand(OngoingCallScreenDestination(establishedCall.conversationId))) + getOngoingCallIntent(this@WireActivity, establishedCall.conversationId.toString()).run { + startActivity(this) + } }, ) CompositionLocalProvider(LocalNavigator provides navigator) { @@ -263,7 +256,6 @@ class WireActivity : AppCompatActivity() { DisposableEffect(navController) { val updateScreenSettingsListener = NavController.OnDestinationChangedListener { _, navDestination, _ -> currentKeyboardController?.hide() - updateScreenSettings(navDestination) } navController.addOnDestinationChangedListener(updateScreenSettingsListener) navController.addOnDestinationChangedListener(currentScreenManager) @@ -480,13 +472,6 @@ class WireActivity : AppCompatActivity() { } } } - - proximitySensorManager.registerListener() - } - - override fun onPause() { - super.onPause() - proximitySensorManager.unRegisterListener() } override fun onSaveInstanceState(outState: Bundle) { @@ -532,15 +517,6 @@ class WireActivity : AppCompatActivity() { // do nothing, already handled in ViewModel } - is DeepLinkResult.IncomingCall -> { - if (result.switchedAccount) navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) - navigate(NavigationCommand(IncomingCallScreenDestination(result.conversationsId))) - } - - is DeepLinkResult.OngoingCall -> { - navigate(NavigationCommand(OngoingCallScreenDestination(result.conversationsId))) - } - is DeepLinkResult.OpenConversation -> { if (result.switchedAccount) navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) navigate(NavigationCommand(ConversationScreenDestination(result.conversationsId), BackStackMode.UPDATE_EXISTED)) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 12ba8586963..79ae1d5e2fc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -486,7 +486,6 @@ class WireActivityViewModel @Inject constructor( CurrentScreen.InBackground, is CurrentScreen.Conversation, CurrentScreen.Home, - is CurrentScreen.CallScreen, is CurrentScreen.OtherUserProfile, CurrentScreen.AuthRelated, CurrentScreen.SomeOther -> true diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt new file mode 100644 index 00000000000..6dfd4a3333a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt @@ -0,0 +1,174 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.togetherWith +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.view.WindowCompat +import com.wire.android.appLogger +import com.wire.android.navigation.style.TransitionAnimationType +import com.wire.android.notification.CallNotificationManager +import com.wire.android.ui.AppLockActivity +import com.wire.android.ui.LocalActivity +import com.wire.android.ui.calling.incoming.IncomingCallScreen +import com.wire.android.ui.calling.initiating.InitiatingCallScreen +import com.wire.android.ui.calling.ongoing.OngoingCallScreen +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.theme.WireTheme +import com.wire.kalium.logic.data.id.QualifiedIdMapperImpl +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class CallActivity : AppCompatActivity() { + + @Inject + lateinit var callNotificationManager: CallNotificationManager + + @Inject + lateinit var proximitySensorManager: ProximitySensorManager + + private val qualifiedIdMapper = QualifiedIdMapperImpl(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + callNotificationManager.hideAllNotifications() + + setUpCallingFlags() + + appLogger.i("$TAG Initializing proximity sensor..") + proximitySensorManager.initialize() + + WindowCompat.setDecorFitsSystemWindows(window, false) + + val conversationId = intent.extras?.getString(EXTRA_CONVERSATION_ID) + val screenType = intent.extras?.getString(EXTRA_SCREEN_TYPE) + + setContent { + val snackbarHostState = remember { SnackbarHostState() } + CompositionLocalProvider( + LocalSnackbarHostState provides snackbarHostState, + LocalActivity provides this + ) { + WireTheme { + var currentCallScreenType by remember { mutableStateOf(screenType) } + currentCallScreenType?.let { currentScreenType -> + AnimatedContent( + targetState = currentScreenType, + transitionSpec = { + TransitionAnimationType.POP_UP.enterTransition.togetherWith( + TransitionAnimationType.POP_UP.exitTransition + ) + }, + label = currentScreenType + ) { screenType -> + conversationId?.let { + when (screenType) { + CallScreenType.Initiating.name -> InitiatingCallScreen( + qualifiedIdMapper.fromStringToQualifiedID(it) + ) { + currentCallScreenType = CallScreenType.Ongoing.name + } + + CallScreenType.Ongoing.name -> OngoingCallScreen( + qualifiedIdMapper.fromStringToQualifiedID(it) + ) + + CallScreenType.Incoming.name -> IncomingCallScreen( + qualifiedIdMapper.fromStringToQualifiedID(it) + ) { + currentCallScreenType = CallScreenType.Ongoing.name + } + } + } + } + } ?: run { + finish() + } + } + } + } + } + + override fun onResume() { + super.onResume() + proximitySensorManager.registerListener() + } + + override fun onPause() { + super.onPause() + proximitySensorManager.unRegisterListener() + } + + companion object { + private const val TAG = "CallActivity" + const val EXTRA_CONVERSATION_ID = "conversation_id" + const val EXTRA_USER_ID = "user_id" + const val EXTRA_SCREEN_TYPE = "screen_type" + } +} + +fun CallActivity.setUpCallingFlags() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON, + ) + } +} + +fun getOngoingCallIntent( + activity: Activity, + conversationId: String +) = Intent(activity, CallActivity::class.java).apply { + putExtra(CallActivity.EXTRA_CONVERSATION_ID, conversationId) + putExtra(CallActivity.EXTRA_SCREEN_TYPE, CallScreenType.Ongoing.name) +} + +fun getInitiatingCallIntent( + activity: Activity, + conversationId: String +) = Intent(activity, CallActivity::class.java).apply { + putExtra(CallActivity.EXTRA_CONVERSATION_ID, conversationId) + putExtra(CallActivity.EXTRA_SCREEN_TYPE, CallScreenType.Initiating.name) +} + +fun CallActivity.openAppLockActivity() { + Intent(this, AppLockActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + }.run { + startActivity(this) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallingNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallScreenType.kt similarity index 85% rename from app/src/main/kotlin/com/wire/android/ui/calling/CallingNavArgs.kt rename to app/src/main/kotlin/com/wire/android/ui/calling/CallScreenType.kt index c8892b46466..e5b43bf08ca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallingNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallScreenType.kt @@ -15,10 +15,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.calling -import com.wire.kalium.logic.data.id.ConversationId +package com.wire.android.ui.calling -data class CallingNavArgs( - val conversationId: ConversationId -) +enum class CallScreenType { + Incoming, + Ongoing, + Initiating +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt index 26d101a46e1..c3d0c3ddf82 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt @@ -22,7 +22,6 @@ import android.view.View import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger @@ -30,7 +29,6 @@ import com.wire.android.mapper.UICallParticipantMapper import com.wire.android.mapper.UserTypeMapper import com.wire.android.media.CallRinger import com.wire.android.model.ImageAsset -import com.wire.android.ui.navArgs import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.dispatchers.DispatcherProvider @@ -41,7 +39,7 @@ import com.wire.kalium.logic.data.call.ConversationType import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails -import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToBackCameraUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToFrontCameraUseCase @@ -55,6 +53,9 @@ import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.util.PlatformView +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow @@ -66,12 +67,11 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("LongParameterList", "TooManyFunctions") -@HiltViewModel -class SharedCallingViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, +@HiltViewModel(assistedFactory = SharedCallingViewModel.Factory::class) +class SharedCallingViewModel @AssistedInject constructor( + @Assisted val conversationId: ConversationId, private val conversationDetails: ObserveConversationDetailsUseCase, private val allCalls: GetAllCallsWithSortedParticipantsUseCase, private val endCall: EndCallUseCase, @@ -92,9 +92,6 @@ class SharedCallingViewModel @Inject constructor( private val dispatchers: DispatcherProvider ) : ViewModel() { - private val callingNavArgs: CallingNavArgs = savedStateHandle.navArgs() - val conversationId: QualifiedID = callingNavArgs.conversationId - var callState by mutableStateOf(CallState(conversationId)) init { @@ -302,4 +299,9 @@ class SharedCallingViewModel @Inject constructor( setVideoPreview(conversationId, PlatformView(view)) } } + + @AssistedFactory + interface Factory { + fun create(conversationId: ConversationId): SharedCallingViewModel + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/common/CallerDetails.kt b/app/src/main/kotlin/com/wire/android/ui/calling/common/CallerDetails.kt index 83bbc428d0a..a9b5baf3c37 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/common/CallerDetails.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/common/CallerDetails.kt @@ -71,7 +71,7 @@ fun CallerDetails( proteusVerificationStatus: Conversation.VerificationStatus?, ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(top = dimensions().spacing32x), verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt index b222ffe6876..6968d6dd00d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt @@ -36,29 +36,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.WakeUpScreenPopUpNavigationAnimation +import com.wire.android.ui.LocalActivity +import com.wire.android.ui.calling.CallActivity import com.wire.android.ui.calling.CallState -import com.wire.android.ui.calling.CallingNavArgs import com.wire.android.ui.calling.SharedCallingViewModel import com.wire.android.ui.calling.common.CallVideoPreview import com.wire.android.ui.calling.common.CallerDetails import com.wire.android.ui.calling.controlbuttons.AcceptButton import com.wire.android.ui.calling.controlbuttons.CallOptionsControls import com.wire.android.ui.calling.controlbuttons.HangUpButton +import com.wire.android.ui.calling.openAppLockActivity import com.wire.android.ui.common.bottomsheet.WireBottomSheetScaffold import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.dialogs.calling.JoinAnywayDialog import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.wire.android.ui.destinations.OngoingCallScreenDestination import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.theme.wireTypography import com.wire.android.util.permission.PermissionDenialType @@ -66,21 +61,29 @@ import com.wire.android.util.permission.rememberCallingRecordAudioRequestFlow import com.wire.kalium.logic.data.call.ConversationType import com.wire.kalium.logic.data.id.ConversationId -@RootNavGraph -@Destination( - navArgsDelegate = CallingNavArgs::class, - style = WakeUpScreenPopUpNavigationAnimation::class -) +@Suppress("ParameterWrapping") @Composable fun IncomingCallScreen( - navigator: Navigator, - sharedCallingViewModel: SharedCallingViewModel = hiltViewModel(), - incomingCallViewModel: IncomingCallViewModel = hiltViewModel() + conversationId: ConversationId, + incomingCallViewModel: IncomingCallViewModel = hiltViewModel( + creationCallback = { factory -> factory.create(conversationId = conversationId) } + ), + sharedCallingViewModel: SharedCallingViewModel = hiltViewModel( + creationCallback = { factory -> factory.create(conversationId = conversationId) } + ), + onCallAccepted: () -> Unit ) { - val permissionPermanentlyDeniedDialogState = rememberVisibilityState() + val activity = LocalActivity.current + + val permissionPermanentlyDeniedDialogState = + rememberVisibilityState() val audioPermissionCheck = AudioPermissionCheckFlow( - onAcceptCall = incomingCallViewModel::acceptCall, + onAcceptCall = { + incomingCallViewModel.acceptCall { + (activity as CallActivity).openAppLockActivity() + } + }, onPermanentPermissionDecline = { permissionPermanentlyDeniedDialogState.show( PermissionPermanentlyDeniedDialogState.Visible( @@ -95,19 +98,23 @@ fun IncomingCallScreen( if (incomingCallState.shouldShowJoinCallAnywayDialog) { JoinAnywayDialog( onDismiss = ::dismissJoinCallAnywayDialog, - onConfirm = ::acceptCallAnyway + onConfirm = { + acceptCallAnyway { + (activity as CallActivity).openAppLockActivity() + } + } ) } } LaunchedEffect(incomingCallViewModel.incomingCallState.flowState) { - when (val flowState = incomingCallViewModel.incomingCallState.flowState) { - is IncomingCallState.FlowState.CallClosed -> navigator.navigateBack() - is IncomingCallState.FlowState.CallAccepted -> navigator.navigate( - NavigationCommand( - OngoingCallScreenDestination(flowState.conversationId), - BackStackMode.REMOVE_CURRENT_AND_REPLACE - ) - ) + when (incomingCallViewModel.incomingCallState.flowState) { + is IncomingCallState.FlowState.CallClosed -> { + activity.finish() + } + + is IncomingCallState.FlowState.CallAccepted -> { + onCallAccepted() + } is IncomingCallState.FlowState.Default -> { /* do nothing */ } @@ -119,7 +126,16 @@ fun IncomingCallScreen( toggleMute = { sharedCallingViewModel.toggleMute(true) }, toggleSpeaker = ::toggleSpeaker, toggleVideo = ::toggleVideo, - declineCall = incomingCallViewModel::declineCall, + declineCall = { + incomingCallViewModel.declineCall( + onAppLocked = { + (activity as CallActivity).openAppLockActivity() + }, + onCallRejected = { + activity.finish() + } + ) + }, acceptCall = audioPermissionCheck::launch, onVideoPreviewCreated = ::setVideoPreview, onSelfClearVideoPreview = ::clearVideoPreview, @@ -266,5 +282,15 @@ fun AudioPermissionCheckFlow( @Preview @Composable fun PreviewIncomingCallScreen() { - IncomingCallContent(CallState(ConversationId("value", "domain")), {}, {}, {}, {}, {}, {}, {}, {}) + IncomingCallContent( + callState = CallState(ConversationId("value", "domain")), + toggleMute = { }, + toggleSpeaker = { }, + toggleVideo = { }, + declineCall = { }, + acceptCall = { }, + onVideoPreviewCreated = { }, + onSelfClearVideoPreview = { }, + onPermissionPermanentlyDenied = { }, + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModel.kt index ddf6c5b3e8f..3b91915fe63 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModel.kt @@ -21,32 +21,32 @@ package com.wire.android.ui.calling.incoming import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.media.CallRinger -import com.wire.android.ui.calling.CallingNavArgs -import com.wire.android.ui.navArgs +import com.wire.android.ui.home.appLock.LockCodeTimeManager import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.GetIncomingCallsUseCase import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.RejectCallUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("LongParameterList") -@HiltViewModel -class IncomingCallViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, +@HiltViewModel(assistedFactory = IncomingCallViewModel.Factory::class) +class IncomingCallViewModel @AssistedInject constructor( + @Assisted val conversationId: ConversationId, private val incomingCalls: GetIncomingCallsUseCase, private val rejectCall: RejectCallUseCase, private val acceptCall: AnswerCallUseCase, @@ -54,11 +54,9 @@ class IncomingCallViewModel @Inject constructor( private val muteCall: MuteCallUseCase, private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, private val endCall: EndCallUseCase, + private val lockCodeTimeManager: LockCodeTimeManager ) : ViewModel() { - private val incomingCallNavArgs: CallingNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = incomingCallNavArgs.conversationId - private lateinit var observeIncomingCallJob: Job private var establishedCallConversationId: ConversationId? = null @@ -94,19 +92,31 @@ class IncomingCallViewModel @Inject constructor( calls.find { call -> call.conversationId == conversationId }.also { if (it == null) { callRinger.stop() - incomingCallState = incomingCallState.copy(flowState = IncomingCallState.FlowState.CallClosed) + incomingCallState = + incomingCallState.copy(flowState = IncomingCallState.FlowState.CallClosed) } } } } - fun declineCall() { + fun declineCall( + onAppLocked: () -> Unit, + onCallRejected: () -> Unit + ) { viewModelScope.launch { - observeIncomingCallJob.cancel() - launch { rejectCall(conversationId = conversationId) } - launch { - callRinger.stop() - incomingCallState = incomingCallState.copy(flowState = IncomingCallState.FlowState.CallClosed) + lockCodeTimeManager.observeAppLock().first().let { + if (it) { + onAppLocked() + } else { + observeIncomingCallJob.cancel() + launch { rejectCall(conversationId = conversationId) } + launch { + callRinger.stop() + incomingCallState = + incomingCallState.copy(flowState = IncomingCallState.FlowState.CallClosed) + } + onCallRejected() + } } } } @@ -119,30 +129,44 @@ class IncomingCallViewModel @Inject constructor( incomingCallState = incomingCallState.copy(shouldShowJoinCallAnywayDialog = false) } - fun acceptCallAnyway() { + fun acceptCallAnyway(onAppLocked: () -> Unit) { viewModelScope.launch { - establishedCallConversationId?.let { - endCall(it) - // we need to update mute state to false, so if the user re-join the call te mic will will be muted - muteCall(it, false) - delay(DELAY_END_CALL) + lockCodeTimeManager.observeAppLock().first().let { + if (it) { + onAppLocked() + } else { + establishedCallConversationId?.let { + endCall(it) + // we need to update mute state to false, so if the user re-join the call te mic will will be muted + muteCall(it, false) + delay(DELAY_END_CALL) + } + acceptCall(onAppLocked) + } } - acceptCall() } } - fun acceptCall() { + fun acceptCall(onAppLocked: () -> Unit) { viewModelScope.launch { - if (incomingCallState.hasEstablishedCall) { - showJoinCallAnywayDialog() - } else { - callRinger.stop() - - dismissJoinCallAnywayDialog() - observeIncomingCallJob.cancel() - - acceptCall(conversationId = conversationId) - incomingCallState = incomingCallState.copy(flowState = IncomingCallState.FlowState.CallAccepted(conversationId)) + lockCodeTimeManager.observeAppLock().first().let { + if (it) { + onAppLocked() + } else { + if (incomingCallState.hasEstablishedCall) { + showJoinCallAnywayDialog() + } else { + callRinger.stop() + + dismissJoinCallAnywayDialog() + observeIncomingCallJob.cancel() + + acceptCall(conversationId = conversationId) + incomingCallState = incomingCallState.copy( + flowState = IncomingCallState.FlowState.CallAccepted(conversationId) + ) + } + } } } } @@ -150,4 +174,9 @@ class IncomingCallViewModel @Inject constructor( companion object { const val DELAY_END_CALL = 200L } + + @AssistedFactory + interface Factory { + fun create(conversationId: ConversationId): IncomingCallViewModel + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallScreen.kt index 7d6440df304..1bf0dc8da96 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallScreen.kt @@ -38,15 +38,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.style.KeepOnScreenPopUpNavigationAnimation -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator +import com.wire.android.ui.LocalActivity import com.wire.android.ui.calling.CallState -import com.wire.android.ui.calling.CallingNavArgs import com.wire.android.ui.calling.SharedCallingViewModel import com.wire.android.ui.calling.common.CallVideoPreview import com.wire.android.ui.calling.common.CallerDetails @@ -56,31 +50,37 @@ import com.wire.android.ui.common.bottomsheet.WireBottomSheetScaffold import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.wire.android.ui.destinations.OngoingCallScreenDestination import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.permission.PermissionDenialType import com.wire.kalium.logic.data.id.ConversationId -@RootNavGraph -@Destination( - navArgsDelegate = CallingNavArgs::class, - style = KeepOnScreenPopUpNavigationAnimation::class -) +@Suppress("ParameterWrapping") @Composable fun InitiatingCallScreen( - navigator: Navigator, - navArgs: CallingNavArgs, - sharedCallingViewModel: SharedCallingViewModel = hiltViewModel(), - initiatingCallViewModel: InitiatingCallViewModel = hiltViewModel() + conversationId: ConversationId, + sharedCallingViewModel: SharedCallingViewModel = hiltViewModel( + creationCallback = { factory -> factory.create(conversationId = conversationId) } + ), + initiatingCallViewModel: InitiatingCallViewModel = hiltViewModel( + creationCallback = { factory -> factory.create(conversationId = conversationId) } + ), + onCallAccepted: () -> Unit ) { - val permissionPermanentlyDeniedDialogState = rememberVisibilityState() + val permissionPermanentlyDeniedDialogState = + rememberVisibilityState() + + val activity = LocalActivity.current LaunchedEffect(initiatingCallViewModel.state.flowState) { when (initiatingCallViewModel.state.flowState) { - InitiatingCallState.FlowState.CallClosed -> navigator.navigateBack() - InitiatingCallState.FlowState.CallEstablished -> - navigator.navigate(NavigationCommand(OngoingCallScreenDestination(navArgs.conversationId), BackStackMode.REMOVE_CURRENT)) + InitiatingCallState.FlowState.CallClosed -> { + activity.finish() + } + + InitiatingCallState.FlowState.CallEstablished -> { + onCallAccepted() + } InitiatingCallState.FlowState.Default -> { /* do nothing */ } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModel.kt index f57349cbb4e..45f5a109c30 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModel.kt @@ -21,29 +21,28 @@ package com.wire.android.ui.calling.initiating import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.media.CallRinger -import com.wire.android.ui.calling.CallingNavArgs -import com.wire.android.ui.navArgs -import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.IsLastCallClosedUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.StartCallUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.util.Calendar -import javax.inject.Inject @Suppress("LongParameterList") -@HiltViewModel -class InitiatingCallViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, +@HiltViewModel(assistedFactory = InitiatingCallViewModel.Factory::class) +class InitiatingCallViewModel @AssistedInject constructor( + @Assisted val conversationId: ConversationId, private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, private val startCall: StartCallUseCase, private val endCall: EndCallUseCase, @@ -51,9 +50,6 @@ class InitiatingCallViewModel @Inject constructor( private val callRinger: CallRinger ) : ViewModel() { - private val initiatingCallNavArgs: CallingNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = initiatingCallNavArgs.conversationId - private val callStartTime: Long = Calendar.getInstance().timeInMillis private var wasCallHangUp: Boolean = false @@ -108,7 +104,11 @@ class InitiatingCallViewModel @Inject constructor( conversationId = conversationId ) when (result) { - StartCallUseCase.Result.Success -> callRinger.ring(resource = R.raw.ringing_from_me, isIncomingCall = false) + StartCallUseCase.Result.Success -> callRinger.ring( + resource = R.raw.ringing_from_me, + isIncomingCall = false + ) + StartCallUseCase.Result.SyncFailure -> {} // TODO: handle case where start call fails } } @@ -120,4 +120,9 @@ class InitiatingCallViewModel @Inject constructor( state = state.copy(flowState = InitiatingCallState.FlowState.CallClosed) } } + + @AssistedFactory + interface Factory { + fun create(conversationId: ConversationId): InitiatingCallViewModel + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index 9b7ad29ce64..90cfd276271 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -51,12 +51,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.WakeUpScreenPopUpNavigationAnimation -import com.wire.android.ui.calling.CallingNavArgs +import com.wire.android.ui.LocalActivity import com.wire.android.ui.calling.ConversationName import com.wire.android.ui.calling.SharedCallingViewModel import com.wire.android.ui.calling.controlbuttons.CameraButton @@ -89,27 +85,33 @@ import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import java.util.Locale -@RootNavGraph -@Destination( - navArgsDelegate = CallingNavArgs::class, - style = WakeUpScreenPopUpNavigationAnimation::class -) +@Suppress("ParameterWrapping") @Composable fun OngoingCallScreen( - navigator: Navigator, - ongoingCallViewModel: OngoingCallViewModel = hiltViewModel(), - sharedCallingViewModel: SharedCallingViewModel = hiltViewModel(), + conversationId: ConversationId, + ongoingCallViewModel: OngoingCallViewModel = hiltViewModel( + creationCallback = { factory -> factory.create(conversationId = conversationId) } + ), + sharedCallingViewModel: SharedCallingViewModel = hiltViewModel( + creationCallback = { factory -> factory.create(conversationId = conversationId) } + ) ) { val permissionPermanentlyDeniedDialogState = rememberVisibilityState() + val activity = LocalActivity.current + LaunchedEffect(ongoingCallViewModel.state.flowState) { when (ongoingCallViewModel.state.flowState) { - OngoingCallState.FlowState.CallClosed -> navigator.navigateBack() + OngoingCallState.FlowState.CallClosed -> { + activity.finish() + } + OngoingCallState.FlowState.Default -> { /* do nothing */ } } } + with(sharedCallingViewModel.callState) { OngoingCallContent( conversationId = conversationId, @@ -126,7 +128,7 @@ fun OngoingCallScreen( shouldShowDoubleTapToast = ongoingCallViewModel.shouldShowDoubleTapToast, toggleSpeaker = sharedCallingViewModel::toggleSpeaker, toggleMute = sharedCallingViewModel::toggleMute, - hangUpCall = { sharedCallingViewModel.hangUpCall(navigator::navigateBack) }, + hangUpCall = { sharedCallingViewModel.hangUpCall { activity.finish() } }, toggleVideo = sharedCallingViewModel::toggleVideo, flipCamera = sharedCallingViewModel::flipCamera, setVideoPreview = { @@ -137,7 +139,7 @@ fun OngoingCallScreen( sharedCallingViewModel.clearVideoPreview() ongoingCallViewModel.stopSendingVideoFeed() }, - navigateBack = navigator::navigateBack, + navigateBack = { activity.finish() }, requestVideoStreams = ongoingCallViewModel::requestVideoStreams, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast, onPermissionPermanentlyDenied = { @@ -151,7 +153,9 @@ fun OngoingCallScreen( } } ) - BackHandler(enabled = isCameraOn, navigator::navigateBack) + BackHandler { + activity.finish() + } } PermissionPermanentlyDeniedDialog( diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 9b3abb360b7..b61a23d7060 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -22,36 +22,36 @@ import android.os.CountDownTimer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount -import com.wire.android.ui.calling.CallingNavArgs import com.wire.android.ui.calling.model.UICallParticipant -import com.wire.android.ui.navArgs import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient import com.wire.kalium.logic.data.call.VideoState -import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.RequestVideoStreamsUseCase import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import javax.inject.Inject @Suppress("LongParameterList") -@HiltViewModel -class OngoingCallViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, +@HiltViewModel(assistedFactory = OngoingCallViewModel.Factory::class) +class OngoingCallViewModel @AssistedInject constructor( + @Assisted + val conversationId: ConversationId, @CurrentAccount private val currentUserId: UserId, private val globalDataStore: GlobalDataStore, @@ -60,10 +60,6 @@ class OngoingCallViewModel @Inject constructor( private val setVideoSendState: SetVideoSendStateUseCase, private val currentScreenManager: CurrentScreenManager ) : ViewModel() { - - private val ongoingCallNavArgs: CallingNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = ongoingCallNavArgs.conversationId - var shouldShowDoubleTapToast: Boolean by mutableStateOf(false) private set private var doubleTapIndicatorCountDownTimer: CountDownTimer? = null @@ -98,6 +94,7 @@ class OngoingCallViewModel @Inject constructor( setVideoSendState(conversationId, VideoState.STARTED) } } + fun stopSendingVideoFeed() { viewModelScope.launch { setVideoSendState(conversationId, VideoState.STOPPED) @@ -109,10 +106,10 @@ class OngoingCallViewModel @Inject constructor( .distinctUntilChanged() .collect { calls -> val currentCall = calls.find { call -> call.conversationId == conversationId } - val currentScreen = currentScreenManager.observeCurrentScreen(viewModelScope).first() - val isCurrentlyOnOngoingScreen = currentScreen is CurrentScreen.OngoingCallScreen + val currentScreen = + currentScreenManager.observeCurrentScreen(viewModelScope).first() val isOnBackground = currentScreen is CurrentScreen.InBackground - if (currentCall == null && (isCurrentlyOnOngoingScreen || isOnBackground)) { + if (currentCall == null && isOnBackground) { state = state.copy(flowState = OngoingCallState.FlowState.CallClosed) } } @@ -137,25 +134,30 @@ class OngoingCallViewModel @Inject constructor( private fun startDoubleTapToastDisplayCountDown() { doubleTapIndicatorCountDownTimer?.cancel() - doubleTapIndicatorCountDownTimer = object : CountDownTimer(DOUBLE_TAP_TOAST_DISPLAY_TIME, COUNT_DOWN_INTERVAL) { - override fun onTick(p0: Long) { - appLogger.i("startDoubleTapToastDisplayCountDown: $p0") - } + doubleTapIndicatorCountDownTimer = + object : CountDownTimer(DOUBLE_TAP_TOAST_DISPLAY_TIME, COUNT_DOWN_INTERVAL) { + override fun onTick(p0: Long) { + appLogger.i("startDoubleTapToastDisplayCountDown: $p0") + } - override fun onFinish() { - shouldShowDoubleTapToast = false - viewModelScope.launch { - globalDataStore.setShouldShowDoubleTapToastStatus(currentUserId.toString(), false) + override fun onFinish() { + shouldShowDoubleTapToast = false + viewModelScope.launch { + globalDataStore.setShouldShowDoubleTapToastStatus( + currentUserId.toString(), + false + ) + } } } - } doubleTapIndicatorCountDownTimer?.start() } private fun showDoubleTapToast() { viewModelScope.launch { delay(DELAY_TO_SHOW_DOUBLE_TAP_TOAST) - shouldShowDoubleTapToast = globalDataStore.getShouldShowDoubleTapToast(currentUserId.toString()) + shouldShowDoubleTapToast = + globalDataStore.getShouldShowDoubleTapToast(currentUserId.toString()) if (shouldShowDoubleTapToast) { startDoubleTapToastDisplayCountDown() } @@ -174,4 +176,9 @@ class OngoingCallViewModel @Inject constructor( const val COUNT_DOWN_INTERVAL = 1000L const val DELAY_TO_SHOW_DOUBLE_TAP_TOAST = 500L } + + @AssistedFactory + interface Factory { + fun create(conversationId: ConversationId): OngoingCallViewModel + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt b/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt index 1e1a8fe5006..dab64da1e8e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt @@ -18,8 +18,6 @@ package com.wire.android.ui.common -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape @@ -40,7 +38,6 @@ import androidx.lifecycle.flowWithLifecycle import com.google.accompanist.placeholder.PlaceholderHighlight import com.google.accompanist.placeholder.placeholder import com.google.accompanist.placeholder.shimmer -import com.wire.android.model.Clickable import com.wire.android.ui.home.conversations.model.messagetypes.asset.UIAssetMessage import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -82,20 +79,6 @@ fun Modifier.shimmerPlaceholder( shape = shape, ) -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun Modifier.clickable(clickable: Clickable?) = clickable?.let { - val onClick = rememberClickBlockAction(clickable.clickBlockParams, clickable.onClick) - val onLongClick = clickable.onLongClick?.let { onLongClick -> - rememberClickBlockAction(clickable.clickBlockParams, onLongClick) - } - this.combinedClickable( - enabled = clickable.enabled, - onClick = onClick, - onLongClick = onLongClick - ) -} ?: this - @Composable fun rememberFlow( flow: Flow, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/PreviewWireDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/PreviewWireDialog.kt new file mode 100644 index 00000000000..c33f8f2e8ef --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/PreviewWireDialog.kt @@ -0,0 +1,174 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.textfield.WirePasswordTextField +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireTypography + +@Preview(showBackground = true) +@Composable +fun PreviewWireDialog() { + var password by remember { mutableStateOf(TextFieldValue("")) } + WireTheme { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + WireDialogContent( + optionButton1Properties = WireDialogButtonProperties( + text = "OK", + onClick = { }, + type = WireDialogButtonType.Primary, + state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, + ), + dismissButtonProperties = WireDialogButtonProperties( + text = "Cancel", + onClick = { } + ), + title = "title", + text = buildAnnotatedString { + val style = SpanStyle( + color = colorsScheme().onBackground, + fontWeight = MaterialTheme.wireTypography.body01.fontWeight, + fontSize = MaterialTheme.wireTypography.body01.fontSize, + fontFamily = MaterialTheme.wireTypography.body01.fontFamily, + fontStyle = MaterialTheme.wireTypography.body01.fontStyle + ) + withStyle(style) { append("text\nsecond line\nthirdLine\nfourth line\nfifth line\nsixth line\nseventh line") } + }, + ) { + WirePasswordTextField( + value = password, + onValueChange = { password = it }, + autofill = false + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Preview(showBackground = true) +@Composable +fun PreviewWireDialogWith2OptionButtons() { + var password by remember { mutableStateOf(TextFieldValue("")) } + WireTheme { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + WireDialogContent( + optionButton1Properties = WireDialogButtonProperties( + text = "OK", + onClick = { }, + type = WireDialogButtonType.Primary, + state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, + ), + optionButton2Properties = WireDialogButtonProperties( + text = "Later", + onClick = { }, + type = WireDialogButtonType.Primary, + state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, + ), + dismissButtonProperties = WireDialogButtonProperties( + text = "Cancel", + onClick = { } + ), + title = "title", + text = buildAnnotatedString { + val style = SpanStyle( + color = colorsScheme().onBackground, + fontWeight = MaterialTheme.wireTypography.body01.fontWeight, + fontSize = MaterialTheme.wireTypography.body01.fontSize, + fontFamily = MaterialTheme.wireTypography.body01.fontFamily, + fontStyle = MaterialTheme.wireTypography.body01.fontStyle + ) + withStyle(style) { append("text") } + }, + buttonsHorizontalAlignment = false + ) { + WirePasswordTextField( + value = password, + onValueChange = { password = it }, + autofill = true + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewWireDialogCentered() { + var password by remember { mutableStateOf(TextFieldValue("")) } + WireTheme { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + WireDialogContent( + optionButton1Properties = WireDialogButtonProperties( + text = "OK", + onClick = { }, + type = WireDialogButtonType.Primary, + state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, + ), + dismissButtonProperties = WireDialogButtonProperties( + text = "Cancel", + onClick = { } + ), + centerContent = true, + title = "title", + text = buildAnnotatedString { + val style = SpanStyle( + color = colorsScheme().onBackground, + fontWeight = MaterialTheme.wireTypography.body01.fontWeight, + fontSize = MaterialTheme.wireTypography.body01.fontSize, + fontFamily = MaterialTheme.wireTypography.body01.fontFamily, + fontStyle = MaterialTheme.wireTypography.body01.fontStyle + ) + withStyle(style) { append("text\nsecond line\nthirdLine\nfourth line\nfifth line\nsixth line\nseventh line") } + }, + ) { + WirePasswordTextField( + value = password, + onValueChange = { password = it }, + autofill = false + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewMenuBottomSheetItem.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewMenuBottomSheetItem.kt new file mode 100644 index 00000000000..d0567d0dd46 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewMenuBottomSheetItem.kt @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.bottomsheet + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.R +import com.wire.android.ui.common.ArrowRightIcon +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireTypography + +@Preview +@Composable +fun PreviewMenuBottomSheetItem() { + MenuBottomSheetItem( + title = "very long looooooong title", + icon = { + MenuItemIcon( + id = R.drawable.ic_erase, + contentDescription = "", + ) + }, + action = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "very long looooooong action", + style = MaterialTheme.wireTypography.body01, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(weight = 1f, fill = false) + ) + Spacer(modifier = Modifier.size(dimensions().spacing16x)) + ArrowRightIcon() + } + } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewWireModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewWireModalSheetLayout.kt new file mode 100644 index 00000000000..2c39752fb6c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/PreviewWireModalSheetLayout.kt @@ -0,0 +1,43 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.bottomsheet + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.edit.ReactionOption +import com.wire.android.ui.home.conversationslist.common.GroupConversationAvatar + +@Preview +@Composable +fun PreviewMenuModalSheetContentWithoutHeader() { + MenuModalSheetContent( + MenuModalSheetHeader.Gone, + listOf { ReactionOption({}) } + ) +} + +@Preview +@Composable +fun PreviewMenuModalSheetContentWithHeader() { + MenuModalSheetContent( + MenuModalSheetHeader.Visible("Title", { GroupConversationAvatar(colorsScheme().primary) }, dimensions().spacing8x), + listOf { ReactionOption({}) } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt index 1747775931b..0e5f5b0123a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt @@ -32,7 +32,6 @@ import com.wire.kalium.logic.data.sync.SyncState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.session.CurrentSessionResult import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest @@ -44,7 +43,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject -@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class CommonTopAppBarViewModel @Inject constructor( private val currentScreenManager: CurrentScreenManager, @@ -114,11 +112,10 @@ class CommonTopAppBarViewModel @Inject constructor( connectivity: Connectivity, activeCall: Call? ): ConnectivityUIState { - val canDisplayActiveCall = currentScreen !is CurrentScreen.OngoingCallScreen val canDisplayConnectivityIssues = currentScreen !is CurrentScreen.AuthRelated - if (activeCall != null && canDisplayActiveCall) { + if (activeCall != null) { return ConnectivityUIState.EstablishedCall(activeCall.conversationId, activeCall.isMuted) } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 75e183b8bf8..030be999d54 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.debug -import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -26,19 +25,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.wire.android.BuildConfig import com.wire.android.R -import com.wire.android.datastore.GlobalDataStore -import com.wire.android.di.CurrentAccount -import com.wire.android.migration.failure.UserMigrationStatus import com.wire.android.model.Clickable import com.wire.android.ui.common.RowItemTemplate import com.wire.android.ui.common.WireDialog @@ -53,214 +45,13 @@ import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.getDependenciesVersion -import com.wire.android.util.getDeviceIdString -import com.wire.android.util.getGitBuildId import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.CoreFailure -import com.wire.kalium.logic.E2EIFailure import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.debug.DisableEventProcessingUseCase -import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult -import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult -import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase import com.wire.kalium.logic.functional.Either -import com.wire.kalium.logic.functional.fold -import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler -import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import javax.inject.Inject - -//region DebugDataOptionsViewModel -data class DebugDataOptionsState( - val isEncryptedProteusStorageEnabled: Boolean = false, - val isEventProcessingDisabled: Boolean = false, - val keyPackagesCount: Int = 0, - val mslClientId: String = "null", - val mlsErrorMessage: String = "null", - val isManualMigrationAllowed: Boolean = false, - val debugId: String = "null", - val commitish: String = "null", - val certificate: String = "null", - val showCertificate: Boolean = false, - val startGettingE2EICertificate: Boolean = false, - val dependencies: ImmutableMap = persistentMapOf() -) - -@Suppress("LongParameterList") -@HiltViewModel -class DebugDataOptionsViewModel -@Inject constructor( - @ApplicationContext private val context: Context, - @CurrentAccount val currentAccount: UserId, - private val globalDataStore: GlobalDataStore, - private val updateApiVersions: UpdateApiVersionsScheduler, - private val mlsKeyPackageCountUseCase: MLSKeyPackageCountUseCase, - private val restartSlowSyncProcessForRecovery: RestartSlowSyncProcessForRecoveryUseCase, - private val disableEventProcessingUseCase: DisableEventProcessingUseCase, - private val checkCrlRevocationListUseCase: CheckCrlRevocationListUseCase -) : ViewModel() { - - var state by mutableStateOf( - DebugDataOptionsState() - ) - - init { - observeEncryptedProteusStorageState() - observeMlsMetadata() - checkIfCanTriggerManualMigration() - checkDependenciesVersion() - setGitHashAndDeviceId() - } - - private fun setGitHashAndDeviceId() { - viewModelScope.launch { - val deviceId = context.getDeviceIdString() ?: "null" - val gitBuildId = context.getGitBuildId() - state = state.copy( - debugId = deviceId, - commitish = gitBuildId - ) - } - } - - fun checkDependenciesVersion() { - viewModelScope.launch { - val dependencies = context.getDependenciesVersion().toImmutableMap() - state = state.copy( - dependencies = dependencies - ) - } - } - - fun checkCrlRevocationList() { - viewModelScope.launch { - checkCrlRevocationListUseCase( - forceUpdate = true - ) - } - } - - fun enableEncryptedProteusStorage(enabled: Boolean) { - if (enabled) { - viewModelScope.launch { - globalDataStore.setEncryptedProteusStorageEnabled(true) - } - } - } - - fun restartSlowSyncForRecovery() { - viewModelScope.launch { - restartSlowSyncProcessForRecovery() - } - } - - fun enrollE2EICertificate() { - state = state.copy(startGettingE2EICertificate = true) - } - - fun handleE2EIEnrollmentResult(result: Either) { - result.fold({ - state = state.copy( - certificate = (it as E2EIFailure.OAuth).reason, - showCertificate = true, - startGettingE2EICertificate = false - ) - }, { - if (it is E2EIEnrollmentResult.Finalized) { - state = state.copy( - certificate = it.certificate, - showCertificate = true, - startGettingE2EICertificate = false - ) - } else { - state.copy( - certificate = it.toString(), - showCertificate = true, - startGettingE2EICertificate = false - ) - } - }) - } - - fun dismissCertificateDialog() { - state = state.copy( - showCertificate = false, - ) - } - - fun forceUpdateApiVersions() { - updateApiVersions.scheduleImmediateApiVersionUpdate() - } - - fun disableEventProcessing(disabled: Boolean) { - viewModelScope.launch { - disableEventProcessingUseCase(disabled) - state = state.copy(isEventProcessingDisabled = disabled) - } - } - - //region Private - private fun observeEncryptedProteusStorageState() { - viewModelScope.launch { - globalDataStore.isEncryptedProteusStorageEnabled().collect { - state = state.copy(isEncryptedProteusStorageEnabled = it) - } - } - } - - // If status is NoNeed, it means that the user has already been migrated in and older app version, - // or it is a new install - // this is why we check the existence of the database file - private fun checkIfCanTriggerManualMigration() { - viewModelScope.launch { - globalDataStore.getUserMigrationStatus(currentAccount.value).first() - .let { migrationStatus -> - if (migrationStatus != UserMigrationStatus.NoNeed) { - context.getDatabasePath(currentAccount.value).let { - state = state.copy( - isManualMigrationAllowed = (it.exists() && it.isFile) - ) - } - } - } - } - } - - private fun observeMlsMetadata() { - viewModelScope.launch { - mlsKeyPackageCountUseCase().let { - when (it) { - is MLSKeyPackageCountResult.Success -> { - state = state.copy( - keyPackagesCount = it.count, - mslClientId = it.clientId.value - ) - } - - is MLSKeyPackageCountResult.Failure.NetworkCallFailure -> { - state = state.copy(mlsErrorMessage = "Network Error!") - } - - is MLSKeyPackageCountResult.Failure.FetchClientIdFailure -> { - state = state.copy(mlsErrorMessage = "ClientId Fetch Error!") - } - - is MLSKeyPackageCountResult.Failure.Generic -> {} - } - } - } - } - //endregion -} -//endregion @Composable fun DebugDataOptions( @@ -283,7 +74,8 @@ fun DebugDataOptions( enrollE2EICertificate = viewModel::enrollE2EICertificate, handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, dismissCertificateDialog = viewModel::dismissCertificateDialog, - checkCrlRevocationList = viewModel::checkCrlRevocationList + checkCrlRevocationList = viewModel::checkCrlRevocationList, + dependenciesMap = viewModel.state.dependencies ) } @@ -302,7 +94,8 @@ fun DebugDataOptionsContent( enrollE2EICertificate: () -> Unit, handleE2EIEnrollmentResult: (Either) -> Unit, dismissCertificateDialog: () -> Unit, - checkCrlRevocationList: () -> Unit + checkCrlRevocationList: () -> Unit, + dependenciesMap: ImmutableMap ) { Column { @@ -337,6 +130,7 @@ fun DebugDataOptionsContent( onClick = { onCopyText(state.commitish) } ) ) + DependenciesItem(dependenciesMap) if (BuildConfig.PRIVATE_BUILD) { SettingsItem( @@ -399,7 +193,6 @@ fun DebugDataOptionsContent( onDisableEventProcessingChange = onDisableEventProcessingChange, onRestartSlowSyncForRecovery = onRestartSlowSyncForRecovery, onForceUpdateApiVersions = onForceUpdateApiVersions, - dependenciesMap = state.dependencies, checkCrlRevocationList = checkCrlRevocationList ) } @@ -569,7 +362,6 @@ private fun DebugToolsOptions( onDisableEventProcessingChange: (Boolean) -> Unit, onRestartSlowSyncForRecovery: () -> Unit, onForceUpdateApiVersions: () -> Unit, - dependenciesMap: ImmutableMap, checkCrlRevocationList: () -> Unit ) { FolderHeader(stringResource(R.string.label_debug_tools_title)) @@ -641,20 +433,32 @@ private fun DebugToolsOptions( ) } ) - RowItemTemplate( - modifier = Modifier.wrapContentWidth(), - title = { - Text( - style = MaterialTheme.wireTypography.body01, - color = MaterialTheme.wireColorScheme.onBackground, - text = prettyPrintMap(dependenciesMap), - modifier = Modifier.padding(start = dimensions().spacing8x) - ) - } - ) } } +/** + * Compose function that will display the list of dependencies + * @param dependencies an Immutable map of a dependency name to its version number + */ +@Composable +fun DependenciesItem(dependencies: ImmutableMap) { + val title = stringResource(id = R.string.item_dependencies_title) + val text = remember { + prettyPrintMap(dependencies, title) + } + RowItemTemplate( + modifier = Modifier.wrapContentWidth(), + title = { + Text( + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + text = text, + modifier = Modifier.padding(start = dimensions().spacing8x) + ) + } + ) +} + @Composable private fun DisableEventProcessingSwitch( isEnabled: Boolean = false, @@ -685,8 +489,8 @@ private fun DisableEventProcessingSwitch( } @Stable -private fun prettyPrintMap(map: ImmutableMap): String = StringBuilder().apply { - append("Dependencies:\n") +private fun prettyPrintMap(map: ImmutableMap, title: String): String = StringBuilder().apply { + append("$title\n") map.forEach { (key, value) -> append("$key: $value\n") } @@ -718,6 +522,7 @@ fun PreviewOtherDebugOptions() { enrollE2EICertificate = {}, handleE2EIEnrollmentResult = {}, dismissCertificateDialog = {}, - checkCrlRevocationList = {} + checkCrlRevocationList = {}, + dependenciesMap = persistentMapOf() ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt new file mode 100644 index 00000000000..c0f648184dc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt @@ -0,0 +1,36 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.debug + +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +data class DebugDataOptionsState( + val isEncryptedProteusStorageEnabled: Boolean = false, + val isEventProcessingDisabled: Boolean = false, + val keyPackagesCount: Int = 0, + val mslClientId: String = "null", + val mlsErrorMessage: String = "null", + val isManualMigrationAllowed: Boolean = false, + val debugId: String = "null", + val commitish: String = "null", + val certificate: String = "null", + val showCertificate: Boolean = false, + val startGettingE2EICertificate: Boolean = false, + val dependencies: ImmutableMap = persistentMapOf() +) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt new file mode 100644 index 00000000000..944c27e0336 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -0,0 +1,215 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.debug + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.di.CurrentAccount +import com.wire.android.migration.failure.UserMigrationStatus +import com.wire.android.util.getDependenciesVersion +import com.wire.android.util.getDeviceIdString +import com.wire.android.util.getGitBuildId +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.E2EIFailure +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult +import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult +import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.fold +import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler +import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@HiltViewModel +class DebugDataOptionsViewModel +@Inject constructor( + @ApplicationContext private val context: Context, + @CurrentAccount val currentAccount: UserId, + private val globalDataStore: GlobalDataStore, + private val updateApiVersions: UpdateApiVersionsScheduler, + private val mlsKeyPackageCount: MLSKeyPackageCountUseCase, + private val restartSlowSyncProcessForRecovery: RestartSlowSyncProcessForRecoveryUseCase, + private val checkCrlRevocationList: CheckCrlRevocationListUseCase +) : ViewModel() { + + var state by mutableStateOf( + DebugDataOptionsState() + ) + + init { + observeEncryptedProteusStorageState() + observeMlsMetadata() + checkIfCanTriggerManualMigration() + setGitHashAndDeviceId() + checkDependenciesVersion() + } + + private fun checkDependenciesVersion() { + viewModelScope.launch { + val dependencies = context.getDependenciesVersion().toImmutableMap() + state = state.copy( + dependencies = dependencies + ) + } + } + + private fun setGitHashAndDeviceId() { + viewModelScope.launch { + val deviceId = context.getDeviceIdString() ?: "null" + val gitBuildId = context.getGitBuildId() + state = state.copy( + debugId = deviceId, + commitish = gitBuildId + ) + } + } + + fun checkCrlRevocationList() { + viewModelScope.launch { + checkCrlRevocationList( + forceUpdate = true + ) + } + } + + fun enableEncryptedProteusStorage(enabled: Boolean) { + if (enabled) { + viewModelScope.launch { + globalDataStore.setEncryptedProteusStorageEnabled(true) + } + } + } + + fun restartSlowSyncForRecovery() { + viewModelScope.launch { + restartSlowSyncProcessForRecovery() + } + } + + fun enrollE2EICertificate() { + state = state.copy(startGettingE2EICertificate = true) + } + + fun handleE2EIEnrollmentResult(result: Either) { + result.fold({ + state = state.copy( + certificate = (it as E2EIFailure.OAuth).reason, + showCertificate = true, + startGettingE2EICertificate = false + ) + }, { + if (it is E2EIEnrollmentResult.Finalized) { + state = state.copy( + certificate = it.certificate, + showCertificate = true, + startGettingE2EICertificate = false + ) + } else { + state.copy( + certificate = it.toString(), + showCertificate = true, + startGettingE2EICertificate = false + ) + } + }) + } + + fun dismissCertificateDialog() { + state = state.copy( + showCertificate = false, + ) + } + + fun forceUpdateApiVersions() { + updateApiVersions.scheduleImmediateApiVersionUpdate() + } + + fun disableEventProcessing(disabled: Boolean) { + viewModelScope.launch { + disableEventProcessing(disabled) + state = state.copy(isEventProcessingDisabled = disabled) + } + } + + //region Private + private fun observeEncryptedProteusStorageState() { + viewModelScope.launch { + globalDataStore.isEncryptedProteusStorageEnabled().collect { + state = state.copy(isEncryptedProteusStorageEnabled = it) + } + } + } + + // If status is NoNeed, it means that the user has already been migrated in and older app version, + // or it is a new install + // this is why we check the existence of the database file + private fun checkIfCanTriggerManualMigration() { + viewModelScope.launch { + globalDataStore.getUserMigrationStatus(currentAccount.value).first() + .let { migrationStatus -> + if (migrationStatus != UserMigrationStatus.NoNeed) { + context.getDatabasePath(currentAccount.value).let { + state = state.copy( + isManualMigrationAllowed = (it.exists() && it.isFile) + ) + } + } + } + } + } + + private fun observeMlsMetadata() { + viewModelScope.launch { + mlsKeyPackageCount().let { + when (it) { + is MLSKeyPackageCountResult.Success -> { + state = state.copy( + keyPackagesCount = it.count, + mslClientId = it.clientId.value + ) + } + + is MLSKeyPackageCountResult.Failure.NetworkCallFailure -> { + state = state.copy(mlsErrorMessage = "Network Error!") + } + + is MLSKeyPackageCountResult.Failure.FetchClientIdFailure -> { + state = state.copy(mlsErrorMessage = "ClientId Fetch Error!") + } + + is MLSKeyPackageCountResult.Failure.Generic -> {} + } + } + } + } + //endregion +} +//endregion diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt index 70667c36575..6a56a3707a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt @@ -86,8 +86,6 @@ fun EnterLockCodeScreen( BackHandler { if (navigator.navController.previousBackStackEntry?.destination() is AppUnlockWithBiometricsScreenDestination) { navigator.navigateBack() - } else { - navigator.finish() } } LaunchedEffect(viewModel.state.done) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 985fba4191d..243f645527e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -81,6 +81,9 @@ import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator +import com.wire.android.ui.LocalActivity +import com.wire.android.ui.calling.getInitiatingCallIntent +import com.wire.android.ui.calling.getOngoingCallIntent import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader import com.wire.android.ui.common.bottomsheet.MenuModalSheetLayout import com.wire.android.ui.common.colorsScheme @@ -102,10 +105,8 @@ import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.destinations.GroupConversationDetailsScreenDestination import com.wire.android.ui.destinations.ImagesPreviewScreenDestination -import com.wire.android.ui.destinations.InitiatingCallScreenDestination import com.wire.android.ui.destinations.MediaGalleryScreenDestination import com.wire.android.ui.destinations.MessageDetailsScreenDestination -import com.wire.android.ui.destinations.OngoingCallScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.home.conversations.AuthorHeaderHelper.rememberShouldHaveSmallBottomPadding @@ -217,6 +218,8 @@ fun ConversationScreen( // then ViewModel also detects it's removed and calls onNotFound which can execute navigateBack again and close the app var alreadyDeletedByUser by rememberSaveable { mutableStateOf(false) } + val activity = LocalActivity.current + LaunchedEffect(alreadyDeletedByUser) { if (!alreadyDeletedByUser) { conversationInfoViewModel.observeConversationDetails(navigator::navigateBack) @@ -248,7 +251,13 @@ fun ConversationScreen( appLogger.i("showing showJoinAnywayDialog..") JoinAnywayDialog( onDismiss = ::dismissJoinCallAnywayDialog, - onConfirm = { joinAnyway { navigator.navigate(NavigationCommand(OngoingCallScreenDestination(it))) } } + onConfirm = { + joinAnyway { + getOngoingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } + } + } ) } } @@ -257,7 +266,9 @@ fun ConversationScreen( ConversationScreenDialogType.ONGOING_ACTIVE_CALL -> { OngoingActiveCallDialog(onJoinAnyways = { conversationCallViewModel.endEstablishedCallIfAny { - navigator.navigate(NavigationCommand(InitiatingCallScreenDestination(conversationCallViewModel.conversationId))) + getInitiatingCallIntent(activity, conversationCallViewModel.conversationId.toString()).run { + activity.startActivity(this) + } } showDialog.value = ConversationScreenDialogType.NONE }, onDialogDismiss = { @@ -281,9 +292,15 @@ fun ConversationScreen( coroutineScope, conversationInfoViewModel.conversationInfoViewState.conversationType, onOpenInitiatingCallScreen = { - navigator.navigate(NavigationCommand(InitiatingCallScreenDestination(it))) + getInitiatingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } } - ) { navigator.navigate(NavigationCommand(OngoingCallScreenDestination(it))) } + ) { + getOngoingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } + } }, onDialogDismiss = { showDialog.value = ConversationScreenDialogType.NONE @@ -307,9 +324,15 @@ fun ConversationScreen( coroutineScope, conversationInfoViewModel.conversationInfoViewState.conversationType, onOpenInitiatingCallScreen = { - navigator.navigate(NavigationCommand(InitiatingCallScreenDestination(it))) + getInitiatingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } + } + ) { + getOngoingCallIntent(activity, it.toString()).run { + activity.startActivity(this) } - ) { navigator.navigate(NavigationCommand(OngoingCallScreenDestination(it))) } + } }, onDialogDismiss = { showDialog.value = ConversationScreenDialogType.NONE } ) @@ -375,12 +398,22 @@ fun ConversationScreen( coroutineScope, conversationInfoViewModel.conversationInfoViewState.conversationType, onOpenInitiatingCallScreen = { - navigator.navigate(NavigationCommand(InitiatingCallScreenDestination(it))) + getInitiatingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } } - ) { navigator.navigate(NavigationCommand(OngoingCallScreenDestination(it))) } + ) { + getOngoingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } + } }, onJoinCall = { - conversationCallViewModel.joinOngoingCall { navigator.navigate(NavigationCommand(OngoingCallScreenDestination(it))) } + conversationCallViewModel.joinOngoingCall { + getOngoingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } + } }, onReactionClick = { messageId, emoji -> conversationMessagesViewModel.toggleReaction(messageId, emoji) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt index 9a936dbbc41..4eebe93a290 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.conversations.info import com.wire.android.model.ImageAsset +import com.wire.android.util.CurrentConversationDetailsCache import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.QualifiedID @@ -37,7 +38,11 @@ data class ConversationInfoViewState( val mlsVerificationStatus: Conversation.VerificationStatus? = null, val proteusVerificationStatus: Conversation.VerificationStatus? = null, val legalHoldStatus: Conversation.LegalHoldStatus = Conversation.LegalHoldStatus.UNKNOWN, -) +) { + init { + CurrentConversationDetailsCache.updateConversationName(conversationName) + } +} sealed class ConversationDetailsData { data object None : ConversationDetailsData() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt index 09a71cc127e..308b57ea5c2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -62,7 +63,12 @@ import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversations.messages.QuotedMessageStyle.COMPLETE import com.wire.android.ui.home.conversations.messages.QuotedMessageStyle.PREVIEW import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.markdown.MarkdownInline +import com.wire.android.ui.markdown.NodeData +import com.wire.android.ui.markdown.getFirstInlines +import com.wire.android.ui.markdown.toMarkdownDocument import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.UIText private const val TEXT_QUOTE_MAX_LINES = 7 @@ -338,14 +344,16 @@ private fun QuotedText( modifier = modifier, startContent = { startContent() - }, centerContent = { + }, + centerContent = { editedTimeDescription?.let { if (style == COMPLETE) { StatusBox(it.asString()) } } - MainContentText(text) - }, footerContent = { + MainMarkdownText(text) + }, + footerContent = { QuotedMessageOriginalDate(originalDateTimeDescription) }, clickable = clickable @@ -515,6 +523,33 @@ fun QuotedAudioMessage( ) } +@Composable +private fun MainMarkdownText(text: String, fontStyle: FontStyle = FontStyle.Normal) { + val nodeData = NodeData( + color = colorsScheme().onSurfaceVariant, + style = MaterialTheme.wireTypography.subline01.copy(fontStyle = fontStyle), + colorScheme = MaterialTheme.wireColorScheme, + typography = MaterialTheme.wireTypography, + searchQuery = "", + mentions = listOf(), + disableLinks = true, + ) + + val markdownPreview = remember(text) { + text.toMarkdownDocument().getFirstInlines() + } + + if (markdownPreview != null) { + MarkdownInline( + inlines = markdownPreview.children, + maxLines = TEXT_QUOTE_MAX_LINES, + nodeData = nodeData + ) + } else { + MainContentText(text, fontStyle) + } +} + @Composable private fun MainContentText(text: String, fontStyle: FontStyle = FontStyle.Normal) { Text( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index 4945a985b23..d85e719fdfd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -105,9 +105,13 @@ internal fun MessageBody( ) ) - text?.also { + val markdownDocument = remember(text) { + text?.toMarkdownDocument() + } + + markdownDocument?.also { MarkdownDocument( - it.toMarkdownDocument(), + it, nodeData, clickable ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt index 668e09e7cc3..8489c7e8c36 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationRouter.kt @@ -30,6 +30,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.wire.android.R import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator +import com.wire.android.ui.LocalActivity +import com.wire.android.ui.calling.getOngoingCallIntent import com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionNavigation import com.wire.android.ui.common.bottomsheet.conversation.ConversationSheetContent import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationSheetState @@ -44,7 +46,6 @@ import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.destinations.NewConversationSearchPeopleScreenDestination -import com.wire.android.ui.destinations.OngoingCallScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.home.HomeSnackbarState import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState @@ -86,6 +87,8 @@ fun ConversationRouterHomeBridge( val viewModel: ConversationListViewModel = hiltViewModel() + val activity = LocalActivity.current + LaunchedEffect(conversationsSource) { viewModel.updateConversationsSource(conversationsSource) } @@ -198,7 +201,11 @@ fun ConversationRouterHomeBridge( { userId -> navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(userId))) } } val onJoinedCall: (ConversationId) -> Unit = remember(navigator) { - { conversationId -> navigator.navigate(NavigationCommand(OngoingCallScreenDestination(conversationId))) } + { + getOngoingCallIntent(activity, it.toString()).run { + activity.startActivity(this) + } + } } with(viewModel.conversationListState) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt index 9b4b0836bec..00c2cc07817 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.conversationslist.common import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.text.style.TextOverflow import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.markdown.MarkdownInline @@ -59,8 +60,13 @@ private fun LastMessageMarkdown(text: String, leadingText: String = "") { disableLinks = true ) - val markdownPreview = text.toMarkdownDocument().getFirstInlines() - val leadingInlines = leadingText.toMarkdownDocument().getFirstInlines()?.children ?: persistentListOf() + val markdownPreview = remember(text) { + text.toMarkdownDocument().getFirstInlines() + } + + val leadingInlines = remember(leadingText) { + leadingText.toMarkdownDocument().getFirstInlines()?.children ?: persistentListOf() + } if (markdownPreview != null) { MarkdownInline( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index fdaec494e6a..d59e07fe469 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -58,6 +58,7 @@ import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSelectItem import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSubMenuState import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder import com.wire.android.ui.home.messagecomposer.state.MessageCompositionType +import com.wire.android.util.CurrentConversationDetailsCache import com.wire.android.util.permission.PermissionDenialType import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId @@ -260,10 +261,7 @@ fun EnabledMessageComposer( additionalOptionStateHolder.toRichTextEditing() }, onCloseRichEditingButtonClicked = additionalOptionStateHolder::toAttachmentAndAdditionalOptionsMenu, - onDrawingModeClicked = { - inputStateHolder.collapseComposer() - additionalOptionStateHolder.toDrawingMode() - } + onDrawingModeClicked = additionalOptionStateHolder::toDrawingMode ) } Box( @@ -302,7 +300,12 @@ fun EnabledMessageComposer( onDismissSketch = { inputStateHolder.collapseComposer(additionalOptionStateHolder.additionalOptionsSubMenuState) }, - onSendSketch = onSendButtonClicked + onSendSketch = { + onAttachmentPicked(UriAsset(it)) + inputStateHolder.collapseComposer(additionalOptionStateHolder.additionalOptionsSubMenuState) + }, + conversationTitle = CurrentConversationDetailsCache.conversationName.asString(), + tempWritableImageUri = tempWritableImageUri ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModel.kt index b306bc7ed66..9f6ad118065 100644 --- a/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModel.kt @@ -98,6 +98,7 @@ class LegalHoldRequestedViewModel @Inject constructor( is LegalHoldRequestData.Pending -> { LegalHoldRequestedState.Visible( requiresPassword = legalHoldRequestData.isPasswordRequired, + acceptEnabled = !legalHoldRequestData.isPasswordRequired, legalHoldDeviceFingerprint = legalHoldRequestData.fingerprint, userId = legalHoldRequestData.userId, ) @@ -115,7 +116,7 @@ class LegalHoldRequestedViewModel @Inject constructor( fun passwordChanged(password: TextFieldValue) { state.ifVisible { - state = it.copy(password = password, acceptEnabled = validatePassword(password.text).isValid) + state = it.copy(password = password, acceptEnabled = !it.requiresPassword || validatePassword(password.text).isValid) } } @@ -127,6 +128,7 @@ class LegalHoldRequestedViewModel @Inject constructor( (legalHoldRequestDataStateFlow.value as? LegalHoldRequestData.Pending)?.let { state = LegalHoldRequestedState.Visible( requiresPassword = it.isPasswordRequired, + acceptEnabled = !it.isPasswordRequired, legalHoldDeviceFingerprint = it.fingerprint, userId = it.userId, ) @@ -137,46 +139,40 @@ class LegalHoldRequestedViewModel @Inject constructor( state.ifVisible { state = it.copy(acceptEnabled = false, loading = true) // the accept button is enabled if the password is valid, this check is for safety only - validatePassword(it.password.text).let { validatePasswordResult -> - when (validatePasswordResult.isValid) { - false -> - state = it.copy( - loading = false, - error = LegalHoldRequestedError.InvalidCredentialsError - ) - - true -> - viewModelScope.launch { - coreLogic.sessionScope(it.userId) { - approveLegalHoldRequest(it.password.text).let { approveLegalHoldResult -> - state = when (approveLegalHoldResult) { - is ApproveLegalHoldRequestUseCase.Result.Success -> - LegalHoldRequestedState.Hidden - - ApproveLegalHoldRequestUseCase.Result.Failure.InvalidPassword -> - it.copy( - loading = false, - error = LegalHoldRequestedError.InvalidCredentialsError - ) - - ApproveLegalHoldRequestUseCase.Result.Failure.PasswordRequired -> - it.copy( - loading = false, - requiresPassword = true, - error = LegalHoldRequestedError.InvalidCredentialsError - ) - - is ApproveLegalHoldRequestUseCase.Result.Failure.GenericFailure -> { - appLogger.e("$TAG: Failed to approve legal hold: ${approveLegalHoldResult.coreFailure}") - it.copy( - loading = false, - error = LegalHoldRequestedError.GenericError(approveLegalHoldResult.coreFailure) - ) - } - } + if (it.requiresPassword && validatePassword(it.password.text).isValid.not()) { + state = it.copy(loading = false, error = LegalHoldRequestedError.InvalidCredentialsError) + } else { + val password = if (it.requiresPassword) it.password.text else null + viewModelScope.launch { + coreLogic.sessionScope(it.userId) { + approveLegalHoldRequest(password).let { approveLegalHoldResult -> + state = when (approveLegalHoldResult) { + is ApproveLegalHoldRequestUseCase.Result.Success -> + LegalHoldRequestedState.Hidden + + ApproveLegalHoldRequestUseCase.Result.Failure.InvalidPassword -> + it.copy( + loading = false, + error = LegalHoldRequestedError.InvalidCredentialsError + ) + + ApproveLegalHoldRequestUseCase.Result.Failure.PasswordRequired -> + it.copy( + loading = false, + requiresPassword = true, + error = LegalHoldRequestedError.InvalidCredentialsError + ) + + is ApproveLegalHoldRequestUseCase.Result.Failure.GenericFailure -> { + appLogger.e("$TAG: Failed to approve legal hold: ${approveLegalHoldResult.coreFailure}") + it.copy( + loading = false, + error = LegalHoldRequestedError.GenericError(approveLegalHoldResult.coreFailure) + ) } } } + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownInline.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownInline.kt index 4367080c4b6..a7f25d1e11f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownInline.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownInline.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow @Composable fun MarkdownInline( inlines: List, + maxLines: Int = 1, nodeData: NodeData ) { val annotatedString = buildAnnotatedString { @@ -36,7 +37,7 @@ fun MarkdownInline( style = nodeData.style, color = nodeData.color, clickable = false, - maxLines = 1, + maxLines = maxLines, overflow = TextOverflow.Ellipsis ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/server/ApiVersioningDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/server/ApiVersioningDialogs.kt index 3bc7c85e5e8..f93d04a3b9f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/server/ApiVersioningDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/server/ApiVersioningDialogs.kt @@ -18,26 +18,15 @@ package com.wire.android.ui.server -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.wire.android.R import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType -import com.wire.android.ui.theme.WireTheme -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun ApiVersioningDialog( title: String, diff --git a/app/src/main/kotlin/com/wire/android/util/CurrentConversationDetailsCache.kt b/app/src/main/kotlin/com/wire/android/util/CurrentConversationDetailsCache.kt new file mode 100644 index 00000000000..8ac6dfc3aae --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/CurrentConversationDetailsCache.kt @@ -0,0 +1,40 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.toUIText + +/** + * Cache for the current conversation details. + * This is used to display the conversation name in the toolbar or can be used for other purposes. + * + * TODO: This is temporary, when we have navigation for sketch, we might do it with navigation arguments. + * TODO: Anyway, this might be useful, and we might keep it or discuss it. + */ +object CurrentConversationDetailsCache { + + @Volatile + var conversationName: UIText = "".toUIText() + private set + + @Synchronized + fun updateConversationName(newName: UIText) { + conversationName = newName + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt index ba44de5a9e1..a2d69dc3e99 100644 --- a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt @@ -38,19 +38,15 @@ import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.ImportMediaScreenDestination -import com.wire.android.ui.destinations.IncomingCallScreenDestination import com.wire.android.ui.destinations.InitialSyncScreenDestination -import com.wire.android.ui.destinations.InitiatingCallScreenDestination import com.wire.android.ui.destinations.LoginScreenDestination import com.wire.android.ui.destinations.MigrationScreenDestination -import com.wire.android.ui.destinations.OngoingCallScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.RegisterDeviceScreenDestination import com.wire.android.ui.destinations.RemoveDeviceScreenDestination import com.wire.android.ui.destinations.SelfDevicesScreenDestination import com.wire.android.ui.destinations.WelcomeScreenDestination import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.id.QualifiedID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -153,17 +149,6 @@ sealed class CurrentScreen { // Another User Profile Screen is opened data class OtherUserProfile(val id: ConversationId) : CurrentScreen() - sealed class CallScreen(open val id: QualifiedID) : CurrentScreen() - - // Ongoing call screen is opened - class OngoingCallScreen(override val id: QualifiedID) : CallScreen(id) - - // Incoming call screen is opened - class IncomingCallScreen(override val id: QualifiedID) : CallScreen(id) - - // Initiating call screen is opened - class InitiatingCallScreen(override val id: QualifiedID) : CallScreen(id) - // Import media screen is opened object ImportMedia : CurrentScreen() @@ -193,15 +178,6 @@ sealed class CurrentScreen { is OtherUserProfileScreenDestination -> destination.argsFrom(arguments).conversationId?.let { OtherUserProfile(it) } ?: SomeOther - is OngoingCallScreenDestination -> - OngoingCallScreen(destination.argsFrom(arguments).conversationId) - - is IncomingCallScreenDestination -> - IncomingCallScreen(destination.argsFrom(arguments).conversationId) - - is InitiatingCallScreenDestination -> - InitiatingCallScreen(destination.argsFrom(arguments).conversationId) - is ImportMediaScreenDestination -> ImportMedia is SelfDevicesScreenDestination -> DeviceManager diff --git a/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt b/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt index 02ee90a3c5d..cae130be666 100644 --- a/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt +++ b/app/src/main/kotlin/com/wire/android/util/debug/FeatureVisibilityFlags.kt @@ -55,7 +55,7 @@ object FeatureVisibilityFlags { const val UserProfileEditIcon = false const val MessageEditIcon = true const val SearchConversationMessages = true - const val DrawingIcon = false + const val DrawingIcon = true } val LocalFeatureVisibilityFlags = staticCompositionLocalOf { FeatureVisibilityFlags } diff --git a/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt b/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt index e8de020f3ac..5065fa3fd71 100644 --- a/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt +++ b/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt @@ -47,12 +47,17 @@ sealed class DeepLinkResult { data class Failure(val ssoError: SSOFailureCodes) : SSOLogin() } - data class IncomingCall(val conversationsId: ConversationId, val switchedAccount: Boolean = false) : DeepLinkResult() + data class OpenConversation( + val conversationsId: ConversationId, + val switchedAccount: Boolean = false + ) : DeepLinkResult() + + data class OpenOtherUserProfile(val userId: QualifiedID, val switchedAccount: Boolean = false) : + DeepLinkResult() + + data class JoinConversation(val code: String, val key: String, val domain: String?) : + DeepLinkResult() - data class OngoingCall(val conversationsId: ConversationId) : DeepLinkResult() - data class OpenConversation(val conversationsId: ConversationId, val switchedAccount: Boolean = false) : DeepLinkResult() - data class OpenOtherUserProfile(val userId: QualifiedID, val switchedAccount: Boolean = false) : DeepLinkResult() - data class JoinConversation(val code: String, val key: String, val domain: String?) : DeepLinkResult() data class MigrationLogin(val userHandle: String) : DeepLinkResult() } @@ -69,10 +74,12 @@ class DeepLinkProcessor @Inject constructor( return when (uri.host) { ACCESS_DEEPLINK_HOST -> getCustomServerConfigDeepLinkResult(uri) SSO_LOGIN_DEEPLINK_HOST -> getSSOLoginDeepLinkResult(uri) - INCOMING_CALL_DEEPLINK_HOST -> getIncomingCallDeepLinkResult(uri, switchedAccount) - ONGOING_CALL_DEEPLINK_HOST -> getOngoingCallDeepLinkResult(uri) CONVERSATION_DEEPLINK_HOST -> getOpenConversationDeepLinkResult(uri, switchedAccount) - OTHER_USER_PROFILE_DEEPLINK_HOST -> getOpenOtherUserProfileDeepLinkResult(uri, switchedAccount) + OTHER_USER_PROFILE_DEEPLINK_HOST -> getOpenOtherUserProfileDeepLinkResult( + uri, + switchedAccount + ) + MIGRATION_LOGIN_HOST -> getOpenMigrationLoginDeepLinkResult(uri) JOIN_CONVERSATION_DEEPLINK_HOST -> getJoinConversationDeepLinkResult(uri) else -> DeepLinkResult.Unknown @@ -80,25 +87,32 @@ class DeepLinkProcessor @Inject constructor( } private suspend fun switchAccountIfNeeded(uri: Uri): Boolean { - uri.getQueryParameter(USER_TO_USE_QUERY_PARAM)?.toQualifiedID(qualifiedIdMapper)?.let { userId -> - val shouldSwitchAccount = when (val result = currentSession()) { - is CurrentSessionResult.Failure.Generic -> true - CurrentSessionResult.Failure.SessionNotFound -> true - is CurrentSessionResult.Success -> result.accountInfo.userId != userId + uri.getQueryParameter(USER_TO_USE_QUERY_PARAM)?.toQualifiedID(qualifiedIdMapper) + ?.let { userId -> + val shouldSwitchAccount = when (val result = currentSession()) { + is CurrentSessionResult.Failure.Generic -> true + CurrentSessionResult.Failure.SessionNotFound -> true + is CurrentSessionResult.Success -> result.accountInfo.userId != userId + } + if (shouldSwitchAccount) { + return accountSwitch(SwitchAccountParam.SwitchToAccount(userId)) == SwitchAccountResult.SwitchedToAnotherAccount + } } - if (shouldSwitchAccount) { - return accountSwitch(SwitchAccountParam.SwitchToAccount(userId)) == SwitchAccountResult.SwitchedToAnotherAccount - } - } return false } - private fun getOpenConversationDeepLinkResult(uri: Uri, switchedAccount: Boolean): DeepLinkResult = + private fun getOpenConversationDeepLinkResult( + uri: Uri, + switchedAccount: Boolean + ): DeepLinkResult = uri.lastPathSegment?.toQualifiedID(qualifiedIdMapper)?.let { conversationId -> DeepLinkResult.OpenConversation(conversationId, switchedAccount) } ?: DeepLinkResult.Unknown - private fun getOpenOtherUserProfileDeepLinkResult(uri: Uri, switchedAccount: Boolean): DeepLinkResult = + private fun getOpenOtherUserProfileDeepLinkResult( + uri: Uri, + switchedAccount: Boolean + ): DeepLinkResult = uri.lastPathSegment?.toQualifiedID(qualifiedIdMapper)?.let { DeepLinkResult.OpenOtherUserProfile(it, switchedAccount) } ?: DeepLinkResult.Unknown @@ -109,18 +123,9 @@ class DeepLinkProcessor @Inject constructor( else DeepLinkResult.MigrationLogin(it) } ?: DeepLinkResult.Unknown - private fun getCustomServerConfigDeepLinkResult(uri: Uri) = uri.getQueryParameter(SERVER_CONFIG_PARAM)?.let { - DeepLinkResult.CustomServerConfig(it) - } ?: DeepLinkResult.Unknown - - private fun getIncomingCallDeepLinkResult(uri: Uri, switchedAccount: Boolean) = - uri.lastPathSegment?.toQualifiedID(qualifiedIdMapper)?.let { - DeepLinkResult.IncomingCall(it, switchedAccount) - } ?: DeepLinkResult.Unknown - - private fun getOngoingCallDeepLinkResult(uri: Uri) = - uri.lastPathSegment?.toQualifiedID(qualifiedIdMapper)?.let { - DeepLinkResult.OngoingCall(it) + private fun getCustomServerConfigDeepLinkResult(uri: Uri) = + uri.getQueryParameter(SERVER_CONFIG_PARAM)?.let { + DeepLinkResult.CustomServerConfig(it) } ?: DeepLinkResult.Unknown private fun getSSOLoginDeepLinkResult(uri: Uri): DeepLinkResult { @@ -162,8 +167,6 @@ class DeepLinkProcessor @Inject constructor( const val SSO_LOGIN_COOKIE_PARAM = "cookie" const val SSO_LOGIN_ERROR_PARAM = "error" const val SSO_LOGIN_SERVER_CONFIG_PARAM = "location" - const val INCOMING_CALL_DEEPLINK_HOST = "incoming-call" - const val ONGOING_CALL_DEEPLINK_HOST = "ongoing-call" const val CONVERSATION_DEEPLINK_HOST = "conversation" const val OTHER_USER_PROFILE_DEEPLINK_HOST = "other-user-profile" const val MIGRATION_LOGIN_HOST = "migration-login" @@ -176,7 +179,10 @@ class DeepLinkProcessor @Inject constructor( } enum class SSOFailureCodes(val label: String, val errorCode: Int) { - ServerErrorUnsupportedSaml("server-error-unsupported-saml", SSOServerErrorCode.SERVER_ERROR_UNSUPPORTED_SAML), + ServerErrorUnsupportedSaml( + "server-error-unsupported-saml", + SSOServerErrorCode.SERVER_ERROR_UNSUPPORTED_SAML + ), BadSuccessRedirect("bad-success-redirect", SSOServerErrorCode.BAD_SUCCESS_REDIRECT), BadFailureRedirect("bad-failure-redirect", SSOServerErrorCode.BAD_FAILURE_REDIRECT), BadUsername("bad-username", SSOServerErrorCode.BAD_USERNAME), @@ -185,7 +191,10 @@ enum class SSOFailureCodes(val label: String, val errorCode: Int) { NotFound("not-found", SSOServerErrorCode.NOT_FOUND), Forbidden("forbidden", SSOServerErrorCode.FORBIDDEN), NoMatchingAuthReq("no-matching-auth-req", SSOServerErrorCode.NO_MATCHING_AUTH_REQ), - InsufficientPermissions("insufficient-permissions", SSOServerErrorCode.INSUFFICIENT_PERMISSIONS), + InsufficientPermissions( + "insufficient-permissions", + SSOServerErrorCode.INSUFFICIENT_PERMISSIONS + ), Unknown("unknown", SSOServerErrorCode.UNKNOWN); companion object { diff --git a/app/src/main/kotlin/com/wire/android/util/ui/ScreenSettingsUtil.kt b/app/src/main/kotlin/com/wire/android/util/ui/ScreenSettingsUtil.kt deleted file mode 100644 index 907702cb7bf..00000000000 --- a/app/src/main/kotlin/com/wire/android/util/ui/ScreenSettingsUtil.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.util.ui - -import android.app.Activity -import android.os.Build -import android.view.WindowManager -import androidx.navigation.NavDestination -import com.wire.android.navigation.style.ScreenMode -import com.wire.android.navigation.style.ScreenModeStyle -import com.wire.android.navigation.toDestination - -fun Activity.updateScreenSettings(navDestination: NavDestination) { - val screenMode = (navDestination.toDestination()?.style as? ScreenModeStyle)?.screenMode() ?: ScreenMode.NONE - updateScreenSettings(screenMode) -} - -private fun Activity.updateScreenSettings(screenMode: ScreenMode?) { - when (screenMode) { - ScreenMode.WAKE_UP -> wakeUpDevice() - ScreenMode.KEEP_ON -> addScreenOnFlags() - else -> removeScreenOnFlags() - } -} - -private fun Activity.wakeUpDevice() { - - addScreenOnFlags() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - setShowWhenLocked(true) - setTurnScreenOn(true) - } else { - window.addFlags( - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - ) - } -} - -private fun Activity.addScreenOnFlags() { - window.addFlags( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON - or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON - ) -} - -private fun Activity.removeScreenOnFlags() { - window.clearFlags( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON - or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - setShowWhenLocked(false) - setTurnScreenOn(false) - } else { - window.clearFlags( - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - ) - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbf1236e7a2..860d6d27dd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,6 +201,7 @@ API VERSIONING E2EI Manual Enrollment Force API versioning update + Dependencies: Update Support Back up & Restore Conversations diff --git a/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt index 0e8364e5bbb..d2551459b28 100644 --- a/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt @@ -149,7 +149,6 @@ class AccountSwitchUseCaseTest { MockKAnnotations.init(this, relaxUnitFun = true) } - @OptIn(ExperimentalCoroutinesApi::class) var accountSwitchUseCase: AccountSwitchUseCase = AccountSwitchUseCase( updateCurrentSession, getSessions, diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 4e3905c7ba6..41c7c7b5df1 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -232,35 +232,6 @@ class WireActivityViewModelTest { verify(exactly = 1) { arrangement.onDeepLinkResult(result) } } - @Test - fun `given Intent with IncomingCall, when currentSession is present, then initialAppState is LOGGED_IN and result IncomingCall`() = - runTest { - val result = DeepLinkResult.IncomingCall(ConversationId("val", "dom")) - val (arrangement, viewModel) = Arrangement() - .withSomeCurrentSession() - .withDeepLinkResult(result) - .arrange() - - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - - assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) - verify(exactly = 1) { arrangement.onDeepLinkResult(result) } - } - - @Test - fun `given Intent with IncomingCall, when currentSession is absent, then initialAppState is NOT_LOGGED_IN`() = runTest { - val result = DeepLinkResult.IncomingCall(ConversationId("val", "dom")) - val (arrangement, viewModel) = Arrangement() - .withNoCurrentSession() - .withDeepLinkResult(result) - .arrange() - - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - - assertEquals(InitialAppState.NOT_LOGGED_IN, viewModel.initialAppState) - verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - } - @Test fun `given Intent with OpenConversation, when currentSession is present, then initialAppState is LOGGED_IN and result OpenConversation`() = runTest { diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index ddb7a36e4ff..50404c04250 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -18,14 +18,12 @@ package com.wire.android.ui.calling -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.datastore.GlobalDataStore import com.wire.android.config.NavigationTestExtension +import com.wire.android.datastore.GlobalDataStore import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.OngoingCallViewModel import com.wire.android.ui.home.conversationslist.model.Membership -import com.wire.android.ui.navArgs import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.kalium.logic.data.call.Call @@ -42,7 +40,6 @@ import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -50,17 +47,14 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(NavigationTestExtension::class) @ExtendWith(CoroutineTestExtension::class) class OngoingCallViewModelTest { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK private lateinit var establishedCall: ObserveEstablishedCallsUseCase @@ -81,14 +75,15 @@ class OngoingCallViewModelTest { @BeforeEach fun setup() { MockKAnnotations.init(this) - every { savedStateHandle.navArgs() } returns CallingNavArgs(conversationId = conversationId) coEvery { establishedCall.invoke() } returns flowOf(listOf(provideCall())) - coEvery { currentScreenManager.observeCurrentScreen(any()) } returns MutableStateFlow(CurrentScreen.SomeOther) + coEvery { currentScreenManager.observeCurrentScreen(any()) } returns MutableStateFlow( + CurrentScreen.SomeOther + ) coEvery { globalDataStore.getShouldShowDoubleTapToast(any()) } returns false coEvery { setVideoSendState.invoke(any(), any()) } returns Unit ongoingCallViewModel = OngoingCallViewModel( - savedStateHandle = savedStateHandle, + conversationId = conversationId, establishedCalls = establishedCall, requestVideoStreams = requestVideoStreams, currentScreenManager = currentScreenManager, @@ -113,26 +108,42 @@ class OngoingCallViewModelTest { } @Test - fun givenParticipantsList_WhenRequestingVideoStream_ThenRequestItForOnlyParticipantsWithVideoEnabled() = runTest { - val expectedClients = listOf( - CallClient(participant1.id.toString(), participant1.clientId), - CallClient(participant3.id.toString(), participant3.clientId) - ) - coEvery { requestVideoStreams(conversationId = conversationId, expectedClients) } returns Unit - - ongoingCallViewModel.requestVideoStreams(participants) - - coVerify(exactly = 1) { requestVideoStreams(conversationId, expectedClients) } - } + fun givenParticipantsList_WhenRequestingVideoStream_ThenRequestItForOnlyParticipantsWithVideoEnabled() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId), + CallClient(participant3.id.toString(), participant3.clientId) + ) + coEvery { + requestVideoStreams( + conversationId = conversationId, + expectedClients + ) + } returns Unit + + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { requestVideoStreams(conversationId, expectedClients) } + } @Test fun givenDoubleTabIndicatorIsDisplayed_whenUserTapsOnIt_thenHideIt() = runTest { - coEvery { globalDataStore.setShouldShowDoubleTapToastStatus(currentUserId.toString(), false) } returns Unit + coEvery { + globalDataStore.setShouldShowDoubleTapToastStatus( + currentUserId.toString(), + false + ) + } returns Unit ongoingCallViewModel.hideDoubleTapToast() assertEquals(false, ongoingCallViewModel.shouldShowDoubleTapToast) - coVerify(exactly = 1) { globalDataStore.setShouldShowDoubleTapToastStatus(currentUserId.toString(), false) } + coVerify(exactly = 1) { + globalDataStore.setShouldShowDoubleTapToastStatus( + currentUserId.toString(), + false + ) + } } companion object { @@ -174,7 +185,12 @@ class OngoingCallViewModelTest { val participants = listOf(participant1, participant2, participant3) } - private fun provideCall(id: ConversationId = ConversationId("some-dummy-value", "some.dummy.domain")) = Call( + private fun provideCall( + id: ConversationId = ConversationId( + "some-dummy-value", + "some.dummy.domain" + ) + ) = Call( conversationId = id, status = CallStatus.ESTABLISHED, callerId = "caller_id", diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt index 82e7ffe8208..e1fa9e5325d 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt @@ -19,14 +19,12 @@ package com.wire.android.ui.calling import android.view.View -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.mapper.UICallParticipantMapper import com.wire.android.mapper.UserTypeMapper import com.wire.android.media.CallRinger -import com.wire.android.ui.navArgs import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.ui.WireSessionImageLoader @@ -63,9 +61,6 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(NavigationTestExtension::class) class SharedCallingViewModelTest { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK private lateinit var allCalls: GetAllCallsWithSortedParticipantsUseCase @@ -131,9 +126,7 @@ class SharedCallingViewModelTest { @BeforeEach fun setup() { - val dummyConversationId = ConversationId("some-dummy-value", "some.dummy.domain") MockKAnnotations.init(this) - every { savedStateHandle.navArgs() } returns CallingNavArgs(conversationId = dummyConversationId) coEvery { allCalls.invoke() } returns emptyFlow() coEvery { observeConversationDetails.invoke(any()) } returns emptyFlow() coEvery { observeSpeaker.invoke() } returns emptyFlow() @@ -142,7 +135,7 @@ class SharedCallingViewModelTest { ) sharedCallingViewModel = SharedCallingViewModel( - savedStateHandle = savedStateHandle, + conversationId = conversationId, conversationDetails = observeConversationDetails, allCalls = allCalls, endCall = endCall, diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt index 7b6ae3ab5db..a180a28366b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt @@ -18,12 +18,10 @@ package com.wire.android.ui.calling.incoming -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.media.CallRinger -import com.wire.android.ui.calling.CallingNavArgs -import com.wire.android.ui.navArgs +import com.wire.android.ui.home.appLock.LockCodeTimeManager import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.conversation.Conversation @@ -59,9 +57,6 @@ class IncomingCallViewModelTest { class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var rejectCall: RejectCallUseCase @@ -83,9 +78,11 @@ class IncomingCallViewModelTest { @MockK lateinit var muteCall: MuteCallUseCase + @MockK + lateinit var lockCodeTimeManager: LockCodeTimeManager + init { MockKAnnotations.init(this) - every { savedStateHandle.navArgs() } returns CallingNavArgs(conversationId = dummyConversationId) // Default empty values coEvery { rejectCall(any()) } returns Unit @@ -97,6 +94,13 @@ class IncomingCallViewModelTest { coEvery { muteCall(any(), any()) } returns Unit } + fun withAppNotLocked() = apply { + every { lockCodeTimeManager.observeAppLock() } returns flowOf(false) + } + fun withAppLocked() = apply { + every { lockCodeTimeManager.observeAppLock() } returns flowOf(true) + } + fun withEstablishedCalls(flow: Flow>) = apply { coEvery { observeEstablishedCalls.invoke() } returns flow } @@ -106,33 +110,62 @@ class IncomingCallViewModelTest { } fun arrange() = this to IncomingCallViewModel( - savedStateHandle = savedStateHandle, + conversationId = dummyConversationId, incomingCalls = incomingCalls, rejectCall = rejectCall, acceptCall = acceptCall, callRinger = callRinger, observeEstablishedCalls = observeEstablishedCalls, endCall = endCall, - muteCall = muteCall + muteCall = muteCall, + lockCodeTimeManager = lockCodeTimeManager ) } + @Test + fun `given app Locked, when the user decline the call, then do not reject the call`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withAppLocked() + .arrange() + + viewModel.declineCall({}, {}) + + coVerify(inverse = true) { arrangement.rejectCall(conversationId = any()) } + verify(inverse = true) { arrangement.callRinger.stop() } + } + @Test fun `given an incoming call, when the user decline the call, then the reject call use case is called`() = runTest { - val (arrangement, viewModel) = Arrangement().arrange() + val (arrangement, viewModel) = Arrangement() + .withAppNotLocked() + .arrange() - viewModel.declineCall() + viewModel.declineCall({}, {}) coVerify(exactly = 1) { arrangement.rejectCall(conversationId = any()) } verify(exactly = 1) { arrangement.callRinger.stop() } assertTrue { viewModel.incomingCallState.flowState is IncomingCallState.FlowState.CallClosed } } + @Test + fun `given app locked, when user tries to accept an incoming call, then do not accept the call`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withAppLocked() + .arrange() + + viewModel.acceptCall({}) + + coVerify(inverse = true) { arrangement.acceptCall(conversationId = any()) } + verify(inverse = true) { arrangement.callRinger.stop() } + } + @Test fun `given no ongoing call, when user tries to accept an incoming call, then invoke answerCall call use case`() = runTest { - val (arrangement, viewModel) = Arrangement().arrange() + val (arrangement, viewModel) = Arrangement() + .withAppNotLocked() + .arrange() - viewModel.acceptCall() + viewModel.acceptCall({}) advanceUntilIdle() coVerify(exactly = 1) { arrangement.acceptCall(conversationId = any()) } @@ -145,10 +178,11 @@ class IncomingCallViewModelTest { @Test fun `given an ongoing call, when user tries to accept an incoming call, then show JoinCallAnywayDialog`() = runTest { val (arrangement, viewModel) = Arrangement() + .withAppNotLocked() .withEstablishedCalls(flowOf(listOf(provideCall(ConversationId("value", "Domain"))))) .arrange() - viewModel.acceptCall() + viewModel.acceptCall({}) assertTrue { viewModel.incomingCallState.flowState is IncomingCallState.FlowState.Default } assertEquals(true, viewModel.incomingCallState.shouldShowJoinCallAnywayDialog) @@ -162,11 +196,12 @@ class IncomingCallViewModelTest { val establishedCallsChannel = Channel>(capacity = Channel.UNLIMITED) .also { it.send(listOf(provideCall(ConversationId("value", "Domain")))) } val (arrangement, viewModel) = Arrangement() + .withAppNotLocked() .withEstablishedCalls(establishedCallsChannel.consumeAsFlow()) .withEndCall { establishedCallsChannel.send(listOf()) } .arrange() - viewModel.acceptCallAnyway() + viewModel.acceptCallAnyway({}) advanceUntilIdle() coVerify(exactly = 1) { arrangement.endCall(any()) } @@ -177,10 +212,11 @@ class IncomingCallViewModelTest { @Test fun `given join dialog displayed, when user dismisses it, then hide it`() = runTest { val (arrangement, viewModel) = Arrangement() + .withAppNotLocked() .withEstablishedCalls(flowOf(listOf(provideCall()))) .arrange() - viewModel.acceptCall() + viewModel.acceptCall({}) viewModel.dismissJoinCallAnywayDialog() diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModelTest.kt index 34987789c08..9f44e203da9 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/initiating/InitiatingCallViewModelTest.kt @@ -18,12 +18,9 @@ package com.wire.android.ui.calling.initiating -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.media.CallRinger -import com.wire.android.ui.calling.CallingNavArgs -import com.wire.android.ui.navArgs import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.IsLastCallClosedUseCase @@ -48,47 +45,46 @@ import org.junit.jupiter.api.extension.ExtendWith class InitiatingCallViewModelTest { @Test - fun `given an outgoing call, when the user ends call, then invoke endCall useCase and close the screen`() = runTest { - // Given - val (arrangement, viewModel) = Arrangement() - .withEndingCall() - .withStartCallSucceeding() - .arrange() - - // When - viewModel.hangUpCall() - advanceUntilIdle() - - // Then - with(arrangement) { - coVerify(exactly = 1) { endCall(any()) } - coVerify(exactly = 1) { callRinger.stop() } + fun `given an outgoing call, when the user ends call, then invoke endCall useCase and close the screen`() = + runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withEndingCall() + .withStartCallSucceeding() + .arrange() + + // When + viewModel.hangUpCall() + advanceUntilIdle() + + // Then + with(arrangement) { + coVerify(exactly = 1) { endCall(any()) } + coVerify(exactly = 1) { callRinger.stop() } + } + assertTrue { viewModel.state.flowState is InitiatingCallState.FlowState.CallClosed } } - assertTrue { viewModel.state.flowState is InitiatingCallState.FlowState.CallClosed } - } @Test - fun `given a start call error, when user tries to start a call, call ring tone is not called`() = runTest { - // Given - val (arrangement, viewModel) = Arrangement() - .withNoInternetConnection() - .withStartCallSucceeding() - .arrange() - - // When - viewModel.initiateCall() - - // Then - with(arrangement) { - coVerify(exactly = 0) { callRinger.ring(any()) } + fun `given a start call error, when user tries to start a call, call ring tone is not called`() = + runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withNoInternetConnection() + .withStartCallSucceeding() + .arrange() + + // When + viewModel.initiateCall() + + // Then + with(arrangement) { + coVerify(exactly = 0) { callRinger.ring(any()) } + } } - } private class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK private lateinit var establishedCalls: ObserveEstablishedCallsUseCase @@ -104,9 +100,11 @@ class InitiatingCallViewModelTest { @MockK lateinit var endCall: EndCallUseCase + val dummyConversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val initiatingCallViewModel by lazy { InitiatingCallViewModel( - savedStateHandle = savedStateHandle, + conversationId = dummyConversationId, observeEstablishedCalls = establishedCalls, startCall = startCall, endCall = endCall, @@ -116,9 +114,7 @@ class InitiatingCallViewModelTest { } init { - val dummyConversationId = ConversationId("some-dummy-value", "some.dummy.domain") MockKAnnotations.init(this) - every { savedStateHandle.navArgs() } returns CallingNavArgs(conversationId = dummyConversationId) coEvery { isLastCallClosed.invoke(any(), any()) } returns flowOf(false) coEvery { establishedCalls() } returns flowOf(emptyList()) every { callRinger.ring(any(), any(), any()) } returns Unit diff --git a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt index 7ad54890d65..7f5b25832c5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt @@ -103,11 +103,11 @@ class CommonTopAppBarViewModelTest { } @Test - fun givenActiveCallAndCallScreenAndConnectivityIssues_whenGettingState_thenShouldHaveConnectivityInfo() = runTest { + fun givenActiveCallAndConnectivityIssues_whenGettingState_thenShouldHaveConnectivityInfo() = runTest { val (_, commonTopAppBarViewModel) = Arrangement() .withCurrentSessionExist() .withActiveCall() - .withCurrentScreen(CurrentScreen.OngoingCallScreen(mockk())) + .withCurrentScreen(CurrentScreen.Home) .withSyncState(SyncState.Waiting) .arrange() @@ -115,7 +115,7 @@ class CommonTopAppBarViewModelTest { val state = commonTopAppBarViewModel.state val info = state.connectivityState - info shouldBeInstanceOf ConnectivityUIState.WaitingConnection::class + info shouldBeInstanceOf ConnectivityUIState.EstablishedCall::class } @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModelTest.kt index 3c6cc6fb916..d918fc74744 100644 --- a/app/src/test/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedViewModelTest.kt @@ -24,16 +24,20 @@ import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import com.wire.kalium.logic.feature.legalhold.ApproveLegalHoldRequestUseCase import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldRequestUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase +import io.mockk.Called import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle @@ -114,6 +118,7 @@ class LegalHoldRequestedViewModelTest { state shouldBeInstanceOf LegalHoldRequestedState.Visible::class state as LegalHoldRequestedState.Visible state.requiresPassword shouldBeEqualTo true + state.acceptEnabled shouldBeEqualTo false } @Test @@ -129,6 +134,7 @@ class LegalHoldRequestedViewModelTest { state shouldBeInstanceOf LegalHoldRequestedState.Visible::class state as LegalHoldRequestedState.Visible state.requiresPassword shouldBeEqualTo false + state.acceptEnabled shouldBeEqualTo true } private fun arrangeWithLegalHoldRequest(isPasswordRequired: Boolean = true) = Arrangement() @@ -234,18 +240,75 @@ class LegalHoldRequestedViewModelTest { viewModel.state shouldBeInstanceOf LegalHoldRequestedState.Hidden::class } + @Test + fun givenPasswordNotRequired_whenApproving_thenShouldNotValidatePasswordAndExecuteWithEmptyPassword() = runTest { + val (arrangement, viewModel) = arrangeWithLegalHoldRequest(isPasswordRequired = false) + .withApproveLegalHoldRequestResult(ApproveLegalHoldRequestUseCase.Result.Success) + .arrange() + advanceUntilIdle() + viewModel.acceptClicked() + verify { arrangement.validatePassword(any()) wasNot Called } + coVerify { arrangement.userSessionScope.approveLegalHoldRequest(matchNullable { it == null }) } + } + + @Test + fun givenPasswordRequired_whenApproving_thenShouldValidatePasswordAndNotExecuteWithEmptyPassword() = runTest { + val (arrangement, viewModel) = arrangeWithLegalHoldRequest(isPasswordRequired = true) + .withValidatePasswordResult(ValidatePasswordResult.Invalid()) + .withApproveLegalHoldRequestResult(ApproveLegalHoldRequestUseCase.Result.Success) + .arrange() + val password = "invalidpassword" + viewModel.passwordChanged(TextFieldValue(password)) + advanceUntilIdle() + viewModel.acceptClicked() + verify { arrangement.validatePassword(password) } + coVerify { arrangement.userSessionScope.approveLegalHoldRequest(matchNullable { it.isNullOrEmpty() }) wasNot Called } + } + + @Test + fun givenPasswordRequiredAndInvalidPassword_whenApproving_thenShouldNotExecuteWithInvalidPassword() = runTest { + val (arrangement, viewModel) = arrangeWithLegalHoldRequest(isPasswordRequired = true) + .withValidatePasswordResult(ValidatePasswordResult.Invalid()) + .withApproveLegalHoldRequestResult(ApproveLegalHoldRequestUseCase.Result.Success) + .arrange() + val password = "invalidpassword" + viewModel.passwordChanged(TextFieldValue(password)) + advanceUntilIdle() + viewModel.acceptClicked() + coVerify { arrangement.userSessionScope.approveLegalHoldRequest(password) wasNot Called } + } + + @Test + fun givenPasswordRequiredAndValidPassword_whenApproving_thenShouldExecuteWithValidPassword() = runTest { + val (arrangement, viewModel) = arrangeWithLegalHoldRequest(isPasswordRequired = true) + .withValidatePasswordResult(ValidatePasswordResult.Valid) + .withApproveLegalHoldRequestResult(ApproveLegalHoldRequestUseCase.Result.Success) + .arrange() + val password = "ValidPassword123!" + viewModel.passwordChanged(TextFieldValue(password)) + advanceUntilIdle() + viewModel.acceptClicked() + coVerify { arrangement.userSessionScope.approveLegalHoldRequest(password) } + } + private class Arrangement { @MockK - private lateinit var validatePassword: ValidatePasswordUseCase + lateinit var validatePassword: ValidatePasswordUseCase + + @MockK + lateinit var coreLogic: CoreLogic @MockK - private lateinit var coreLogic: CoreLogic + lateinit var userSessionScope: UserSessionScope + + val userId = UserId("userId", "domain") val viewModel by lazy { LegalHoldRequestedViewModel(validatePassword, coreLogic) } init { MockKAnnotations.init(this) + every { coreLogic.getSessionScope(userId) } returns userSessionScope } fun withNotCurrentSession() = apply { every { coreLogic.globalScope { session.currentSessionFlow() } } returns @@ -257,19 +320,19 @@ class LegalHoldRequestedViewModelTest { } fun withCurrentSessionExists() = apply { every { coreLogic.globalScope { session.currentSessionFlow() } } returns - flowOf(CurrentSessionResult.Success(AccountInfo.Valid(UserId("userId", "domain")))) + flowOf(CurrentSessionResult.Success(AccountInfo.Valid(userId))) } fun withLegalHoldRequestResult(result: ObserveLegalHoldRequestUseCase.Result) = apply { - every { coreLogic.getSessionScope(any()).observeLegalHoldRequest() } returns flowOf(result) + every { userSessionScope.observeLegalHoldRequest() } returns flowOf(result) } fun withIsPasswordRequiredResult(result: IsPasswordRequiredUseCase.Result) = apply { - coEvery { coreLogic.getSessionScope(any()).users.isPasswordRequired() } returns result + coEvery { userSessionScope.users.isPasswordRequired() } returns result } fun withValidatePasswordResult(result: ValidatePasswordResult) = apply { coEvery { validatePassword(any()) } returns result } fun withApproveLegalHoldRequestResult(result: ApproveLegalHoldRequestUseCase.Result) = apply { - coEvery { coreLogic.getSessionScope(any()).approveLegalHoldRequest(any()) } returns result + coEvery { userSessionScope.approveLegalHoldRequest(any()) } returns result } fun arrange() = this to viewModel.apply { observeLegalHoldRequest() } diff --git a/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt b/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt index 6fe11e9b580..f14c907c145 100644 --- a/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt @@ -105,34 +105,6 @@ class DeepLinkProcessorTest { ) } - @Test - fun `given a incoming call deeplink for current user, returns IncomingCall with conversationId and not switched account`() = runTest { - val (arrangement, deepLinkProcessor) = Arrangement() - .withIncomingCallDeepLink(CURRENT_USER_ID) - .withCurrentSessionSuccess(CURRENT_USER_ID) - .arrange() - val incomingCallResult = deepLinkProcessor(arrangement.uri) - assertInstanceOf(DeepLinkResult.IncomingCall::class.java, incomingCallResult) - assertEquals( - DeepLinkResult.IncomingCall(CONVERSATION_ID, false), - incomingCallResult - ) - } - - @Test - fun `given a incoming call deeplink for other user, returns IncomingCall with conversationId and switched account`() = runTest { - val (arrangement, deepLinkProcessor) = Arrangement() - .withIncomingCallDeepLink(OTHER_USER_ID) - .withCurrentSessionSuccess(CURRENT_USER_ID) - .arrange() - val incomingCallResult = deepLinkProcessor(arrangement.uri) - assertInstanceOf(DeepLinkResult.IncomingCall::class.java, incomingCallResult) - assertEquals( - DeepLinkResult.IncomingCall(CONVERSATION_ID, true), - incomingCallResult - ) - } - @Test fun `given a invalid deeplink, returns Unknown object`() = runTest { val (arrangement, deepLinkProcessor) = Arrangement() @@ -248,12 +220,6 @@ class DeepLinkProcessorTest { coEvery { uri.getQueryParameter(DeepLinkProcessor.USER_TO_USE_QUERY_PARAM) } returns null } - fun withIncomingCallDeepLink(userId: UserId = CURRENT_USER_ID) = apply { - coEvery { uri.host } returns DeepLinkProcessor.INCOMING_CALL_DEEPLINK_HOST - coEvery { uri.lastPathSegment } returns CONVERSATION_ID.toString() - coEvery { uri.getQueryParameter(DeepLinkProcessor.USER_TO_USE_QUERY_PARAM) } returns userId.toString() - } - fun withConversationDeepLink(userId: UserId = CURRENT_USER_ID) = apply { coEvery { uri.host } returns DeepLinkProcessor.CONVERSATION_DEEPLINK_HOST coEvery { uri.lastPathSegment } returns CONVERSATION_ID.toString() diff --git a/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts b/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts index ef75caaec30..fbd0013248d 100644 --- a/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts +++ b/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts @@ -36,7 +36,7 @@ val dependenciesVersionTask = project.tasks.register("dependenciesVersionTask", val catalog = catalogs.named("klibs") val pairs = mapOf( "avs" to catalog.findVersion("avs").get().requiredVersion, - "core-crypto" to catalog.findVersion("core-crypto-multiplatform").get().requiredVersion + "core-crypto" to catalog.findVersion("core-crypto").get().requiredVersion ) keyValues.set(pairs) } diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 036cb7bb572..c7b56dc5bc3 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -433,7 +433,6 @@ MultiLineIfElse:WireTextField.kt$it MultiLineIfElse:WriteStorageRequestFlow.kt$onGranted() NoBlankLineBeforeRbrace:CreateAccountEmailViewState.kt$CreateAccountEmailViewState$ - NoBlankLineBeforeRbrace:DrawingCanvasBottomSheet.kt$ NoBlankLineBeforeRbrace:DrawingCanvasViewModel.kt$DrawingCanvasViewModel$ NoBlankLineBeforeRbrace:EmailComposer.kt$EmailComposer$ NoBlankLineBeforeRbrace:FakeKaliumFileSystem.kt$FakeKaliumFileSystem$ @@ -485,7 +484,6 @@ NoTrailingSpaces:ScalaServerConfigDAOTest.kt$ScalaServerConfigDAOTest.Arrangement$ NoUnusedImports:ApiVersioningDialogs.kt$com.wire.android.ui.server.ApiVersioningDialogs.kt NoUnusedImports:CreateAccountDetailsScreen.kt$import com.wire.android.ui.common.scaffold.WireScaffold - NoUnusedImports:DateTimeUtilKtTest.kt$com.wire.android.util.DateTimeUtilKtTest.kt NoUnusedImports:SearchPeopleScreenState.kt$com.wire.android.ui.home.conversations.search.SearchPeopleScreenState.kt NoUnusedImports:SpeakerButton.kt$com.wire.android.ui.calling.controlbuttons.SpeakerButton.kt NoUnusedImports:ZoomableImage.kt$com.wire.android.ui.home.gallery.ZoomableImage.kt @@ -642,6 +640,7 @@ SpacingBetweenDeclarationsWithAnnotations:UpdateReceiver.kt$UpdateReceiver$@Inject lateinit var migrationManager: MigrationManager SpacingBetweenDeclarationsWithAnnotations:UpdateReceiver.kt$UpdateReceiver$@Inject lateinit var workManager: WorkManager SpacingBetweenDeclarationsWithAnnotations:UriUtilTest.kt$UriUtilTest$@Test fun givenLinkWithoutParams_whenCallingFindParameterValue_thenReturnsParamValue() + SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val Chocolate = Color(0xFF622F00) SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val DarkAmber100 = Color(0xFFFFF6D4) SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val DarkAmber200 = Color(0xFFFFEEA8) SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val DarkAmber300 = Color(0xFFFFE57D) @@ -761,6 +760,9 @@ SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val LightRed700 = Color(0xFF74000B) SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val LightRed800 = Color(0xFF4E0008) SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val LightRed900 = Color(0xFF3A0006) + SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val Orange = Color(0xFFFD8312) + SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val Pink = Color(0xFFEB239B) + SpacingBetweenDeclarationsWithAnnotations:WireColorPalette.kt$WireColorPalette$@Stable val Turquoise = Color(0xFF01718E) SpacingBetweenDeclarationsWithAnnotations:WirePrimaryIconButton.kt$@Preview @Composable fun PreviewWirePrimaryIconButtonLoading() SpacingBetweenDeclarationsWithAnnotations:WirePrimaryIconButton.kt$@Preview @Composable fun PreviewWirePrimaryIconButtonRound() SpreadOperator:ViewModelScopedPreview.kt$ViewModelScopedPreviewProcessor$(aggregating = true, *items.mapNotNull { it.containingFile }.toTypedArray()) @@ -779,7 +781,6 @@ UnusedParameter:LoginSSOScreen.kt$serverTitle: String UnusedParameter:MentionScreen.kt$openConversationNotificationsSettings: (ConversationItem) -> Unit UnusedParameter:ShouldTriggerMigrationForUserUserCaseTest.kt$ShouldTriggerMigrationForUserUserCaseTest.Arrangement$version: Int? - UnusedParameter:SwipeableSnackbar.kt$onDismiss: () -> Unit = { hostState.currentSnackbarData?.dismiss() } UnusedParameter:TrackingNavController.kt$nameFromRoute: (String) -> String? UnusedParameter:WireButtonDefaults.kt$WireButtonColors$interactionSource: InteractionSource UnusedParameter:WireItemLabel.kt$minHeight: Dp = dimensions().badgeSmallMinSize.height @@ -796,9 +797,6 @@ UnusedPrivateProperty:SelfUserProfileViewModel.kt$SelfUserProfileViewModel$private val notificationChannelsManager: NotificationChannelsManager UnusedPrivateProperty:SendMessageViewModel.kt$SendMessageViewModel.Companion$private const val sizeOf1MB = 1024 * 1024 UnusedPrivateProperty:SendMessageViewModelArrangement.kt$SendMessageViewModelArrangement$@MockK private lateinit var savedStateHandle: SavedStateHandle - UnusedPrivateProperty:SwipeableSnackbar.kt$val currentScreenWidth = with(density) { configuration.screenWidthDp.dp.toPx() } - UnusedPrivateProperty:SwipeableSnackbar.kt$val positionalThreshold: (Float) -> Float = { distance -> distance * 0.5f } - UnusedPrivateProperty:SwipeableSnackbar.kt$val velocityThreshold: () -> Float = with(density) { { 125.dp.toPx() } } UnusedPrivateProperty:WireNotificationManagerTest.kt$WireNotificationManagerTest.Companion$private val TEST_SERVER_CONFIG: ServerConfig = newServerConfig(1) Wrapping:AddMembersToConversationViewModel.kt$AddMembersToConversationViewModel$( Wrapping:AssetImageFetcherTest.kt$AssetImageFetcherTest.Arrangement$( data = imageData, options ?: Options( context = mockContext, parameters = Parameters.Builder().set(key = OPTION_PARAMETER_RETRY_KEY, value = 0, memoryCacheKey = null).build() ) ) diff --git a/core/.gitkeep b/core/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt index e6af44b0eb5..55b4ba6dd17 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt @@ -18,6 +18,8 @@ package com.wire.android.ui.common import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -27,6 +29,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import com.wire.android.model.ClickBlockParams +import com.wire.android.model.Clickable import com.wire.android.util.LocalSyncStateObserver @Composable @@ -47,10 +50,26 @@ fun rememberClickBlockAction(clickBlockParams: ClickBlockParams, clickAction: () when { clickBlockParams.blockWhenConnecting && syncStateObserver.isConnecting -> Toast.makeText(context, context.getString(R.string.label_wait_until_connected), Toast.LENGTH_SHORT).show() + clickBlockParams.blockWhenSyncing && syncStateObserver.isSyncing -> Toast.makeText(context, context.getString(R.string.label_wait_until_synchronised), Toast.LENGTH_SHORT).show() + else -> clickAction() } } } } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Modifier.clickable(clickable: Clickable?) = clickable?.let { + val onClick = rememberClickBlockAction(clickable.clickBlockParams, clickable.onClick) + val onLongClick = clickable.onLongClick?.let { onLongClick -> + rememberClickBlockAction(clickable.clickBlockParams, onLongClick) + } + this.combinedClickable( + enabled = clickable.enabled, + onClick = onClick, + onLongClick = onLongClick + ) +} ?: this diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt similarity index 65% rename from app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt index 14f24856b50..79abc2029a5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt @@ -31,17 +31,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalUriHandler @@ -49,9 +45,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.wire.android.ui.common.button.WireButtonState @@ -59,9 +53,6 @@ import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.button.WireTertiaryButton import com.wire.android.ui.common.progress.WireCircularProgressIndicator -import com.wire.android.ui.common.textfield.WirePasswordTextField -import com.wire.android.ui.markdown.MarkdownConstants -import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -160,7 +151,7 @@ fun WireDialog( } @Composable -private fun WireDialogContent( +fun WireDialogContent( title: String, titleLoading: Boolean = false, text: AnnotatedString? = null, @@ -209,7 +200,7 @@ private fun WireDialogContent( modifier = Modifier.padding(bottom = MaterialTheme.wireDimensions.dialogTextsSpacing), onClick = { offset -> text.getStringAnnotations( - tag = MarkdownConstants.TAG_URL, + tag = TAG_URL, start = offset, end = offset, ).firstOrNull()?.let { result -> uriHandler.openUri(result.item) } @@ -299,143 +290,6 @@ private fun WireDialogButtonProperties?.getButton(modifier: Modifier = Modifier) } } -@OptIn(ExperimentalComposeUiApi::class) -@Preview(showBackground = true) -@Composable -fun PreviewWireDialog() { - var password by remember { mutableStateOf(TextFieldValue("")) } - WireTheme { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - WireDialogContent( - optionButton1Properties = WireDialogButtonProperties( - text = "OK", - onClick = { }, - type = WireDialogButtonType.Primary, - state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, - ), - dismissButtonProperties = WireDialogButtonProperties( - text = "Cancel", - onClick = { } - ), - title = "title", - text = buildAnnotatedString { - val style = SpanStyle( - color = colorsScheme().onBackground, - fontWeight = MaterialTheme.wireTypography.body01.fontWeight, - fontSize = MaterialTheme.wireTypography.body01.fontSize, - fontFamily = MaterialTheme.wireTypography.body01.fontFamily, - fontStyle = MaterialTheme.wireTypography.body01.fontStyle - ) - withStyle(style) { append("text\nsecond line\nthirdLine\nfourth line\nfifth line\nsixth line\nseventh line") } - }, - ) { - WirePasswordTextField( - value = password, - onValueChange = { password = it }, - autofill = false - ) - } - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Preview(showBackground = true) -@Composable -fun PreviewWireDialogWith2OptionButtons() { - var password by remember { mutableStateOf(TextFieldValue("")) } - WireTheme { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - WireDialogContent( - optionButton1Properties = WireDialogButtonProperties( - text = "OK", - onClick = { }, - type = WireDialogButtonType.Primary, - state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, - ), - optionButton2Properties = WireDialogButtonProperties( - text = "Later", - onClick = { }, - type = WireDialogButtonType.Primary, - state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, - ), - dismissButtonProperties = WireDialogButtonProperties( - text = "Cancel", - onClick = { } - ), - title = "title", - text = buildAnnotatedString { - val style = SpanStyle( - color = colorsScheme().onBackground, - fontWeight = MaterialTheme.wireTypography.body01.fontWeight, - fontSize = MaterialTheme.wireTypography.body01.fontSize, - fontFamily = MaterialTheme.wireTypography.body01.fontFamily, - fontStyle = MaterialTheme.wireTypography.body01.fontStyle - ) - withStyle(style) { append("text") } - }, - buttonsHorizontalAlignment = false - ) { - WirePasswordTextField( - value = password, - onValueChange = { password = it }, - autofill = true - ) - } - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Preview(showBackground = true) -@Composable -fun PreviewWireDialogCentered() { - var password by remember { mutableStateOf(TextFieldValue("")) } - WireTheme { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - WireDialogContent( - optionButton1Properties = WireDialogButtonProperties( - text = "OK", - onClick = { }, - type = WireDialogButtonType.Primary, - state = if (password.text.isEmpty()) WireButtonState.Disabled else WireButtonState.Error, - ), - dismissButtonProperties = WireDialogButtonProperties( - text = "Cancel", - onClick = { } - ), - centerContent = true, - title = "title", - text = buildAnnotatedString { - val style = SpanStyle( - color = colorsScheme().onBackground, - fontWeight = MaterialTheme.wireTypography.body01.fontWeight, - fontSize = MaterialTheme.wireTypography.body01.fontSize, - fontFamily = MaterialTheme.wireTypography.body01.fontFamily, - fontStyle = MaterialTheme.wireTypography.body01.fontStyle - ) - withStyle(style) { append("text\nsecond line\nthirdLine\nfourth line\nfifth line\nsixth line\nseventh line") } - }, - ) { - WirePasswordTextField( - value = password, - onValueChange = { password = it }, - autofill = false - ) - } - } - } -} - enum class WireDialogButtonType { Primary, Secondary, Tertiary } data class WireDialogButtonProperties( @@ -445,3 +299,6 @@ data class WireDialogButtonProperties( val type: WireDialogButtonType = WireDialogButtonType.Secondary, val loading: Boolean = false ) + +// todo, replace when markdown is moved to commons +private const val TAG_URL = "linkTag" diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/MenuBottomSheetItem.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/MenuBottomSheetItem.kt similarity index 80% rename from app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/MenuBottomSheetItem.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/MenuBottomSheetItem.kt index 3ec23ab13fe..0d4a3b67566 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/MenuBottomSheetItem.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/MenuBottomSheetItem.kt @@ -37,16 +37,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.wire.android.R import com.wire.android.model.ClickBlockParams import com.wire.android.model.Clickable -import com.wire.android.ui.common.ArrowRightIcon import com.wire.android.ui.common.clickable -import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.divider.WireDivider import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -130,30 +125,3 @@ fun MenuItemIcon( .then(modifier) ) } - -@Preview -@Composable -fun PreviewMenuBottomSheetItem() { - MenuBottomSheetItem( - title = "very long looooooong title", - icon = { - MenuItemIcon( - id = R.drawable.ic_erase, - contentDescription = "", - ) - }, - action = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "very long looooooong action", - style = MaterialTheme.wireTypography.body01, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier.weight(weight = 1f, fill = false) - ) - Spacer(modifier = Modifier.size(dimensions().spacing16x)) - ArrowRightIcon() - } - } - ) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt similarity index 100% rename from app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/ModalSheetHeaderItem.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt similarity index 100% rename from app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt similarity index 100% rename from app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt similarity index 82% rename from app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt index 22d45749928..e8de411621f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt @@ -30,13 +30,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.wire.android.ui.common.colorsScheme -import com.wire.android.ui.common.dimensions -import com.wire.android.ui.edit.ReactionOption -import com.wire.android.ui.home.conversationslist.common.GroupConversationAvatar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -98,21 +93,3 @@ fun MenuModalSheetContent( buildMenuSheetItems(items = menuItems) } } - -@Preview -@Composable -fun PreviewMenuModalSheetContentWithoutHeader() { - MenuModalSheetContent( - MenuModalSheetHeader.Gone, - listOf { ReactionOption({}) } - ) -} - -@Preview -@Composable -fun PreviewMenuModalSheetContentWithHeader() { - MenuModalSheetContent( - MenuModalSheetHeader.Visible("Title", { GroupConversationAvatar(colorsScheme().primary) }, dimensions().spacing8x), - listOf { ReactionOption({}) } - ) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt similarity index 100% rename from app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/common/divider/WireDivider.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/divider/WireDivider.kt similarity index 100% rename from app/src/main/kotlin/com/wire/android/ui/common/divider/WireDivider.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/divider/WireDivider.kt diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt index 4fb5a1af31a..189689e2e7c 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt @@ -309,4 +309,15 @@ object WireColorPalette { @Stable val BlackAlpha55 = Color(0x8C000000) + + @Stable + val Brown = Color(0xFFA25915) + @Stable + val Chocolate = Color(0xFF622F00) + @Stable + val Orange = Color(0xFFFD8312) + @Stable + val Pink = Color(0xFFEB239B) + @Stable + val Turquoise = Color(0xFF01718E) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt index 5c06715738b..6977a7ba6c8 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt @@ -104,7 +104,8 @@ data class WireColorScheme( val validE2eiStatusColor: Color, val mlsVerificationTextColor: Color, val wireAccentColors: WireAccentColors, - val checkboxTextDisabled: Color + val checkboxTextDisabled: Color, + val sketchColorPalette: List ) { fun toColorScheme(): ColorScheme = ColorScheme( primary = primary, @@ -251,7 +252,27 @@ private val LightWireColorScheme = WireColorScheme( Accent.Unknown -> WireColorPalette.LightBlue500 } }, - checkboxTextDisabled = WireColorPalette.Gray70 + checkboxTextDisabled = WireColorPalette.Gray70, + sketchColorPalette = listOf( + Color.Black, + Color.White, + WireColorPalette.LightBlue500, + WireColorPalette.LightGreen550, + WireColorPalette.DarkAmber500, + WireColorPalette.LightRed500, + WireColorPalette.Orange, + WireColorPalette.LightPurple600, + WireColorPalette.Brown, + WireColorPalette.Turquoise, + WireColorPalette.DarkBlue500, + WireColorPalette.DarkGreen500, + WireColorPalette.DarkPetrol500, + WireColorPalette.DarkPurple500, + WireColorPalette.DarkRed500, + WireColorPalette.Pink, + WireColorPalette.Chocolate, + WireColorPalette.Gray70, + ) ) // Dark WireColorScheme @@ -372,7 +393,27 @@ private val DarkWireColorScheme = WireColorScheme( Accent.Unknown -> WireColorPalette.DarkBlue500 } }, - checkboxTextDisabled = WireColorPalette.Gray70 + checkboxTextDisabled = WireColorPalette.Gray70, + sketchColorPalette = listOf( + Color.Black, + Color.White, + WireColorPalette.LightBlue500, + WireColorPalette.LightGreen550, + WireColorPalette.DarkAmber500, + WireColorPalette.LightRed500, + WireColorPalette.Orange, + WireColorPalette.LightPurple600, + WireColorPalette.Brown, + WireColorPalette.Turquoise, + WireColorPalette.DarkBlue500, + WireColorPalette.DarkGreen500, + WireColorPalette.DarkPetrol500, + WireColorPalette.DarkPurple500, + WireColorPalette.DarkRed500, + WireColorPalette.Pink, + WireColorPalette.Chocolate, + WireColorPalette.Gray70, + ) ) @PackagePrivate diff --git a/features/sketch/build.gradle.kts b/features/sketch/build.gradle.kts index 69fd7f00ca7..5f7e54c713a 100644 --- a/features/sketch/build.gradle.kts +++ b/features/sketch/build.gradle.kts @@ -4,9 +4,11 @@ plugins { } dependencies { + implementation(project(":core:ui-common")) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) implementation(libs.material) + implementation(libs.ktx.immutableCollections) val composeBom = platform(libs.compose.bom) implementation(composeBom) @@ -15,9 +17,12 @@ dependencies { implementation(libs.compose.ui.graphics) implementation(libs.compose.material.core) implementation(libs.compose.material3) + implementation(libs.compose.material.icons) implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.compose.ui.preview) testImplementation(libs.junit4) + testImplementation(libs.coroutines.test) androidTestImplementation(libs.androidx.test.extJunit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/features/sketch/src/main/AndroidManifest.xml b/features/sketch/src/main/AndroidManifest.xml index 44b6ed94db1..bb266d68bbf 100644 --- a/features/sketch/src/main/AndroidManifest.xml +++ b/features/sketch/src/main/AndroidManifest.xml @@ -15,6 +15,12 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see http://www.gnu.org/licenses/. --> - + + + diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasBottomSheet.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasBottomSheet.kt index 614e5b3f945..15d60f7361a 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasBottomSheet.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasBottomSheet.kt @@ -17,70 +17,228 @@ */ package com.wire.android.feature.sketch +import android.net.Uri +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Circle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.lifecycle.viewmodel.compose.viewModel +import com.wire.android.feature.sketch.model.DrawingState +import com.wire.android.model.ClickBlockParams +import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.button.IconAlignment +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryIconButton +import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.ui.common.button.WireSecondaryIconButton +import com.wire.android.ui.common.button.WireTertiaryIconButton +import com.wire.android.ui.common.button.wireSendPrimaryButtonColors +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun DrawingCanvasBottomSheet( onDismissSketch: () -> Unit, - onSendSketch: () -> Unit + onSendSketch: (Uri) -> Unit, + tempWritableImageUri: Uri?, + conversationTitle: String = "", + viewModel: DrawingCanvasViewModel = viewModel(), ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { false }) + val onDismissEvent: () -> Unit = remember { + { + if (viewModel.state.paths.isNotEmpty()) { + viewModel.onShowConfirmationDialog() + } else { + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissSketch() } + } + } + } + val dismissEvent: () -> Unit = remember { + { + viewModel.initializeCanvas() + onDismissSketch() + } + } + ModalBottomSheet( + shape = CutCornerShape(dimensions().spacing0x), + containerColor = colorsScheme().background, dragHandle = { - Row( - Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - val scope = rememberCoroutineScope() - IconButton( - onClick = { - scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissSketch() } - }, - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource( - com.google.android.material.R.string.mtrl_picker_cancel - ) - ) - } - IconButton( - onClick = { - onSendSketch() - }, - ) { - Icon( - Icons.Filled.Send, - contentDescription = stringResource( - com.google.android.material.R.string.mtrl_picker_cancel - ) - ) - } - - } + DrawingTopBar(conversationTitle, onDismissEvent, viewModel::onUndoLastStroke, viewModel.state) }, sheetState = sheetState, - onDismissRequest = { - onDismissSketch() + onDismissRequest = onDismissEvent, + properties = ModalBottomSheetProperties( + isFocusable = true, + securePolicy = SecureFlagPolicy.SecureOn, + shouldDismissOnBackPress = false + ) + ) { + Row( + Modifier + .fillMaxWidth() + .weight(weight = 1f, fill = true) + ) { + DrawingCanvasComponent( + state = viewModel.state, + onStartDrawingEvent = viewModel::onStartDrawingEvent, + onDrawEvent = viewModel::onDrawEvent, + onStopDrawingEvent = viewModel::onStopDrawingEvent, + onSizeChanged = viewModel::onSizeChanged, + onStartDrawing = viewModel::onStartDrawing, + onDraw = viewModel::onDraw, + onStopDrawing = viewModel::onStopDrawing + ) } + DrawingToolbar( + state = viewModel.state, + onColorChanged = viewModel::onColorChanged, + onSendSketch = { + scope.launch { onSendSketch(viewModel.saveImage(context, tempWritableImageUri)) } + .invokeOnCompletion { scope.launch { sheetState.hide() } } + } + ) + } + + if (viewModel.state.showConfirmationDialog) { + DiscardDialogConfirmation(scope, sheetState, dismissEvent, viewModel::onHideConfirmationDialog) + } +} + +@Composable +internal fun DrawingTopBar( + conversationTitle: String, + dismissAction: () -> Unit, + onUndoStroke: () -> Unit, + state: DrawingState +) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing8x), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + WireTertiaryIconButton( + onButtonClicked = dismissAction, + iconResource = R.drawable.ic_close, + contentDescription = R.string.content_description_close_button, + minSize = MaterialTheme.wireDimensions.buttonCircleMinSize, + minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize, + ) + Text( + text = conversationTitle, + style = MaterialTheme.wireTypography.title01, + modifier = Modifier.align(Alignment.CenterVertically), + maxLines = MAX_LINES_TOPBAR, + overflow = TextOverflow.Ellipsis + ) + WireSecondaryIconButton( + onButtonClicked = onUndoStroke, + iconResource = R.drawable.ic_undo, + contentDescription = R.string.content_description_undo_button, + state = if (state.paths.isNotEmpty()) WireButtonState.Default else WireButtonState.Disabled, + minSize = MaterialTheme.wireDimensions.buttonCircleMinSize, + minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DrawingToolbar( + state: DrawingState, + onColorChanged: (Color) -> Unit, + onSendSketch: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val sheetState = rememberWireModalSheetState() + val openColorPickerSheet: () -> Unit = remember { { scope.launch { sheetState.show() } } } + val closeColorPickerSheet: () -> Unit = remember { { scope.launch { sheetState.hide() } } } + Row( + Modifier + .height(dimensions().spacing80x) + .padding(horizontal = dimensions().spacing8x) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - DrawingCanvasComponent() + WireSecondaryButton( + onClick = openColorPickerSheet, + leadingIcon = { + Icon( + Icons.Default.Circle, + null, + tint = state.currentPath.color, + modifier = Modifier + .border( + shape = CircleShape, + border = BorderStroke(dimensions().spacing1x, colorsScheme().secondaryText) + ) + ) + }, + leadingIconAlignment = IconAlignment.Center, + fillMaxWidth = false, + minSize = dimensions().buttonSmallMinSize, + minClickableSize = dimensions().buttonMinClickableSize, + shape = RoundedCornerShape(dimensions().spacing12x), + contentPadding = PaddingValues(horizontal = dimensions().spacing8x, vertical = dimensions().spacing4x), + ) + Spacer(Modifier.size(dimensions().spacing2x)) + WirePrimaryIconButton( + onButtonClicked = onSendSketch, + iconResource = R.drawable.ic_send, + contentDescription = R.string.content_description_send_button, + state = if (state.paths.isNotEmpty()) WireButtonState.Default else WireButtonState.Disabled, + shape = RoundedCornerShape(dimensions().spacing20x), + colors = wireSendPrimaryButtonColors(), + clickBlockParams = ClickBlockParams(blockWhenSyncing = true, blockWhenConnecting = true), + minSize = MaterialTheme.wireDimensions.buttonCircleMinSize, + minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize, + ) } + + DrawingToolPicker( + sheetState = sheetState, + currentColor = state.currentPath.color, + onColorSelected = { + onColorChanged(it) + closeColorPickerSheet() + } + ) } + +private const val MAX_LINES_TOPBAR = 1 diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasComponent.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasComponent.kt index 7268c8c62fa..91b7520cc2a 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasComponent.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasComponent.kt @@ -24,60 +24,134 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.toSize import com.wire.android.feature.sketch.model.DrawingMotionEvent +import com.wire.android.feature.sketch.model.DrawingState +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.theme.wireTypography @Composable internal fun DrawingCanvasComponent( - viewModel: DrawingCanvasViewModel = viewModel() + state: DrawingState, + onStartDrawingEvent: () -> Unit, + onDrawEvent: () -> Unit, + onStopDrawingEvent: () -> Unit, + onSizeChanged: (Size) -> Unit, + onStartDrawing: (Offset) -> Unit, + onDraw: (Offset) -> Unit, + onStopDrawing: () -> Unit ) { - with(viewModel.state) { - val drawModifier = Modifier - .fillMaxSize() - .clipToBounds() // necessary to draw inside the canvas. - .background(MaterialTheme.colorScheme.background) - .pointerInput(Unit) { - awaitEachGesture { - handleGestures( - viewModel::onStartDrawing, - viewModel::onDraw, - viewModel::onStopDrawing + CanvasLayout( + state = state, + onStartDrawingEvent = onStartDrawingEvent, + onDrawEvent = onDrawEvent, + onStopDrawingEvent = onStopDrawingEvent, + onSizeChanged = onSizeChanged, + onStartDrawing = onStartDrawing, + onDraw = onDraw, + onStopDrawing = onStopDrawing + ) +} + +@Composable +private fun CanvasLayout( + state: DrawingState, + onStartDrawingEvent: () -> Unit, + onDrawEvent: () -> Unit, + onStopDrawingEvent: () -> Unit, + onSizeChanged: (Size) -> Unit, + onStartDrawing: (Offset) -> Unit, + onDraw: (Offset) -> Unit, + onStopDrawing: () -> Unit +) = with(state) { + val textMeasurer = rememberTextMeasurer() + val emptyCanvasText = stringResource(id = R.string.sketch_details_empty_text) + val emptyCanvasStyle = MaterialTheme.wireTypography.body01.copy(color = colorsScheme().secondaryText) + val textLayoutResult = remember(emptyCanvasText) { + textMeasurer.measure(emptyCanvasText, emptyCanvasStyle) + } + val vector = ImageVector.vectorResource(id = R.drawable.ic_long_arrow) + val painter = rememberVectorPainter(image = vector) + val drawModifier = Modifier + .fillMaxSize() + .clipToBounds() // necessary to draw inside the canvas. + .background(Color.White) + .onSizeChanged { onSizeChanged(it.toSize()) } + .pointerInput(Unit) { awaitEachGesture { handleGestures(onStartDrawing, onDraw, onStopDrawing) } } + Canvas(modifier = drawModifier) { + with(drawContext.canvas.nativeCanvas) { + val checkPoint = saveLayer(null, null) + when (drawingMotionEvent) { + DrawingMotionEvent.Idle -> Unit + DrawingMotionEvent.Down -> onStartDrawingEvent() + DrawingMotionEvent.Move -> { + onDrawEvent() + // todo: draw with selected properties, out of scope for this first ticket. + drawCircle( + center = currentPosition, + color = Color.Gray, + radius = currentPath.strokeWidth / 2, + style = Stroke(width = 1f) ) } - } - Canvas(modifier = drawModifier) { - with(drawContext.canvas.nativeCanvas) { - val checkPoint = saveLayer(null, null) - when (drawingMotionEvent) { - DrawingMotionEvent.Idle -> Unit - DrawingMotionEvent.Down -> viewModel.onStartDrawingEvent() - DrawingMotionEvent.Move -> { - viewModel.onDrawEvent() - // todo: draw with selected properties, out of scope for this first ticket. - drawCircle( - center = currentPosition, - color = Color.Gray, - radius = currentPath.strokeWidth / 2, - style = Stroke(width = 1f) - ) - } - DrawingMotionEvent.Up -> viewModel.onStopDrawingEvent() - } - paths.forEach { path -> - path.draw(this@Canvas /*, bitmap*/) - } - restoreToCount(checkPoint) + DrawingMotionEvent.Up -> onStopDrawingEvent() } + paths.forEach { path -> + path.draw(this@Canvas /*, bitmap*/) + } + restoreToCount(checkPoint) + } + if (paths.isEmpty()) { + emptyCanvasState(textMeasurer, emptyCanvasText, emptyCanvasStyle, textLayoutResult, painter) + } + } +} + +private fun DrawScope.emptyCanvasState( + textMeasurer: TextMeasurer, + emptyCanvasText: String, + emptyCanvasStyle: TextStyle, + textLayoutResult: TextLayoutResult, + painter: VectorPainter +) { + drawText( + textMeasurer = textMeasurer, + text = emptyCanvasText, + style = emptyCanvasStyle, + topLeft = Offset( + x = center.x - textLayoutResult.size.width / 2, + y = center.y - textLayoutResult.size.height / 2 + ) + ) + // todo. uncomment when figure it out how to position this correctly on the canvas. + val enabled = false + if (enabled) { + with(painter) { + draw(painter.intrinsicSize, alpha = .5f) } } } @@ -98,13 +172,9 @@ private suspend fun AwaitPointerEventScope.handleGestures( do { val event = awaitPointerEvent() onDraw(event.changes.first().position) - val hasNewLineDraw = event.changes - .first() - .positionChange() != Offset.Zero + val hasNewLineDraw = event.changes.first().positionChange() != Offset.Zero if (hasNewLineDraw) { - event.changes - .first() - .consume() + event.changes.first().consume() } } while (event.changes.any { it.pressed }) onStopDrawing() diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt index 74fe6f6cc2c..f3183943b26 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt @@ -17,20 +17,53 @@ */ package com.wire.android.feature.sketch +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.net.Uri +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.net.toUri import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.wire.android.feature.sketch.model.DrawingMotionEvent import com.wire.android.feature.sketch.model.DrawingPathProperties import com.wire.android.feature.sketch.model.DrawingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream -internal class DrawingCanvasViewModel : ViewModel() { +@Suppress("TooManyFunctions") +class DrawingCanvasViewModel : ViewModel() { - var state: DrawingState by mutableStateOf(DrawingState()) + internal var state: DrawingState by mutableStateOf(DrawingState()) private set + init { + initializeCanvas() + } + + fun initializeCanvas() { + state = DrawingState(currentPath = DrawingPathProperties()) + } + + fun onShowConfirmationDialog() { + state = state.copy(showConfirmationDialog = true) + } + + fun onHideConfirmationDialog() { + state = state.copy(showConfirmationDialog = false) + } + /** * Marks the start of the drawing. */ @@ -84,4 +117,80 @@ internal class DrawingCanvasViewModel : ViewModel() { ) } + /** + * Sets the canvas size or modifies it if zoom is implemented. + */ + fun onSizeChanged(canvasSize: Size) { + state = state.copy(canvasSize = canvasSize) + } + + /** + * Undoes the last stroke. + */ + fun onUndoLastStroke() { + if (state.paths.isNotEmpty()) { + state = state.copy( + paths = state.paths.dropLast(1), + pathsUndone = state.pathsUndone + state.paths.last() + ) + } + } + + /** + * Saves the image to the provided URI and resets the canvas. + * + * @param context The context to use to open the file descriptor. + * @param tempWritableImageUri The URI to save the image to. + * + * @return The [Uri] of the saved image. + */ + suspend fun saveImage(context: Context, tempWritableImageUri: Uri?): Uri { + val tempSketchFile = tempWritableImageUri.orTempUri(context) + viewModelScope.launch { + withContext(Dispatchers.IO) { + with(state) { + if (canvasSize == null || state.paths.isEmpty()) return@withContext + + val bitmap = Bitmap.createBitmap( + canvasSize!!.width.toInt(), + canvasSize!!.height.toInt(), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap).apply { drawPaint(Paint().apply { color = Color.White.toArgb() }) } + context.contentResolver.openFileDescriptor(tempSketchFile, "rw")?.use { fileDescriptor -> + FileOutputStream(fileDescriptor.fileDescriptor).use { fileOutputStream -> + paths.forEach { path -> path.drawNative(canvas) } + bitmap.compress(Bitmap.CompressFormat.JPEG, QUALITY, fileOutputStream) + fileOutputStream.flush() + }.also { + Log.d("DrawingCanvasViewModel", "Image written to: $tempSketchFile") + } + } + } + } + }.join() + initializeCanvas() + return tempSketchFile + } + + fun onColorChanged(selectedColor: Color) { + state = state.copy( + currentPath = DrawingPathProperties().apply { + strokeWidth = state.currentPath.strokeWidth + color = selectedColor + drawMode = state.currentPath.drawMode + } + ) + } + + private fun Uri?.orTempUri(context: Context): Uri = this ?: run { + val tempFile = File.createTempFile("temp_sketch", ".jpg", context.cacheDir) + tempFile.deleteOnExit() + tempFile.toUri() + } + + companion object { + private const val QUALITY = 50 + } + } diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingDiscardDialog.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingDiscardDialog.kt new file mode 100644 index 00000000000..bd4747bf777 --- /dev/null +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingDiscardDialog.kt @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.sketch + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.button.WireButtonState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DiscardDialogConfirmation( + scope: CoroutineScope, + sheetState: SheetState, + onDismissSketch: () -> Unit, + onHideConfirmationDialog: () -> Unit, +) { + WireDialog( + title = stringResource(id = R.string.confirm_changes_title), + text = stringResource(id = R.string.confirm_changes_text), + onDismiss = onHideConfirmationDialog, + optionButton1Properties = WireDialogButtonProperties( + onClick = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + onHideConfirmationDialog() + onDismissSketch() + } + }, + text = stringResource(id = R.string.confirm_changes_dismiss), + type = WireDialogButtonType.Primary, + state = WireButtonState.Error + ), + dismissButtonProperties = WireDialogButtonProperties( + text = stringResource(id = R.string.confirm_changes_confirm), + onClick = onHideConfirmationDialog + ), + properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = false, dismissOnClickOutside = false) + ) +} diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingToolPicker.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingToolPicker.kt new file mode 100644 index 00000000000..0182f0f1b5d --- /dev/null +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingToolPicker.kt @@ -0,0 +1,205 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.sketch + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconToggleButtonColors +import androidx.compose.material3.OutlinedIconToggleButton +import androidx.compose.material3.SheetValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.ui.common.bottomsheet.MenuModalSheetContent +import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout +import com.wire.android.ui.common.bottomsheet.WireModalSheetState +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireColorPalette +import com.wire.android.ui.theme.WireTheme + +@Composable +fun DrawingToolPicker( + sheetState: WireModalSheetState, + currentColor: Color, + onColorSelected: (Color) -> Unit, +) { + val scope = rememberCoroutineScope() + val colorPalette = colorsScheme().sketchColorPalette + WireModalSheetLayout( + dragHandle = null, + sheetState = sheetState, + coroutineScope = scope, + ) { + Column( + modifier = Modifier + .background(colorsScheme().surface) + .wrapContentSize() + ) { + Box( + Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = dimensions().spacing12x) + .size(width = dimensions().modalBottomSheetDividerWidth, height = dimensions().spacing4x) + .background(color = colorsScheme().background, shape = RoundedCornerShape(size = dimensions().spacing2x)) + + ) + MenuModalSheetContent( + header = MenuModalSheetHeader.Visible(title = stringResource(id = R.string.title_color_picker)), + menuItems = listOf { + LazyVerticalGrid( + modifier = Modifier + .background(colorsScheme().surface) + .padding(PaddingValues(horizontal = dimensions().spacing8x)), + columns = GridCells.Fixed(GRID_CELLS), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + items(colorPalette.size) { index -> + val color = colorPalette[index] + ColorOptionButton( + color = color, + selected = color == currentColor, + onColorSelected = { onColorSelected(color) } + ) + } + } + } + ) + } + } +} + +@Composable +private fun ColorOptionButton(color: Color, selected: Boolean = false, onColorSelected: () -> Unit) { + when (selected) { + true -> SelectedColor(color, onColorSelected) + false -> NonSelectedColor(color, onColorSelected) + } +} + +@Composable +private fun NonSelectedColor(color: Color, onColorSelected: () -> Unit) { + OutlinedIconToggleButton( + modifier = Modifier + .fillMaxSize() + .aspectRatio(1f) + .padding(dimensions().spacing12x), + checked = false, + border = if (color == Color.White) BorderStroke(dimensions().spacing1x, colorsScheme().onBackground) else null, + colors = IconToggleButtonColors( + containerColor = color, + checkedContainerColor = color, + checkedContentColor = color, + disabledContainerColor = color, + disabledContentColor = color, + contentColor = color, + ), + onCheckedChange = { onColorSelected() }, + content = { } + ) +} + +@Composable +private fun SelectedColor(color: Color, onColorSelected: () -> Unit) { + OutlinedIconToggleButton( + modifier = Modifier + .fillMaxSize() + .aspectRatio(1f) + .padding(if (color.isWhite()) dimensions().corner14x else dimensions().corner12x) + .background(colorsScheme().surface, CircleShape) + .border(dimensions().spacing2x, colorsScheme().secondaryText, CircleShape), + checked = true, + border = BorderStroke( + if (color.isWhite()) dimensions().spacing1x else dimensions().spacing2x, + if (color.isWhite()) colorsScheme().onSurface else colorsScheme().surface + ), + colors = IconToggleButtonColors( + containerColor = color, + checkedContainerColor = color, + checkedContentColor = color, + disabledContainerColor = color, + disabledContentColor = color, + contentColor = color, + ), + onCheckedChange = { onColorSelected() } + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = if (color.isWhite()) Color.Black else Color.White, + modifier = Modifier.size( + dimensions().spacing16x + ) + ) + } +} + +private const val GRID_CELLS = 6 + +private fun Color.isWhite() = this == Color.White + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun PreviewDrawingToolPickerSelectedNonWhite() { + WireTheme { + DrawingToolPicker( + sheetState = WireModalSheetState(SheetValue.Expanded), + currentColor = WireColorPalette.LightBlue500, + onColorSelected = {} + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun PreviewDrawingToolPickerSelectedWhite() { + WireTheme { + DrawingToolPicker( + sheetState = WireModalSheetState(SheetValue.Expanded), + currentColor = Color.White, + onColorSelected = {} + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/style/ScreenModeStyle.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/PreviewDrawingComponents.kt similarity index 57% rename from app/src/main/kotlin/com/wire/android/navigation/style/ScreenModeStyle.kt rename to features/sketch/src/main/java/com/wire/android/feature/sketch/PreviewDrawingComponents.kt index 048828392b7..b7146cf9173 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/style/ScreenModeStyle.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/PreviewDrawingComponents.kt @@ -15,14 +15,25 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.navigation.style +package com.wire.android.feature.sketch -interface ScreenModeStyle { - fun screenMode(): ScreenMode +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.feature.sketch.model.DrawingState +import com.wire.android.ui.theme.WireTheme + +@Preview(showBackground = true) +@Composable +fun PreviewToolBar() { + WireTheme { + DrawingToolbar(DrawingState(), {}, {}) + } } -enum class ScreenMode { - KEEP_ON, // keep screen on while that NavigationItem is visible (i.e CallScreen) - WAKE_UP, // wake up the device on navigating to that NavigationItem (i.e IncomingCall) - NONE // do not wake up and allow device to sleep +@Preview(showBackground = true) +@Composable +fun PreviewTopBar() { + WireTheme { + DrawingTopBar("Conversation Name", {}, {}, DrawingState()) + } } diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingPathProperties.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingPathProperties.kt index 39f8eb0fdf2..44e66765510 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingPathProperties.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingPathProperties.kt @@ -19,20 +19,29 @@ package com.wire.android.feature.sketch.model import android.graphics.Bitmap import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Paint import android.graphics.Shader +import android.os.Build import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +/** + * Represents the current path properties [DrawingState.currentPath] + * This can be extended in the future to [strokeWidth], [drawMode] ,etc. + */ internal class DrawingPathProperties( var path: Path = Path(), var strokeWidth: Float = 10f, - var color: Color = Color.Blue, + var color: Color = Color.Black, var drawMode: DrawMode = DrawMode.Pen ) { fun draw(scope: DrawScope, bitmap: Bitmap? = null) { @@ -80,4 +89,51 @@ internal class DrawingPathProperties( DrawMode.None -> {} } } + + fun drawNative(canvas: Canvas) { + when (drawMode) { + DrawMode.Pen -> { + canvas.drawPath( + androidPath, + paint + ) + } + + DrawMode.Eraser -> { + canvas.drawPath( + androidPath, + paint + ) + } + + DrawMode.None -> {} + } + } + + private val androidPath + get() = path.asAndroidPath() + + private val paint: Paint + get() { + return if (drawMode == DrawMode.Pen) { + Paint().apply { + color = this@DrawingPathProperties.color.toArgb() + style = Paint.Style.STROKE + strokeWidth = this@DrawingPathProperties.strokeWidth + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + } else { + Paint().apply { + color = Color.Transparent.toArgb() + style = Paint.Style.STROKE + strokeWidth = this@DrawingPathProperties.strokeWidth + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + blendMode = android.graphics.BlendMode.CLEAR + } + } + } + } } diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingState.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingState.kt index 283797ea902..12de2bae850 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingState.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/model/DrawingState.kt @@ -18,11 +18,14 @@ package com.wire.android.feature.sketch.model import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size internal data class DrawingState( val paths: List = listOf(), val pathsUndone: List = listOf(), val drawingMotionEvent: DrawingMotionEvent = DrawingMotionEvent.Idle, val currentPath: DrawingPathProperties = DrawingPathProperties(), - val currentPosition: Offset = Offset.Unspecified + val currentPosition: Offset = Offset.Unspecified, + var canvasSize: Size? = null, + val showConfirmationDialog: Boolean = false ) diff --git a/features/sketch/src/test/java/com/wire/android/feature/sketch/ExampleUnitTest.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/tools/DrawingToolsConfig.kt similarity index 61% rename from features/sketch/src/test/java/com/wire/android/feature/sketch/ExampleUnitTest.kt rename to features/sketch/src/main/java/com/wire/android/feature/sketch/tools/DrawingToolsConfig.kt index 0fe155d0de8..9c56240a6cc 100644 --- a/features/sketch/src/test/java/com/wire/android/feature/sketch/ExampleUnitTest.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/tools/DrawingToolsConfig.kt @@ -15,20 +15,17 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.sketch +package com.wire.android.feature.sketch.tools -import org.junit.Test - -import org.junit.Assert.* +import androidx.compose.ui.graphics.Color +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). + * Configuration for the drawing tools. + * ie. colors, stroke width, etc. */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} +data class DrawingToolsConfig( + val colors: ImmutableList = persistentListOf(Color.Blue), + // todo. later add more configurations, like stroke width, etc. +) diff --git a/features/sketch/src/main/res/drawable/ic_close.xml b/features/sketch/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000000..e88f1d6bc70 --- /dev/null +++ b/features/sketch/src/main/res/drawable/ic_close.xml @@ -0,0 +1,26 @@ + + + + diff --git a/features/sketch/src/main/res/drawable/ic_long_arrow.xml b/features/sketch/src/main/res/drawable/ic_long_arrow.xml new file mode 100644 index 00000000000..2119c886028 --- /dev/null +++ b/features/sketch/src/main/res/drawable/ic_long_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/sketch/src/main/res/drawable/ic_send.xml b/features/sketch/src/main/res/drawable/ic_send.xml new file mode 100644 index 00000000000..537ef313c36 --- /dev/null +++ b/features/sketch/src/main/res/drawable/ic_send.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/features/sketch/src/main/res/drawable/ic_undo.xml b/features/sketch/src/main/res/drawable/ic_undo.xml new file mode 100644 index 00000000000..54e6d97a25e --- /dev/null +++ b/features/sketch/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/features/sketch/src/main/res/values/strings.xml b/features/sketch/src/main/res/values/strings.xml index 9147ed8f0c8..af2e78af51c 100644 --- a/features/sketch/src/main/res/values/strings.xml +++ b/features/sketch/src/main/res/values/strings.xml @@ -17,4 +17,13 @@ --> Sketch module + Send + Undo + Pick your favorite color and start sketching 🎨 + close + Color + Discard drawing? + If you leave without sending, your drawing will be lost. + Cancel + Discard diff --git a/features/sketch/src/test/java/com/wire/android/feature/sketch/DrawingCanvasViewModelTest.kt b/features/sketch/src/test/java/com/wire/android/feature/sketch/DrawingCanvasViewModelTest.kt new file mode 100644 index 00000000000..c283683861a --- /dev/null +++ b/features/sketch/src/test/java/com/wire/android/feature/sketch/DrawingCanvasViewModelTest.kt @@ -0,0 +1,170 @@ +package com.wire.android.feature.sketch + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import com.wire.android.feature.sketch.model.DrawingMotionEvent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class DrawingCanvasViewModelTest { + + @Test + fun givenOnStartDrawingIsCalled_WhenCallingTheAction_ThenUpdateStateWithEventDown() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + + // when + viewModel.onStartDrawing(INITIAL_OFFSET) + + // then + assertEquals(DrawingMotionEvent.Down, viewModel.state.drawingMotionEvent) + } + + @Test + fun givenOnDrawIsCalled_WhenCallingTheAction_ThenUpdateStateWithEventMove() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + + // when + viewModel.onDraw(INITIAL_OFFSET) + + // then + assertEquals(DrawingMotionEvent.Move, viewModel.state.drawingMotionEvent) + } + + @Test + fun givenOnStopDrawingIsCalled_WhenCallingTheAction_ThenUpdateStateWithEventUp() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + + // when + viewModel.onStopDrawing() + + // then + assertEquals(DrawingMotionEvent.Up, viewModel.state.drawingMotionEvent) + } + + @Test + fun givenStartDrawingEvent_WhenCallingTheAction_ThenUpdateTheStateWithTheInitialPathPosition() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + assertEquals(viewModel.state.currentPosition, Offset.Unspecified) + + // when + startDrawing(viewModel) + + // then + with(viewModel.state) { + assertEquals(DrawingMotionEvent.Down, drawingMotionEvent) + assertEquals(currentPath.path, paths.first().path) + assertEquals(currentPosition, INITIAL_OFFSET) + } + } + + @Test + fun givenDrawingEvent_WhenCallingTheAction_ThenUpdateTheStateWithTheCurrentMovingPathPosition() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + assertEquals(viewModel.state.currentPosition, Offset.Unspecified) + + // when + draw(viewModel) + + // then + with(viewModel.state) { + assertEquals(DrawingMotionEvent.Move, drawingMotionEvent) + assertEquals(currentPath.path, paths.first().path) + assertEquals(currentPosition, MOVED_OFFSET) + } + } + + @Test + fun givenStopDrawingEvent_WhenCallingTheAction_ThenUpdateTheStateWithTheFinalPathPosition() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + assertEquals(viewModel.state.currentPosition, Offset.Unspecified) + + // when + stopDrawing(viewModel) + + // then + with(viewModel.state) { + assertEquals(DrawingMotionEvent.Idle, drawingMotionEvent) + assertEquals(currentPosition, Offset.Unspecified) + } + } + + @Test + fun givenOnColorChanged_WhenCallingTheAction_ThenUpdateCurrentPathWithTheSelectedColor() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + assertEquals(viewModel.state.currentPath.color, Color.Black) + + // when + val newColor = Color.Magenta + viewModel.onColorChanged(newColor) + + // then + with(viewModel.state) { + assertEquals(currentPath.color, newColor) + } + } + + @Test + fun givenWeWantToDiscard_WhenCallingTheAction_ThenUpdateStateToShowConfirmation() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + + // when + viewModel.onShowConfirmationDialog() + + // then + with(viewModel.state) { + assertEquals(true, showConfirmationDialog) + } + } + + @Test + fun givenWeCancelToDiscard_WhenCallingTheAction_ThenUpdateStateToHideConfirmation() = runTest { + // given + val (_, viewModel) = Arrangement().arrange() + + // when + viewModel.onHideConfirmationDialog() + + // then + with(viewModel.state) { + assertEquals(false, showConfirmationDialog) + } + } + + private fun stopDrawing(viewModel: DrawingCanvasViewModel) = with(viewModel) { + draw(viewModel) + onStopDrawing() + onStopDrawingEvent() + } + + // simulates the drawing of strokes + private fun draw(viewModel: DrawingCanvasViewModel) = with(viewModel) { + startDrawing(viewModel) + onDraw(MOVED_OFFSET) + onDrawEvent() + } + + // simulates the start of drawing of strokes + private fun startDrawing(viewModel: DrawingCanvasViewModel) = with(viewModel) { + onStartDrawing(INITIAL_OFFSET) + onStartDrawingEvent() + } + + private class Arrangement { + val viewModel = DrawingCanvasViewModel() + fun arrange() = this to viewModel + } + + private companion object { + val INITIAL_OFFSET = Offset(0f, 0f) + val MOVED_OFFSET = Offset(10f, 10f) + } +}