1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-07 21:37:02 +09:00

DROID-3557 Migration | Enhancement | Add process progress to migration screen (#2279)

This commit is contained in:
Evgenii Kozlov 2025-04-10 15:51:34 +02:00 committed by GitHub
parent 4a3a4947c5
commit 338706ff52
Signed by: github
GPG key ID: B5690EEEBB952194
15 changed files with 430 additions and 32 deletions

View file

@ -5,6 +5,8 @@ import com.anytypeio.anytype.CrashReporter
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.data.auth.event.EventProcessMigrationDateChannel
import com.anytypeio.anytype.data.auth.event.EventProcessMigrationRemoteChannel
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.auth.interactor.CheckAuthorizationStatus
@ -31,7 +33,10 @@ import com.anytypeio.anytype.domain.search.RelationsSubscriptionManager
import com.anytypeio.anytype.domain.spaces.SpaceDeletedStatusWatcher
import com.anytypeio.anytype.domain.subscriptions.GlobalSubscriptionManager
import com.anytypeio.anytype.domain.templates.GetTemplates
import com.anytypeio.anytype.domain.workspace.EventProcessMigrationChannel
import com.anytypeio.anytype.domain.workspace.SpaceManager
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.interactor.EventProcessMigrationMiddlewareChannel
import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate
import com.anytypeio.anytype.presentation.auth.account.MigrationHelperDelegate
import com.anytypeio.anytype.presentation.splash.SplashViewModelFactory
@ -185,6 +190,18 @@ object SplashModule {
fun bindMigrationHelperDelegate(
impl: MigrationHelperDelegate.Impl
): MigrationHelperDelegate
@Binds
@PerScreen
fun bindMigrationChannel(
impl: EventProcessMigrationDateChannel
): EventProcessMigrationChannel
@Binds
@PerScreen
fun bindMigrationRemoteChannel(
impl: EventProcessMigrationMiddlewareChannel
): EventProcessMigrationRemoteChannel
}
}
@ -211,4 +228,5 @@ interface SplashDependencies : ComponentDependencies {
fun globalSubscriptionManager(): GlobalSubscriptionManager
fun logger(): Logger
fun spaceViewSubscriptionContainer(): SpaceViewSubscriptionContainer
fun eventProxy(): EventProxy
}

View file

@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.CrashReporter
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.data.auth.event.EventProcessMigrationDateChannel
import com.anytypeio.anytype.data.auth.event.EventProcessMigrationRemoteChannel
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
@ -21,7 +23,10 @@ import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.domain.platform.InitialParamsProvider
import com.anytypeio.anytype.domain.spaces.SpaceDeletedStatusWatcher
import com.anytypeio.anytype.domain.subscriptions.GlobalSubscriptionManager
import com.anytypeio.anytype.domain.workspace.EventProcessMigrationChannel
import com.anytypeio.anytype.domain.workspace.SpaceManager
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.interactor.EventProcessMigrationMiddlewareChannel
import com.anytypeio.anytype.presentation.auth.account.MigrationHelperDelegate
import com.anytypeio.anytype.presentation.onboarding.login.OnboardingMnemonicLoginViewModel
import com.anytypeio.anytype.presentation.util.downloader.UriFileProvider
@ -81,6 +86,18 @@ object OnboardingMnemonicLoginModule {
impl: MigrationHelperDelegate.Impl
): MigrationHelperDelegate
@Binds
@PerScreen
fun bindMigrationChannel(
impl: EventProcessMigrationDateChannel
): EventProcessMigrationChannel
@Binds
@PerScreen
fun bindMigrationRemoteChannel(
impl: EventProcessMigrationMiddlewareChannel
): EventProcessMigrationRemoteChannel
@Binds
@PerScreen
fun bindViewModelFactory(
@ -108,4 +125,5 @@ interface OnboardingMnemonicLoginDependencies : ComponentDependencies {
fun globalSubscriptionManager(): GlobalSubscriptionManager
fun debugAccountSelectTrace(): DebugAccountSelectTrace
fun logger(): Logger
fun eventProxy(): EventProxy
}

View file

@ -228,7 +228,7 @@ fun RecoveryScreen(
)
}
is SetupState.Migration.InProgress -> {
MigrationInProgressScreen()
MigrationInProgressScreen(progress = state.progress.progress)
}
is SetupState.Migration.AwaitingStart -> {
MigrationStartScreen(

View file

@ -90,7 +90,9 @@ class SplashFragment : BaseFragment<FragmentSplashBinding>(R.layout.fragment_spl
)
}
is SplashViewModel.State.Migration.InProgress -> {
MigrationInProgressScreen()
MigrationInProgressScreen(
progress = state.progress
)
}
is SplashViewModel.State.Migration.Failed -> {
MigrationFailedScreen(

View file

@ -1,5 +1,6 @@
package com.anytypeio.anytype.ui.update
import androidx.annotation.FloatRange
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -245,7 +246,9 @@ fun MigrationStartScreenPreview() {
}
@Composable
fun MigrationInProgressScreen() {
fun MigrationInProgressScreen(
@FloatRange(from = 0.0, to = 1.0) progress: Float
) {
Box(
modifier = Modifier
.fillMaxSize()
@ -260,6 +263,7 @@ fun MigrationInProgressScreen() {
.fillMaxWidth()
) {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier
.size(88.dp)
.align(Alignment.Center),
@ -370,7 +374,9 @@ fun MigrationFailedScreen(
@DefaultPreviews
@Composable
fun MigrationInProgressScreenPreview() {
MigrationInProgressScreen()
MigrationInProgressScreen(
progress = 0.2f
)
}
@DefaultPreviews

View file

@ -32,6 +32,12 @@ data class Process(
sealed class Event {
sealed class Migration : Event() {
data class New(val process: Process) : Migration()
data class Update(val process: Process) : Migration()
data class Done(val process: Process) : Migration()
}
sealed class DropFiles : Event() {
data class New(
val process: Process

View file

@ -9,6 +9,8 @@ dependencies {
implementation libs.kotlin
implementation libs.coroutines
compileOnly libs.javaxInject
testImplementation project(":test:utils")
testImplementation project(":test:core-models-stub")
testImplementation libs.junit

View file

@ -3,6 +3,8 @@ package com.anytypeio.anytype.data.auth.event
import com.anytypeio.anytype.core_models.Process
import com.anytypeio.anytype.domain.workspace.EventProcessDropFilesChannel
import com.anytypeio.anytype.domain.workspace.EventProcessImportChannel
import com.anytypeio.anytype.domain.workspace.EventProcessMigrationChannel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
interface EventProcessImportRemoteChannel {
@ -29,4 +31,17 @@ class EventProcessDropFilesDateChannel(
override fun observe(): Flow<List<Process.Event.DropFiles>> {
return channel.observe()
}
}
}
interface EventProcessMigrationRemoteChannel {
fun observe(): Flow<List<Process.Event.Migration>>
}
class EventProcessMigrationDateChannel @Inject constructor(
private val channel: EventProcessMigrationRemoteChannel
) : EventProcessMigrationChannel {
override fun observe(): Flow<List<Process.Event.Migration>> {
return channel.observe()
}
}

View file

@ -37,4 +37,5 @@ class MigrateAccount @Inject constructor(
data object Current : Params()
data class Other(val acc: Id) : Params()
}
}
}

View file

@ -9,4 +9,8 @@ interface EventProcessImportChannel {
interface EventProcessDropFilesChannel {
fun observe(): Flow<List<Process.Event.DropFiles>>
}
interface EventProcessMigrationChannel {
fun observe(): Flow<List<Process.Event.Migration>>
}

View file

@ -3,8 +3,10 @@ package com.anytypeio.anytype.middleware.interactor
import com.anytypeio.anytype.core_models.Process
import com.anytypeio.anytype.data.auth.event.EventProcessDropFilesRemoteChannel
import com.anytypeio.anytype.data.auth.event.EventProcessImportRemoteChannel
import com.anytypeio.anytype.data.auth.event.EventProcessMigrationRemoteChannel
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.mappers.toCoreModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
@ -113,6 +115,59 @@ class EventProcessImportMiddlewareChannel(
}
}
else -> null
}
}
}
}
}
class EventProcessMigrationMiddlewareChannel @Inject constructor(
private val events: EventProxy
) : EventProcessMigrationRemoteChannel {
override fun observe(): Flow<List<Process.Event.Migration>> {
return events.flow()
.mapNotNull { emission ->
emission.messages.mapNotNull { message ->
val eventProcessNew = message.processNew
val eventProcessUpdate = message.processUpdate
val eventProcessDone = message.processDone
when {
eventProcessNew != null -> {
val process = eventProcessNew.process
val processType = process?.migration
if (processType != null) {
Process.Event.Migration.New(
process = process.toCoreModel()
)
} else {
null
}
}
eventProcessUpdate != null -> {
val process = eventProcessUpdate.process
val processType = process?.migration
if (processType != null) {
Process.Event.Migration.Update(
process = process.toCoreModel()
)
} else {
null
}
}
eventProcessDone != null -> {
val process = eventProcessDone.process
val processType = process?.migration
if (processType != null) {
Process.Event.Migration.Done(
process = process.toCoreModel()
)
} else {
null
}
}
else -> null
}
}

View file

@ -1,13 +1,22 @@
package com.anytypeio.anytype.presentation.auth.account
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Process
import com.anytypeio.anytype.core_models.Process.Event
import com.anytypeio.anytype.core_models.exceptions.MigrationFailedException
import com.anytypeio.anytype.domain.auth.interactor.MigrateAccount
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.Resultat
import com.anytypeio.anytype.domain.workspace.EventProcessMigrationChannel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
interface MigrationHelperDelegate {
@ -15,26 +24,36 @@ interface MigrationHelperDelegate {
class Impl @Inject constructor(
private val migrateAccount: MigrateAccount,
private val dispatchers: AppCoroutineDispatchers
private val dispatchers: AppCoroutineDispatchers,
private val processProgressObserver: MigrationProgressObserver
) : MigrationHelperDelegate {
override suspend fun proceedWithMigration(): Flow<State> {
return migrateAccount
.stream(MigrateAccount.Params.Current)
.map { result ->
when(result) {
is Resultat.Failure -> {
val exception = result.exception
if (exception is MigrationFailedException.NotEnoughSpace) {
State.Failed.NotEnoughSpace(
requiredSpaceInMegabytes = (exception.requiredSpaceInBytes / 1_048_576)
)
} else {
State.Failed.UnknownError(result.exception)
.flatMapLatest { result ->
flow {
when(result) {
is Resultat.Failure -> {
val exception = result.exception
if (exception is MigrationFailedException.NotEnoughSpace) {
emit(
State.Failed.NotEnoughSpace(
requiredSpaceInMegabytes = (exception.requiredSpaceInBytes / 1_048_576)
)
)
} else {
emit(State.Failed.UnknownError(result.exception))
}
}
is Resultat.Loading -> {
emitAll(processProgressObserver.state)
}
is Resultat.Success -> {
emit(State.Migrated)
}
}
is Resultat.Loading -> State.InProgress
is Resultat.Success -> State.Migrated
}
}
.flowOn(dispatchers.io)
@ -43,11 +62,79 @@ interface MigrationHelperDelegate {
sealed class State {
data object Init: State()
data object InProgress : State()
sealed class InProgress : State() {
abstract val progress: Float
data object Idle : InProgress() {
override val progress: Float = 0f
}
data class Progress(val processId: Id, override val progress: Float) : InProgress()
}
sealed class Failed : State() {
data class UnknownError(val error: Throwable) : Failed()
data class NotEnoughSpace(val requiredSpaceInMegabytes: Long) : Failed()
}
data object Migrated : State()
}
}
typealias MigrationEvents = List<Event.Migration>
class MigrationProgressObserver @Inject constructor(
private val channel: EventProcessMigrationChannel
) {
val state : Flow<MigrationHelperDelegate.State> get() = channel
.observe()
.scan<MigrationEvents, MigrationHelperDelegate.State>(
initial = MigrationHelperDelegate.State.InProgress.Idle
) { state, events ->
var result = state
events.forEach { event ->
when (event) {
is Event.Migration.New -> {
if (result is MigrationHelperDelegate.State.InProgress.Idle && event.process.state == Process.State.RUNNING) {
result = MigrationHelperDelegate.State.InProgress.Progress(
processId = event.process.id,
progress = 0f
)
} else {
// Some process is already running
}
}
is Event.Migration.Update -> {
val currentProgressState = result
val newProcess = event.process
if (currentProgressState is MigrationHelperDelegate.State.InProgress.Progress
&& currentProgressState.processId == event.process.id
&& newProcess.state == Process.State.RUNNING
) {
val progress = newProcess.progress
val total = progress?.total
val done = progress?.done
result =
if (total != null && total != 0L && done != null) {
currentProgressState.copy(
progress = (done.toFloat() / total).coerceIn(0f, 1f)
)
} else {
currentProgressState.copy(progress = 0f)
}
}
}
is Event.Migration.Done -> {
val currentProgressState = result
if (currentProgressState is MigrationHelperDelegate.State.InProgress.Progress
&& event.process.state == Process.State.DONE
&& event.process.id == currentProgressState.processId
) {
result = MigrationHelperDelegate.State.Migrated
}
}
}
}
result
}
.distinctUntilChanged()
.catch {
emit(MigrationHelperDelegate.State.Failed.UnknownError(it))
}
}

View file

@ -318,15 +318,16 @@ class OnboardingMnemonicLoginViewModel @Inject constructor(
account = id
)
}
MigrationHelperDelegate.State.InProgress -> {
is MigrationHelperDelegate.State.InProgress -> {
state.value = SetupState.Migration.InProgress(
account = id
account = id,
progress = migrationState
)
}
MigrationHelperDelegate.State.Migrated -> {
is MigrationHelperDelegate.State.Migrated -> {
proceedWithSelectingAccount(id)
}
MigrationHelperDelegate.State.Init -> {
is MigrationHelperDelegate.State.Init -> {
// Do nothing.
}
}
@ -435,7 +436,10 @@ class OnboardingMnemonicLoginViewModel @Inject constructor(
sealed class Migration : SetupState() {
abstract val account: Id
data class AwaitingStart(override val account: Id) : Migration()
data class InProgress(override val account: Id): Migration()
data class InProgress(
override val account: Id,
val progress: MigrationHelperDelegate.State.InProgress
): Migration()
data class Failed(
val state: MigrationHelperDelegate.State.Failed,
override val account: Id

View file

@ -17,11 +17,9 @@ import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.ObjectTypeIds.COLLECTION
import com.anytypeio.anytype.core_models.SupportedLayouts
import com.anytypeio.anytype.core_models.exceptions.AccountMigrationNeededException
import com.anytypeio.anytype.core_models.exceptions.MigrationFailedException
import com.anytypeio.anytype.core_models.exceptions.NeedToUpdateApplicationException
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_models.primitives.TypeKey
import com.anytypeio.anytype.core_utils.ui.ViewState
import com.anytypeio.anytype.domain.auth.interactor.CheckAuthorizationStatus
import com.anytypeio.anytype.domain.auth.interactor.GetLastOpenedObject
import com.anytypeio.anytype.domain.auth.interactor.LaunchAccount
@ -35,13 +33,11 @@ import com.anytypeio.anytype.domain.page.CreateObjectByTypeAndTemplate
import com.anytypeio.anytype.domain.spaces.GetLastOpenedSpace
import com.anytypeio.anytype.domain.subscriptions.GlobalSubscriptionManager
import com.anytypeio.anytype.domain.workspace.SpaceManager
import com.anytypeio.anytype.presentation.BuildConfig
import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate
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.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
@ -116,7 +112,9 @@ class SplashViewModel(
// Do nothing.
}
is MigrationHelperDelegate.State.InProgress -> {
state.value = State.Migration.InProgress
state.value = State.Migration.InProgress(
progress = migrationState.progress
)
}
is MigrationHelperDelegate.State.Migrated -> {
proceedWithLaunchingAccount()
@ -455,7 +453,7 @@ class SplashViewModel(
data class Error(val msg: String): State()
sealed class Migration : State() {
data object AwaitingStart: Migration()
data object InProgress: Migration()
data class InProgress(val progress: Float): Migration()
data class Failed(val state: MigrationHelperDelegate.State.Failed) : Migration()
}
}

View file

@ -0,0 +1,182 @@
package com.anytypeio.anytype.presentation
import app.cash.turbine.test
import com.anytypeio.anytype.core_models.Process.Event
import com.anytypeio.anytype.domain.workspace.EventProcessMigrationChannel
import com.anytypeio.anytype.presentation.auth.account.MigrationHelperDelegate
import com.anytypeio.anytype.presentation.auth.account.MigrationProgressObserver
import com.anytypeio.anytype.presentation.util.DefaultCoroutineTestRule
import kotlin.test.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
class MigrationProgressObserverTest {
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
val coroutineTestRule = DefaultCoroutineTestRule()
@Mock
lateinit var channel: EventProcessMigrationChannel
lateinit var observer: MigrationProgressObserver
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
observer = MigrationProgressObserver(channel)
}
@Test
fun test() = runTest {
val processId = "test-process"
val initEvent = Event.Migration.New(
process = com.anytypeio.anytype.core_models.Process(
id = processId,
spaceId = "space-id",
type = com.anytypeio.anytype.core_models.Process.Type.MIGRATION,
state = com.anytypeio.anytype.core_models.Process.State.RUNNING,
progress = null
)
)
val updateEvent = Event.Migration.Update(
process = com.anytypeio.anytype.core_models.Process(
id = processId,
spaceId = "space-id",
type = com.anytypeio.anytype.core_models.Process.Type.MIGRATION,
state = com.anytypeio.anytype.core_models.Process.State.RUNNING,
progress = com.anytypeio.anytype.core_models.Process.Progress(done = 50, total = 100, message = "")
)
)
val eventsFlow = flow {
emit(listOf(initEvent)) // Emit the initial event
emit(listOf(updateEvent)) // Emit the update event
}
channel.stub {
on { observe() } doReturn eventsFlow
}
observer.state.test {
// Test initial state
val firstState = awaitItem() // Should receive initial event's state
assertEquals(
expected = MigrationHelperDelegate.State.InProgress.Idle,
actual = firstState
)
val secondState = awaitItem()
assertEquals(
expected = MigrationHelperDelegate.State.InProgress.Progress(
processId = processId,
progress = 0f
),
actual = secondState
)
// Test the updated state
val updatedState = awaitItem() // Should receive the update event's state
assert(updatedState is MigrationHelperDelegate.State.InProgress.Progress)
assert((updatedState as MigrationHelperDelegate.State.InProgress.Progress).progress == 0.5f)
awaitComplete() // Ensure the flow completes properly
}
}
@Test
fun testIdleState() = runTest {
val eventsFlow = flow<List<Event.Migration>> {
emit(listOf()) // Emit the initial event with no progress
}
channel.stub {
on { observe() } doReturn eventsFlow
}
observer.state.test {
// Test initial state
val firstState = awaitItem() // Should receive initial event's state
assertEquals(
expected = MigrationHelperDelegate.State.InProgress.Idle,
actual = firstState
)
awaitComplete() // Ensure the flow completes properly
}
}
@Test
fun testCompletedProgressState() = runTest {
val processId = "test-process"
val initEvent = Event.Migration.New(
process = com.anytypeio.anytype.core_models.Process(
id = processId,
spaceId = "space-id",
type = com.anytypeio.anytype.core_models.Process.Type.MIGRATION,
state = com.anytypeio.anytype.core_models.Process.State.RUNNING,
progress = null
)
)
val completedEvent = Event.Migration.Done(
process = com.anytypeio.anytype.core_models.Process(
id = processId,
spaceId = "space-id",
type = com.anytypeio.anytype.core_models.Process.Type.MIGRATION,
state = com.anytypeio.anytype.core_models.Process.State.DONE,
progress = com.anytypeio.anytype.core_models.Process.Progress(done = 100, total = 100, message = "Migration complete")
)
)
val eventsFlow = flow {
emit(listOf(initEvent)) // Emit the initial event
delay(100) // Simulate some delay
emit(listOf(completedEvent)) // Emit the completed event
}
channel.stub {
on { observe() } doReturn eventsFlow
}
observer.state.test {
// Test initial state
val firstState = awaitItem() // Should receive initial event's state
assertEquals(
expected = MigrationHelperDelegate.State.InProgress.Idle,
actual = firstState
)
val secondState = awaitItem()
assertEquals(
expected = MigrationHelperDelegate.State.InProgress.Progress(
processId = processId,
progress = 0f
),
actual = secondState
)
val completedState = awaitItem()
assertTrue(completedState is MigrationHelperDelegate.State.Migrated)
awaitComplete()
}
}
}