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)
+ }
+ }
+}