Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2024-08-31 12:06:19 +01:00
99 changed files with 2345 additions and 689 deletions
@@ -424,6 +424,7 @@ fun PreviewIncomingCallLockScreenAlert() {
) {
IncomingCallLockScreenAlertLayout(
invitation = RcvCallInvitation(
callUUID = "",
remoteHostId = null,
user = User.sampleData,
contact = Contact.sampleData,
@@ -770,6 +770,7 @@ fun PreviewActiveCallOverlayVideo() {
callState = CallState.Negotiated,
localMedia = CallMediaType.Video,
peerMedia = CallMediaType.Video,
callUUID = "",
connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
RTCIceCandidate(RTCIceCandidateType.Host, "tcp")
@@ -799,6 +800,7 @@ fun PreviewActiveCallOverlayAudio() {
callState = CallState.Negotiated,
localMedia = CallMediaType.Audio,
peerMedia = CallMediaType.Audio,
callUUID = "",
connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
RTCIceCandidate(RTCIceCandidateType.Host, "udp")
@@ -14,6 +14,7 @@ actual fun authenticate(
promptSubtitle: String,
selfDestruct: Boolean,
usingLAMode: LAMode,
oneTime: Boolean,
completed: (LAResult) -> Unit
) {
val activity = mainActivity.get() ?: return completed(LAResult.Error(""))
@@ -27,7 +28,7 @@ actual fun authenticate(
else -> completed(LAResult.Unavailable())
}
LAMode.PASSCODE -> {
authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed)
authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, oneTime, completed)
}
}
}
@@ -14,13 +14,11 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -33,7 +31,6 @@ import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.saveAppLocale
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.compose.painterResource
@@ -82,7 +79,7 @@ fun AppearanceScope.AppearanceLayout(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.appearance_settings))
SectionView(stringResource(MR.strings.settings_section_title_interface), padding = PaddingValues()) {
SectionView(stringResource(MR.strings.settings_section_title_interface), contentPadding = PaddingValues()) {
val context = LocalContext.current
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// SectionItemWithValue(
@@ -123,7 +120,7 @@ fun AppearanceScope.AppearanceLayout(
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
SectionView(stringResource(MR.strings.settings_section_title_icon), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
LazyRow {
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
val item = AppIcon.values()[index]
@@ -74,6 +74,7 @@ fun MainScreen() {
LaunchedEffect(showAdvertiseLAAlert) {
if (
!chatModel.controller.appPrefs.laNoticeShown.get()
&& !appPrefs.performLA.get()
&& showAdvertiseLAAlert
&& chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete
&& chatModel.chats.size > 3
@@ -211,10 +212,8 @@ fun MainScreen() {
} else {
ActiveCallView()
}
} else {
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
ModalManager.fullscreen.showPasscodeInView()
}
ModalManager.fullscreen.showOneTimePasscodeInView()
AlertManager.privacySensitive.showInView()
if (onboarding == OnboardingStage.OnboardingComplete) {
LaunchedEffect(chatModel.currentUser.value, chatModel.appOpenUrl.value) {
@@ -5,6 +5,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.localauth.SetAppPasscodeView
@@ -31,7 +32,7 @@ object AppLock {
fun showLANotice(laNoticeShown: SharedPreference<Boolean>) {
Log.d(TAG, "showLANotice")
if (!laNoticeShown.get()) {
if (!laNoticeShown.get() && !appPrefs.performLA.get()) {
laNoticeShown.set(true)
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.la_notice_title_simplex_lock),
@@ -57,6 +58,8 @@ object AppLock {
private fun showChooseLAMode() {
Log.d(TAG, "showLANotice")
if (appPrefs.performLA.get()) return
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.la_lock_mode),
text = null,
@@ -80,21 +83,23 @@ object AppLock {
authenticate(
generalGetString(MR.strings.auth_enable_simplex_lock),
generalGetString(MR.strings.auth_confirm_credential),
oneTime = true,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
m.performLA.value = true
m.showAuthScreen.value = true
appPrefs.performLA.set(true)
laTurnedOnAlert()
}
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
is LAResult.Error -> {
m.performLA.value = false
appPrefs.performLA.set(false)
m.showAuthScreen.value = false
// Don't drop auth pref in case of state inconsistency (eg, you have set passcode but somehow bypassed toggle and turned it off and then on)
// appPrefs.performLA.set(false)
laFailedAlert()
}
is LAResult.Unavailable -> {
m.performLA.value = false
m.showAuthScreen.value = false
appPrefs.performLA.set(false)
m.showAdvertiseLAUnavailableAlert.value = true
}
@@ -104,19 +109,22 @@ object AppLock {
}
private fun setPasscode() {
if (appPrefs.performLA.get()) return
val appPrefs = ChatController.appPrefs
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
SetAppPasscodeView(
submit = {
ChatModel.performLA.value = true
ChatModel.showAuthScreen.value = true
appPrefs.performLA.set(true)
appPrefs.laMode.set(LAMode.PASSCODE)
laTurnedOnAlert()
},
cancel = {
ChatModel.performLA.value = false
appPrefs.performLA.set(false)
ChatModel.showAuthScreen.value = false
// Don't drop auth pref in case of state inconsistency (eg, you have set passcode but somehow bypassed toggle and turned it off and then on)
// appPrefs.performLA.set(false)
laPasscodeNotSetAlert()
},
close = close
@@ -147,6 +155,7 @@ object AppLock {
else
generalGetString(MR.strings.auth_unlock),
selfDestruct = true,
oneTime = false,
completed = { laResult ->
when (laResult) {
LAResult.Success ->
@@ -160,7 +169,7 @@ object AppLock {
}
is LAResult.Unavailable -> {
userAuthorized.value = true
m.performLA.value = false
m.showAuthScreen.value = false
m.controller.appPrefs.performLA.set(false)
laUnavailableTurningOffAlert()
}
@@ -192,22 +201,23 @@ object AppLock {
generalGetString(MR.strings.auth_confirm_credential)
else
"",
oneTime = true,
completed = { laResult ->
val prefPerformLA = m.controller.appPrefs.performLA
when (laResult) {
LAResult.Success -> {
m.performLA.value = true
m.showAuthScreen.value = true
prefPerformLA.set(true)
laTurnedOnAlert()
}
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
is LAResult.Error -> {
m.performLA.value = false
m.showAuthScreen.value = false
prefPerformLA.set(false)
laFailedAlert()
}
is LAResult.Unavailable -> {
m.performLA.value = false
m.showAuthScreen.value = false
prefPerformLA.set(false)
laUnavailableInstructionAlert()
}
@@ -227,12 +237,13 @@ object AppLock {
generalGetString(MR.strings.auth_confirm_credential)
else
generalGetString(MR.strings.auth_disable_simplex_lock),
oneTime = true,
completed = { laResult ->
val prefPerformLA = m.controller.appPrefs.performLA
val selfDestructPref = m.controller.appPrefs.selfDestruct
when (laResult) {
LAResult.Success -> {
m.performLA.value = false
m.showAuthScreen.value = false
prefPerformLA.set(false)
DatabaseUtils.ksAppPassword.remove()
selfDestructPref.set(false)
@@ -240,12 +251,12 @@ object AppLock {
}
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
is LAResult.Error -> {
m.performLA.value = true
m.showAuthScreen.value = true
prefPerformLA.set(true)
laFailedAlert()
}
is LAResult.Unavailable -> {
m.performLA.value = false
m.showAuthScreen.value = false
prefPerformLA.set(false)
laUnavailableTurningOffAlert()
}
@@ -20,7 +20,6 @@ import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.internal.ChannelFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.*
@@ -98,7 +97,7 @@ object ChatModel {
}
)
}
val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
val showAuthScreen by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
val showChatPreviews by lazy { mutableStateOf(ChatController.appPrefs.privacyShowChatPreviews.get()) }
@@ -784,7 +783,8 @@ object ChatModel {
data class ShowingInvitation(
val connId: String,
val connReq: String,
val connChatUsed: Boolean
val connChatUsed: Boolean,
val conn: PendingContactConnection
)
enum class ChatType(val type: String) {
@@ -985,6 +985,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val fullName get() = contact.fullName
override val image get() = contact.image
override val localAlias: String get() = contact.localAlias
override fun anyNameContains(searchAnyCase: String): Boolean = contact.anyNameContains(searchAnyCase)
companion object {
val sampleData = Direct(Contact.sampleData)
@@ -1219,6 +1220,12 @@ data class Contact(
override val localAlias get() = profile.localAlias
val verified get() = activeConn?.connectionCode != null
override fun anyNameContains(searchAnyCase: String): Boolean {
val s = searchAnyCase.trim().lowercase()
return profile.chatViewName.lowercase().contains(s) || profile.displayName.lowercase().contains(s) || profile.fullName.lowercase().contains(s)
}
val directOrUsed: Boolean get() =
if (activeConn != null) {
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
@@ -1132,9 +1132,30 @@ object ChatController {
suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? {
val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito))
if (r is CR.ConnectionIncognitoUpdated) return r.toConnection
Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}")
return null
return when (r) {
is CR.ConnectionIncognitoUpdated -> r.toConnection
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r)
}
null
}
}
}
suspend fun apiChangeConnectionUser(rh: Long?, connId: Long, userId: Long): PendingContactConnection? {
val r = sendCmd(rh, CC.ApiChangeConnectionUser(connId, userId))
return when (r) {
is CR.ConnectionUserChanged -> r.toConnection
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r)
}
null
}
}
}
suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? {
@@ -2916,6 +2937,7 @@ sealed class CC {
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
@@ -3071,6 +3093,7 @@ sealed class CC {
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}"
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
is ApiChangeConnectionUser -> "/_set conn user :$connId $userId"
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
@@ -3213,6 +3236,7 @@ sealed class CC {
is APIVerifyGroupMember -> "apiVerifyGroupMember"
is APIAddContact -> "apiAddContact"
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
is ApiChangeConnectionUser -> "apiChangeConnectionUser"
is APIConnectPlan -> "apiConnectPlan"
is APIConnect -> "apiConnect"
is ApiConnectContactViaAddress -> "apiConnectContactViaAddress"
@@ -4757,6 +4781,7 @@ sealed class CR {
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR()
@@ -4935,6 +4960,7 @@ sealed class CR {
is ConnectionVerified -> "connectionVerified"
is Invitation -> "invitation"
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
is ConnectionUserChanged -> "ConnectionUserChanged"
is CRConnectionPlan -> "connectionPlan"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
@@ -5103,6 +5129,7 @@ sealed class CR {
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" )
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
is SentConfirmation -> withUser(user, json.encodeToString(connection))
is SentInvitation -> withUser(user, json.encodeToString(connection))
@@ -5553,6 +5580,7 @@ sealed class ChatErrorType {
is AgentCommandError -> "agentCommandError"
is InvalidFileDescription -> "invalidFileDescription"
is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited"
is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited"
is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible"
is InternalError -> "internalError"
is CEException -> "exception $message"
@@ -5630,6 +5658,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType()
@Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType()
@Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType()
@Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType()
@Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType()
@Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType()
@Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType()
@@ -47,6 +47,7 @@ class CallManager(val chatModel: ChatModel) {
remoteHostId = invitation.remoteHostId,
userProfile = userProfile,
contact = invitation.contact,
callUUID = invitation.callUUID,
callState = CallState.InvitationAccepted,
localMedia = invitation.callType.media,
sharedKey = invitation.sharedKey,
@@ -115,6 +115,7 @@ fun PreviewIncomingCallAlertLayout() {
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callUUID = "",
callTs = Clock.System.now()
),
chatModel = ChatModel,
@@ -13,6 +13,7 @@ data class Call(
val remoteHostId: Long?,
val userProfile: Profile,
val contact: Contact,
val callUUID: String?,
val callState: CallState,
val localMedia: CallMediaType,
val localCapabilities: CallCapabilities? = null,
@@ -105,6 +106,7 @@ sealed class WCallResponse {
val contact: Contact,
val callType: CallType,
val sharedKey: String? = null,
val callUUID: String,
val callTs: Instant
) {
val callTypeText: String get() = generalGetString(when(callType.media) {
@@ -282,14 +282,14 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val versions = ciInfo.itemVersions
if (versions.isNotEmpty()) {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.edit_history), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
versions.forEachIndexed { i, ciVersion ->
ItemVersionView(ciVersion, current = i == 0)
}
}
} else {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(MR.strings.no_history), color = MaterialTheme.colors.secondary)
}
@@ -304,7 +304,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.in_reply_to), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
QuotedMsgView(qi)
}
@@ -381,14 +381,14 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val mss = membersStatuses(chatModel, memberDeliveryStatuses)
if (mss.isNotEmpty()) {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.delivery), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
mss.forEach { (member, status, sentViaProxy) ->
MemberDeliveryStatusView(member, status, sentViaProxy)
}
}
} else {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(MR.strings.no_info_on_delivery), color = MaterialTheme.colors.secondary)
}
@@ -86,6 +86,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
.collect { chatId ->
markUnreadChatAsRead(chatId)
showSearch.value = false
searchText.value = ""
selectedChatItems.value = null
}
}
@@ -544,7 +545,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType)
if (chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(remoteHostId, chatInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callUUID = null, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
@@ -509,11 +509,11 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -
verticalAlignment = Alignment.CenterVertically
) {
Row(
Modifier.weight(1f).padding(end = DEFAULT_PADDING),
Modifier.weight(1f).padding(top = 8.dp, end = DEFAULT_PADDING, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
MemberProfileImage(size = DEFAULT_MIN_SECTION_ITEM_HEIGHT, member)
MemberProfileImage(size = 42.dp, member)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -80,7 +80,7 @@ private fun ShareListNavLinkLayout(
click: () -> Unit,
stopped: Boolean,
) {
SectionItemView(minHeight = 50.dp, click = click, disabled = stopped) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 8.dp), click = click, disabled = stopped) {
chatLinkPreview()
}
Divider(Modifier.padding(horizontal = 8.dp))
@@ -98,9 +98,11 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) {
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (chat.chatInfo is ChatInfo.Local) {
ProfileImage(size = 46.dp, null, icon = MR.images.ic_folder_filled, color = NoteFolderIconColor)
ProfileImage(size = 42.dp, null, icon = MR.images.ic_folder_filled, color = NoteFolderIconColor)
} else if (chat.chatInfo is ChatInfo.Group) {
ProfileImage(size = 42.dp, chat.chatInfo.image, icon = MR.images.ic_supervised_user_circle_filled)
} else {
ProfileImage(size = 46.dp, chat.chatInfo.image)
ProfileImage(size = 42.dp, chat.chatInfo.image)
}
Text(
chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
@@ -94,10 +94,17 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
})
UserPicker(
chatModel,
userPickerState,
showSettings = false,
showCancel = true,
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart,
cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
}
)
}
}
}
@@ -5,9 +5,7 @@ import SectionSpacer
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -66,7 +64,7 @@ fun DatabaseErrorView(
Modifier.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
style = MaterialTheme.typography.h1
)
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), content)
SectionView(null, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), content = content)
}
@Composable
@@ -423,6 +423,7 @@ fun authStopChat(m: ChatModel, progressIndicator: MutableState<Boolean>? = null,
authenticate(
generalGetString(MR.strings.auth_stop_chat),
generalGetString(MR.strings.auth_log_in_using_credential),
oneTime = true,
completed = { laResult ->
when (laResult) {
LAResult.Success, is LAResult.Unavailable -> {
@@ -85,7 +85,8 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co
Text(
title.value,
fontWeight = FontWeight.SemiBold,
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} else {
@@ -34,6 +34,7 @@ expect fun authenticate(
promptSubtitle: String,
selfDestruct: Boolean = false,
usingLAMode: LAMode = ChatModel.controller.appPrefs.laMode.get(),
oneTime: Boolean,
completed: (LAResult) -> Unit
)
@@ -41,10 +42,11 @@ fun authenticateWithPasscode(
promptTitle: String,
promptSubtitle: String,
selfDestruct: Boolean,
oneTime: Boolean,
completed: (LAResult) -> Unit
) {
val password = DatabaseUtils.ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(MR.strings.la_no_app_password)))
ModalManager.fullscreen.showPasscodeCustomModal { close ->
ModalManager.fullscreen.showPasscodeCustomModal(oneTime) { close ->
BackHandler {
close()
completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled)))
@@ -69,6 +69,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// Don't use mutableStateOf() here, because it produces this if showing from SimpleXAPI.startChat():
// java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
val data = ModalData()
@@ -105,9 +106,13 @@ class ModalManager(private val placement: ModalPlacement? = null) {
}
}
fun showPasscodeCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showPasscodeCustomModal")
passcodeView.value = modal
fun showPasscodeCustomModal(oneTime: Boolean, modal: @Composable (close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showPasscodeCustomModal, oneTime: $oneTime")
if (oneTime) {
onTimePasscodeView.value = modal
} else {
passcodeView.value = modal
}
}
fun hasModalsOpen() = modalCount.value > 0
@@ -179,6 +184,11 @@ class ModalManager(private val placement: ModalPlacement? = null) {
passcodeView.collectAsState().value?.invoke { passcodeView.value = null }
}
@Composable
fun showOneTimePasscodeInView() {
onTimePasscodeView.collectAsState().value?.invoke { onTimePasscodeView.value = null }
}
/**
* Allows to modify a list without getting [ConcurrentModificationException]
* */
@@ -21,15 +21,15 @@ import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
import chat.simplex.res.MR
@Composable
fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(), content: (@Composable ColumnScope.() -> Unit)) {
fun SectionView(title: String? = null, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, content: (@Composable ColumnScope.() -> Unit)) {
Column {
if (title != null) {
Text(
title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = DEFAULT_PADDING), fontSize = 12.sp
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = headerBottomPadding), fontSize = 12.sp
)
}
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() }
}
}
@@ -39,7 +39,7 @@ fun ContactConnectionInfoView(
) {
LaunchedEffect(connReqInvitation) {
if (connReqInvitation != null) {
chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false)
chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false, conn = contactConnection)
}
}
/** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation].
@@ -1,7 +1,9 @@
package chat.simplex.common.views.newchat
import SectionDivider
import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionView
import TextIconSpaced
import androidx.compose.desktop.ui.tooling.preview.Preview
@@ -164,6 +166,10 @@ private fun NewChatSheetLayout(
)
val sectionModifier = Modifier.fillMaxWidth()
val deletedContactTypes = listOf(ContactType.CHAT_DELETED)
val deletedChats by remember(chatModel.chats.value, deletedContactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, deletedContactTypes) }
}
LazyColumnWithScrollBar(
Modifier.fillMaxSize(),
@@ -224,7 +230,9 @@ private fun NewChatSheetLayout(
}
}
item {
Spacer(Modifier.padding(bottom = 27.dp))
if (searchText.value.text.isEmpty()) {
Spacer(Modifier.padding(bottom = 27.dp))
}
val actionButtonsOriginal = listOf(
Triple(
@@ -262,37 +270,30 @@ private fun NewChatSheetLayout(
}
}
}
val deletedContactTypes = listOf(ContactType.CHAT_DELETED)
val deletedChats by remember(chatModel.chats.value, deletedContactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, deletedContactTypes) }
}
if (deletedChats.isNotEmpty()) {
SectionDividerSpaced(maxBottomPadding = false)
Row(modifier = sectionModifier) {
SectionView {
SectionItemView(
click = {
ModalManager.start.showCustomModal { closeDeletedChats ->
ModalView(
close = closeDeletedChats,
closeOnTop = !oneHandUI.value,
) {
DeletedContactsView(rh = rh, closeDeletedChats = closeDeletedChats, close = {
ModalManager.start.closeModals()
})
}
SectionView {
SectionItemView(
click = {
ModalManager.start.showCustomModal { closeDeletedChats ->
ModalView(
close = closeDeletedChats,
closeOnTop = !oneHandUI.value,
) {
DeletedContactsView(rh = rh, closeDeletedChats = closeDeletedChats, close = {
ModalManager.start.closeModals()
})
}
}
) {
Icon(
painterResource(MR.images.ic_inventory_2),
contentDescription = stringResource(MR.strings.deleted_chats),
tint = MaterialTheme.colors.secondary,
)
TextIconSpaced(false)
Text(text = stringResource(MR.strings.deleted_chats), color = MaterialTheme.colors.onBackground)
}
) {
Icon(
painterResource(MR.images.ic_inventory_2),
contentDescription = stringResource(MR.strings.deleted_chats),
tint = MaterialTheme.colors.secondary,
)
TextIconSpaced(false)
Text(text = stringResource(MR.strings.deleted_chats), color = MaterialTheme.colors.onBackground)
}
}
}
@@ -300,16 +301,28 @@ private fun NewChatSheetLayout(
}
item {
if (filteredContactChats.isNotEmpty() && !oneHandUI.value) {
if (searchText.value.text.isNotEmpty()) {
Spacer(Modifier.height(DEFAULT_PADDING))
} else {
if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) {
if (!oneHandUI.value) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {}
} else {
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {}
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
}
}
}
item {
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(sectionModifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
}
}
Text(
stringResource(MR.strings.contact_list_header_title).uppercase(), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2,
modifier = sectionModifier.padding(start = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF), fontSize = 12.sp
)
}
}
@@ -322,17 +335,6 @@ private fun NewChatSheetLayout(
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = true)
}
}
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(sectionModifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
}
}
}
}
@Composable
@@ -555,78 +557,75 @@ private fun DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats: () -> Un
}
}
}
) {
Column(
Modifier
.fillMaxSize()
.padding(it)
) { contentPadding ->
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { appPrefs.showUnreadAndFavorites.state }.value
val allChats by remember(chatModel.chats.value) {
derivedStateOf { filterContactTypes(chatModel.chats.value, listOf(ContactType.CHAT_DELETED)) }
}
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
searchShowingSimplexLink = searchShowingSimplexLink,
searchText = searchText.value.text,
contactChats = allChats
)
LazyColumnWithScrollBar(
Modifier.fillMaxSize(),
contentPadding = contentPadding,
reverseLayout = oneHandUI.value,
) {
if (!oneHandUI.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.deleted_chats),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { appPrefs.showUnreadAndFavorites.state }.value
val allChats by remember(chatModel.chats.value) {
derivedStateOf { filterContactTypes(chatModel.chats.value, listOf(ContactType.CHAT_DELETED)) }
}
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
searchShowingSimplexLink = searchShowingSimplexLink,
searchText = searchText.value.text,
contactChats = allChats
)
LazyColumnWithScrollBar(
Modifier.fillMaxSize(),
reverseLayout = oneHandUI.value,
) {
item {
if (!oneHandUI.value) {
Divider()
}
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
Divider()
Spacer(Modifier.padding(bottom = DEFAULT_PADDING))
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = false)
}
}
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary,
item {
if (!oneHandUI.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.deleted_chats),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
}
item {
if (!oneHandUI.value) {
Divider()
}
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
Divider()
}
item {
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary,
)
}
}
}
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = false)
}
}
}
}
@@ -2,20 +2,25 @@ package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import TextIconSpaced
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle
@@ -32,6 +37,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URI
@@ -43,7 +49,7 @@ enum class NewChatOption {
fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) {
val selection = remember { stateGetOrPut("selection") { selection } }
val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } }
val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) }
val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) }
val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } }
val creatingConnReq = rememberSaveable { mutableStateOf(false) }
val pastedLink = rememberSaveable { mutableStateOf("") }
@@ -177,6 +183,15 @@ private fun CreatingLinkProgressView() {
DefaultProgressView(stringResource(MR.strings.creating_link))
}
private fun updateShownConnection(conn: PendingContactConnection) {
chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy(
conn = conn,
connId = conn.id,
connReq = conn.connReqInv ?: "",
connChatUsed = true
)
}
@Composable
private fun RetryButton(onClick: () -> Unit) {
Column(
@@ -192,35 +207,326 @@ private fun RetryButton(onClick: () -> Unit) {
}
}
@Composable
private fun ProfilePickerOption(
title: String,
selected: Boolean,
disabled: Boolean,
onSelected: () -> Unit,
image: @Composable () -> Unit,
onInfo: (() -> Unit)? = null
) {
Row(
Modifier
.fillMaxWidth()
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp)
.clickable(enabled = !disabled, onClick = onSelected)
.padding(horizontal = DEFAULT_PADDING, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
image()
TextIconSpaced(false)
Text(title, modifier = Modifier.align(Alignment.CenterVertically))
if (onInfo != null) {
Spacer(Modifier.padding(6.dp))
Column(Modifier
.size(48.dp)
.clip(CircleShape)
.clickable(
enabled = !disabled,
onClick = { ModalManager.start.showModal { IncognitoView() } }
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painterResource(MR.images.ic_info),
stringResource(MR.strings.incognito),
tint = MaterialTheme.colors.primary
)
}
}
Spacer(Modifier.weight(1f))
if (selected) {
Icon(
painterResource(
MR.images.ic_check
),
title,
Modifier.size(20.dp),
tint = MaterialTheme.colors.primary,
)
}
}
Divider(
Modifier.padding(
start = DEFAULT_PADDING_HALF,
end = DEFAULT_PADDING_HALF,
)
)
}
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return users.filter { u ->
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
true
} else {
correctPassword(u, s)
}
}
}
@Composable
private fun ActiveProfilePicker(
search: MutableState<String>,
contactConnection: PendingContactConnection?,
close: () -> Unit,
rhId: Long?
) {
val switchingProfile = remember { mutableStateOf(false) }
val incognito = remember {
chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get()
}
val selectedProfile by remember { chatModel.currentUser }
val searchTextOrPassword = rememberSaveable { search }
val profiles = remember {
chatModel.users.map { it.user }.sortedBy { !it.activeUser }
}
val filteredProfiles by remember {
derivedStateOf { filteredProfiles(profiles, searchTextOrPassword.value) }
}
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(switchingProfile.value) {
progressByTimeout = if (switchingProfile.value) {
delay(500)
switchingProfile.value
} else {
false
}
}
@Composable
fun ProfilePickerUserOption(user: User) {
val selected = selectedProfile?.userId == user.userId && !incognito
ProfilePickerOption(
title = user.chatViewName,
disabled = switchingProfile.value || selected,
selected = selected,
onSelected = {
switchingProfile.value = true
withApi {
try {
if (contactConnection != null) {
val conn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
if (conn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
}
controller.changeActiveUser_(
rhId = user.remoteHostId,
toUserId = user.userId,
viewPwd = if (user.hidden) searchTextOrPassword.value else null
)
if (chatModel.currentUser.value?.userId != user.userId) {
AlertManager.shared.showAlertMsg(generalGetString(
MR.strings.switching_profile_error_title),
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
)
}
withChats {
updateContactConnection(user.remoteHostId, conn)
}
close.invoke()
}
}
} finally {
switchingProfile.value = false
}
}
},
image = { ProfileImage(size = 42.dp, image = user.image) }
)
}
@Composable
fun IncognitoUserOption() {
ProfilePickerOption(
disabled = switchingProfile.value,
title = stringResource(MR.strings.incognito),
selected = incognito,
onSelected = {
if (!incognito) {
switchingProfile.value = true
withApi {
try {
if (contactConnection != null) {
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
if (conn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
}
close.invoke()
}
}
} finally {
switchingProfile.value = false
}
}
}
},
image = {
Spacer(Modifier.width(8.dp))
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
contentDescription = stringResource(MR.strings.incognito),
Modifier.size(32.dp),
tint = Indigo,
)
Spacer(Modifier.width(2.dp))
},
onInfo = { ModalManager.start.showModal { IncognitoView() } },
)
}
BoxWithConstraints {
Column(
Modifier
.fillMaxSize()
.alpha(if (progressByTimeout) 0.6f else 1f)
) {
LazyColumnWithScrollBar(userScrollEnabled = !switchingProfile.value) {
item {
AppBarTitle(stringResource(MR.strings.select_chat_profile), hostDevice(rhId), bottomPadding = DEFAULT_PADDING)
}
val activeProfile = filteredProfiles.firstOrNull { it.activeUser }
if (activeProfile != null) {
val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId }
if (incognito) {
item {
IncognitoUserOption()
}
item {
ProfilePickerUserOption(activeProfile)
}
} else {
item {
ProfilePickerUserOption(activeProfile)
}
item {
IncognitoUserOption()
}
}
itemsIndexed(otherProfiles) { _, p ->
ProfilePickerUserOption(p)
}
} else {
item {
IncognitoUserOption()
}
itemsIndexed(filteredProfiles) { _, p ->
ProfilePickerUserOption(p)
}
}
}
}
if (progressByTimeout) {
DefaultProgressView("")
}
}
}
@Composable
private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState<PendingContactConnection?>) {
SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase()) {
SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) {
LinkTextView(connReqInvitation, true)
}
Spacer(Modifier.height(10.dp))
SectionView(stringResource(MR.strings.or_show_this_qr_code).uppercase()) {
SectionView(stringResource(MR.strings.or_show_this_qr_code).uppercase(), headerBottomPadding = 5.dp) {
SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() })
}
Spacer(Modifier.height(10.dp))
val incognito = remember { mutableStateOf(controller.appPrefs.incognito.get()) }
IncognitoToggle(controller.appPrefs.incognito, incognito) {
ModalManager.start.showModal { IncognitoView() }
Spacer(Modifier.height(DEFAULT_PADDING))
val incognito by remember(chatModel.showingInvitation.value?.conn?.incognito, controller.appPrefs.incognito.get()) {
derivedStateOf {
chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get()
}
}
KeyChangeEffect(incognito.value) {
withBGApi {
val contactConn = contactConnection.value ?: return@withBGApi
val conn = controller.apiSetConnectionIncognito(rhId, contactConn.pccConnId, incognito.value) ?: return@withBGApi
withChats {
contactConnection.value = conn
updateContactConnection(rhId, conn)
val currentUser = remember { chatModel.currentUser }.value
if (currentUser != null) {
SectionView(stringResource(MR.strings.new_chat_share_profile).uppercase(), headerBottomPadding = 5.dp) {
SectionItemView(
padding = PaddingValues(
top = 0.dp,
bottom = 0.dp,
start = 16.dp,
end = 16.dp
),
click = {
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = {
ActiveProfilePicker(
search = search,
close = close,
rhId = rhId,
contactConnection = contactConnection.value
)
})
}
}
) {
if (incognito) {
Spacer(Modifier.width(8.dp))
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
contentDescription = stringResource(MR.strings.incognito),
tint = Indigo,
modifier = Modifier.size(32.dp)
)
Spacer(Modifier.width(2.dp))
} else {
ProfileImage(size = 42.dp, image = currentUser.image)
}
TextIconSpaced(false)
Text(
text = if (incognito) stringResource(MR.strings.incognito) else currentUser.chatViewName,
color = MaterialTheme.colors.onBackground
)
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) {
Icon(
painter = painterResource(MR.images.ic_arrow_forward_ios),
contentDescription = stringResource(MR.strings.new_chat_share_profile),
tint = MaterialTheme.colors.secondary,
)
}
}
}
chatModel.markShowingInvitationUsed()
if (incognito) {
SectionTextFooter(generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared))
}
}
SectionTextFooter(sharedProfileInfo(chatModel, incognito.value))
}
@Composable
@@ -242,14 +548,14 @@ fun AddContactLearnMoreButton() {
@Composable
private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState<Boolean>, pastedLink: MutableState<String>, close: () -> Unit) {
SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase()) {
SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase(), headerBottomPadding = 5.dp) {
PasteLinkView(rhId, pastedLink, showQRCodeScanner, close)
}
if (appPlatform.isAndroid) {
Spacer(Modifier.height(10.dp))
SectionView(stringResource(MR.strings.or_scan_qr_code).uppercase()) {
SectionView(stringResource(MR.strings.or_scan_qr_code).uppercase(), headerBottomPadding = 5.dp) {
QRCodeScanner(showQRCodeScanner) { text ->
withBGApi {
val res = verify(rhId, text, close)
@@ -366,7 +672,7 @@ private fun createInvitation(
if (r != null) {
withChats {
updateContactConnection(rhId, r.second)
chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false)
chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false, conn = r.second)
contactConnection.value = r.second
}
} else {
@@ -8,9 +8,7 @@ import SectionSpacer
import SectionView
import TextIconSpaced
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@@ -149,7 +147,7 @@ private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList<Re
@Composable
private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) {
AppBarTitle(stringResource(MR.strings.connecting_to_desktop))
SectionView(stringResource(MR.strings.connecting_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.connecting_to_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
@@ -199,7 +197,7 @@ private fun SearchingDesktop(deviceName: String, remoteCtrls: SnapshotStateList<
}
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.found_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.waiting_for_desktop), fontStyle = FontStyle.Italic)
}
SectionSpacer()
@@ -226,7 +224,7 @@ private fun FoundDesktop(
}
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.found_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
CtrlDeviceVersionText(session)
if (!compatible) {
@@ -258,7 +256,7 @@ private fun FoundDesktop(
@Composable
private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList<RemoteCtrlInfo>) {
AppBarTitle(stringResource(MR.strings.verify_connection))
SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
@@ -313,7 +311,7 @@ private fun CtrlDeviceVersionText(session: RemoteCtrlSession) {
@Composable
private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: () -> Unit) {
AppBarTitle(stringResource(MR.strings.connected_to_desktop))
SectionView(stringResource(MR.strings.connected_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.connected_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(rc.deviceViewName)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
@@ -53,7 +53,7 @@ expect fun AppearanceView(m: ChatModel)
object AppearanceScope {
@Composable
fun ProfileImageSection() {
SectionView(stringResource(MR.strings.settings_section_title_profile_images).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.settings_section_title_profile_images).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
val image = remember { chatModel.currentUser }.value?.image
Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
val size = 60
@@ -86,7 +86,7 @@ object AppearanceScope {
@Composable
fun FontScaleSection() {
val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) }
SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(60.dp)
.background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22))
@@ -1,15 +1,12 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionItemView
import SectionItemViewSpaceBetween
import SectionItemViewWithoutMinPadding
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@@ -64,7 +61,7 @@ private fun HiddenProfileLayout(
.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.hide_profile))
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
UserProfileRow(user)
}
SectionSpacer()
@@ -22,7 +22,6 @@ import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
@@ -264,7 +263,7 @@ fun SocksProxySettings(
.fillMaxWidth()
) {
AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings))
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
DefaultConfigurableTextField(
hostUnsaved,
stringResource(MR.strings.host_verb),
@@ -1,12 +1,10 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -34,8 +32,6 @@ import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import kotlin.math.min
import kotlin.math.roundToInt
enum class LAMode {
SYSTEM,
@@ -374,7 +370,8 @@ fun SimplexLockView(
currentLAMode: SharedPreference<LAMode>,
setPerformLA: (Boolean) -> Unit
) {
val performLA = remember { chatModel.performLA }
val showAuthScreen = remember { chatModel.showAuthScreen }
val performLA = remember { appPrefs.performLA.state }
val laMode = remember { chatModel.controller.appPrefs.laMode.state }
val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay }
val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } }
@@ -382,13 +379,9 @@ fun SimplexLockView(
val selfDestructDisplayName = remember { mutableStateOf(chatModel.controller.appPrefs.selfDestructDisplayName.get() ?: "") }
val selfDestructDisplayNamePref = remember { chatModel.controller.appPrefs.selfDestructDisplayName }
fun resetLAEnabled(onOff: Boolean) {
chatModel.controller.appPrefs.performLA.set(onOff)
chatModel.performLA.value = onOff
}
fun disableUnavailableLA() {
resetLAEnabled(false)
chatModel.controller.appPrefs.performLA.set(false)
chatModel.showAuthScreen.value = false
currentLAMode.set(LAMode.default)
laUnavailableInstructionAlert()
}
@@ -405,7 +398,8 @@ fun SimplexLockView(
} else {
generalGetString(MR.strings.chat_lock)
},
generalGetString(MR.strings.change_lock_mode)
generalGetString(MR.strings.change_lock_mode),
oneTime = true,
) { laResult ->
when (laResult) {
is LAResult.Error -> {
@@ -415,7 +409,7 @@ fun SimplexLockView(
LAResult.Success -> {
when (toLAMode) {
LAMode.SYSTEM -> {
authenticate(generalGetString(MR.strings.auth_enable_simplex_lock), promptSubtitle = "", usingLAMode = toLAMode) { laResult ->
authenticate(generalGetString(MR.strings.auth_enable_simplex_lock), promptSubtitle = "", usingLAMode = toLAMode, oneTime = true) { laResult ->
when (laResult) {
LAResult.Success -> {
currentLAMode.set(toLAMode)
@@ -451,7 +445,7 @@ fun SimplexLockView(
}
fun toggleSelfDestruct(selfDestruct: SharedPreference<Boolean>) {
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_mode)) { laResult ->
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_mode), oneTime = true) { laResult ->
when (laResult) {
is LAResult.Error -> laFailedAlert()
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
@@ -470,7 +464,7 @@ fun SimplexLockView(
}
fun changeLAPassword() {
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode)) { laResult ->
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode), oneTime = true) { laResult ->
when (laResult) {
LAResult.Success -> {
ModalManager.fullscreen.showCustomModal { close ->
@@ -494,7 +488,7 @@ fun SimplexLockView(
}
fun changeSelfDestructPassword() {
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode)) { laResult ->
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode), oneTime = true) { laResult ->
when (laResult) {
LAResult.Success -> {
ModalManager.fullscreen.showCustomModal { close ->
@@ -525,8 +519,8 @@ fun SimplexLockView(
) {
AppBarTitle(stringResource(MR.strings.chat_lock))
SectionView {
EnableLock(performLA) { performLAToggle ->
performLA.value = performLAToggle
EnableLock(remember { appPrefs.performLA.state }) { performLAToggle ->
showAuthScreen.value = performLAToggle
chatModel.controller.appPrefs.laNoticeShown.set(true)
if (performLAToggle) {
when (currentLAMode.state.value) {
@@ -543,7 +537,9 @@ fun SimplexLockView(
passcodeAlert(generalGetString(MR.strings.passcode_set))
},
cancel = {
resetLAEnabled(false)
chatModel.showAuthScreen.value = false
// Don't drop auth pref in case of state inconsistency (eg, you have set passcode but somehow bypassed toggle and turned it off and then on)
// chatModel.controller.appPrefs.performLA.set(false)
},
close = close
)
@@ -660,7 +656,7 @@ private fun EnableSelfDestruct(
}
@Composable
private fun EnableLock(performLA: MutableState<Boolean>, onCheckedChange: (Boolean) -> Unit) {
private fun EnableLock(performLA: State<Boolean>, onCheckedChange: (Boolean) -> Unit) {
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
@@ -234,7 +235,7 @@ fun ChatLockItem(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
setPerformLA: (Boolean) -> Unit
) {
val performLA = remember { ChatModel.performLA }
val performLA = remember { appPrefs.performLA.state }
val currentLAMode = remember { ChatModel.controller.appPrefs.laMode }
SettingsActionItemWithContent(
click = showSettingsModal { SimplexLockView(ChatModel, currentLAMode, setPerformLA) },
@@ -505,6 +506,7 @@ private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) ->
authenticate(
title,
desc,
oneTime = true,
completed = { laResult ->
onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable)
}
@@ -8,7 +8,6 @@ import SectionItemViewWithoutMinPadding
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -270,7 +269,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (
@Composable fun ActionHeader(title: StringResource) {
AppBarTitle(stringResource(title))
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
SectionView(contentPadding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
UserProfileRow(user)
}
SectionSpacer()
@@ -304,7 +303,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (
}
}
private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return m.users.filter { u ->
@@ -318,7 +317,7 @@ private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User
private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size
private fun correctPassword(user: User, pwd: String): Boolean {
fun correctPassword(user: User, pwd: String): Boolean {
val ph = user.viewPwdHash
return ph != null && pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
}
@@ -665,6 +665,10 @@
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
<string name="or_show_this_qr_code">Or show this code</string>
<string name="new_chat_share_profile">Share profile</string>
<string name="select_chat_profile">Select chat profile</string>
<string name="switching_profile_error_title">Error switching profile</string>
<string name="switching_profile_error_message">Your connection was moved to %s but an unexpected error occurred while redirecting you to the profile.</string>
<string name="or_scan_qr_code">Or scan QR code</string>
<string name="keep_unused_invitation_question">Keep unused invitation?</string>
<string name="you_can_view_invitation_link_again">You can view invitation link again in connection details.</string>
@@ -14,7 +14,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme
@@ -27,7 +26,6 @@ import kotlinx.coroutines.*
import java.awt.event.WindowEvent
import java.awt.event.WindowFocusListener
import java.io.File
import kotlin.math.sqrt
import kotlin.system.exitProcess
val simplexWindowState = SimplexWindowState()
@@ -172,7 +170,7 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.performLA.value && delay > 0) {
if (!windowFocused && ChatModel.showAuthScreen.value && delay > 0) {
delay(delay * 1000L)
// Trigger auth state check when delay ends (and if it ends)
AppLock.recheckAuthState()
@@ -19,12 +19,76 @@ import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.Closeable
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import kotlin.math.min
data class SemVer(
val major: Int,
val minor: Int,
val patch: Int,
val preRelease: String? = null,
val buildNumber: Int? = null,
): Comparable<SemVer?> {
val isNotStable: Boolean = preRelease != null
override fun compareTo(other: SemVer?): Int {
if (other == null) return 1
return when {
major != other.major -> major.compareTo(other.major)
minor != other.minor -> minor.compareTo(other.minor)
patch != other.patch -> patch.compareTo(other.patch)
preRelease != null && other.preRelease != null -> {
val pr = preRelease.compareTo(other.preRelease, ignoreCase = true)
when {
pr != 0 -> pr
buildNumber != null && other.buildNumber != null -> buildNumber.compareTo(other.buildNumber)
buildNumber != null -> -1
other.buildNumber != null -> 1
else -> 0
}
}
preRelease != null -> -1
other.preRelease != null -> 1
else -> 0
}
}
companion object {
private val regex = Regex("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([A-Za-z]+)\\.(\\d+))?\$")
fun from(tagName: String): SemVer? {
val trimmed = tagName.trimStart { it == 'v' }
val redacted = when {
trimmed.contains('-') && trimmed.substringBefore('-').count { it == '.' } == 1 -> "${trimmed.substringBefore('-')}.0-${trimmed.substringAfter('-')}"
trimmed.substringBefore('-').count { it == '.' } == 1 -> "${trimmed}.0"
else -> trimmed
}
val group = regex.matchEntire(redacted)?.groups
return if (group != null) {
SemVer(
major = group[1]?.value?.toIntOrNull() ?: return null,
minor = group[2]?.value?.toIntOrNull() ?: return null,
patch = group[3]?.value?.toIntOrNull() ?: return null,
preRelease = group[4]?.value,
buildNumber = group[5]?.value?.toIntOrNull(),
)
} else {
null
}
}
fun fromCurrentVersionName(): SemVer? {
val currentVersionName = if (appPlatform.isAndroid) BuildConfigCommon.ANDROID_VERSION_NAME else BuildConfigCommon.DESKTOP_VERSION_NAME
return from(currentVersionName)
}
}
}
@Serializable
data class GitHubRelease(
@@ -34,12 +98,18 @@ data class GitHubRelease(
val htmlUrl: String,
val name: String,
val draft: Boolean,
val prerelease: Boolean,
@SerialName("prerelease")
private val preRelease: Boolean,
val body: String,
@SerialName("published_at")
val publishedAt: String,
val assets: List<GitHubAsset>
)
) {
@Transient
val semVer: SemVer? = SemVer.from(tagName)
val isConsideredBeta: Boolean = preRelease || semVer == null || semVer.isNotStable
}
@Serializable
data class GitHubAsset(
@@ -105,25 +175,25 @@ private fun createUpdateJob() {
fun checkForUpdate() {
Log.d(TAG, "Checking for update")
val currentSemVer = SemVer.fromCurrentVersionName()
if (currentSemVer == null) {
Log.e(TAG, "Current SemVer cannot be parsed")
return
}
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 releases = json.decodeFromString<List<GitHubRelease>>(body)
val release = when (appPrefs.appUpdateChannel.get()) {
AppUpdatesChannel.STABLE -> releases.firstOrNull { !it.prerelease }
AppUpdatesChannel.BETA -> releases.firstOrNull()
AppUpdatesChannel.STABLE -> releases.firstOrNull { r -> !r.draft && !r.isConsideredBeta && currentSemVer < r.semVer }
AppUpdatesChannel.BETA -> releases.firstOrNull { r -> !r.draft && currentSemVer < r.semVer }
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) {
if (release == null || release.tagName == appPrefs.appSkippedUpdate.get()) {
Log.d(TAG, "Skipping update because of the same version or skipped version")
return
}
@@ -298,13 +368,15 @@ private suspend fun downloadAsset(asset: GitHubAsset) {
}
}
private fun isRunningFromAppImage(): Boolean = System.getenv("APPIMAGE") != null
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) {
} else if (!isRunningFromAppImage() && 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 {
@@ -7,10 +7,11 @@ actual fun authenticate(
promptSubtitle: String,
selfDestruct: Boolean,
usingLAMode: LAMode,
oneTime: Boolean,
completed: (LAResult) -> Unit
) {
when (usingLAMode) {
LAMode.PASSCODE -> authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed)
LAMode.PASSCODE -> authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, oneTime, completed)
else -> {}
}
}
@@ -25,7 +25,6 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
import java.util.Locale
import kotlin.math.roundToInt
@Composable
actual fun AppearanceView(m: ChatModel) {
@@ -44,7 +43,7 @@ fun AppearanceScope.AppearanceLayout(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.appearance_settings))
SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) {
SectionView(stringResource(MR.strings.settings_section_title_language), contentPadding = PaddingValues()) {
val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") }
LangSelector(state) {
state.value = it
@@ -79,7 +78,7 @@ fun AppearanceScope.AppearanceLayout(
@Composable
fun DensityScaleSection() {
val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) }
SectionView(stringResource(MR.strings.appearance_zoom).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionView(stringResource(MR.strings.appearance_zoom).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(60.dp)
.background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22))
@@ -0,0 +1,63 @@
package chat.simplex.app
import chat.simplex.common.views.helpers.SemVer
import kotlin.test.Test
import kotlin.test.assertEquals
// use this command for testing:
// ./gradlew desktopTest
class SemVerTest {
@Test
fun testValidSemVer() {
assertEquals(SemVer.from("1.0.0"), SemVer(1, 0, 0))
assertEquals(SemVer.from("1.0"), SemVer(1, 0, 0))
assertEquals(SemVer.from("v1.0"), SemVer(1, 0, 0))
assertEquals(SemVer.from("v1.0-beta.1"), SemVer(1, 0, 0, "beta", 1))
val r = listOf<Pair<String, SemVer>>(
"0.0.4" to SemVer(0, 0, 4),
"1.2.3" to SemVer(1, 2, 3),
"10.20.30" to SemVer(10, 20, 30),
"1.0.0-alpha.1" to SemVer(1, 0, 0, "alpha", buildNumber = 1),
"1.0.0" to SemVer(1, 0, 0),
"2.0.0" to SemVer(2, 0, 0),
"1.1.7" to SemVer(1, 1, 7),
"2.0.1-alpha.1227" to SemVer(2, 0, 1, "alpha", 1227),
)
r.forEach { (value, correct) ->
assertEquals(SemVer.from(value), correct)
}
}
@Test
fun testComparisonSemVer() {
assert(SemVer(0, 1, 0) == SemVer.from("0.1.0"))
assert(SemVer(1, 1, 0) == SemVer.from("v1.1.0"))
assert(SemVer(0, 1, 0) > SemVer(0, 0, 1))
assert(SemVer(1, 0, 0) > SemVer(0, 100, 100))
assert(SemVer(0, 200, 0) > SemVer(0, 100, 100))
assert(SemVer(0, 1, 0, "beta") > SemVer(0, 1, 0, "alpha"))
assert(SemVer(0, 1, 0) > SemVer(0, 1, 0, "alpha"))
assert(SemVer(0, 1, 0) > SemVer(0, 1, 0, "beta"))
assert(SemVer(0, 1, 0) > SemVer(0, 1, 0, "beta.0"))
assert(SemVer(0, 1, 0, "beta", 1) > SemVer(0, 1, 0, "beta", 0))
assert(SemVer(0, 1, 0, "beta", 11) > SemVer(0, 1, 0, "beta", 10))
assert(SemVer(0, 1, 0, "beta", 11) > SemVer(0, 1, 0, "beta", 9))
assert(SemVer(0, 1, 0, "beta.1") > SemVer(0, 1, 0, "alpha.2"))
assert(SemVer(1, 1, 0, "beta.1") > SemVer(0, 1, 0, "beta.1"))
assert(SemVer(1, 0, 0) > SemVer(1, 0, 0, "beta.1"))
assert(SemVer(1, 0, 0) > null)
assert(SemVer.from("v6.0.0")!! > SemVer.from("v6.0.0-beta.3"))
assert(SemVer.from("v6.0.0-beta.3")!! > SemVer.from("v6.0.0-beta.2"))
assert(SemVer.from("0.1.0") == SemVer.from("0.1.0"))
assert(SemVer.from("0.1.1")!! > SemVer.from("0.1.0"))
assert(SemVer.from("0.2.1")!! > SemVer.from("0.1.1"))
assert(SemVer.from("2.0.1")!! > SemVer.from("0.1.1"))
assert(SemVer.from("0.1.1-beta.0")!! > SemVer.from("0.1.0-beta.0"))
assert(SemVer.from("0.1.1-beta.0")!! == SemVer.from("0.1.1-beta.0"))
assert(SemVer.from("0.1.1-beta.1")!! > SemVer.from("0.1.1-beta.0"))
assert(SemVer.from("10.0.0-beta.12")!! > SemVer.from("1.1.1"))
assert(SemVer.from("1.1.1-beta.120")!! > SemVer.from("1.1.1-alpha.9"))
assert(SemVer.from("1.1.1-beta.120")!! > SemVer.from("1.1.1-alpha.120"))
assert(SemVer.from("2.0.1")!! > SemVer.from("0.1.1"))
}
}
+4 -4
View File
@@ -26,11 +26,11 @@ android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.0.3
android.version_code=235
android.version_name=6.0.4
android.version_code=237
desktop.version_name=6.0.3
desktop.version_code=64
desktop.version_name=6.0.4
desktop.version_code=65
kotlin.version=1.9.23
gradle.plugin.version=8.2.0