From ced6769a9a37c43cbe086aac24d41c712e5bcfdb Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:05:58 +0200 Subject: [PATCH] feat(session): prepare secondary app activities --- .../di/metro/AppSessionViewModelGraph.kt | 7 -- .../feature/ObserveAppLockConfigUseCase.kt | 14 ++- .../com/wire/android/ui/AppLockActivity.kt | 75 +++++++++++++++- .../wire/android/ui/calling/CallActivity.kt | 88 +++++++++++++++++-- .../calling/common/ProximitySensorManager.kt | 12 ++- .../ui/calling/ongoing/OngoingCallActivity.kt | 3 +- .../ObserveAppLockConfigUseCaseTest.kt | 30 ++++++- 7 files changed, 207 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt index e5888b4116e..de59cc4c96d 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt @@ -75,10 +75,3 @@ fun WireApplicationGraph.createSessionViewModelGraph( ): AppSessionViewModelGraph { return asContribution().createAppSessionViewModelGraph(currentAccount, userSessionScope) } - -/** - * Compatibility path for secondary activities while their preparation entry points are migrated - * in the next stack slice. - */ -fun WireApplicationGraph.createSessionViewModelGraph(currentAccount: UserId): AppSessionViewModelGraph = - createSessionViewModelGraph(currentAccount, coreLogic.getSessionScope(currentAccount)) diff --git a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt index b7a89a5ede0..6850a16d7c2 100644 --- a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt @@ -19,6 +19,8 @@ package com.wire.android.feature import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.KaliumCoreLogic +import com.wire.android.session.AppUserSessionPreparationResult +import com.wire.android.session.UserSessionPreparationGate import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.feature.session.CurrentSessionResult import kotlinx.coroutines.flow.Flow @@ -36,13 +38,21 @@ class ObserveAppLockConfigUseCase @Inject constructor( private val globalDataStore: GlobalDataStore, @KaliumCoreLogic private val coreLogic: CoreLogic ) { + private val userSessionPreparationGate by lazy { UserSessionPreparationGate(coreLogic) } + operator fun invoke(): Flow = channelFlow { coreLogic.getGlobalScope().session.currentSessionFlow().collectLatest { sessionResult -> when { sessionResult is CurrentSessionResult.Success && sessionResult.accountInfo.isValid() -> { val userId = sessionResult.accountInfo.userId - val appLockTeamFeatureConfigFlow = - coreLogic.getSessionScope(userId).appLockTeamFeatureConfigObserver + val sessionScope = when (val preparation = userSessionPreparationGate.prepare(userId)) { + is AppUserSessionPreparationResult.Ready -> preparation.sessionScope + is AppUserSessionPreparationResult.Failed -> { + send(AppLockConfig.Disabled(DEFAULT_APP_LOCK_TIMEOUT)) + return@collectLatest + } + } + val appLockTeamFeatureConfigFlow = sessionScope.appLockTeamFeatureConfigObserver appLockTeamFeatureConfigFlow().combineTransform( globalDataStore.isAppLockPasscodeSetFlow() 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 5d424a8c51b..69cdf7afaf1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt @@ -17,13 +17,19 @@ */ package com.wire.android.ui +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.biometric.BiometricManager 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.lifecycle.lifecycleScope import com.ramcosta.composedestinations.generated.app.destinations.AppUnlockWithBiometricsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.EnterLockCodeScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.SetLockCodeScreenDestination @@ -35,12 +41,24 @@ import com.wire.android.model.LocalWireSessionImageLoader import com.wire.android.navigation.LoginTypeSelector import com.wire.android.navigation.MainNavHost import com.wire.android.navigation.rememberNavigator +import com.wire.android.session.AppUserSessionPreparationResult +import com.wire.android.session.UserSessionPreparationGate import com.wire.android.ui.common.setupOrientationForDevice import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.SupportPage +import com.wire.android.util.SupportUrlResolver +import com.wire.android.util.launchUpdateTheApp import com.wire.kalium.logic.data.id.QualifiedIdMapper +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope import dev.zacsweers.metro.Inject import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch class AppLockActivity : BaseActivity() { @@ -48,13 +66,16 @@ class AppLockActivity : BaseActivity() { lateinit var loginTypeSelector: LoginTypeSelector private val qualifiedIdMapper = QualifiedIdMapper(null) + private var preparationState by mutableStateOf( + UserSessionPreparationUiState.ResolvingSession + ) + private var preparationJob: Job? = null override fun onCreate(savedInstanceState: Bundle?) { wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) - val sessionViewModelGraph = intent.getStringExtra(EXTRA_USER_ID) + val userId = intent.getStringExtra(EXTRA_USER_ID) ?.let(qualifiedIdMapper::fromStringToQualifiedID) - ?.let(wireApplicationGraph::createSessionViewModelGraph) ?: run { appLogger.e("appLock: missing session user id, closing app lock activity") finish() @@ -62,6 +83,49 @@ class AppLockActivity : BaseActivity() { } setupOrientationForDevice() enableEdgeToEdge() + showPreparation(userId) + } + + private fun showPreparation(userId: UserId) { + setContent { + WireTheme { + UserSessionPreparationScreen( + state = preparationState, + onRetry = { prepareSession(userId) }, + onUpdate = ::updateTheApp, + onContactSupport = ::openSupport, + ) + } + } + prepareSession(userId) + } + + private fun prepareSession(userId: UserId) { + if (preparationJob?.isActive == true) return + preparationState = UserSessionPreparationUiState.ResolvingSession + preparationJob = lifecycleScope.launch { + val gate = UserSessionPreparationGate(wireApplicationGraph.coreLogic) + val result = coroutineScope { + val observer = launch(start = CoroutineStart.UNDISPATCHED) { + gate.observe(userId).collect { preparationState = it.toUiState() } + } + try { + gate.prepare(userId) + } finally { + observer.cancelAndJoin() + } + } + when (result) { + is AppUserSessionPreparationResult.Ready -> showAppLock(userId, result.sessionScope) + is AppUserSessionPreparationResult.Failed -> { + preparationState = UserSessionPreparationUiState.Failed(result.reason.toUiFailure()) + } + } + } + } + + private fun showAppLock(userId: UserId, userSessionScope: UserSessionScope) { + val sessionViewModelGraph = wireApplicationGraph.createSessionViewModelGraph(userId, userSessionScope) setContent { val snackbarHostState = remember { SnackbarHostState() } val rememberedSessionViewModelGraph = remember { sessionViewModelGraph } @@ -102,6 +166,13 @@ class AppLockActivity : BaseActivity() { } } + private fun openSupport() { + val supportUrl = SupportUrlResolver.resolve(resources, SupportPage.SUPPORT) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(supportUrl))) + } + + private fun updateTheApp() = launchUpdateTheApp() + companion object { const val SET_TEAM_APP_LOCK = "set_team_app_lock" const val EXTRA_USER_ID = "user_id" 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 index 5eb5a9b2bb6..4fae9ad012a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt @@ -18,6 +18,7 @@ package com.wire.android.ui.calling import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle import android.view.WindowManager @@ -32,7 +33,10 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable 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.compose.ui.Modifier import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId @@ -41,13 +45,16 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.wire.android.appLogger import com.wire.android.di.metro.LocalWireViewModelScopeKey -import com.wire.android.di.metro.AppSessionViewModelGraph import com.wire.android.di.metro.createSessionViewModelGraph import com.wire.android.di.metro.wireApplicationGraph import com.wire.android.model.LocalWireSessionImageLoader import com.wire.android.ui.AppLockActivity import com.wire.android.ui.BaseActivity import com.wire.android.ui.LocalActivity +import com.wire.android.ui.UserSessionPreparationScreen +import com.wire.android.ui.UserSessionPreparationUiState +import com.wire.android.ui.toUiFailure +import com.wire.android.ui.toUiState import com.wire.android.ui.calling.common.ProximitySensorManager import com.wire.android.ui.common.setupOrientationForDevice import com.wire.android.ui.common.snackbar.LocalSnackbarHostState @@ -55,15 +62,27 @@ import com.wire.android.ui.common.topappbar.CommonTopAppBarParams import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel import com.wire.android.ui.common.topappbar.WireTopAppBar import com.wire.android.ui.theme.WireTheme +import com.wire.android.session.AppUserSessionPreparationResult +import com.wire.android.session.UserSessionPreparationGate +import com.wire.android.util.SupportPage +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.SwitchAccountObserver +import com.wire.android.util.launchUpdateTheApp import com.wire.kalium.logic.data.id.QualifiedIdMapper +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope import dev.zacsweers.metro.HasMemberInjections import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Provider import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @HasMemberInjections +@Suppress("TooManyFunctions") abstract class CallActivity : BaseActivity() { @Inject @@ -101,6 +120,10 @@ abstract class CallActivity : BaseActivity() { } } protected val qualifiedIdMapper = QualifiedIdMapper(null) + private var preparationState by mutableStateOf( + UserSessionPreparationUiState.ResolvingSession + ) + private var preparationJob: Job? = null override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) @@ -112,18 +135,65 @@ abstract class CallActivity : BaseActivity() { wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) setupOrientationForDevice() - setUpScreenshotPreventionFlag() setUpCallingFlags() enableEdgeToEdge() - val sessionViewModelGraph = createCallSessionViewModelGraph(intent) ?: run { + val userId = intent.getStringExtra(EXTRA_USER_ID) + ?.let(qualifiedIdMapper::fromStringToQualifiedID) + ?: run { appLogger.e("$TAG missing call session user id, closing call activity") finish() return } + showPreparation(userId) + } + + private fun showPreparation(userId: UserId) { + setContent { + WireTheme { + UserSessionPreparationScreen( + state = preparationState, + onRetry = { prepareSession(userId) }, + onUpdate = ::updateTheApp, + onContactSupport = ::openSupport, + ) + } + } + prepareSession(userId) + } + + private fun prepareSession(userId: UserId) { + if (preparationJob?.isActive == true) return + preparationState = UserSessionPreparationUiState.ResolvingSession + preparationJob = lifecycleScope.launch { + val gate = UserSessionPreparationGate(wireApplicationGraph.coreLogic) + val result = coroutineScope { + val observer = launch(start = CoroutineStart.UNDISPATCHED) { + gate.observe(userId).collect { preparationState = it.toUiState() } + } + try { + gate.prepare(userId) + } finally { + observer.cancelAndJoin() + } + } + when (result) { + is AppUserSessionPreparationResult.Ready -> showCall(userId, result.sessionScope) + is AppUserSessionPreparationResult.Failed -> { + preparationState = UserSessionPreparationUiState.Failed(result.reason.toUiFailure()) + } + } + } + } + + private fun showCall(userId: UserId, userSessionScope: UserSessionScope) { + val sessionViewModelGraph = wireApplicationGraph.createSessionViewModelGraph(userId, userSessionScope) + handleNewIntent(intent) + onSessionPrepared() + setUpScreenshotPreventionFlag() appLogger.i("$TAG Initializing proximity sensor..") proximitySensorManager.initialize() @@ -159,13 +229,17 @@ abstract class CallActivity : BaseActivity() { protected abstract fun handleNewIntent(intent: Intent) + protected open fun onSessionPrepared() = Unit + @Composable protected abstract fun Content() - private fun createCallSessionViewModelGraph(intent: Intent): AppSessionViewModelGraph? = - intent.getStringExtra(EXTRA_USER_ID) - ?.let(qualifiedIdMapper::fromStringToQualifiedID) - ?.let(wireApplicationGraph::createSessionViewModelGraph) + private fun updateTheApp() = launchUpdateTheApp() + + private fun openSupport() { + val supportUrl = SupportUrlResolver.resolve(resources, SupportPage.SUPPORT) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(supportUrl))) + } fun switchAccountIfNeeded(userId: String?) { userId?.let { diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/common/ProximitySensorManager.kt b/app/src/main/kotlin/com/wire/android/ui/calling/common/ProximitySensorManager.kt index 6399c0ac488..a23d790f996 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/common/ProximitySensorManager.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/common/ProximitySensorManager.kt @@ -28,6 +28,8 @@ import androidx.appcompat.app.AppCompatActivity import com.wire.android.appLogger import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic +import com.wire.android.session.AppUserSessionPreparationResult +import com.wire.android.session.UserSessionPreparationGate import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase @@ -44,6 +46,7 @@ class ProximitySensorManager @Inject constructor( @KaliumCoreLogic private val coreLogic: Lazy, @ApplicationScope private val appCoroutineScope: CoroutineScope ) { + private val userSessionPreparationGate by lazy { UserSessionPreparationGate(coreLogic.value) } private lateinit var sensorManager: SensorManager private var proximity: Sensor? = null @@ -78,7 +81,14 @@ class ProximitySensorManager @Inject constructor( when { currentSession is CurrentSessionResult.Success && currentSession.accountInfo.isValid() -> { val userId = currentSession.accountInfo.userId - val isCallRunning = coreLogic.value.getSessionScope(userId).calls.isCallRunning() + val sessionScope = when (val preparation = userSessionPreparationGate.prepare(userId)) { + is AppUserSessionPreparationResult.Ready -> preparation.sessionScope + is AppUserSessionPreparationResult.Failed -> { + if (wakeLock.isHeld) wakeLock.release() + return@launch + } + } + val isCallRunning = sessionScope.calls.isCallRunning() val distance = event.values.first() val shouldTurnOffScreen = distance == NEAR_DISTANCE && isCallRunning appLogger.i( diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt index 5424860d77e..00a4fa100b3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt @@ -71,11 +71,12 @@ class OngoingCallActivity : CallActivity() { switchAccountIfNeeded(userId) } - @SuppressLint("UnusedContentLambdaTargetStateParameter") override fun onCreate(savedInstanceState: Bundle?) { wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) + } + override fun onSessionPrepared() { if (shouldAnswerCall && userId != null && conversationId != null) { callNotificationManager.hideIncomingCallNotification(userId!!, conversationId!!) servicesManager.startCallServiceToAnswer( diff --git a/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt index b37864ed08b..d37b909ddb2 100644 --- a/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt @@ -20,6 +20,8 @@ package com.wire.android.feature import app.cash.turbine.test import com.wire.android.datastore.GlobalDataStore import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.PrepareUserSessionResult +import com.wire.kalium.logic.UserSessionPreparationFailure import com.wire.kalium.logic.configuration.AppLockTeamConfig import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.logout.LogoutReason @@ -31,6 +33,7 @@ import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -127,6 +130,19 @@ class ObserveAppLockConfigUseCaseTest { } } + @Test + fun givenSessionPreparationFails_whenObservingAppLock_thenSendSafeDisabledStatus() = runTest { + val (_, useCase) = Arrangement() + .withValidSession() + .withPreparationFailure() + .arrange() + + useCase().test { + assertEquals(AppLockConfig.Disabled(timeout), awaitItem()) + awaitComplete() + } + } + inner class Arrangement { @MockK @@ -150,6 +166,7 @@ class ObserveAppLockConfigUseCaseTest { init { MockKAnnotations.init(this, relaxUnitFun = true) + coEvery { coreLogic.prepareUserSession(any()) } returns preparationSuccess() } fun arrange() = this to useCase @@ -170,7 +187,6 @@ class ObserveAppLockConfigUseCaseTest { } fun withTeamAppLockEnabled() = apply { - every { coreLogic.getSessionScope(any()) } returns userSessionScope every { userSessionScope.appLockTeamFeatureConfigObserver } returns appLockTeamFeatureConfigObserver @@ -180,7 +196,6 @@ class ObserveAppLockConfigUseCaseTest { } fun withTeamAppLockDisabled() = apply { - every { coreLogic.getSessionScope(any()) } returns userSessionScope every { userSessionScope.appLockTeamFeatureConfigObserver } returns appLockTeamFeatureConfigObserver @@ -196,6 +211,17 @@ class ObserveAppLockConfigUseCaseTest { fun withAppNonLockedByCurrentUser() = apply { every { globalDataStore.isAppLockPasscodeSetFlow() } returns flowOf(false) } + + fun withPreparationFailure() = apply { + val failure = mockk() + every { failure.reason } returns UserSessionPreparationFailure.TemporarilyUnavailable + coEvery { coreLogic.prepareUserSession(any()) } returns failure + } + + private fun preparationSuccess(): PrepareUserSessionResult.Success = + mockk().also { result -> + every { result.sessionScope } returns userSessionScope + } } companion object {