From ae0b2fc98cbe5f2b47ba4d31580b7ef8cfb6d288 Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Mon, 28 Apr 2025 16:33:21 +0200 Subject: [PATCH] DROID-3611 Navigation | Fix | Allow restoration of last opened space from loading state on splash screen - Hotfix (#2361) --- .../anytype/core_models/ObjectWrapper.kt | 6 + .../presentation/splash/SplashViewModel.kt | 83 +++++---- .../splash/SplashViewModelTest.kt | 171 ++++++++++++++++++ .../anytypeio/anytype/core_models/DataView.kt | 11 +- 4 files changed, 233 insertions(+), 38 deletions(-) diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/ObjectWrapper.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/ObjectWrapper.kt index 2862047cee..6ca644268f 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/ObjectWrapper.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/ObjectWrapper.kt @@ -354,6 +354,12 @@ sealed class ObjectWrapper { && spaceAccountStatus != SpaceStatus.SPACE_REMOVING && spaceAccountStatus != SpaceStatus.SPACE_DELETED } + + val isUnknown: Boolean + get() { + return spaceLocalStatus == SpaceStatus.UNKNOWN + && spaceAccountStatus == SpaceStatus.UNKNOWN + } } inline fun getValue(relation: Key): T? { diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/splash/SplashViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/splash/SplashViewModel.kt index 5da2d2c452..a86109700e 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/splash/SplashViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/splash/SplashViewModel.kt @@ -40,13 +40,16 @@ import com.anytypeio.anytype.presentation.auth.account.MigrationHelperDelegate import com.anytypeio.anytype.presentation.confgs.ChatConfig import com.anytypeio.anytype.presentation.extension.sendAnalyticsObjectCreateEvent import com.anytypeio.anytype.presentation.search.ObjectSearchConstants +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber /** @@ -307,7 +310,7 @@ class SplashViewModel( .observe(SpaceId(space)) .take(1) .collect { view -> - if (view.isActive) { + if (view.isActive || view.isLoading) { when (response.obj.layout) { ObjectType.Layout.SET, ObjectType.Layout.COLLECTION -> commands.emit( @@ -367,43 +370,50 @@ class SplashViewModel( Timber.d("proceedWithVaultNavigation deep link: $deeplink") val space = getLastOpenedSpace.async(Unit).getOrNull() if (space != null && spaceManager.getState() != SpaceManager.State.NoSpace) { - Timber.d("Got the last opened space from settings: ${space.id}") - spaceManager - .observe() - .take(1) - .flatMapLatest { config -> - spaceViews - .observe(SpaceId(config.space)) - .filterNot { view -> - // Wait until the space view is no longer in a fully UNKNOWN state - view.spaceLocalStatus == SpaceStatus.UNKNOWN - && view.spaceAccountStatus == SpaceStatus.UNKNOWN - } - .take(1) - } - .collect { view -> - if (view.isActive || view.isLoading) { - val chat = view.chatId - if (chat.isNullOrEmpty() || !ChatConfig.isChatAllowed(space.id)) { - commands.emit( - Command.NavigateToWidgets( - space = space.id, - deeplink = deeplink - ) - ) - } else { - commands.emit( - Command.NavigateToSpaceLevelChat( - space = space.id, - chat = chat, - deeplink = deeplink - ) - ) - } - } else { - commands.emit(Command.NavigateToVault(deeplink)) + val view = withTimeoutOrNull(SPACE_LOADING_TIMEOUT) { + spaceManager + .observe() + .take(1) + .flatMapLatest { config -> + spaceViews + .observe(SpaceId(config.space)) + .filterNot { view -> + if (view.isUnknown) { + Timber.w("View is unknown during restoration of the last opened space") + } + view.isUnknown + } + .take(1) } + .firstOrNull() + } + + if (view != null) { + if (view.isActive || view.isLoading) { + val chat = view.chatId + if (chat.isNullOrEmpty() || !ChatConfig.isChatAllowed(space.id)) { + commands.emit( + Command.NavigateToWidgets( + space = space.id, + deeplink = deeplink + ) + ) + } else { + commands.emit( + Command.NavigateToSpaceLevelChat( + space = space.id, + chat = chat, + deeplink = deeplink + ) + ) + } + } else { + commands.emit(Command.NavigateToVault(deeplink)) } + } else { + Timber.w("Timeout while waiting for space view. Navigating to vault.") + commands.emit(Command.NavigateToVault(deeplink)) + } } else { commands.emit(Command.NavigateToVault(deeplink)) } @@ -454,6 +464,7 @@ class SplashViewModel( const val ERROR_MESSAGE = "An error occurred while starting account" const val ERROR_NEED_UPDATE = "Unable to retrieve account. Please update Anytype to the latest version." const val ERROR_CREATE_OBJECT = "Error while creating object: object type not found" + const val SPACE_LOADING_TIMEOUT = 5000L } sealed class State { diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/splash/SplashViewModelTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/splash/SplashViewModelTest.kt index 6ef9ad6810..229a9ba318 100644 --- a/presentation/src/test/java/com/anytypeio/anytype/presentation/splash/SplashViewModelTest.kt +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/splash/SplashViewModelTest.kt @@ -1,16 +1,21 @@ package com.anytypeio.anytype.presentation.splash import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test import com.anytypeio.anytype.CrashReporter import com.anytypeio.anytype.analytics.base.Analytics import com.anytypeio.anytype.core_models.StubConfig +import com.anytypeio.anytype.core_models.StubSpaceView import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.core_models.restrictions.SpaceStatus import com.anytypeio.anytype.domain.auth.interactor.CheckAuthorizationStatus import com.anytypeio.anytype.domain.auth.interactor.GetLastOpenedObject import com.anytypeio.anytype.domain.auth.interactor.LaunchAccount import com.anytypeio.anytype.domain.auth.interactor.LaunchWallet import com.anytypeio.anytype.domain.auth.model.AuthStatus import com.anytypeio.anytype.domain.base.Either +import com.anytypeio.anytype.domain.base.Result +import com.anytypeio.anytype.domain.base.Resultat import com.anytypeio.anytype.domain.block.repo.BlockRepository import com.anytypeio.anytype.domain.config.UserSettingsRepository import com.anytypeio.anytype.domain.misc.LocaleProvider @@ -23,7 +28,13 @@ import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate import com.anytypeio.anytype.presentation.auth.account.MigrationHelperDelegate import com.anytypeio.anytype.presentation.util.CoroutinesTestRule import java.util.Locale +import kotlin.test.assertEquals +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -186,6 +197,166 @@ class SplashViewModelTest { } } + @Test + fun `should navigate to vault if space view loading times out`() = runTest { + // GIVEN + + val deeplink = "test-deeplink" + + val status = AuthStatus.AUTHORIZED + val response = Either.Right(status) + + val space = defaultSpaceConfig.space + + stubCheckAuthStatus(response) + stubLaunchWallet() + stubLaunchAccount() + stubGetLastOpenedObject() + + getLastOpenedSpace.stub { + onBlocking { + async(Unit) + } doReturn Resultat.Success( + SpaceId(space) + ) + } + + initViewModel() + + // WHEN + // simulate spaceManager.observe() never emits a valid space view (simulate timeout) + spaceManager.stub { + on { observe() } doReturn flow { /* no emission */ } + } + spaceViewSubscriptionContainer.stub { + on { observe() } doReturn flow { /* no emission */ } + } + + vm.commands.test { + // Act: manually trigger proceedWithVaultNavigation + vm.onDeepLinkLaunch(deeplink) + + // THEN + // small delay to allow the coroutine to timeout internally + delay(SplashViewModel.SPACE_LOADING_TIMEOUT + 100) + + + val first = awaitItem() + assertEquals( + expected = SplashViewModel.Command.NavigateToVault(deeplink), + actual = first + ) + } + } + + @Test + fun `should navigate to space view when space is restored`() = runTest { + // GIVEN + + val deeplink = "test-deeplink" + + val status = AuthStatus.AUTHORIZED + val response = Either.Right(status) + + val space = defaultSpaceConfig.space + + val spaceView = StubSpaceView( + targetSpaceId = space, + spaceLocalStatus = SpaceStatus.OK, + spaceAccountStatus = SpaceStatus.OK + ) + + stubCheckAuthStatus(response) + stubLaunchWallet() + stubLaunchAccount() + stubGetLastOpenedObject() + + getLastOpenedSpace.stub { + onBlocking { + async(Unit) + } doReturn Resultat.Success( + SpaceId(space) + ) + } + + initViewModel() + + // WHEN + // simulate spaceManager.observe() never emits a valid space view (simulate timeout) + spaceManager.stub { + on { observe() } doReturn flowOf(defaultSpaceConfig) + } + spaceViewSubscriptionContainer.stub { + on { observe(SpaceId(defaultSpaceConfig.space)) } doReturn flowOf(spaceView) + } + + vm.commands.test { + // Act: manually trigger proceedWithVaultNavigation + vm.onDeepLinkLaunch(deeplink) + + val first = awaitItem() + assertEquals( + expected = SplashViewModel.Command.NavigateToWidgets( + space = defaultSpaceConfig.space, + deeplink + ), + actual = first + ) + } + } + + @Test + fun `should navigate to space-level chat if chat is available`() = runTest { + // GIVEN + val deeplink = "test-deeplink" + val status = AuthStatus.AUTHORIZED + val response = Either.Right(status) + + val space = defaultSpaceConfig.space + val chatId = "chat-id" + + val spaceView = StubSpaceView( + targetSpaceId = space, + spaceLocalStatus = SpaceStatus.OK, + spaceAccountStatus = SpaceStatus.OK, + chatId = chatId + ) + + stubCheckAuthStatus(response) + stubLaunchWallet() + stubLaunchAccount() + stubGetLastOpenedObject() + + getLastOpenedSpace.stub { + onBlocking { async(Unit) } doReturn Resultat.Success(SpaceId(space)) + } + + initViewModel() + + // WHEN + spaceManager.stub { + on { observe() } doReturn flowOf(defaultSpaceConfig) + } + spaceViewSubscriptionContainer.stub { + on { observe(SpaceId(defaultSpaceConfig.space)) } doReturn flowOf(spaceView) + } + + vm.commands.test { + // Act + vm.onDeepLinkLaunch(deeplink) + + val first = awaitItem() + assertEquals( + expected = SplashViewModel.Command.NavigateToSpaceLevelChat( + space = space, + chat = chatId, + deeplink = deeplink + ), + actual = first + ) + } + } + private fun stubCheckAuthStatus(response: Either.Right) { checkAuthorizationStatus.stub { onBlocking { invoke(eq(Unit)) } doReturn response diff --git a/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/DataView.kt b/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/DataView.kt index e0d24b362f..4109d42b06 100644 --- a/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/DataView.kt +++ b/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/DataView.kt @@ -1,6 +1,7 @@ package com.anytypeio.anytype.core_models import com.anytypeio.anytype.core_models.multiplayer.SpaceAccessType +import com.anytypeio.anytype.core_models.restrictions.SpaceStatus import com.anytypeio.anytype.test_utils.MockDataFactory fun StubDataView( @@ -100,13 +101,19 @@ fun StubSpaceView( id: Id = MockDataFactory.randomUuid(), targetSpaceId: Id = MockDataFactory.randomUuid(), spaceAccessType: SpaceAccessType = SpaceAccessType.DEFAULT, - sharedSpaceLimit: Int? = null + sharedSpaceLimit: Int? = null, + spaceAccountStatus: SpaceStatus? = null, + spaceLocalStatus: SpaceStatus? = null, + chatId: Id? = null ) = ObjectWrapper.SpaceView( map = mapOf( Relations.ID to id, + Relations.CHAT_ID to chatId, Relations.TARGET_SPACE_ID to targetSpaceId, Relations.SPACE_ACCESS_TYPE to spaceAccessType.code.toDouble(), - Relations.SHARED_SPACES_LIMIT to sharedSpaceLimit?.toDouble() + Relations.SHARED_SPACES_LIMIT to sharedSpaceLimit?.toDouble(), + Relations.SPACE_ACCOUNT_STATUS to spaceAccountStatus?.code?.toDouble(), + Relations.SPACE_LOCAL_STATUS to spaceLocalStatus?.code?.toDouble() ) ) \ No newline at end of file