From 670bf34ff5fd2436c9a7835015ac8b277c9a5000 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 14 Jul 2024 02:06:04 +0700 Subject: [PATCH] desktop: in-app update functionality (#4443) * desktop: in-app update functionality * without Android * refactor * working windows * tabs vs spaces * better working mac * changes * repo * undo manifest changes * changes * changes * unneeded changes * revert * new line * fix update notice * different way * changes to mac logic * changes to mac logic * more * update strings --------- Co-authored-by: Evgeny Poberezkin --- apps/multiplatform/common/build.gradle.kts | 1 + .../simplex/common/platform/Files.android.kt | 2 + .../kotlin/chat/simplex/common/App.kt | 1 + .../chat/simplex/common/model/ChatModel.kt | 4 + .../chat/simplex/common/model/SimpleXAPI.kt | 6 + .../chat/simplex/common/platform/AppCommon.kt | 15 + .../chat/simplex/common/platform/Files.kt | 2 + .../chat/simplex/common/platform/Platform.kt | 1 + .../common/views/chat/item/CIFileView.kt | 60 +-- .../common/views/chatlist/ChatListView.kt | 24 +- .../common/views/helpers/AlertManager.kt | 23 +- .../common/views/onboarding/WhatsNewView.kt | 17 +- .../common/views/usersettings/SettingsView.kt | 3 + .../commonMain/resources/MR/base/strings.xml | 19 + .../common/platform/AppCommon.desktop.kt | 1 + .../simplex/common/platform/Files.desktop.kt | 6 +- .../common/platform/Platform.desktop.kt | 12 +- .../common/views/helpers/AppUpdater.kt | 394 ++++++++++++++++++ .../views/helpers/OkHttpProgressListener.kt | 46 ++ .../usersettings/SettingsView.desktop.kt | 17 +- .../kotlin/chat/simplex/desktop/Main.kt | 28 ++ 21 files changed, 632 insertions(+), 50 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/OkHttpProgressListener.kt diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index ac131c9748..7e97ea3414 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -105,6 +105,7 @@ kotlin { implementation("uk.co.caprica:vlcj:4.8.2") implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") + implementation("com.squareup.okhttp3:okhttp:4.12.0") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index ea092453ee..bfe961a512 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -29,6 +29,8 @@ actual val remoteHostsDir: File = File(tmpDir.absolutePath + File.separator + "r actual fun desktopOpenDatabaseDir() {} +actual fun desktopOpenDir(dir: File) {} + @Composable actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher { val launcher = rememberLauncherForActivityResult( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index bd35594ac0..59f5307a19 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -80,6 +80,7 @@ fun MainScreen() { laUnavailableInstructionAlert() } } + platform.desktopShowAppUpdateNotice() LaunchedEffect(chatModel.clearOverlays.value) { if (chatModel.clearOverlays.value) { ModalManager.closeAllModalsEverywhere() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 5e2cf9481c..5fd813f09f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -28,6 +28,7 @@ import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +import java.io.Closeable import java.io.File import java.net.URI import java.time.format.DateTimeFormatter @@ -122,6 +123,9 @@ object ChatModel { val clipboardHasText = mutableStateOf(false) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) + val updatingProgress = mutableStateOf(null as Float?) + var updatingRequest: Closeable? = null + val updatingChatsMutex: Mutex = Mutex() val changingActiveUserMutex: Mutex = Mutex() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 70b18fb379..d259aa65c8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -160,6 +160,9 @@ class AppPreferences { val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) + val appUpdateChannel = mkEnumPreference(SHARED_PREFS_APP_UPDATE_CHANNEL, AppUpdatesChannel.DISABLED) { AppUpdatesChannel.entries.firstOrNull { it.name == this } } + val appSkippedUpdate = mkStrPreference(SHARED_PREFS_APP_SKIPPED_UPDATE, "") + val appUpdateNoticeShown = mkBoolPreference(SHARED_PREFS_APP_UPDATE_NOTICE_SHOWN, false) val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } } val migrationToStage = mkStrPreference(SHARED_PREFS_MIGRATION_TO_STAGE, null) @@ -331,6 +334,9 @@ class AppPreferences { private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" + private const val SHARED_PREFS_APP_UPDATE_CHANNEL = "AppUpdateChannel" + private const val SHARED_PREFS_APP_SKIPPED_UPDATE = "AppSkippedUpdate" + private const val SHARED_PREFS_APP_UPDATE_NOTICE_SHOWN = "AppUpdateNoticeShown" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" const val SHARED_PREFS_MIGRATION_TO_STAGE = "MigrationToStage" const val SHARED_PREFS_MIGRATION_FROM_STAGE = "MigrationFromStage" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 110e878c44..60a65eaac6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -3,6 +3,8 @@ package chat.simplex.common.platform import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR import java.util.* enum class AppPlatform { @@ -56,3 +58,16 @@ fun runMigrations() { } } } + +enum class AppUpdatesChannel { + DISABLED, + STABLE, + BETA; + + val text: String + get() = when (this) { + DISABLED -> generalGetString(MR.strings.app_check_for_updates_disabled) + STABLE -> generalGetString(MR.strings.app_check_for_updates_stable) + BETA -> generalGetString(MR.strings.app_check_for_updates_beta) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 250afe03c4..9110987190 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -34,6 +34,8 @@ expect val remoteHostsDir: File expect fun desktopOpenDatabaseDir() +expect fun desktopOpenDir(dir: File) + fun createURIFromPath(absolutePath: String): URI = URI.create(URLEncoder.encode(absolutePath, "UTF-8")) fun URI.toFile(): File = File(URLDecoder.decode(rawPath, "UTF-8").removePrefix("file:")) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index f61c5bc83e..7020a42c1e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -29,6 +29,7 @@ interface PlatformInterface { @Composable fun desktopScrollBarComponents(): Triple, Modifier, MutableState> = remember { Triple(Animatable(0f), Modifier, mutableStateOf(Job())) } @Composable fun desktopScrollBar(state: LazyListState, modifier: Modifier, scrollBarAlpha: Animatable, scrollJob: MutableState, reversed: Boolean) {} @Composable fun desktopScrollBar(state: ScrollState, modifier: Modifier, scrollBarAlpha: Animatable, scrollJob: MutableState, reversed: Boolean) {} + @Composable fun desktopShowAppUpdateNotice() {} } /** * Multiplatform project has separate directories per platform + common directory that contains directories per platform + common for all of them. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 89751dd140..f181126b33 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -128,30 +128,6 @@ fun CIFileView( } } - @Composable - fun progressIndicator() { - CircularProgressIndicator( - Modifier.size(32.dp), - color = if (isInDarkTheme()) FileDark else FileLight, - strokeWidth = 3.dp - ) - } - - @Composable - fun progressCircle(progress: Long, total: Long) { - val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat() - val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } - val strokeColor = if (isInDarkTheme()) FileDark else FileLight - Surface( - Modifier.drawRingModifier(angle, strokeColor, strokeWidth), - color = Color.Transparent, - shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)), - contentColor = LocalContentColor.current - ) { - Box(Modifier.size(32.dp)) - } - } - @Composable fun fileIndicator() { Box( @@ -164,14 +140,14 @@ fun CIFileView( when (file.fileStatus) { is CIFileStatus.SndStored -> when (file.fileProtocol) { - FileProtocol.XFTP -> progressIndicator() + FileProtocol.XFTP -> CIFileViewScope.progressIndicator() FileProtocol.SMP -> fileIcon() FileProtocol.LOCAL -> fileIcon() } is CIFileStatus.SndTransfer -> when (file.fileProtocol) { - FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal) - FileProtocol.SMP -> progressIndicator() + FileProtocol.XFTP -> CIFileViewScope.progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal) + FileProtocol.SMP -> CIFileViewScope.progressIndicator() FileProtocol.LOCAL -> {} } is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(MR.images.ic_check_filled)) @@ -186,9 +162,9 @@ fun CIFileView( is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(MR.images.ic_more_horiz)) is CIFileStatus.RcvTransfer -> if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { - progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal) + CIFileViewScope.progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal) } else { - progressIndicator() + CIFileViewScope.progressIndicator() } is CIFileStatus.RcvAborted -> fileIcon(innerIcon = painterResource(MR.images.ic_sync_problem), color = MaterialTheme.colors.primary) @@ -265,6 +241,32 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = } } +object CIFileViewScope { + @Composable + fun progressIndicator() { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 3.dp + ) + } + + @Composable + fun progressCircle(progress: Long, total: Long) { + val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat() + val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } + val strokeColor = if (isInDarkTheme()) FileDark else FileLight + Surface( + Modifier.drawRingModifier(angle, strokeColor, strokeWidth), + color = Color.Transparent, + shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)), + contentColor = LocalContentColor.current + ) { + Box(Modifier.size(32.dp)) + } + } +} + /* class ChatItemProvider: PreviewParameterProvider { private val sentFile = ChatItem( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 45e851055e..5483b3f041 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1,6 +1,8 @@ package chat.simplex.common.views.chatlist import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape @@ -30,6 +32,8 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call +import chat.simplex.common.views.chat.group.ProgressIndicator +import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -187,8 +191,24 @@ private fun ConnectButton(text: String, onClick: () -> Unit) { private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean) { val serversSummary: MutableState = remember { mutableStateOf(null) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() - - if (stopped) { + val updatingProgress = remember { chatModel.updatingProgress }.value + if (updatingProgress != null) { + barButtons.add { + val interactionSource = remember { MutableInteractionSource() } + val hovered = interactionSource.collectIsHoveredAsState().value + IconButton(onClick = { + chatModel.updatingRequest?.close() + }, Modifier.hoverable(interactionSource)) { + if (hovered) { + Icon(painterResource(MR.images.ic_close), null, tint = WarningOrange) + } else if (updatingProgress == -1f) { + CIFileViewScope.progressIndicator() + } else { + CIFileViewScope.progressCircle((updatingProgress * 100).toLong(), 100) + } + } + } + } else if (stopped) { barButtons.add { IconButton(onClick = { AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 98f3921059..6bfcf2809f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -69,16 +69,19 @@ class AlertManager { fun showAlertDialogButtonsColumn( title: String, text: String? = null, + textAlign: TextAlign = TextAlign.Center, + dismissible: Boolean = true, onDismissRequest: (() -> Unit)? = null, hostDevice: Pair? = null, + belowTextContent: @Composable (() -> Unit) = {}, buttons: @Composable () -> Unit, ) { showAlert { AlertDialog( - onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, + onDismissRequest = { onDismissRequest?.invoke(); if (dismissible) hideAlert() }, title = alertTitle(title), buttons = { - AlertContent(text, hostDevice, extraPadding = true) { + AlertContent(text, hostDevice, extraPadding = true, textAlign = textAlign, belowTextContent = belowTextContent) { buttons() } }, @@ -286,7 +289,14 @@ private fun alertTitle(title: String): (@Composable () -> Unit)? { } @Composable -private fun AlertContent(text: String?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { +private fun AlertContent( + text: String?, + hostDevice: Pair?, + extraPadding: Boolean = false, + textAlign: TextAlign = TextAlign.Center, + belowTextContent: @Composable (() -> Unit) = {}, + content: @Composable (() -> Unit) +) { BoxWithConstraints { Column( Modifier @@ -300,17 +310,20 @@ private fun AlertContent(text: String?, hostDevice: Pair?, extraP CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { if (text != null) { Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) + .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING) .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( escapedHtmlToAnnotatedString(text, LocalDensity.current), - Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), + Modifier.fillMaxWidth(), fontSize = 16.sp, - textAlign = TextAlign.Center, + textAlign = textAlign, color = MaterialTheme.colors.secondary ) } + belowTextContent() + Spacer(Modifier.height(DEFAULT_PADDING * 1.5f)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index caf021a381..340346f5b3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -118,13 +118,8 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { featureDescription(painterResource(feature.icon), feature.titleId, feature.descrId, feature.link) } - val uriHandler = LocalUriHandler.current if (v.post != null) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = DEFAULT_PADDING.div(4))) { - Text(stringResource(MR.strings.whats_new_read_more), color = MaterialTheme.colors.primary, - modifier = Modifier.clickable { uriHandler.openUriCatching(v.post) }) - Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.whats_new_read_more), tint = MaterialTheme.colors.primary) - } + ReadMoreButton(v.post) } if (!viaSettings) { @@ -149,6 +144,16 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { } } +@Composable +fun ReadMoreButton(url: String) { + val uriHandler = LocalUriHandler.current + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = DEFAULT_PADDING.div(4))) { + Text(stringResource(MR.strings.whats_new_read_more), color = MaterialTheme.colors.primary, + modifier = Modifier.clickable { uriHandler.openUriCatching(url) }) + Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.whats_new_read_more), tint = MaterialTheme.colors.primary) + } +} + private data class FeatureDescription( val icon: ImageResource, val titleId: StringResource, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index a78b7663ab..3dfe8c3590 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -73,6 +73,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt withAuth = ::doWithAuth, drawerState = drawerState, ) + KeyChangeEffect(chatModel.updatingProgress.value != null) { + drawerState.close() + } } val simplexTeamUri = diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index a447c9d8e5..e08ccb23d1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -776,6 +776,25 @@ App build: %s Core version: v%s simplexmq: v%s (%2s) + Check for updates + Disabled + Stable + Beta + Update available: %s + Download %s (%s) + Skip this version + Downloading app update, don\'t close the app + App update is downloaded + Open file location + Install update + Installed successfully + Please restart the app. + Update download canceled + Remind later + Check for updates + To be notified about the new releases, turn on periodic check for Stable or Beta versions. + Disable + Show: Hide: Show developer options diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 9fc84dc86f..38d87fc497 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -6,6 +6,7 @@ import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.helpers.* import java.util.* import chat.simplex.res.MR +import java.io.File actual val appPlatform = AppPlatform.DESKTOP diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 5f33e2a943..eeeb13e5cc 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -26,9 +26,13 @@ actual val databaseExportDir: File = tmpDir actual val remoteHostsDir: File = File(dataDir.absolutePath + File.separator + "remote_hosts") actual fun desktopOpenDatabaseDir() { + desktopOpenDir(dataDir) +} + +actual fun desktopOpenDir(dir: File) { if (Desktop.isDesktopSupported()) { try { - Desktop.getDesktop().open(dataDir); + Desktop.getDesktop().open(dir); } catch (e: IOException) { Log.e(TAG, e.stackTraceToString()) AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 9217551a8d..97de08b07e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -8,12 +8,12 @@ private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex" val desktopPlatform = detectDesktopPlatform() -enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String) { - LINUX_X86_64("so", unixConfigPath, unixDataPath), - LINUX_AARCH64("so", unixConfigPath, unixDataPath), - WINDOWS_X86_64("dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX"), - MAC_X86_64("dylib", unixConfigPath, unixDataPath), - MAC_AARCH64("dylib", unixConfigPath, unixDataPath); +enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String, val githubAssetName: String) { + LINUX_X86_64("so", unixConfigPath, unixDataPath, "simplex-desktop-x86_64.AppImage"), + LINUX_AARCH64("so", unixConfigPath, unixDataPath, " simplex-desktop-aarch64.AppImage"), + WINDOWS_X86_64("dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX", "simplex-desktop-windows-x86_64.msi"), + MAC_X86_64("dylib", unixConfigPath, unixDataPath, "simplex-desktop-macos-x86_64.dmg"), + MAC_AARCH64("dylib", unixConfigPath, unixDataPath, "simplex-desktop-macos-aarch64.dmg"); fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64 fun isWindows() = this == WINDOWS_X86_64 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt new file mode 100644 index 0000000000..faef957705 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -0,0 +1,394 @@ +package chat.simplex.common.views.helpers + +import SectionItemView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.getNetCfg +import chat.simplex.common.model.json +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.onboarding.ReadMoreButton +import chat.simplex.res.MR +import kotlinx.coroutines.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.Closeable +import java.io.File +import java.net.InetSocketAddress +import java.net.Proxy + +@Serializable +data class GitHubRelease( + @SerialName("tag_name") + val tagName: String, + @SerialName("html_url") + val htmlUrl: String, + val name: String, + val draft: Boolean, + val prerelease: Boolean, + val body: String, + @SerialName("published_at") + val publishedAt: String, + val assets: List +) + +@Serializable +data class GitHubAsset( + @SerialName("browser_download_url") + val browserDownloadUrl: String, + val name: String, + val size: Long, + + val isAppImage: Boolean = name.lowercase().contains(".appimage") +) + +fun showAppUpdateNotice() { + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.app_check_for_updates_notice_title), + text = generalGetString(MR.strings.app_check_for_updates_notice_desc), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + appPrefs.appUpdateChannel.set(AppUpdatesChannel.STABLE) + setupUpdateChecker() + }) { + Text(generalGetString(MR.strings.app_check_for_updates_stable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + + SectionItemView({ + AlertManager.shared.hideAlert() + appPrefs.appUpdateChannel.set(AppUpdatesChannel.BETA) + setupUpdateChecker() + }) { + Text(generalGetString(MR.strings.app_check_for_updates_beta), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + + SectionItemView({ + AlertManager.shared.hideAlert() + appPrefs.appUpdateChannel.set(AppUpdatesChannel.DISABLED) + }) { + Text(generalGetString(MR.strings.app_check_for_updates_notice_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +private var updateCheckerJob: Job = Job() +fun setupUpdateChecker() = withLongRunningApi { + updateCheckerJob.cancel() + if (appPrefs.appUpdateChannel.get() == AppUpdatesChannel.DISABLED) { + return@withLongRunningApi + } + checkForUpdate() + createUpdateJob() +} + +private fun createUpdateJob() { + updateCheckerJob = withLongRunningApi { + delay(24 * 60 * 60 * 1000) + checkForUpdate() + createUpdateJob() + } +} + + +fun checkForUpdate() { + Log.d(TAG, "Checking for update") + val client = setupHttpClient() + try { + val request = Request.Builder().url("https://api.github.com/repos/simplex-chat/simplex-chat/releases").addHeader("User-agent", "curl").build() + client.newCall(request).execute().use { response -> + response.body?.use { + val body = it.string() + val releases = json.decodeFromString>(body).filterNot { it.draft } + val release = when (appPrefs.appUpdateChannel.get()) { + AppUpdatesChannel.STABLE -> releases.firstOrNull { !it.prerelease } + AppUpdatesChannel.BETA -> releases.firstOrNull() + AppUpdatesChannel.DISABLED -> return + } ?: return + val currentVersionName = "v" + (if (appPlatform.isAndroid) BuildConfigCommon.ANDROID_VERSION_NAME else BuildConfigCommon.DESKTOP_VERSION_NAME) + val redactedCurrentVersionName = when { + currentVersionName.contains('-') && currentVersionName.substringBefore('-').count { it == '.' } == 1 -> "${currentVersionName.substringBefore('-')}.0-${currentVersionName.substringAfter('-')}" + currentVersionName.substringBefore('-').count { it == '.' } == 1 -> "${currentVersionName}.0" + else -> currentVersionName + } + if (release.tagName == appPrefs.appSkippedUpdate.get() || release.tagName == currentVersionName || release.tagName == redactedCurrentVersionName) { + Log.d(TAG, "Skipping update because of the same version or skipped version") + return + } + val assets = chooseGitHubReleaseAssets(release) + // No need to show an alert if no suitable packages were found. But for Flatpak users it's useful to see release notes anyway + if (assets.isEmpty() && !isRunningFromFlatpak()) { + Log.d(TAG, "No assets to download for current system") + return + } + val lines = ArrayList() + for (line in release.body.lines()) { + if (line == "Commits:") break + lines.add(line) + } + val text = lines.joinToString("\n") + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.app_check_for_updates_update_available).format(release.name), + text = text, + textAlign = TextAlign.Start, + dismissible = false, + belowTextContent = { + ReadMoreButton(release.htmlUrl) + }, + buttons = { + Column { + for (asset in assets) { + SectionItemView({ + AlertManager.shared.hideAlert() + chatModel.updatingProgress.value = 0f + withLongRunningApi { + try { + downloadAsset(asset) + } finally { + chatModel.updatingProgress.value = null + } + } + }) { + Text( + generalGetString(MR.strings.app_check_for_updates_button_download).format( + if (asset.name.length > 34) "…" + asset.name.substringAfter("simplex-desktop-") else asset.name, + formatBytes(asset.size)), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary + ) + } + } + + SectionItemView({ + AlertManager.shared.hideAlert() + skipRelease(release) + }) { + Text(generalGetString(MR.strings.app_check_for_updates_button_skip), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = WarningOrange) + } + + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.app_check_for_updates_button_remind_later), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get the latest release: ${e.stackTraceToString()}") + } +} + +private fun setupHttpClient(): OkHttpClient { + val netCfg = getNetCfg() + var proxy: Proxy? = null + if (netCfg.useSocksProxy && netCfg.socksProxy != null) { + val hostname = netCfg.socksProxy.substringBefore(":").ifEmpty { "localhost" } + val port = netCfg.socksProxy.substringAfter(":").toIntOrNull() + if (port != null) { + proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) + } + } + return OkHttpClient.Builder().proxy(proxy).followRedirects(true).build() +} + +private fun skipRelease(release: GitHubRelease) { + appPrefs.appSkippedUpdate.set(release.tagName) +} + +private suspend fun downloadAsset(asset: GitHubAsset) { + withContext(Dispatchers.Main) { + showToast(generalGetString(MR.strings.app_check_for_updates_download_started)) + } + val progressListener = object: ProgressListener { + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + if (contentLength != -1L) { + chatModel.updatingProgress.value = if (done) 1f else bytesRead / contentLength.toFloat() + } + } + } + val client = setupHttpClient().newBuilder() + .addNetworkInterceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + val body = originalResponse.body + if (body != null) { + originalResponse.newBuilder().body(ProgressResponseBody(body, progressListener)).build() + } else { + originalResponse + } + } + .build() + + try { + val request = Request.Builder().url(asset.browserDownloadUrl).addHeader("User-agent", "curl").build() + val call = client.newCall(request) + chatModel.updatingRequest = Closeable { + call.cancel() + withApi { + showToast(generalGetString(MR.strings.app_check_for_updates_canceled)) + } + } + call.execute().use { response -> + response.body?.use { body -> + body.byteStream().use { stream -> + createTmpFileAndDelete { file -> + // It's important to close output stream (with use{}), otherwise, Windows cannot rename the file + file.outputStream().use { output -> + stream.copyTo(output) + } + val newFile = File(file.parentFile, asset.name) + file.renameTo(newFile) + + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.app_check_for_updates_download_completed_title), + dismissible = false, + buttons = { + Column { + // It's problematic to install .deb package because it requires either root or GUI package installer which is not available on + // Debian by default. Let the user install it manually only + if (!asset.name.lowercase().endsWith(".deb")) { + SectionItemView({ + AlertManager.shared.hideAlert() + chatModel.updatingProgress.value = -1f + withLongRunningApi { + try { + installAppUpdate(newFile) + } finally { + chatModel.updatingProgress.value = null + } + } + }) { + Text(generalGetString(MR.strings.app_check_for_updates_button_install), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + + SectionItemView({ + desktopOpenDir(newFile.parentFile) + }) { + Text(generalGetString(MR.strings.app_check_for_updates_button_open), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + + SectionItemView({ + AlertManager.shared.hideAlert() + newFile.delete() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + } + } + ) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to download the asset from release: ${e.stackTraceToString()}") + } +} + +private fun isRunningFromFlatpak(): Boolean = System.getenv("container") == "flatpak" + +private fun chooseGitHubReleaseAssets(release: GitHubRelease): List { + val res = if (isRunningFromFlatpak()) { + // No need to show download options for Flatpak users + emptyList() + } else if (Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) { + // Show all available .deb packages and user will choose the one that works on his system (for Debian derivatives) + release.assets.filter { it.name.lowercase().endsWith(".deb") } + } else { + release.assets.filter { it.name == desktopPlatform.githubAssetName } + } + return res +} + +private suspend fun installAppUpdate(file: File) = withContext(Dispatchers.IO) { + when { + desktopPlatform.isLinux() -> { + val process = Runtime.getRuntime().exec("xdg-open ${file.absolutePath}").onExit().join() + val startedInstallation = process.exitValue() == 0 && process.children().count() > 0 + if (!startedInstallation) { + Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}") + // Failed to start installation. show directory with the file for manual installation + desktopOpenDir(file.parentFile) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title), + text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc) + ) + file.delete() + } + } + desktopPlatform.isWindows() -> { + val process = Runtime.getRuntime().exec("msiexec /i ${file.absolutePath}"/* /qb */).onExit().join() + val startedInstallation = process.exitValue() == 0 + if (!startedInstallation) { + Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}") + // Failed to start installation. show directory with the file for manual installation + desktopOpenDir(file.parentFile) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title), + text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc) + ) + file.delete() + } + } + desktopPlatform.isMac() -> { + // Default mount point if no other DMGs were mounted before + var volume = "/Volumes/SimpleX" + try { + val process = Runtime.getRuntime().exec("hdiutil mount ${file.absolutePath}").onExit().join() + val startedInstallation = process.exitValue() == 0 + val lines = process.inputReader().use { it.readLines() } + // This is needed for situations when mount point has non-default path. + // For example, when a user already had mounted SimpleX.dmg before and default mount point is not available. + // Mac will make volume like /Volumes/SimpleX 1 + val lastLine = lines.lastOrNull()?.substringAfterLast('\t') + if (!startedInstallation || lastLine == null || !lastLine.lowercase().contains("/volumes/")) { + Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}") + // Failed to start installation. show directory with the file for manual installation + desktopOpenDir(file.parentFile) + return@withContext + } + volume = lastLine + File("/Applications/SimpleX.app").renameTo(File("/Applications/SimpleX-old.app")) + val process2 = Runtime.getRuntime().exec(arrayOf("cp", "-R", "${volume}/SimpleX.app", "/Applications")).onExit().join() + val copiedSuccessfully = process2.exitValue() == 0 + if (!copiedSuccessfully) { + Log.e(TAG, "Error copying the app: ${process2.inputReader().use { it.readLines().joinToString("\n") }}${process2.errorStream.use { String(it.readAllBytes()) }}") + // Failed to start installation. show directory with the file for manual installation + desktopOpenDir(file.parentFile) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title), + text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc) + ) + file.delete() + } + } finally { + try { + Runtime.getRuntime().exec(arrayOf("hdiutil", "unmount", volume)).onExit().join() + } finally { + if (!File("/Applications/SimpleX.app").exists()) { + File("/Applications/SimpleX-old.app").renameTo(File("/Applications/SimpleX.app")) + } else { + Runtime.getRuntime().exec("rm -rf /Applications/SimpleX-old.app").onExit().join() + } + } + } + } + } + Unit +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/OkHttpProgressListener.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/OkHttpProgressListener.kt new file mode 100644 index 0000000000..24fa0b8ef1 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/OkHttpProgressListener.kt @@ -0,0 +1,46 @@ +package chat.simplex.common.views.helpers + +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.* + +// https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java +class ProgressResponseBody( + val responseBody: ResponseBody, + val progressListener: ProgressListener +): ResponseBody() { + private var bufferedSource: BufferedSource? = null + + override fun contentType(): MediaType? { + return responseBody.contentType() + } + + override fun contentLength(): Long { + return responseBody.contentLength() + } + + override fun source(): BufferedSource { + if (bufferedSource == null) { + bufferedSource = source(responseBody.source()).buffer() + } + return bufferedSource!! + } + + private fun source(source: Source): Source { + return object: ForwardingSource(source) { + var totalBytesRead = 0L + + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + // read() returns the number of bytes read, or -1 if this source is exhausted. + totalBytesRead += if (bytesRead != -1L) bytesRead else 0L + progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) + return bytesRead; + } + } + } +} + +interface ProgressListener { + fun update(bytesRead: Long, contentLength: Long, done: Boolean); +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt index 5a42b4b756..ed1d820259 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -1,9 +1,16 @@ package chat.simplex.common.views.usersettings import SectionView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel -import chat.simplex.common.views.helpers.ModalData +import chat.simplex.common.platform.AppUpdatesChannel +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -17,6 +24,14 @@ actual fun SettingsSectionApp( ) { SectionView(stringResource(MR.strings.settings_section_title_app)) { SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }, extraPadding = true) + val selectedChannel = remember { appPrefs.appUpdateChannel.state } + val values = AppUpdatesChannel.entries.map { it to it.text } + Box(Modifier.padding(start = DEFAULT_PADDING_HALF * 1.4f)) { + ExposedDropDownSettingRow(stringResource(MR.strings.app_check_for_updates), values, selectedChannel) { + appPrefs.appUpdateChannel.set(it) + setupUpdateChecker() + } + } AppVersionItem(showVersion) } } diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index f69cf817e5..d6ee659dbf 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -8,10 +8,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.platform.DesktopPlatform import chat.simplex.common.showApp import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingStage import kotlinx.coroutines.* import java.io.File @@ -20,6 +22,7 @@ fun main() { //System.setProperty("skiko.renderApi", "SOFTWARE") initHaskell() runMigrations() + setupUpdateChecker() initApp() tmpDir.deleteRecursively() tmpDir.mkdir() @@ -75,6 +78,31 @@ private fun initHaskell() { override fun desktopScrollBar(state: ScrollState, modifier: Modifier, scrollBarAlpha: Animatable, scrollJob: MutableState, reversed: Boolean) { DesktopScrollBar(rememberScrollbarAdapter(scrollState = state), modifier, scrollBarAlpha, scrollJob, reversed) } + + @Composable + override fun desktopShowAppUpdateNotice() { + fun showNoticeIfNeeded() { + if ( + !chatModel.controller.appPrefs.appUpdateNoticeShown.get() + && chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete + && chatModel.chats.size > 3 + && chatModel.activeCallInvitation.value == null + ) { + appPrefs.appUpdateNoticeShown.set(true) + showAppUpdateNotice() + } + } + // Will show notice if chats were loaded before that moment and number of chats > 3 + LaunchedEffect(Unit) { + showNoticeIfNeeded() + } + // Will show notice if chats were loaded later (a lot of chats/slow query) and number of chats > 3 + KeyChangeEffect(chatModel.chats.size) { oldSize -> + if (oldSize == 0) { + showNoticeIfNeeded() + } + } + } } }