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 <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko
2024-07-14 02:06:04 +07:00
committed by GitHub
parent 3e873fcb32
commit 670bf34ff5
21 changed files with 632 additions and 50 deletions
@@ -80,6 +80,7 @@ fun MainScreen() {
laUnavailableInstructionAlert()
}
}
platform.desktopShowAppUpdateNotice()
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value) {
ModalManager.closeAllModalsEverywhere()
@@ -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()
@@ -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"
@@ -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)
}
}
@@ -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:"))
@@ -29,6 +29,7 @@ interface PlatformInterface {
@Composable fun desktopScrollBarComponents(): Triple<Animatable<Float, AnimationVector1D>, Modifier, MutableState<Job>> = remember { Triple(Animatable(0f), Modifier, mutableStateOf(Job())) }
@Composable fun desktopScrollBar(state: LazyListState, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean) {}
@Composable fun desktopScrollBar(state: ScrollState, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean) {}
@Composable fun desktopShowAppUpdateNotice() {}
}
/**
* Multiplatform project has separate directories per platform + common directory that contains directories per platform + common for all of them.
@@ -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<ChatItem> {
private val sentFile = ChatItem(
@@ -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<AnimatedViewState>, stopped: Boolean) {
val serversSummary: MutableState<PresentedServersSummary?> = 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(
@@ -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<Long?, String>? = 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<Long?, String>?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) {
private fun AlertContent(
text: String?,
hostDevice: Pair<Long?, String>?,
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<Long?, String>?, 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))
}
}
}
@@ -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,
@@ -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 =
@@ -776,6 +776,25 @@
<string name="app_version_code">App build: %s</string>
<string name="core_version">Core version: v%s</string>
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
<string name="app_check_for_updates">Check for updates</string>
<string name="app_check_for_updates_disabled">Disabled</string>
<string name="app_check_for_updates_stable">Stable</string>
<string name="app_check_for_updates_beta">Beta</string>
<string name="app_check_for_updates_update_available">Update available: %s</string>
<string name="app_check_for_updates_button_download">Download %s (%s)</string>
<string name="app_check_for_updates_button_skip">Skip this version</string>
<string name="app_check_for_updates_download_started">Downloading app update, don\'t close the app</string>
<string name="app_check_for_updates_download_completed_title">App update is downloaded</string>
<string name="app_check_for_updates_button_open">Open file location</string>
<string name="app_check_for_updates_button_install">Install update</string>
<string name="app_check_for_updates_installed_successfully_title">Installed successfully</string>
<string name="app_check_for_updates_installed_successfully_desc">Please restart the app.</string>
<string name="app_check_for_updates_canceled">Update download canceled</string>
<string name="app_check_for_updates_button_remind_later">Remind later</string>
<string name="app_check_for_updates_notice_title">Check for updates</string>
<string name="app_check_for_updates_notice_desc">To be notified about the new releases, turn on periodic check for Stable or Beta versions.</string>
<string name="app_check_for_updates_notice_disable">Disable</string>
<string name="show_dev_options">Show:</string>
<string name="hide_dev_options">Hide:</string>
<string name="show_developer_options">Show developer options</string>