From e9c247ad8f445a06e7a1b72804ba9b1c3de26775 Mon Sep 17 00:00:00 2001 From: Raphael TEYSSANDIER Date: Tue, 1 Sep 2026 17:51:44 +0200 Subject: [PATCH] feat: Improve update --- .../composeResources/values/strings.xml | 2 + .../openflocon/flocondesktop/app/AppScreen.kt | 17 +- .../app/ui/view/topbar/MainScreenTopBar.kt | 85 +++++++- .../app/version/VersionCheckerViewModel.kt | 46 ++++ .../data/settings/SettingsRepositoryImpl.kt | 14 ++ .../datasource/local/SettingsDataSource.kt | 7 + .../local/SettingsDataSourcePrefs.kt | 16 ++ .../settings/repository/SettingsRepository.kt | 10 + .../github/openflocon/domain/versions/DI.kt | 6 + .../CheckIsDesktopOnLastVersionUseCase.kt | 14 +- .../usecase/DismissClientVersionUseCase.kt | 11 + .../usecase/DismissDesktopVersionUseCase.kt | 11 + .../ObserveIsClientOnLastVersionUseCase.kt | 15 +- .../usecase/ObserveUpdateAvailableUseCase.kt | 39 ++++ .../CheckIsDesktopOnLastVersionUseCaseTest.kt | 199 +++++++++++++++++ ...ObserveIsClientOnLastVersionUseCaseTest.kt | 204 ++++++++++++++++++ .../ObserveUpdateAvailableUseCaseTest.kt | 166 ++++++++++++++ 17 files changed, 840 insertions(+), 22 deletions(-) create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissClientVersionUseCase.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissDesktopVersionUseCase.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveUpdateAvailableUseCase.kt create mode 100644 FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/CheckIsDesktopOnLastVersionUseCaseTest.kt create mode 100644 FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveIsClientOnLastVersionUseCaseTest.kt create mode 100644 FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveUpdateAvailableUseCaseTest.kt diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml index bcf94b0df..2bc33d7e5 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml @@ -75,6 +75,8 @@ New client version available: %1$s Don’t forget to update the app version\n(current: %1$s) New destkop version available: %1$s + Update: %1$s + SDK update: %1$s No analytics No Devices Found Open in external editor diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt index 2e9556ce0..ba33a6d70 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt @@ -7,11 +7,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.scene.SinglePaneSceneStrategy import io.github.openflocon.flocondesktop.app.ui.settings.settingsRoutes import io.github.openflocon.flocondesktop.app.ui.view.leftpannel.LeftPanelView import io.github.openflocon.flocondesktop.app.ui.view.topbar.MainScreenTopBar +import io.github.openflocon.flocondesktop.app.version.VersionCheckerViewModel import io.github.openflocon.flocondesktop.app.version.VersionCheckerView import io.github.openflocon.flocondesktop.common.ui.feedback.FeedbackDisplayerView import io.github.openflocon.flocondesktop.features.adbcommander.adbCommanderRoutes @@ -38,12 +40,17 @@ import org.koin.compose.viewmodel.koinViewModel fun AppScreen() { val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val versionCheckerViewModel = koinViewModel() + val updateChip by versionCheckerViewModel.updateChip.collectAsStateWithLifecycle() + val uriHandler = LocalUriHandler.current Box(modifier = Modifier.fillMaxSize()) { Content( uiState = uiState, navigationState = viewModel.navigationState, - onAction = viewModel::onAction + onAction = viewModel::onAction, + updateChip = updateChip, + onUpdateChipClicked = { uriHandler.openUri(it.link) }, ) FeedbackDisplayerView() VersionCheckerView() @@ -54,7 +61,9 @@ fun AppScreen() { private fun Content( uiState: AppUiState, navigationState: MainFloconNavigationState, - onAction: (AppAction) -> Unit + onAction: (AppAction) -> Unit, + updateChip: VersionCheckerViewModel.UpdateChipUiModel?, + onUpdateChipClicked: (VersionCheckerViewModel.UpdateChipUiModel) -> Unit, ) { val sceneStrategies = remember { listOf( @@ -90,7 +99,9 @@ private fun Content( onAppSelected = { onAction(AppAction.SelectApp(it)) }, onRecordClicked = { onAction(AppAction.Record) }, onRestartClicked = { onAction(AppAction.Restart) }, - onTakeScreenshotClicked = { onAction(AppAction.Screenshoot) } + onTakeScreenshotClicked = { onAction(AppAction.Screenshoot) }, + updateChip = updateChip, + onUpdateChipClicked = onUpdateChipClicked, ) } ) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt index 4a5d43c46..8661931f0 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt @@ -2,18 +2,27 @@ package io.github.openflocon.flocondesktop.app.ui.view.topbar import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowCircleUp +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -25,6 +34,7 @@ import io.github.openflocon.flocondesktop.app.ui.model.DeviceItemUiModel import io.github.openflocon.flocondesktop.app.ui.model.DevicesStateUiModel import io.github.openflocon.flocondesktop.app.ui.model.RecordVideoStateUiModel import io.github.openflocon.flocondesktop.app.ui.view.topbar.actions.TopBarActions +import io.github.openflocon.flocondesktop.app.version.VersionCheckerViewModel import io.github.openflocon.library.designsystem.FloconTheme import org.jetbrains.compose.resources.painterResource @@ -41,6 +51,8 @@ fun MainScreenTopBar( recordState: RecordVideoStateUiModel, onRecordClicked: () -> Unit, onRestartClicked: () -> Unit, + updateChip: VersionCheckerViewModel.UpdateChipUiModel? = null, + onUpdateChipClicked: (VersionCheckerViewModel.UpdateChipUiModel) -> Unit = {}, ) { Row( modifier = modifier @@ -48,7 +60,10 @@ fun MainScreenTopBar( .padding(vertical = 8.dp, horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Title() + Title( + updateChip = updateChip, + onUpdateChipClicked = onUpdateChipClicked, + ) Spacer(modifier = Modifier.width(18.dp)) TopBarDeviceAndAppView( devicesState = devicesState, @@ -69,9 +84,49 @@ fun MainScreenTopBar( } } +@Composable +private fun TopBarUpdateChip( + uiModel: VersionCheckerViewModel.UpdateChipUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(FloconTheme.colorPalette.accent.copy(alpha = 0.2f)) + .border( + width = 1.dp, + color = FloconTheme.colorPalette.onAccent.copy(alpha = 0.4f), + shape = RoundedCornerShape(4.dp), + ) + .clickable(onClick = onClick) + .pointerHoverIcon(PointerIcon.Hand) + .padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.ArrowCircleUp, + contentDescription = null, + tint = FloconTheme.colorPalette.onAccent, + modifier = Modifier.size(12.dp), + ) + Text( + text = uiModel.text, + color = FloconTheme.colorPalette.onAccent, + style = FloconTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + ), + ) + } +} + @Composable private fun Title( modifier: Modifier = Modifier, + updateChip: VersionCheckerViewModel.UpdateChipUiModel? = null, + onUpdateChipClicked: (VersionCheckerViewModel.UpdateChipUiModel) -> Unit = {}, ) { Row( modifier = modifier, @@ -86,13 +141,25 @@ private fun Title( contentDescription = "Description de mon image", ) - Text( - text = "Flocon", - style = FloconTheme.typography.titleSmall.copy( - fontSize = 18.sp, - color = FloconTheme.colorPalette.onSurface, - fontWeight = FontWeight.SemiBold, - ), - ) + Column( + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Flocon", + style = FloconTheme.typography.titleSmall.copy( + fontSize = 18.sp, + color = FloconTheme.colorPalette.onSurface, + fontWeight = FontWeight.SemiBold, + ), + ) + + if (updateChip != null) { + Spacer(modifier = Modifier.height(2.dp)) + TopBarUpdateChip( + uiModel = updateChip, + onClick = { onUpdateChipClicked(updateChip) }, + ) + } + } } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/version/VersionCheckerViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/version/VersionCheckerViewModel.kt index af6526754..43a67cb54 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/version/VersionCheckerViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/version/VersionCheckerViewModel.kt @@ -6,12 +6,19 @@ import flocondesktop.composeapp.generated.resources.Res import flocondesktop.composeapp.generated.resources.new_client_version import flocondesktop.composeapp.generated.resources.new_client_version_desc import flocondesktop.composeapp.generated.resources.new_desktop_version +import flocondesktop.composeapp.generated.resources.update_available_client +import flocondesktop.composeapp.generated.resources.update_available_desktop import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.versions.model.IsLastVersionDomainModel import io.github.openflocon.domain.versions.usecase.CheckIsDesktopOnLastVersionUseCase +import io.github.openflocon.domain.versions.usecase.DismissClientVersionUseCase +import io.github.openflocon.domain.versions.usecase.DismissDesktopVersionUseCase import io.github.openflocon.domain.versions.usecase.ObserveIsClientOnLastVersionUseCase +import io.github.openflocon.domain.versions.usecase.ObserveUpdateAvailableUseCase import io.github.openflocon.flocondesktop.BuildConfig import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -22,10 +29,18 @@ import org.jetbrains.compose.resources.getString class VersionCheckerViewModel( private val checkIsDesktopOnLastVersionUseCase: CheckIsDesktopOnLastVersionUseCase, + private val dismissDesktopVersionUseCase: DismissDesktopVersionUseCase, + private val dismissClientVersionUseCase: DismissClientVersionUseCase, private val observeIsClientOnLastVersionUseCase: ObserveIsClientOnLastVersionUseCase, + private val observeUpdateAvailableUseCase: ObserveUpdateAvailableUseCase, private val dispatcherProvider: DispatcherProvider, ) : ViewModel() { + data class UpdateChipUiModel( + val text: String, + val link: String, + ) + data class VersionAvailableUiModel( val version: String, val link: String, @@ -38,6 +53,31 @@ class VersionCheckerViewModel( val client: VersionAvailableUiModel?, ) + val updateChip: StateFlow = observeUpdateAvailableUseCase(BuildConfig.APP_VERSION) + .map { update -> + when (update) { + is ObserveUpdateAvailableUseCase.UpdateAvailableDomainModel.DesktopUpdate -> { + UpdateChipUiModel( + text = getString(Res.string.update_available_desktop, update.version), + link = update.link, + ) + } + is ObserveUpdateAvailableUseCase.UpdateAvailableDomainModel.ClientUpdate -> { + UpdateChipUiModel( + text = getString(Res.string.update_available_client, update.version), + link = update.link, + ) + } + ObserveUpdateAvailableUseCase.UpdateAvailableDomainModel.None -> null + } + } + .flowOn(dispatcherProvider.viewModel) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null, + ) + private val hiddenClientDialogs = MutableStateFlow>(emptySet()) private val desktopVersionAvailable = MutableStateFlow(null) @@ -108,9 +148,15 @@ class VersionCheckerViewModel( fun hideDesktopNewVersionDialog(uimodel: VersionAvailableUiModel) { desktopVersionAvailable.update { null } + viewModelScope.launch(dispatcherProvider.viewModel) { + dismissDesktopVersionUseCase(uimodel.version) + } } fun hideClientNewVersionDialog(uimodel: VersionAvailableUiModel) { hiddenClientDialogs.update { it + uimodel } + viewModelScope.launch(dispatcherProvider.viewModel) { + dismissClientVersionUseCase(uimodel.version) + } } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt index 143382897..d63742348 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt @@ -49,4 +49,18 @@ internal class SettingsRepositoryImpl( override suspend fun setTheme(value: ThemeSetting) { localSettingsDataSource.setTheme(value) } + + override fun getDismissedDesktopVersion(): String? = localSettingsDataSource.getDismissedDesktopVersion() + + override suspend fun setDismissedDesktopVersion(version: String) { + localSettingsDataSource.setDismissedDesktopVersion(version) + } + + override val dismissedClientVersionFlow: Flow = localSettingsDataSource.dismissedClientVersionFlow + + override fun getDismissedClientVersion(): String? = localSettingsDataSource.getDismissedClientVersion() + + override suspend fun setDismissedClientVersion(version: String) { + localSettingsDataSource.setDismissedClientVersion(version) + } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt index b3ed91d61..b93ebbf7c 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt @@ -15,7 +15,14 @@ internal interface SettingsDataSource { suspend fun setFontSizeMultiplier(value: Float) suspend fun setTheme(value: ThemeSetting) + fun getDismissedDesktopVersion(): String? + suspend fun setDismissedDesktopVersion(version: String) + + fun getDismissedClientVersion(): String? + suspend fun setDismissedClientVersion(version: String) + val adbPath: Flow val fontSizeMultiplier: StateFlow val theme: StateFlow + val dismissedClientVersionFlow: Flow } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt index 6b7fd83bf..379242387 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt @@ -66,6 +66,20 @@ internal class SettingsDataSourcePrefs( settings.putString(THEME, value.name) } + override fun getDismissedDesktopVersion(): String? = settings.getStringOrNull(DISMISSED_DESKTOP_VERSION) + + override suspend fun setDismissedDesktopVersion(version: String) { + settings.putString(DISMISSED_DESKTOP_VERSION, version) + } + + override val dismissedClientVersionFlow: Flow = flowSettings.getStringOrNullFlow(DISMISSED_CLIENT_VERSION) + + override fun getDismissedClientVersion(): String? = settings.getStringOrNull(DISMISSED_CLIENT_VERSION) + + override suspend fun setDismissedClientVersion(version: String) { + settings.putString(DISMISSED_CLIENT_VERSION, version) + } + private fun String?.toThemeSetting(): ThemeSetting = this ?.let { name -> ThemeSetting.entries.firstOrNull { it.name == name } } ?: ThemeSetting.DEFAULT @@ -102,6 +116,8 @@ internal class SettingsDataSourcePrefs( private const val ADB_PATH = "adb_path" private const val FONT_SIZE_MULTIPLIER = "font_size_multiplier" private const val THEME = "theme" + private const val DISMISSED_DESKTOP_VERSION = "dismissed_desktop_version" + private const val DISMISSED_CLIENT_VERSION = "dismissed_client_version" private const val NETWORK_SETTINGS = "network_settings" } diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt index d170a0d74..e23296f3b 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt @@ -19,6 +19,16 @@ interface SettingsRepository { suspend fun setTheme(value: ThemeSetting) + fun getDismissedDesktopVersion(): String? + + suspend fun setDismissedDesktopVersion(version: String) + + val dismissedClientVersionFlow: Flow + + fun getDismissedClientVersion(): String? + + suspend fun setDismissedClientVersion(version: String) + val adbPath: Flow val fontSizeMultiplier: StateFlow val theme: StateFlow diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/DI.kt index f0136b4d2..10eb423e0 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/DI.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/DI.kt @@ -1,13 +1,19 @@ package io.github.openflocon.domain.versions import io.github.openflocon.domain.versions.usecase.CheckIsDesktopOnLastVersionUseCase +import io.github.openflocon.domain.versions.usecase.DismissClientVersionUseCase +import io.github.openflocon.domain.versions.usecase.DismissDesktopVersionUseCase import io.github.openflocon.domain.versions.usecase.ObserveIsClientOnLastVersionUseCase import io.github.openflocon.domain.versions.usecase.ObserveLastAvailableFloconVersionUseCase +import io.github.openflocon.domain.versions.usecase.ObserveUpdateAvailableUseCase import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module internal val versionModule = module { factoryOf(::CheckIsDesktopOnLastVersionUseCase) + factoryOf(::DismissDesktopVersionUseCase) + factoryOf(::DismissClientVersionUseCase) factoryOf(::ObserveLastAvailableFloconVersionUseCase) factoryOf(::ObserveIsClientOnLastVersionUseCase) + factoryOf(::ObserveUpdateAvailableUseCase) } diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/CheckIsDesktopOnLastVersionUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/CheckIsDesktopOnLastVersionUseCase.kt index 0cbc3715a..32c745bee 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/CheckIsDesktopOnLastVersionUseCase.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/CheckIsDesktopOnLastVersionUseCase.kt @@ -1,22 +1,28 @@ package io.github.openflocon.domain.versions.usecase import io.github.openflocon.domain.common.Either +import io.github.openflocon.domain.settings.repository.SettingsRepository import io.github.openflocon.domain.versions.model.IsLastVersionDomainModel import io.github.openflocon.domain.versions.repository.VersionsCheckerRepository class CheckIsDesktopOnLastVersionUseCase( private val versionsCheckerRepository: VersionsCheckerRepository, + private val settingsRepository: SettingsRepository, ) { suspend operator fun invoke(current: String): Either = versionsCheckerRepository.checkIsLastVersion() .mapSuccess { lastVersion -> - val isLastVersion = isRemoteVersionNewer(localVersion = current, remoteVersion = lastVersion) - when (isLastVersion) { - true -> IsLastVersionDomainModel.NewVersionAvailable( + val isNewerThanCurrent = isRemoteVersionNewer(localVersion = current, remoteVersion = lastVersion) + val dismissedVersion = settingsRepository.getDismissedDesktopVersion() + val isNewerThanDismissed = dismissedVersion == null || isRemoteVersionNewer(localVersion = dismissedVersion, remoteVersion = lastVersion) + + if (isNewerThanCurrent && isNewerThanDismissed) { + IsLastVersionDomainModel.NewVersionAvailable( name = lastVersion, link = "https://github.com/openflocon/Flocon/releases/tag/$lastVersion", oldVersion = current, ) - false -> IsLastVersionDomainModel.RunningLastVersion + } else { + IsLastVersionDomainModel.RunningLastVersion } } } diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissClientVersionUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissClientVersionUseCase.kt new file mode 100644 index 000000000..758313f54 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissClientVersionUseCase.kt @@ -0,0 +1,11 @@ +package io.github.openflocon.domain.versions.usecase + +import io.github.openflocon.domain.settings.repository.SettingsRepository + +class DismissClientVersionUseCase( + private val settingsRepository: SettingsRepository, +) { + suspend operator fun invoke(version: String) { + settingsRepository.setDismissedClientVersion(version) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissDesktopVersionUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissDesktopVersionUseCase.kt new file mode 100644 index 000000000..f6db6c8fb --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/DismissDesktopVersionUseCase.kt @@ -0,0 +1,11 @@ +package io.github.openflocon.domain.versions.usecase + +import io.github.openflocon.domain.settings.repository.SettingsRepository + +class DismissDesktopVersionUseCase( + private val settingsRepository: SettingsRepository, +) { + suspend operator fun invoke(version: String) { + settingsRepository.setDismissedDesktopVersion(version) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveIsClientOnLastVersionUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveIsClientOnLastVersionUseCase.kt index 1520f5f11..c1af30961 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveIsClientOnLastVersionUseCase.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveIsClientOnLastVersionUseCase.kt @@ -2,6 +2,7 @@ package io.github.openflocon.domain.versions.usecase import io.github.openflocon.domain.common.combines import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceFloconSdkVersionNameUseCase +import io.github.openflocon.domain.settings.repository.SettingsRepository import io.github.openflocon.domain.versions.model.IsLastVersionDomainModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -9,17 +10,19 @@ import kotlinx.coroutines.flow.map class ObserveIsClientOnLastVersionUseCase( private val observeLastAvailableFloconVersionUseCase: ObserveLastAvailableFloconVersionUseCase, private val observeCurrentDeviceFloconSdkVersionNameUseCase: ObserveCurrentDeviceFloconSdkVersionNameUseCase, + private val settingsRepository: SettingsRepository, ) { operator fun invoke(): Flow = combines( observeLastAvailableFloconVersionUseCase(), observeCurrentDeviceFloconSdkVersionNameUseCase(), - ).map { (remote, local) -> - if (local == null || remote == null) + settingsRepository.dismissedClientVersionFlow, + ).map { (remote, local, dismissed) -> + if (local == null || remote == null) { IsLastVersionDomainModel.RunningLastVersion - else { - val newVersionAvailable = - isRemoteVersionNewer(localVersion = local, remoteVersion = remote) - if (newVersionAvailable) { + } else { + val isNewerThanLocal = isRemoteVersionNewer(localVersion = local, remoteVersion = remote) + val isNewerThanDismissed = dismissed == null || isRemoteVersionNewer(localVersion = dismissed, remoteVersion = remote) + if (isNewerThanLocal && isNewerThanDismissed) { IsLastVersionDomainModel.NewVersionAvailable( name = remote, link = "https://github.com/openflocon/Flocon/releases/tag/$remote", diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveUpdateAvailableUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveUpdateAvailableUseCase.kt new file mode 100644 index 000000000..e4302fb2f --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/versions/usecase/ObserveUpdateAvailableUseCase.kt @@ -0,0 +1,39 @@ +package io.github.openflocon.domain.versions.usecase + +import io.github.openflocon.domain.common.combines +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceFloconSdkVersionNameUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ObserveUpdateAvailableUseCase( + private val observeLastAvailableFloconVersionUseCase: ObserveLastAvailableFloconVersionUseCase, + private val observeCurrentDeviceFloconSdkVersionNameUseCase: ObserveCurrentDeviceFloconSdkVersionNameUseCase, +) { + sealed interface UpdateAvailableDomainModel { + data class DesktopUpdate(val version: String, val link: String) : UpdateAvailableDomainModel + data class ClientUpdate(val version: String, val oldVersion: String, val link: String) : UpdateAvailableDomainModel + data object None : UpdateAvailableDomainModel + } + + operator fun invoke(desktopAppVersion: String): Flow = combines( + observeLastAvailableFloconVersionUseCase(), + observeCurrentDeviceFloconSdkVersionNameUseCase(), + ).map { (remote, clientLocal) -> + if (remote == null) { + UpdateAvailableDomainModel.None + } else if (isRemoteVersionNewer(localVersion = desktopAppVersion, remoteVersion = remote)) { + UpdateAvailableDomainModel.DesktopUpdate( + version = remote, + link = "https://github.com/openflocon/Flocon/releases/tag/$remote", + ) + } else if (clientLocal != null && isRemoteVersionNewer(localVersion = clientLocal, remoteVersion = remote)) { + UpdateAvailableDomainModel.ClientUpdate( + version = remote, + oldVersion = clientLocal, + link = "https://github.com/openflocon/Flocon/releases/tag/$remote", + ) + } else { + UpdateAvailableDomainModel.None + } + } +} diff --git a/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/CheckIsDesktopOnLastVersionUseCaseTest.kt b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/CheckIsDesktopOnLastVersionUseCaseTest.kt new file mode 100644 index 000000000..e3047beda --- /dev/null +++ b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/CheckIsDesktopOnLastVersionUseCaseTest.kt @@ -0,0 +1,199 @@ +package io.github.openflocon.domain.versions + +import io.github.openflocon.domain.common.Either +import io.github.openflocon.domain.common.Success +import io.github.openflocon.domain.models.settings.NetworkSettings +import io.github.openflocon.domain.models.settings.ThemeSetting +import io.github.openflocon.domain.settings.repository.AdbForwardStatus +import io.github.openflocon.domain.settings.repository.SettingsRepository +import io.github.openflocon.domain.versions.model.IsLastVersionDomainModel +import io.github.openflocon.domain.versions.repository.VersionsCheckerRepository +import io.github.openflocon.domain.versions.usecase.CheckIsDesktopOnLastVersionUseCase +import io.github.openflocon.domain.versions.usecase.isRemoteVersionNewer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CheckIsDesktopOnLastVersionUseCaseTest { + + private class FakeVersionsCheckerRepository( + var remoteVersion: String = "1.0.0", + ) : VersionsCheckerRepository { + override val lastVersion: Flow = emptyFlow() + override suspend fun checkIsLastVersion(): Either = Success(remoteVersion) + } + + private class FakeSettingsRepository( + var dismissedVersion: String? = null, + ) : SettingsRepository { + override var networkSettings: NetworkSettings = NetworkSettings( + pinnedDetails = false, + displayOldSessions = false, + autoScroll = false, + invertList = false, + ) + override val networkSettingsFlow: Flow = emptyFlow() + override fun getAdbPath(): String? = null + override suspend fun setAdbPath(path: String) {} + override suspend fun setFontSizeMultiplier(value: Float) {} + override suspend fun setTheme(value: ThemeSetting) {} + override fun getDismissedDesktopVersion(): String? = dismissedVersion + override suspend fun setDismissedDesktopVersion(version: String) { + dismissedVersion = version + } + override val dismissedClientVersionFlow: Flow = emptyFlow() + override fun getDismissedClientVersion(): String? = null + override suspend fun setDismissedClientVersion(version: String) {} + override val adbPath: Flow = emptyFlow() + override val fontSizeMultiplier: StateFlow = MutableStateFlow(1f) + override val theme: StateFlow = MutableStateFlow(ThemeSetting.DEFAULT) + override val adbForwardStatus: StateFlow = MutableStateFlow(AdbForwardStatus.UNKNOWN) + override fun setAdbForwardStatus(status: AdbForwardStatus) {} + } + + @Test + fun `when remote version is newer and no dismissed version, returns NewVersionAvailable`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(remoteVersion = "1.1.0") + val settingsRepo = FakeSettingsRepository(dismissedVersion = null) + val useCase = CheckIsDesktopOnLastVersionUseCase( + versionsCheckerRepository = versionsRepo, + settingsRepository = settingsRepo, + ) + + val result = useCase(current = "1.0.0") + + assertIs>(result) + val model = result.value + assertIs(model) + assertEquals("1.1.0", model.name) + assertEquals("1.0.0", model.oldVersion) + } + } + + @Test + fun `when remote version is newer and dismissed version is equal to remote version, returns RunningLastVersion`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(remoteVersion = "1.1.0") + val settingsRepo = FakeSettingsRepository(dismissedVersion = "1.1.0") + val useCase = CheckIsDesktopOnLastVersionUseCase( + versionsCheckerRepository = versionsRepo, + settingsRepository = settingsRepo, + ) + + val result = useCase(current = "1.0.0") + + assertIs>(result) + val model = result.value + assertIs(model) + } + } + + @Test + fun `when remote version is newer than both current and dismissed version, returns NewVersionAvailable`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(remoteVersion = "1.2.0") + val settingsRepo = FakeSettingsRepository(dismissedVersion = "1.1.0") + val useCase = CheckIsDesktopOnLastVersionUseCase( + versionsCheckerRepository = versionsRepo, + settingsRepository = settingsRepo, + ) + + val result = useCase(current = "1.0.0") + + assertIs>(result) + val model = result.value + assertIs(model) + assertEquals("1.2.0", model.name) + assertEquals("1.0.0", model.oldVersion) + } + } + + @Test + fun `when remote version is equal or older than current version, returns RunningLastVersion`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(remoteVersion = "1.0.0") + val settingsRepo = FakeSettingsRepository(dismissedVersion = null) + val useCase = CheckIsDesktopOnLastVersionUseCase( + versionsCheckerRepository = versionsRepo, + settingsRepository = settingsRepo, + ) + + val result = useCase(current = "1.0.0") + + assertIs>(result) + val model = result.value + assertIs(model) + } + } + + @Test + fun `test DismissDesktopVersionUseCase saves dismissed version`() { + runBlocking { + val settingsRepo = FakeSettingsRepository() + val dismissUseCase = io.github.openflocon.domain.versions.usecase.DismissDesktopVersionUseCase(settingsRepo) + + dismissUseCase("1.1.0") + + assertEquals("1.1.0", settingsRepo.getDismissedDesktopVersion()) + } + } + + @Test + fun `test complete lifecycle of dismissing version and receiving newer version`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(remoteVersion = "1.1.0") + val settingsRepo = FakeSettingsRepository(dismissedVersion = null) + val checkUseCase = CheckIsDesktopOnLastVersionUseCase( + versionsCheckerRepository = versionsRepo, + settingsRepository = settingsRepo, + ) + val dismissUseCase = io.github.openflocon.domain.versions.usecase.DismissDesktopVersionUseCase(settingsRepo) + + // Step 1: 1.1.0 is available, notification shown + val result1 = checkUseCase(current = "1.0.0") + assertIs>(result1) + assertIs(result1.value) + + // Step 2: User dismisses 1.1.0 + dismissUseCase("1.1.0") + + // Step 3: Next launch, still on 1.0.0 and remote is 1.1.0 -> suppressed + val result2 = checkUseCase(current = "1.0.0") + assertIs>(result2) + assertIs(result2.value) + + // Step 4: New release 1.2.0 published -> notification shown again + versionsRepo.remoteVersion = "1.2.0" + val result3 = checkUseCase(current = "1.0.0") + assertIs>(result3) + val model3 = result3.value + assertIs(model3) + assertEquals("1.2.0", model3.name) + + // Step 5: User updates app to 1.2.0 -> no notification + val result4 = checkUseCase(current = "1.2.0") + assertIs>(result4) + assertIs(result4.value) + } + } + + @Test + fun `test isRemoteVersionNewer comparisons`() { + assertTrue(isRemoteVersionNewer(localVersion = "1.0.0", remoteVersion = "1.0.1")) + assertTrue(isRemoteVersionNewer(localVersion = "1.0.0", remoteVersion = "1.1.0")) + assertTrue(isRemoteVersionNewer(localVersion = "1.0.0", remoteVersion = "2.0.0")) + assertTrue(isRemoteVersionNewer(localVersion = "1.1.0", remoteVersion = "1.2.0")) + + assertFalse(isRemoteVersionNewer(localVersion = "1.1.0", remoteVersion = "1.1.0")) + assertFalse(isRemoteVersionNewer(localVersion = "1.2.0", remoteVersion = "1.1.0")) + assertFalse(isRemoteVersionNewer(localVersion = "2.0.0", remoteVersion = "1.9.9")) + } +} diff --git a/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveIsClientOnLastVersionUseCaseTest.kt b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveIsClientOnLastVersionUseCaseTest.kt new file mode 100644 index 000000000..6044aa177 --- /dev/null +++ b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveIsClientOnLastVersionUseCaseTest.kt @@ -0,0 +1,204 @@ +package io.github.openflocon.domain.versions + +import io.github.openflocon.domain.common.Either +import io.github.openflocon.domain.device.models.AppPackageName +import io.github.openflocon.domain.device.models.DeviceAppDomainModel +import io.github.openflocon.domain.device.models.DeviceDomainModel +import io.github.openflocon.domain.device.models.DeviceId +import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel +import io.github.openflocon.domain.device.models.HandleDeviceResultDomainModel +import io.github.openflocon.domain.device.models.RegisterDeviceWithAppDomainModel +import io.github.openflocon.domain.device.repository.DevicesRepository +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceFloconSdkVersionNameUseCase +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdUseCase +import io.github.openflocon.domain.models.settings.NetworkSettings +import io.github.openflocon.domain.models.settings.ThemeSetting +import io.github.openflocon.domain.settings.repository.AdbForwardStatus +import io.github.openflocon.domain.settings.repository.SettingsRepository +import io.github.openflocon.domain.versions.model.IsLastVersionDomainModel +import io.github.openflocon.domain.versions.repository.VersionsCheckerRepository +import io.github.openflocon.domain.versions.usecase.DismissClientVersionUseCase +import io.github.openflocon.domain.versions.usecase.ObserveIsClientOnLastVersionUseCase +import io.github.openflocon.domain.versions.usecase.ObserveLastAvailableFloconVersionUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ObserveIsClientOnLastVersionUseCaseTest { + + private class FakeVersionsCheckerRepository( + initialVersion: String? = null + ) : VersionsCheckerRepository { + val versionFlow = MutableStateFlow(initialVersion) + override val lastVersion: Flow = versionFlow.asStateFlow() + override suspend fun checkIsLastVersion(): Either = throw NotImplementedError() + } + + private class FakeSettingsRepository( + initialDismissedClientVersion: String? = null + ) : SettingsRepository { + val dismissedClientFlow = MutableStateFlow(initialDismissedClientVersion) + override val dismissedClientVersionFlow: Flow = dismissedClientFlow.asStateFlow() + override fun getDismissedClientVersion(): String? = dismissedClientFlow.value + override suspend fun setDismissedClientVersion(version: String) { + dismissedClientFlow.value = version + } + override var networkSettings: NetworkSettings = NetworkSettings( + pinnedDetails = false, + displayOldSessions = false, + autoScroll = false, + invertList = false, + ) + override val networkSettingsFlow: Flow = emptyFlow() + override fun getAdbPath(): String? = null + override suspend fun setAdbPath(path: String) {} + override suspend fun setFontSizeMultiplier(value: Float) {} + override suspend fun setTheme(value: ThemeSetting) {} + override fun getDismissedDesktopVersion(): String? = null + override suspend fun setDismissedDesktopVersion(version: String) {} + override val adbPath: Flow = emptyFlow() + override val fontSizeMultiplier: StateFlow = MutableStateFlow(1f) + override val theme: StateFlow = MutableStateFlow(ThemeSetting.DEFAULT) + override val adbForwardStatus: StateFlow = MutableStateFlow(AdbForwardStatus.UNKNOWN) + override fun setAdbForwardStatus(status: AdbForwardStatus) {} + } + + private class FakeDevicesRepository( + var currentDeviceIdValue: String? = "device1", + var sdkVersionValue: String? = "1.0.0", + ) : DevicesRepository { + override val devices: Flow> = emptyFlow() + override val currentDeviceId: Flow = flowOf(currentDeviceIdValue) + override val activeDevices: Flow> = emptyFlow() + + override suspend fun getCurrentDeviceId(): DeviceId? = currentDeviceIdValue + override suspend fun register(registerDeviceWithApp: RegisterDeviceWithAppDomainModel): HandleDeviceResultDomainModel = throw NotImplementedError() + override suspend fun getCurrentDevice(): DeviceDomainModel? = null + override suspend fun selectDevice(deviceId: DeviceId) {} + + override fun observeDeviceApps(deviceId: DeviceId): Flow> = emptyFlow() + override fun observeDeviceSelectedApp(deviceId: DeviceId): Flow = flowOf( + DeviceAppDomainModel( + name = "TestApp", + packageName = "com.test.app", + iconEncoded = null, + lastAppInstance = 1, + floconVersionOnDevice = sdkVersionValue ?: "", + ) + ) + override suspend fun getDeviceSelectedApp(deviceId: DeviceId): DeviceAppDomainModel? = null + override suspend fun getDeviceAppByPackage(deviceId: DeviceId, appPackageName: String): DeviceAppDomainModel? = null + override suspend fun selectApp(deviceId: DeviceId, app: DeviceAppDomainModel) {} + + override suspend fun saveAppIcon(deviceId: DeviceId, appPackageName: String, iconEncoded: String) {} + override suspend fun hasAppIcon(deviceId: DeviceId, appPackageName: String): Boolean = false + override suspend fun askForDeviceAppIcon(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel) {} + + override fun observeDeviceSdkVersion(deviceId: DeviceId, appPackageName: String): Flow = flowOf(sdkVersionValue) + + override suspend fun deleteDevice(deviceId: DeviceId) {} + override suspend fun deleteApplication(deviceId: DeviceId, packageName: AppPackageName) {} + override suspend fun restartApp(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel) {} + override suspend fun clear() {} + override fun observeCurrentDevice(): Flow = emptyFlow() + } + + private fun createObserveSdkUseCase(devicesRepo: DevicesRepository): ObserveCurrentDeviceFloconSdkVersionNameUseCase { + val observeCurrentDeviceIdUseCase = ObserveCurrentDeviceIdUseCase(devicesRepo) + val observeCurrentDeviceIdAndPackageNameUseCase = ObserveCurrentDeviceIdAndPackageNameUseCase( + observeCurrentDeviceIdUseCase = observeCurrentDeviceIdUseCase, + devicesRepository = devicesRepo, + ) + return ObserveCurrentDeviceFloconSdkVersionNameUseCase( + observeCurrentDeviceIdAndPackageNameUseCase = observeCurrentDeviceIdAndPackageNameUseCase, + devicesRepository = devicesRepo, + ) + } + + @Test + fun `when remote client version is newer and no dismissed version, emits NewVersionAvailable`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(initialVersion = "1.1.0") + val settingsRepo = FakeSettingsRepository(initialDismissedClientVersion = null) + val observeRemoteUseCase = ObserveLastAvailableFloconVersionUseCase(versionsRepo) + val devicesRepo = FakeDevicesRepository(sdkVersionValue = "1.0.0") + val observeSdkUseCase = createObserveSdkUseCase(devicesRepo) + + val useCase = ObserveIsClientOnLastVersionUseCase( + observeLastAvailableFloconVersionUseCase = observeRemoteUseCase, + observeCurrentDeviceFloconSdkVersionNameUseCase = observeSdkUseCase, + settingsRepository = settingsRepo, + ) + + val result = useCase().first() + assertIs(result) + assertEquals("1.1.0", result.name) + assertEquals("1.0.0", result.oldVersion) + } + } + + @Test + fun `when remote client version is newer and dismissed, emits RunningLastVersion`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(initialVersion = "1.1.0") + val settingsRepo = FakeSettingsRepository(initialDismissedClientVersion = "1.1.0") + val observeRemoteUseCase = ObserveLastAvailableFloconVersionUseCase(versionsRepo) + val devicesRepo = FakeDevicesRepository(sdkVersionValue = "1.0.0") + val observeSdkUseCase = createObserveSdkUseCase(devicesRepo) + + val useCase = ObserveIsClientOnLastVersionUseCase( + observeLastAvailableFloconVersionUseCase = observeRemoteUseCase, + observeCurrentDeviceFloconSdkVersionNameUseCase = observeSdkUseCase, + settingsRepository = settingsRepo, + ) + + val result = useCase().first() + assertIs(result) + } + } + + @Test + fun `test complete client dismissal and newer version lifecycle`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(initialVersion = "1.1.0") + val settingsRepo = FakeSettingsRepository(initialDismissedClientVersion = null) + val observeRemoteUseCase = ObserveLastAvailableFloconVersionUseCase(versionsRepo) + val devicesRepo = FakeDevicesRepository(sdkVersionValue = "1.0.0") + val observeSdkUseCase = createObserveSdkUseCase(devicesRepo) + + val useCase = ObserveIsClientOnLastVersionUseCase( + observeLastAvailableFloconVersionUseCase = observeRemoteUseCase, + observeCurrentDeviceFloconSdkVersionNameUseCase = observeSdkUseCase, + settingsRepository = settingsRepo, + ) + val dismissUseCase = DismissClientVersionUseCase(settingsRepo) + + // 1. Initial check: New version available + val result1 = useCase().first() + assertIs(result1) + assertEquals("1.1.0", result1.name) + + // 2. Dismiss 1.1.0 + dismissUseCase("1.1.0") + + // 3. Check again: Suppressed + val result2 = useCase().first() + assertIs(result2) + + // 4. Remote version updated to 1.2.0: Emits NewVersionAvailable for 1.2.0 + versionsRepo.versionFlow.value = "1.2.0" + val result3 = useCase().first() + assertIs(result3) + assertEquals("1.2.0", result3.name) + } + } +} diff --git a/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveUpdateAvailableUseCaseTest.kt b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveUpdateAvailableUseCaseTest.kt new file mode 100644 index 000000000..597fa0c06 --- /dev/null +++ b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/versions/ObserveUpdateAvailableUseCaseTest.kt @@ -0,0 +1,166 @@ +package io.github.openflocon.domain.versions + +import io.github.openflocon.domain.common.Either +import io.github.openflocon.domain.device.models.AppPackageName +import io.github.openflocon.domain.device.models.DeviceAppDomainModel +import io.github.openflocon.domain.device.models.DeviceDomainModel +import io.github.openflocon.domain.device.models.DeviceId +import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel +import io.github.openflocon.domain.device.models.HandleDeviceResultDomainModel +import io.github.openflocon.domain.device.models.RegisterDeviceWithAppDomainModel +import io.github.openflocon.domain.device.repository.DevicesRepository +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceFloconSdkVersionNameUseCase +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdUseCase +import io.github.openflocon.domain.versions.repository.VersionsCheckerRepository +import io.github.openflocon.domain.versions.usecase.ObserveLastAvailableFloconVersionUseCase +import io.github.openflocon.domain.versions.usecase.ObserveUpdateAvailableUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ObserveUpdateAvailableUseCaseTest { + + private class FakeVersionsCheckerRepository( + initialVersion: String? = null + ) : VersionsCheckerRepository { + val versionFlow = MutableStateFlow(initialVersion) + override val lastVersion: Flow = versionFlow.asStateFlow() + override suspend fun checkIsLastVersion(): Either = throw NotImplementedError() + } + + private class FakeDevicesRepository( + var currentDeviceIdValue: String? = "device1", + var sdkVersionValue: String? = null, + ) : DevicesRepository { + override val devices: Flow> = emptyFlow() + override val currentDeviceId: Flow = flowOf(currentDeviceIdValue) + override val activeDevices: Flow> = emptyFlow() + + override suspend fun getCurrentDeviceId(): DeviceId? = currentDeviceIdValue + override suspend fun register(registerDeviceWithApp: RegisterDeviceWithAppDomainModel): HandleDeviceResultDomainModel = throw NotImplementedError() + override suspend fun getCurrentDevice(): DeviceDomainModel? = null + override suspend fun selectDevice(deviceId: DeviceId) {} + + override fun observeDeviceApps(deviceId: DeviceId): Flow> = emptyFlow() + override fun observeDeviceSelectedApp(deviceId: DeviceId): Flow = flowOf( + DeviceAppDomainModel( + name = "TestApp", + packageName = "com.test.app", + iconEncoded = null, + lastAppInstance = 1, + floconVersionOnDevice = sdkVersionValue ?: "", + ) + ) + override suspend fun getDeviceSelectedApp(deviceId: DeviceId): DeviceAppDomainModel? = null + override suspend fun getDeviceAppByPackage(deviceId: DeviceId, appPackageName: String): DeviceAppDomainModel? = null + override suspend fun selectApp(deviceId: DeviceId, app: DeviceAppDomainModel) {} + + override suspend fun saveAppIcon(deviceId: DeviceId, appPackageName: String, iconEncoded: String) {} + override suspend fun hasAppIcon(deviceId: DeviceId, appPackageName: String): Boolean = false + override suspend fun askForDeviceAppIcon(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel) {} + + override fun observeDeviceSdkVersion(deviceId: DeviceId, appPackageName: String): Flow = flowOf(sdkVersionValue) + + override suspend fun deleteDevice(deviceId: DeviceId) {} + override suspend fun deleteApplication(deviceId: DeviceId, packageName: AppPackageName) {} + override suspend fun restartApp(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel) {} + override suspend fun clear() {} + override fun observeCurrentDevice(): Flow = emptyFlow() + } + + private fun createObserveSdkUseCase(devicesRepo: DevicesRepository): ObserveCurrentDeviceFloconSdkVersionNameUseCase { + val observeCurrentDeviceIdUseCase = ObserveCurrentDeviceIdUseCase(devicesRepo) + val observeCurrentDeviceIdAndPackageNameUseCase = ObserveCurrentDeviceIdAndPackageNameUseCase( + observeCurrentDeviceIdUseCase = observeCurrentDeviceIdUseCase, + devicesRepository = devicesRepo, + ) + return ObserveCurrentDeviceFloconSdkVersionNameUseCase( + observeCurrentDeviceIdAndPackageNameUseCase = observeCurrentDeviceIdAndPackageNameUseCase, + devicesRepository = devicesRepo, + ) + } + + @Test + fun `when remote version is newer than desktop, emits DesktopUpdate`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(initialVersion = "1.1.0") + val devicesRepo = FakeDevicesRepository(sdkVersionValue = "1.0.0") + val observeRemoteUseCase = ObserveLastAvailableFloconVersionUseCase(versionsRepo) + val observeSdkUseCase = createObserveSdkUseCase(devicesRepo) + + val useCase = ObserveUpdateAvailableUseCase( + observeLastAvailableFloconVersionUseCase = observeRemoteUseCase, + observeCurrentDeviceFloconSdkVersionNameUseCase = observeSdkUseCase, + ) + + val result = useCase(desktopAppVersion = "1.0.0").first() + assertIs(result) + assertEquals("1.1.0", result.version) + assertEquals("https://github.com/openflocon/Flocon/releases/tag/1.1.0", result.link) + } + } + + @Test + fun `when desktop is up to date and client is older, emits ClientUpdate`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(initialVersion = "1.1.0") + val devicesRepo = FakeDevicesRepository(sdkVersionValue = "1.0.0") + val observeRemoteUseCase = ObserveLastAvailableFloconVersionUseCase(versionsRepo) + val observeSdkUseCase = createObserveSdkUseCase(devicesRepo) + + val useCase = ObserveUpdateAvailableUseCase( + observeLastAvailableFloconVersionUseCase = observeRemoteUseCase, + observeCurrentDeviceFloconSdkVersionNameUseCase = observeSdkUseCase, + ) + + val result = useCase(desktopAppVersion = "1.1.0").first() + assertIs(result) + assertEquals("1.1.0", result.version) + assertEquals("1.0.0", result.oldVersion) + } + } + + @Test + fun `when both desktop and client are up to date, emits None`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(initialVersion = "1.1.0") + val devicesRepo = FakeDevicesRepository(sdkVersionValue = "1.1.0") + val observeRemoteUseCase = ObserveLastAvailableFloconVersionUseCase(versionsRepo) + val observeSdkUseCase = createObserveSdkUseCase(devicesRepo) + + val useCase = ObserveUpdateAvailableUseCase( + observeLastAvailableFloconVersionUseCase = observeRemoteUseCase, + observeCurrentDeviceFloconSdkVersionNameUseCase = observeSdkUseCase, + ) + + val result = useCase(desktopAppVersion = "1.1.0").first() + assertIs(result) + } + } + + @Test + fun `when remote is null, emits None`() { + runBlocking { + val versionsRepo = FakeVersionsCheckerRepository(initialVersion = null) + val devicesRepo = FakeDevicesRepository(sdkVersionValue = "1.0.0") + val observeRemoteUseCase = ObserveLastAvailableFloconVersionUseCase(versionsRepo) + val observeSdkUseCase = createObserveSdkUseCase(devicesRepo) + + val useCase = ObserveUpdateAvailableUseCase( + observeLastAvailableFloconVersionUseCase = observeRemoteUseCase, + observeCurrentDeviceFloconSdkVersionNameUseCase = observeSdkUseCase, + ) + + val result = useCase(desktopAppVersion = "1.0.0").first() + assertIs(result) + } + } +}