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
@@ -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
@@ -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(
@@ -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>
@@ -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
@@ -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(
@@ -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
@@ -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<GitHubAsset>
)
@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<List<GitHubRelease>>(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<String>()
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<GitHubAsset> {
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
}
@@ -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);
}
@@ -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)
}
}
@@ -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<Float, AnimationVector1D>, scrollJob: MutableState<Job>, 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()
}
}
}
}
}