Merge branch 'master' into master-ghc8107

This commit is contained in:
Evgeny Poberezkin 2023-12-01 17:46:12 +00:00
commit 3b5e806418
33 changed files with 874 additions and 434 deletions

View File

@ -75,7 +75,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) {
SimplexService.showBackgroundServiceNoticeIfNeeded()
}
/**

View File

@ -0,0 +1,15 @@
package chat.simplex.common.views.onboarding
import androidx.compose.runtime.Composable
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.model.User
import chat.simplex.res.MR
@Composable
actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)?) {
if (user == null) {
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick = onclick)
} else {
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick)
}
}

View File

@ -37,8 +37,7 @@ import kotlinx.coroutines.flow.*
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState,
val switchingUsersAndHosts: MutableState<Boolean>
val scaffoldState: ScaffoldState
)
@Composable
@ -102,11 +101,8 @@ fun MainScreen() {
}
Box {
var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) }
LaunchedEffect(Unit) {
snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it }
}
val userCreated = chatModel.userCreated.value
val onboarding by remember { chatModel.controller.appPrefs.onboardingStage.state }
val localUserCreated = chatModel.localUserCreated.value
var showInitializationView by remember { mutableStateOf(false) }
when {
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
@ -115,14 +111,18 @@ fun MainScreen() {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
remember { chatModel.chatDbEncrypted }.value == null || localUserCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete -> {
Box {
showAdvertiseLAAlert = true
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(if (chatModel.desktopNoUserNoRemote()) AnimatedViewState.VISIBLE else AnimatedViewState.GONE)) }
KeyChangeEffect(chatModel.desktopNoUserNoRemote) {
if (chatModel.desktopNoUserNoRemote() && !ModalManager.start.hasModalsOpen()) {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
val scaffoldState = rememberScaffoldState()
val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
if (appPlatform.isAndroid) {
AndroidScreen(settingsState)
} else {
@ -137,12 +137,14 @@ fun MainScreen() {
}
}
onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
onboarding == OnboardingStage.LinkAMobile -> LinkAMobile()
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
if (appPlatform.isAndroid) {
ModalManager.fullscreen.showInView()
SwitchingUsersView()
}
val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } }
@ -262,7 +264,7 @@ fun CenterPartOfScreen() {
.background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
Text(stringResource(MR.strings.no_selected_chat))
Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat))
}
} else {
ModalManager.center.showInView()
@ -286,6 +288,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
@ -298,7 +301,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) {
Box(
@ -312,7 +315,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
@ -335,3 +338,26 @@ fun InitializationView() {
}
}
}
@Composable
private fun SwitchingUsersView() {
if (remember { chatModel.switchingUsersAndHosts }.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
}

View File

@ -2,7 +2,7 @@ package chat.simplex.common.model
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
@ -43,7 +43,7 @@ object ChatModel {
val setDeliveryReceipts = mutableStateOf(false)
val currentUser = mutableStateOf<User?>(null)
val users = mutableStateListOf<UserInfo>()
val userCreated = mutableStateOf<Boolean?>(null)
val localUserCreated = mutableStateOf<Boolean?>(null)
val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false)
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
@ -51,6 +51,7 @@ object ChatModel {
val chats = mutableStateListOf<Chat>()
// map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
val switchingUsersAndHosts = mutableStateOf(false)
// current chat
val chatId = mutableStateOf<String?>(null)
@ -108,6 +109,9 @@ object ChatModel {
var updatingChatsMutex: Mutex = Mutex()
val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
fun desktopNoUserNoRemote(): Boolean = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
// remote controller
val remoteHosts = mutableStateListOf<RemoteHostInfo>()
val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
@ -620,6 +624,7 @@ object ChatModel {
terminalItems.add(item)
}
val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
}

View File

@ -362,7 +362,7 @@ object ChatController {
chatModel.users.addAll(users)
if (justStarted) {
chatModel.currentUser.value = user
chatModel.userCreated.value = true
chatModel.localUserCreated.value = true
getUserChatData(null)
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
@ -382,6 +382,31 @@ object ChatController {
}
}
suspend fun startChatWithoutUser() {
Log.d(TAG, "user: null")
try {
if (chatModel.chatRunning.value == true) return
apiSetTempFolder(coreTmpDir.absolutePath)
apiSetFilesFolder(appFilesDir.absolutePath)
if (appPlatform.isDesktop) {
apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
}
apiSetXFTPConfig(getXFTPCfg())
apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
chatModel.users.clear()
chatModel.currentUser.value = null
chatModel.localUserCreated.value = false
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
startReceiver()
setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!)
Log.d(TAG, "startChat: started without user")
} catch (e: Error) {
Log.e(TAG, "failed starting chat without user $e")
throw e
}
}
suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) {
try {
changeActiveUser_(rhId, toUserId, viewPwd)
@ -405,8 +430,9 @@ object ChatController {
}
suspend fun getUserChatData(rhId: Long?) {
chatModel.userAddress.value = apiGetUserAddress(rhId)
chatModel.chatItemTTL.value = getChatItemTTL(rhId)
val hasUser = chatModel.currentUser.value != null
chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null
chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None
updatingChatsMutex.withLock {
val chats = apiGetChats(rhId)
chatModel.updateChats(chats)
@ -475,7 +501,9 @@ object ChatController {
val r = sendCmd(rh, CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
chatModel.userCreated.value = false
if (rh == null) {
chatModel.localUserCreated.value = false
}
return null
}
@ -1990,7 +2018,7 @@ object ChatController {
chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err))
}
suspend fun switchUIRemoteHost(rhId: Long?) {
suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded {
// TODO lock the switch so that two switches can't run concurrently?
chatModel.chatId.value = null
ModalManager.center.closeModals()
@ -2003,7 +2031,10 @@ object ChatController {
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.currentUser.value = user
chatModel.userCreated.value = true
if (user == null) {
chatModel.chatItems.clear()
chatModel.chats.clear()
}
val statuses = apiGetNetworkStatuses(rhId)
if (statuses != null) {
chatModel.networkStatuses.clear()
@ -2013,6 +2044,23 @@ object ChatController {
getUserChatData(rhId)
}
suspend fun showProgressIfNeeded(block: suspend () -> Unit) {
val job = withBGApi {
try {
delay(500)
chatModel.switchingUsersAndHosts.value = true
} catch (e: Throwable) {
chatModel.switchingUsersAndHosts.value = false
}
}
try {
block()
} finally {
job.cancel()
chatModel.switchingUsersAndHosts.value = false
}
}
fun getXFTPCfg(): XFTPFileConfig {
return XFTPFileConfig(minFileSize = 0)
}

View File

@ -55,10 +55,22 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser(null)
if (user == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = null
chatModel.users.clear()
if (appPlatform.isDesktop) {
/**
* Setting it here to null because otherwise the screen will flash in [MainScreen] after the first start
* because of default value of [OnboardingStage.OnboardingComplete]
* */
chatModel.localUserCreated.value = null
if (chatController.listRemoteHosts()?.isEmpty() == true) {
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
}
chatController.startChatWithoutUser()
} else {
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
}
} else {
val savedOnboardingStage = appPreferences.onboardingStage.get()
appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {

View File

@ -59,7 +59,9 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications?
chatModel.controller.changeActiveUser(null, userId, null)
chatModel.controller.showProgressIfNeeded {
chatModel.controller.changeActiveUser(null, userId, null)
}
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
@ -72,7 +74,9 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications?
chatModel.controller.changeActiveUser(null, userId, null)
chatModel.controller.showProgressIfNeeded {
chatModel.controller.changeActiveUser(null, userId, null)
}
}
chatModel.chatId.value = null
chatModel.clearOverlays.value = true

View File

@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.Profile
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@ -76,7 +76,13 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
disabled = !canCreateProfile(displayName.value),
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
click = { createProfileInProfiles(chatModel, displayName.value, close) },
click = {
if (chatModel.localUserCreated.value == true) {
createProfileInProfiles(chatModel, displayName.value, close)
} else {
createProfileInNoProfileSetup(displayName.value, close)
}
},
)
SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device))
SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts))
@ -168,6 +174,17 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
}
}
fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
withApi {
val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null)) ?: return@withApi
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
chatModel.chatRunning.value = false
controller.startChat(user)
controller.switchUIRemoteHost(null)
close()
}
}
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withApi {
val rhId = chatModel.remoteHostId()

View File

@ -68,14 +68,14 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
val (userPickerState, scaffoldState ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchInList.isEmpty()) {
if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) {
FloatingActionButton(
onClick = {
if (!stopped) {
@ -104,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
) {
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, search = searchInList)
} else if (!switchingUsersAndHosts.value) {
} else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Box(Modifier.fillMaxSize()) {
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
OnboardingButtons(showNewChatSheet)
@ -121,19 +121,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
}
if (switchingUsersAndHosts.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
}
}
@Composable
@ -209,7 +201,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
navigationButton = {
if (showSearch) {
NavigationButtonBack(hideSearchOnBack)
} else if (chatModel.users.isEmpty()) {
} else if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
@ -304,17 +296,6 @@ private fun ToggleFilterButton() {
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
}
@Composable
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)

View File

@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState
val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Scaffold(
Modifier.padding(end = endPadding),
@ -47,7 +47,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = {
UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
})

View File

@ -26,7 +26,9 @@ import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.remote.*
import chat.simplex.common.views.usersettings.doWithAuth
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
@ -38,7 +40,6 @@ import kotlin.math.roundToInt
fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
switchingUsersAndHosts: MutableState<Boolean>,
showSettings: Boolean = true,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
@ -123,14 +124,10 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
scope.launch {
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
controller.showProgressIfNeeded {
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
}
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
job.cancel()
switchingUsersAndHosts.value = false
}
}
}
@ -162,13 +159,13 @@ fun UserPicker(
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null) {
if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
}
Divider(Modifier.requiredHeight(1.dp))
} else {
} else if (currentRemoteHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = {
@ -176,7 +173,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting)
switchToRemoteHost(currentRemoteHost, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
@ -184,7 +181,7 @@ fun UserPicker(
UsersView()
if (remoteHosts.isNotEmpty() && currentRemoteHost != null) {
if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(false) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
@ -199,7 +196,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(h, false)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, switchingUsersAndHosts, connecting)
switchToRemoteHost(h, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
@ -220,6 +217,18 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.GONE
}
Divider(Modifier.requiredHeight(1.dp))
} else if (chatModel.desktopNoUserNoRemote) {
CreateInitialProfile {
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
userPickerState.value = AnimatedViewState.HIDING
}
CreateProfile(chat.simplex.common.platform.chatModel, close)
}
}
}
Divider(Modifier.requiredHeight(1.dp))
}
if (showSettings) {
SettingsPickerItem(settingsClicked)
@ -401,6 +410,16 @@ private fun LinkAMobilePickerItem(onClick: () -> Unit) {
}
}
@Composable
private fun CreateInitialProfile(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.create_chat_profile)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black)
}
}
@Composable
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
@ -441,21 +460,15 @@ private fun switchToLocalDevice() {
}
}
private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState<Boolean>, connecting: MutableState<Boolean>) {
private fun switchToRemoteHost(h: RemoteHostInfo, connecting: MutableState<Boolean>) {
if (!h.activeHost()) {
withBGApi {
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere()
if (h.sessionState != null) {
chatModel.controller.switchUIRemoteHost(h.remoteHostId)
} else {
connectMobileDevice(h, connecting)
}
job.cancel()
switchingUsersAndHosts.value = false
}
} else {
connectMobileDevice(h, connecting)

View File

@ -264,7 +264,8 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
text,
generalGetString(MR.strings.enter_passphrase),
isValid = ::validKey,
keyboardActions = KeyboardActions(onDone = if (enabled) {
// Don't enable this on desktop since it interfere with key event listener
keyboardActions = KeyboardActions(onDone = if (enabled && appPlatform.isAndroid) {
{ onClick?.invoke() }
} else null
),

View File

@ -4,6 +4,7 @@ import SectionBottomSpacer
import SectionDividerSpaced
import SectionTextFooter
import SectionItemView
import SectionSpacer
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
@ -20,6 +21,7 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@ -59,7 +61,9 @@ fun DatabaseView(
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) {
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator)
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) {
startChat(m, chatLastStart, m.chatDbChanged)
}
}
}
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
@ -77,7 +81,6 @@ fun DatabaseView(
m.chatDbEncrypted.value,
m.controller.appPrefs.storeDBPassphrase.state.value,
m.controller.appPrefs.initialRandomDBPassphrase,
m.controller.appPrefs.developerTools.state.value,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
@ -100,7 +103,13 @@ fun DatabaseView(
setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize)
}
},
showSettingsModal
showSettingsModal,
disconnectAllHosts = {
val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected }
connected.forEachIndexed { index, h ->
controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote())
}
}
)
if (progressIndicator.value) {
Box(
@ -129,7 +138,6 @@ fun DatabaseLayout(
chatDbEncrypted: Boolean?,
passphraseSaved: Boolean,
initialRandomDBPassphrase: SharedPreference<Boolean>,
developerTools: Boolean,
importArchiveLauncher: FileChooserLauncher,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
@ -144,36 +152,43 @@ fun DatabaseLayout(
deleteChatAlert: () -> Unit,
deleteAppFilesAndMedia: () -> Unit,
onChatItemTTLSelected: (ChatItemTTL) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
disconnectAllHosts: () -> Unit,
) {
val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator
val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(MR.strings.your_chat_database))
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) {
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
}
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.messages_section_description) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
if (!chatModel.desktopNoUserNoRemote) {
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) {
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
}
)
if (currentRemoteHost == null) {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.messages_section_description) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
SectionDividerSpaced(maxTopPadding = true)
}
val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected }
if (chatModel.localUserCreated.value == true) {
SectionView(stringResource(MR.strings.run_chat_section)) {
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
if (!toggleEnabled) {
SectionItemView(disconnectAllHosts) {
Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange)
}
}
RunChatSetting(runChat, stopped, toggleEnabled, startChat, stopChatAlert)
}
SectionTextFooter(
if (stopped) {
@ -183,92 +198,96 @@ fun DatabaseLayout(
}
)
SectionDividerSpaced()
SectionView(stringResource(MR.strings.chat_database_section)) {
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
else painterResource(MR.images.ic_lock),
stringResource(MR.strings.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
disabled = operationsDisabled
)
if (appPlatform.isDesktop && developerTools) {
SettingsActionItem(
painterResource(MR.images.ic_folder_open),
stringResource(MR.strings.open_database_folder),
::desktopOpenDatabaseDir,
disabled = operationsDisabled
)
}
SettingsActionItem(
painterResource(MR.images.ic_ios_share),
stringResource(MR.strings.export_database),
click = {
if (initialRandomDBPassphrase.get()) {
exportProhibitedAlert()
} else {
exportArchive()
}
},
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
SettingsActionItem(
painterResource(MR.images.ic_download),
stringResource(MR.strings.import_database),
{ withApi { importArchiveLauncher.launch("application/zip") } },
textColor = Color.Red,
iconColor = Color.Red,
disabled = operationsDisabled
)
val chatArchiveNameVal = chatArchiveName.value
val chatArchiveTimeVal = chatArchiveTime.value
val chatLastStartVal = chatLastStart.value
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
SettingsActionItem(
painterResource(MR.images.ic_inventory_2),
title,
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
disabled = operationsDisabled
)
}
SettingsActionItem(
painterResource(MR.images.ic_delete_forever),
stringResource(MR.strings.delete_database),
deleteChatAlert,
textColor = Color.Red,
iconColor = Color.Red,
disabled = operationsDisabled
)
}
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) {
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
SectionItemView(
deleteAppFilesAndMedia,
disabled = deleteFilesDisabled
) {
Text(
stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all),
color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red
)
}
}
val (count, size) = appFilesCountAndSize.value
SectionTextFooter(
if (count == 0) {
stringResource(MR.strings.no_received_app_files)
} else {
String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size))
}
)
}
SectionView(stringResource(MR.strings.chat_database_section)) {
if (chatModel.localUserCreated.value != true && !toggleEnabled) {
SectionItemView(disconnectAllHosts) {
Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange)
}
}
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
else painterResource(MR.images.ic_lock),
stringResource(MR.strings.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
disabled = operationsDisabled
)
if (appPlatform.isDesktop) {
SettingsActionItem(
painterResource(MR.images.ic_folder_open),
stringResource(MR.strings.open_database_folder),
::desktopOpenDatabaseDir,
disabled = operationsDisabled
)
}
SettingsActionItem(
painterResource(MR.images.ic_ios_share),
stringResource(MR.strings.export_database),
click = {
if (initialRandomDBPassphrase.get()) {
exportProhibitedAlert()
} else {
exportArchive()
}
},
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
SettingsActionItem(
painterResource(MR.images.ic_download),
stringResource(MR.strings.import_database),
{ withApi { importArchiveLauncher.launch("application/zip") } },
textColor = Color.Red,
iconColor = Color.Red,
disabled = operationsDisabled
)
val chatArchiveNameVal = chatArchiveName.value
val chatArchiveTimeVal = chatArchiveTime.value
val chatLastStartVal = chatLastStart.value
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
SettingsActionItem(
painterResource(MR.images.ic_inventory_2),
title,
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
disabled = operationsDisabled
)
}
SettingsActionItem(
painterResource(MR.images.ic_delete_forever),
stringResource(MR.strings.delete_database),
deleteChatAlert,
textColor = Color.Red,
iconColor = Color.Red,
disabled = operationsDisabled
)
}
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) {
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
SectionItemView(
deleteAppFilesAndMedia,
disabled = deleteFilesDisabled
) {
Text(
stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all),
color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red
)
}
}
val (count, size) = appFilesCountAndSize.value
SectionTextFooter(
if (count == 0) {
stringResource(MR.strings.no_received_app_files)
} else {
String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size))
}
)
SectionBottomSpacer()
}
}
@ -319,6 +338,7 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
enabled: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
@ -337,6 +357,7 @@ fun RunChatSetting(
stopChatAlert()
}
},
enabled = enabled,
)
}
}
@ -501,13 +522,14 @@ private fun importArchiveAlert(
m: ChatModel,
importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>
progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.import_database_question),
text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(MR.strings.import_database_confirmation),
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) },
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator, startChat) },
destructive = true,
)
}
@ -516,7 +538,8 @@ private fun importArchive(
m: ChatModel,
importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>
progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
) {
progressIndicator.value = true
val archivePath = saveArchiveFromURI(importedArchiveURI)
@ -533,6 +556,10 @@ private fun importArchive(
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database))
}
if (chatModel.localUserCreated.value == false) {
chatModel.chatRunning.value = false
startChat()
}
} else {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import))
@ -681,7 +708,6 @@ fun PreviewDatabaseLayout() {
chatDbEncrypted = false,
passphraseSaved = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}),
developerTools = true,
importArchiveLauncher = rememberFileChooserLauncher(true) {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
@ -697,6 +723,7 @@ fun PreviewDatabaseLayout() {
deleteAppFilesAndMedia = {},
showSettingsModal = { {} },
onChatItemTTLSelected = {},
disconnectAllHosts = {},
)
}
}

View File

@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.onRightClick
@ -202,13 +203,14 @@ fun SectionTextFooter(text: String) {
}
@Composable
fun SectionTextFooter(text: AnnotatedString) {
fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) {
Text(
text,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
color = MaterialTheme.colors.secondary,
lineHeight = 18.sp,
fontSize = 14.sp
fontSize = 14.sp,
textAlign = textAlign
)
}

View File

@ -182,6 +182,10 @@ private fun prepareChatBeforeAddressCreation(rhId: Long?) {
val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
if (appPlatform.isDesktop) {
// Make possible to use chat after going to remote device linking and returning back to local profile creation
chatModel.chatRunning.value = false
}
chatModel.controller.startChat(user)
} else {
val users = chatModel.controller.listUsers(rhId)

View File

@ -0,0 +1,91 @@
package chat.simplex.common.views.onboarding
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.remote.AddingMobileDevice
import chat.simplex.common.views.remote.DeviceNameField
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun LinkAMobile() {
val connecting = rememberSaveable { mutableStateOf(false) }
val deviceName = chatModel.controller.appPrefs.deviceNameForRemoteAccess
var deviceNameInQrCode by remember { mutableStateOf(chatModel.controller.appPrefs.deviceNameForRemoteAccess.get()) }
val staleQrCode = remember { mutableStateOf(false) }
LinkAMobileLayout(
deviceName = remember { deviceName.state },
connecting,
staleQrCode,
updateDeviceName = {
withBGApi {
if (it != "" && it != deviceName.get()) {
chatModel.controller.setLocalDeviceName(it)
deviceName.set(it)
staleQrCode.value = deviceName.get() != deviceNameInQrCode
}
}
}
)
KeyChangeEffect(staleQrCode.value) {
if (!staleQrCode.value) {
deviceNameInQrCode = deviceName.get()
}
}
}
@Composable
private fun LinkAMobileLayout(
deviceName: State<String?>,
connecting: MutableState<Boolean>,
staleQrCode: MutableState<Boolean>,
updateDeviceName: (String) -> Unit,
) {
Column(Modifier.padding(top = 20.dp)) {
AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles))
Row(Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING * 2), verticalAlignment = Alignment.CenterVertically) {
Column(
Modifier.weight(0.3f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SectionView(generalGetString(MR.strings.this_device_name).uppercase()) {
DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) }
SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile))
PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) {
ChatModel.controller.appPrefs.offerRemoteMulticast.set(it)
}
}
}
Box(Modifier.weight(0.7f)) {
AddingMobileDevice(false, staleQrCode, connecting) {
if (chatModel.remoteHosts.isEmpty()) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
}
}
}
}
SimpleButtonDecorated(
text = stringResource(MR.strings.about_simplex),
icon = painterResource(MR.images.ic_arrow_back_ios_new),
textDecoration = TextDecoration.None,
fontWeight = FontWeight.Medium
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }
}
}

View File

@ -3,6 +3,7 @@ package chat.simplex.common.views.onboarding
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
LinkAMobile,
Step2_5_SetupDatabasePassphrase,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,

View File

@ -43,7 +43,11 @@ fun SetupDatabasePassphrase(m: ChatModel) {
val newKey = rememberSaveable { mutableStateOf("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
} else {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
}
}
SetupDatabasePassphraseLayout(
currentKey,
@ -159,10 +163,7 @@ private fun SetupDatabasePassphraseLayout(
}
},
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }),
)
Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) {

View File

@ -8,6 +8,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@ -99,26 +100,22 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour
}
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null) {
if (user == null) {
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick)
} else {
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick)
}
}
expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null)
@Composable
fun OnboardingActionButton(
labelId: StringResource,
onboarding: OnboardingStage?,
border: Boolean,
icon: Painter? = null,
iconColor: Color = MaterialTheme.colors.primary,
onclick: (() -> Unit)?
) {
val modifier = if (border) {
Modifier
.border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50))
.padding(
horizontal = DEFAULT_PADDING * 2,
horizontal = if (icon == null) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF,
vertical = 4.dp
)
} else {
@ -131,6 +128,9 @@ fun OnboardingActionButton(
ChatController.appPrefs.onboardingStage.set(onboarding)
}
}, modifier) {
if (icon != null) {
Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor)
}
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp)
Icon(
painterResource(MR.images.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary,

View File

@ -13,12 +13,14 @@ import androidx.compose.foundation.text.selection.SelectionContainer
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.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
@ -97,9 +99,11 @@ fun ConnectMobileLayout(
SectionDividerSpaced(maxBottomPadding = false)
}
SectionView(stringResource(MR.strings.devices).uppercase()) {
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
if (connectedHost.value == null) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
if (chatModel.localUserCreated.value == true) {
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
if (connectedHost.value == null) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
}
}
}
@ -162,26 +166,37 @@ fun DeviceNameField(
@Composable
private fun ConnectMobileViewLayout(
title: String,
title: String?,
invitation: String?,
deviceName: String?,
sessionCode: String?,
port: String?
port: String?,
staleQrCode: Boolean = false,
refreshQrCode: () -> Unit = {}
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AppBarTitle(title)
if (title != null) {
AppBarTitle(title)
}
SectionView {
if (invitation != null && sessionCode == null && port != null) {
QRCode(
invitation, Modifier
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code))
SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port))
Box {
QRCode(
invitation, Modifier
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
if (staleQrCode) {
Box(Modifier.matchParentSize().background(MaterialTheme.colors.background.copy(alpha = 0.9f)), contentAlignment = Alignment.Center) {
SimpleButtonDecorated(stringResource(MR.strings.refresh_qr_code), painterResource(MR.images.ic_refresh), click = refreshQrCode)
}
}
}
SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code), textAlign = TextAlign.Center)
SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port), textAlign = TextAlign.Center)
if (remember { controller.appPrefs.developerTools.state }.value) {
val clipboard = LocalClipboardManager.current
@ -218,6 +233,7 @@ private fun ConnectMobileViewLayout(
}
}
}
SectionBottomSpacer()
}
}
@ -237,55 +253,72 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState<Boolean>) {
private fun showAddingMobileDevice(connecting: MutableState<Boolean>) {
ModalManager.start.showModalCloseable { close ->
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
val port = rememberSaveable { mutableStateOf<String?>(null) }
val pairing = remember { chatModel.remoteHostPairing }
val sessionCode = when (val state = pairing.value?.second) {
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
else -> null
AddingMobileDevice(true, remember { mutableStateOf(false) }, connecting, close)
}
}
@Composable
fun AddingMobileDevice(showTitle: Boolean, staleQrCode: MutableState<Boolean>, connecting: MutableState<Boolean>, close: () -> Unit) {
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
val port = rememberSaveable { mutableStateOf<String?>(null) }
val startRemoteHost = suspend {
val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get())
if (r != null) {
connecting.value = true
invitation.value = r.second
port.value = r.third
chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting
}
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
if (cachedSessionCode == null && sessionCode != null) {
cachedSessionCode = sessionCode
}
val remoteDeviceName = pairing.value?.first?.hostDeviceName
ConnectMobileViewLayout(
title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
invitation = invitation.value,
deviceName = remoteDeviceName,
sessionCode = cachedSessionCode,
port = port.value
)
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) {
close()
}
}
KeyChangeEffect(pairing.value) {
if (pairing.value == null) {
close()
}
}
DisposableEffect(Unit) {
}
val pairing = remember { chatModel.remoteHostPairing }
val sessionCode = when (val state = pairing.value?.second) {
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
else -> null
}
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
if (cachedSessionCode == null && sessionCode != null) {
cachedSessionCode = sessionCode
}
val remoteDeviceName = pairing.value?.first?.hostDeviceName
ConnectMobileViewLayout(
title = if (!showTitle) null else if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
invitation = invitation.value,
deviceName = remoteDeviceName,
sessionCode = cachedSessionCode,
port = port.value,
staleQrCode = staleQrCode.value,
refreshQrCode = {
withBGApi {
val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get())
if (r != null) {
connecting.value = true
invitation.value = r.second
port.value = r.third
chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting
if (chatController.stopRemoteHost(null)) {
startRemoteHost()
staleQrCode.value = false
}
}
onDispose {
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) {
withBGApi {
chatController.stopRemoteHost(null)
}
},
)
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) {
close()
}
}
KeyChangeEffect(pairing.value) {
if (pairing.value == null) {
close()
}
}
DisposableEffect(Unit) {
withBGApi {
startRemoteHost()
}
onDispose {
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) {
withBGApi {
chatController.stopRemoteHost(null)
}
chatModel.remoteHostPairing.value = null
}
chatModel.remoteHostPairing.value = null
}
}
}

View File

@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ClickableText
import chat.simplex.common.views.helpers.*
@ -169,18 +170,20 @@ fun NetworkAndServersView(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AppBarTitle(stringResource(MR.strings.network_and_servers))
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
if (!chatModel.desktopNoUserNoRemote) {
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
if (developerTools) {
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
if (developerTools) {
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
}
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
}
if (currentRemoteHost == null && networkUseSocksProxy.value) {
@ -192,7 +195,7 @@ fun NetworkAndServersView(
}
}
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
} else {
} else if (!chatModel.desktopNoUserNoRemote) {
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
}

View File

@ -92,7 +92,6 @@ fun PrivacySettingsView(
chatModel.simplexLinkMode.value = it
})
}
SectionDividerSpaced()
val currentUser = chatModel.currentUser.value
if (currentUser != null) {
@ -142,39 +141,42 @@ fun PrivacySettingsView(
}
}
DeliveryReceiptsSection(
currentUser = currentUser,
setOrAskSendReceiptsContacts = { enable ->
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Direct) {
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
if (!chatModel.desktopNoUserNoRemote) {
SectionDividerSpaced()
DeliveryReceiptsSection(
currentUser = currentUser,
setOrAskSendReceiptsContacts = { enable ->
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Direct) {
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
} else {
count
}
}
if (contactReceiptsOverrides == 0) {
setSendReceiptsContacts(enable, clearOverrides = false)
} else {
count
showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts)
}
},
setOrAskSendReceiptsGroups = { enable ->
val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Group) {
val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
} else {
count
}
}
if (groupReceiptsOverrides == 0) {
setSendReceiptsGroups(enable, clearOverrides = false)
} else {
showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups)
}
}
if (contactReceiptsOverrides == 0) {
setSendReceiptsContacts(enable, clearOverrides = false)
} else {
showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts)
}
},
setOrAskSendReceiptsGroups = { enable ->
val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Group) {
val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
} else {
count
}
}
if (groupReceiptsOverrides == 0) {
setSendReceiptsGroups(enable, clearOverrides = false)
} else {
showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups)
}
}
)
)
}
}
SectionBottomSpacer()
}

View File

@ -25,6 +25,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.SimpleXInfo
@ -38,76 +39,39 @@ import kotlinx.coroutines.launch
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
if (user != null) {
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state }
SettingsLayout(
profile = user.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
remember { chatModel.controller.appPrefs.notificationsMode.state },
user.displayName,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
showSettingsModalWithSearch = { modalView ->
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 = { modalView(chatModel, search) })
SettingsLayout(
profile = user?.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
remember { chatModel.controller.appPrefs.notificationsMode.state },
user?.displayName,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
showSettingsModalWithSearch = { modalView ->
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 = { modalView(chatModel, search) })
}
},
showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } },
showVersion = {
withApi {
val info = chatModel.controller.apiGetVersion()
if (info != null) {
ModalManager.start.showModal { VersionInfoView(info) }
}
},
showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } },
showVersion = {
withApi {
val info = chatModel.controller.apiGetVersion()
if (info != null) {
ModalManager.start.showModal { VersionInfoView(info) }
}
}
},
withAuth = { title, desc, block ->
if (!requireAuth.value) {
block()
} else {
var autoShow = true
ModalManager.fullscreen.showModalCloseable { close ->
val onFinishAuth = { success: Boolean ->
if (success) {
close()
block()
}
}
LaunchedEffect(Unit) {
if (autoShow) {
autoShow = false
runAuth(title, desc, onFinishAuth)
}
}
Box(
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(MR.strings.auth_unlock),
icon = painterResource(MR.images.ic_lock),
click = {
runAuth(title, desc, onFinishAuth)
}
)
}
}
}
},
drawerState = drawerState,
)
}
}
},
withAuth = ::doWithAuth,
drawerState = drawerState,
)
}
val simplexTeamUri =
@ -115,12 +79,12 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: LocalProfile,
profile: LocalProfile?,
stopped: Boolean,
encrypted: Boolean,
passphraseSaved: Boolean,
notificationsMode: State<NotificationsMode>,
userDisplayName: String,
userDisplayName: String?,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
@ -150,13 +114,22 @@ fun SettingsLayout(
AppBarTitle(stringResource(MR.strings.your_settings))
SectionView(stringResource(MR.strings.settings_section_title_you)) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
val profileHidden = rememberSaveable { mutableStateOf(false) }
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
if (profile != null) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
} else if (chatModel.localUserCreated.value == false) {
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
closeSettings()
}
CreateProfile(chatModel, close)
} } }, disabled = stopped, extraPadding = true)
}
if (appPlatform.isDesktop) {
SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true)
} else {
@ -176,10 +149,12 @@ fun SettingsLayout(
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true)
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true)
}
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true)
}
SectionDividerSpaced()
@ -469,6 +444,42 @@ fun PreferenceToggleWithIcon(
}
}
fun doWithAuth(title: String, desc: String, block: () -> Unit) {
val requireAuth = chatModel.controller.appPrefs.performLA.get()
if (!requireAuth) {
block()
} else {
var autoShow = true
ModalManager.fullscreen.showModalCloseable { close ->
val onFinishAuth = { success: Boolean ->
if (success) {
close()
block()
}
}
LaunchedEffect(Unit) {
if (autoShow) {
autoShow = false
runAuth(title, desc, onFinishAuth)
}
}
Box(
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(MR.strings.auth_unlock),
icon = painterResource(MR.images.ic_lock),
click = {
runAuth(title, desc, onFinishAuth)
}
)
}
}
}
}
private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) {
authenticate(
title,

View File

@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
@ -56,7 +57,9 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
ModalManager.end.closeModals()
}
withBGApi {
m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
controller.showProgressIfNeeded {
m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
}
}
},
removeUser = { user ->

View File

@ -565,6 +565,7 @@
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your SimpleX address</string>
<string name="your_chat_profiles">Your chat profiles</string>
<string name="create_chat_profile">Create chat profile</string>
<string name="database_passphrase_and_export">Database passphrase &amp; export</string>
<string name="about_simplex_chat">About SimpleX Chat</string>
<string name="how_to_use_simplex_chat">How to use it</string>
@ -1659,6 +1660,7 @@
<string name="unlink_desktop_question">Unlink desktop?</string>
<string name="unlink_desktop">Unlink</string>
<string name="disconnect_remote_host">Disconnect</string>
<string name="disconnect_remote_hosts">Disconnect mobiles</string>
<string name="remote_host_was_disconnected_toast"><![CDATA[Mobile <b>%s</b> was disconnected]]></string>
<string name="disconnect_desktop_question">Disconnect desktop?</string>
<string name="only_one_device_can_work_at_the_same_time">Only one device can work at the same time</string>
@ -1689,6 +1691,8 @@
<string name="paste_desktop_address">Paste desktop address</string>
<string name="desktop_device">Desktop</string>
<string name="not_compatible">Not compatible!</string>
<string name="refresh_qr_code">Refresh</string>
<string name="no_connected_mobile">No connected mobile</string>
<!-- Under development -->
<string name="in_developing_title">Coming soon!</string>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480.433-164.5q-131.583 0-223.758-92.216Q164.5-348.932 164.5-480.082q0-131.149 92.175-223.284Q348.85-795.5 480.5-795.5q84 0 147.75 34.25T738.5-666.5v-129H796V-547H547.5v-57.5H713q-38.032-60.033-96.537-96.767Q557.959-738 480.539-738 372-738 297-663.015q-75 74.986-75 183.25 0 108.265 74.875 183.015Q371.75-222 480.331-222q82.298 0 150.734-47 68.435-47 95.623-124.5H786q-28.5 103-113.49 166-84.991 63-192.077 63Z"/></svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -0,0 +1,23 @@
package chat.simplex.common.views.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.model.User
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)?) {
if (user == null) {
Row(horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING * 2.5f)) {
OnboardingActionButton(MR.strings.link_a_mobile, onboarding = if (controller.appPrefs.initialRandomDBPassphrase.get()) OnboardingStage.Step2_5_SetupDatabasePassphrase else OnboardingStage.LinkAMobile, true, icon = painterResource(MR.images.ic_smartphone_300), onclick = onclick)
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, icon = painterResource(MR.images.ic_desktop), onclick = onclick)
}
} else {
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick)
}
}

View File

@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 6bffcc8503e5193d57e543ac0100712a8e27d454
tag: 90a8fc91d35c578c3b52ad296a6f1df715da2278
source-repository-package
type: git
@ -43,5 +43,5 @@ source-repository-package
source-repository-package
type: git
location: https://github.com/zw3rk/android-support.git
tag: 3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb
location: https://github.com/simplex-chat/android-support.git
tag: 9aa09f148089d6752ce563b14c2df1895718d806

View File

@ -0,0 +1,110 @@
# Inactive group members
## Problem
Group traffic is higher than necessary due to lack of diagnosis of inactive group members. By inactive we understand group members who went offline for indefinitely long time, uninstalled application without leaving group, failed to send x.grp.leave message before deleting connection, or in any other way failed to explicitly communicate further inactivity.
Currently other group members continue to identify such members as active and to send messages to their connections until exceeding receiving SMP queues quotas, with pending messages being slowly retried even after that.
## Solution
Identify inactive members and don't send messages to their connections. Silent periodically online members should continue to receive messages, so decision to mark member as inactive should be made conservatively.
Agent:
- on SMP.QUOTA error notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA).
- on receiving QCONT notify client (new event).
Chat, on sending side, per member:
- unanswered_snd_msg_count - number of messages that were sent consecutively without receiving a message from member.
- last_rcv_ts - timestamp of last received message.
- inactive flag.
- set inactive if:
- agent reports QUOTA error.
- on sending message: (unanswered_snd_msg_count > K) && (last_rcv_ts earlier than Ddiff days ago), Ddiff = 1/2/3 days?
- reset inactive:
- on receiving QCONT.
- on receiving message or receipt. Also reset unanswered_snd_msg_count, last_rcv_ts.
- don't send to member if inactive.
- don't send only content messages (x.msg.new, etc.) and always send messages altering group state?
- unanswered_snd_msg_count, last_rcv_ts to be tracked, checked, reset only for members with compatible version.
Chat, on receiving side, per member:
- unanswered_rcv_msg_count - number of messages that were received consecutively without sending a message to member.
- send non-optional receipt / another (new) protocol message if:
- on receiving message: unanswered_rcv_msg_count > M, M < K.
- on sending a message or receipt to member reset unanswered_rcv_msg_count.
- unanswered_rcv_msg_count to be tracked, checked, reset only for members with compatible version.
\***
Consider above condition:
> (unanswered_snd_msg_count > K) && (last_rcv_ts earlier than Ddiff days ago)
It still doesn't account for following situation:
1. Sending member sends a few (N1, N1 < M) messages to silent member on day D1.
2. Sending member doesn't send messages for several days.
3. Sending member sends more messages (N2, N1 + N2 > K) to silent member on day DI (DI - D1 > diff in days in above condition), while silent member is offline.
- Sending member checks above condition and evaluates it to be true, marks silent member as inactive.
- Simply remembering last_snd_ts on sending side and adding check for it not being from several days ago to above condition is not enough, as it will be overwritten by current day sends and will only evaluate false for the first send. What could work is remembering prev_session_last_snd_ts or prev_day_last_snd_ts, but it further complicates logic, and still probably wouldn't account for some time zone differences.
4. Sending member sends yet more messages, which will not be queued for silent member marked inactive.
5. Silent member comes online, sends receipt upon receiving message fulfilling above condition: `unanswered_rcv_msg_count > M`, and will lose following messages.
- If sending member created messages from 4 as pending, and sent them upon receiving receipt from silent member, silent member would only receive them after sending member coming online. If they are in different time zones it may happen on next day.
Same situation can occur even without step 1, simply by sending many messages while other member is offline.
The problem is less acute the greater the difference between K and M, but making K >> M renders this whole mechanism obsolete, as we could then simply rely on QUOTA errors to mark group members inactive (and don't slow retry in agent?).
Perhaps an acceptable way to solve this problem is to add a task to cleanup manager that would send receipts to all members on condition: (unanswered_rcv_msg_count > 0) && (last_reply_ts earlier than 1 day ago). (Adds last_reply_ts to tracking on receiving side). Perhaps it should be a task separate from cleanup manager that only occurs once per start, or with longer interval.
\***
Additionally we could consider group member connection as disabled with smaller AUTH error count. Currently it's 10 messages, could be 1.
### Delivery suspension notice
When receiving side comes back online, replies and continues to receive messages, it has no way of knowing there was a gap in messages from sending member. To notify receiving member about delivery suspension, sending member should send notice containing shared message id of the last sent message (new protocol event) to them:
```haskell
XGrpMemSuspended :: SharedMsgId -> ChatMsgEvent 'Json
```
Sending side additionally tracks:
- xgrpmemsuspended_sent flag - to only send it once.
When processing it, receiving member creates a "gap" chat item (e.g. event saying "member x suspended delivery to you due to your inactivity, there may be a gap in messages").
After receiving member signals activity by sending any reply, sending member may send message history before continuing normal delivery.
Starting point for message history: either receiving member could request history starting from specific shared message id (received in XGrpMemSuspended) with another new protocol event, or sending member can remember it instead of just flag.
### Sending message history
New protocol event:
```haskell
XGrpMsgHistory :: [ChatMessage 'Json] -> ChatMsgEvent 'Json
```
Sending member builds messages history starting starting from requested/remembered shared message id:
- `messages` table is periodically cleaned up, so messages would be retrieved from `chat_items`.
- if chat item for starting shared message id is not found (it may have been deleted manually or as a disappearing message), abort?
- sending member could track number of skipped messages per member, but again if any chat items were deleted, older (previously successfully sent) chat items would be retrieved, resulting in duplicate messages. If receiving member has also cleaned up records in `messages` table, they wouldn't be deduplicated.
- sending member could track timestamp of first unsent message instead of shared msg id.
- sending member should probably limit maximum number of messages sent as history (100?).
- only XMsgNew events should be sent in XGrpMsgHistory (chat items to be transformed back into text messages).
- updates, deletions would be reflected in chat item list.
- reactions would be omitted.
- files would be likely expired by the time of sending history, so only file name and size may be sent in FileInvitation, with invitation being practically not acceptable.
- add new flag to CIFile "expired" for receiving member to mark chat items created based on such invitations.
- FileInvitation in MsgContainer could also contain this flag as optional to explicitly communicate that only file metadata is sent.
- alternatively sending member could re-upload files, but this seems excessive.
- XMsgNew events don't include message timestamps (instead usually broker ts is retrieved from agent message meta), so receiving member wouldn't be able to restore them from history. Perhaps history should include XGrpMsgForward events containing XMsgNew events instead.
- XGrpMsgHistory is likely to exceed message block limit.
- either multiple messages comprising a history can be batched as a single message on chat level until the block size is exceeded.
- or large history messages could be batched on agent level.
\***
Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions.

View File

@ -32,10 +32,10 @@ dependencies:
- filepath == 1.4.*
- http-types == 0.12.*
- http2 >= 4.2.2 && < 4.3
- memory == 0.15.*
- mtl == 2.2.*
- memory >= 0.15 && < 0.19
- mtl >= 2.2 && < 3
- network >= 3.1.2.7 && < 3.2
- network-transport == 0.5.6
- network-transport >= 0.5.6 && < 0.6
- optparse-applicative >= 0.15 && < 0.17
- process == 1.6.*
- random >= 1.1 && < 1.3
@ -45,14 +45,14 @@ dependencies:
- socks == 0.6.*
- sqlcipher-simple == 0.4.*
- stm == 2.5.*
- template-haskell == 2.16.*
- template-haskell >= 2.16 && < 2.21
- terminal == 0.2.*
- text == 1.2.*
- text >= 2.0 && < 3
- time == 1.9.*
- tls >= 1.6.0 && < 1.7
- unliftio == 0.2.*
- unliftio-core == 0.2.*
- zip == 1.7.*
- zip >= 1.7 && < 2.1
flags:
swift:

View File

@ -1,10 +1,10 @@
{
"https://github.com/simplex-chat/simplexmq.git"."6bffcc8503e5193d57e543ac0100712a8e27d454" = "131kdcvh01985lnf4azss4rg7swpjjh647c29m95b33hd1f7mf17";
"https://github.com/simplex-chat/simplexmq.git"."90a8fc91d35c578c3b52ad296a6f1df715da2278" = "1yjixh6b2s1law3kh885fsbr1inv1r7iy4g9g2bn6j4ygdn8vlzy";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr";
"https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
"https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn";
}

View File

@ -184,10 +184,10 @@ library
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory ==0.15.*
, mtl ==2.2.*
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2
, network-transport ==0.5.4
, network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@ -197,14 +197,14 @@ library
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, template-haskell >=2.16 && <2.21
, terminal ==0.2.*
, text ==1.2.*
, text >=2.0 && <3
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip ==1.7.*
, zip >=1.7 && <2.1
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@ -236,10 +236,10 @@ executable simplex-bot
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory ==0.15.*
, mtl ==2.2.*
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2
, network-transport ==0.5.4
, network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@ -250,14 +250,14 @@ executable simplex-bot
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, template-haskell >=2.16 && <2.21
, terminal ==0.2.*
, text ==1.2.*
, text >=2.0 && <3
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip ==1.7.*
, zip >=1.7 && <2.1
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@ -289,10 +289,10 @@ executable simplex-bot-advanced
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory ==0.15.*
, mtl ==2.2.*
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2
, network-transport ==0.5.4
, network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@ -303,14 +303,14 @@ executable simplex-bot-advanced
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, template-haskell >=2.16 && <2.21
, terminal ==0.2.*
, text ==1.2.*
, text >=2.0 && <3
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip ==1.7.*
, zip >=1.7 && <2.1
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@ -344,10 +344,10 @@ executable simplex-broadcast-bot
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory ==0.15.*
, mtl ==2.2.*
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2
, network-transport ==0.5.4
, network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@ -358,14 +358,14 @@ executable simplex-broadcast-bot
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, template-haskell >=2.16 && <2.21
, terminal ==0.2.*
, text ==1.2.*
, text >=2.0 && <3
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip ==1.7.*
, zip >=1.7 && <2.1
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@ -398,10 +398,10 @@ executable simplex-chat
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory ==0.15.*
, mtl ==2.2.*
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, network ==3.1.*
, network-transport ==0.5.4
, network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@ -412,15 +412,15 @@ executable simplex-chat
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, template-haskell >=2.16 && <2.21
, terminal ==0.2.*
, text ==1.2.*
, text >=2.0 && <3
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, websockets ==0.12.*
, zip ==1.7.*
, zip >=1.7 && <2.1
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@ -456,10 +456,10 @@ executable simplex-directory-service
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory ==0.15.*
, mtl ==2.2.*
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2
, network-transport ==0.5.4
, network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@ -470,14 +470,14 @@ executable simplex-directory-service
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, template-haskell >=2.16 && <2.21
, terminal ==0.2.*
, text ==1.2.*
, text >=2.0 && <3
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip ==1.7.*
, zip >=1.7 && <2.1
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@ -540,10 +540,10 @@ test-suite simplex-chat-test
, hspec ==2.7.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory ==0.15.*
, mtl ==2.2.*
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, network ==3.1.*
, network-transport ==0.5.4
, network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@ -555,14 +555,14 @@ test-suite simplex-chat-test
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, template-haskell >=2.16 && <2.21
, terminal ==0.2.*
, text ==1.2.*
, text >=2.0 && <3
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip ==1.7.*
, zip >=1.7 && <2.1
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON

View File

@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: 6bffcc8503e5193d57e543ac0100712a8e27d454
commit: 90a8fc91d35c578c3b52ad296a6f1df715da2278
- github: kazu-yamamoto/http2
commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb
# - ../direct-sqlcipher
@ -63,6 +63,8 @@ extra-deps:
commit: aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b
- github: simplex-chat/haskell-terminal
commit: f708b00009b54890172068f168bf98508ffcd495
- github: simplex-chat/android-support
commit: 9aa09f148089d6752ce563b14c2df1895718d806
#
# extra-deps: []