diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index a3bb82218c..8d70506f26 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -35,6 +35,7 @@ import chat.simplex.app.views.chatlist.* import chat.simplex.app.views.database.DatabaseErrorView import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword +import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword import chat.simplex.app.views.localauth.SetAppPasscodeView import chat.simplex.app.views.newchat.* import chat.simplex.app.views.onboarding.* @@ -179,6 +180,7 @@ class MainActivity: FragmentActivity() { generalGetString(R.string.auth_log_in_using_credential) else generalGetString(R.string.auth_unlock), + selfDestruct = true, this@MainActivity, completed = { laResult -> when (laResult) { @@ -248,7 +250,7 @@ class MainActivity: FragmentActivity() { authenticate( generalGetString(R.string.auth_enable_simplex_lock), generalGetString(R.string.auth_confirm_credential), - activity, + activity = activity, completed = { laResult -> when (laResult) { LAResult.Success -> { @@ -289,7 +291,8 @@ class MainActivity: FragmentActivity() { appPrefs.performLA.set(false) laPasscodeNotSetAlert() }, - close) + close = close + ) } } } @@ -314,7 +317,7 @@ class MainActivity: FragmentActivity() { generalGetString(R.string.auth_confirm_credential) else "", - activity, + activity = activity, completed = { laResult -> val prefPerformLA = m.controller.appPrefs.performLA when (laResult) { @@ -350,14 +353,17 @@ class MainActivity: FragmentActivity() { generalGetString(R.string.auth_confirm_credential) else generalGetString(R.string.auth_disable_simplex_lock), - activity, + activity = activity, completed = { laResult -> val prefPerformLA = m.controller.appPrefs.performLA + val selfDestructPref = m.controller.appPrefs.selfDestruct when (laResult) { LAResult.Success -> { m.performLA.value = false prefPerformLA.set(false) ksAppPassword.remove() + selfDestructPref.set(false) + ksSelfDestructPassword.remove() } is LAResult.Failed -> { /* Can be called multiple times on every failure */ } is LAResult.Error -> { diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index b2d4a868cc..ed2c03b897 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -42,7 +42,7 @@ class SimplexApp: Application(), LifecycleEventObserver { val defaultLocale: Locale = Locale.getDefault() - fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) { + suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) { val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context) val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp @@ -65,25 +65,25 @@ class SimplexApp: Application(), LifecycleEventObserver { } else if (startChat) { // If we migrated successfully means previous re-encryption process on database level finished successfully too if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) - withApi { - val user = chatController.apiGetActiveUser() - if (user == null) { - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + val user = chatController.apiGetActiveUser() + if (user == null) { + chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + chatModel.currentUser.value = null + chatModel.users.clear() + } else { + val savedOnboardingStage = appPreferences.onboardingStage.get() + chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { + OnboardingStage.Step3_CreateSimpleXAddress } else { - val savedOnboardingStage = appPreferences.onboardingStage.get() - chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { - OnboardingStage.Step3_CreateSimpleXAddress - } else { - savedOnboardingStage - } - chatController.startChat(user) - // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { - chatController.showBackgroundServiceNoticeIfNeeded() - if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name) - SimplexService.start(applicationContext) - } + savedOnboardingStage + } + chatController.startChat(user) + // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet + if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + chatController.showBackgroundServiceNoticeIfNeeded() + if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name) + SimplexService.start(applicationContext) } } } @@ -103,10 +103,12 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun onCreate() { super.onCreate() context = this - initChatController() - ProcessLifecycleOwner.get().lifecycle.addObserver(this) context.getDir("temp", MODE_PRIVATE).deleteRecursively() - runMigrations() + runBlocking { + initChatController() + ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp) + runMigrations() + } } override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 99930c4df7..65a2992917 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -41,7 +41,6 @@ class ChatModel(val controller: ChatController) { val chatDbChanged = mutableStateOf(false) val chatDbEncrypted = mutableStateOf(false) val chatDbStatus = mutableStateOf(null) - val chatDbDeleted = mutableStateOf(false) val chats = mutableStateListOf() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index a4c4c8aeab..bc5985c5e5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -231,6 +231,10 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference manager.cancel(CallNotificationId) } + fun cancelAllNotifications() { + manager.cancelAll() + } + fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() } private fun hideSecrets(cItem: ChatItem) : String { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 0b719b4b7d..9f0af623e2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -148,8 +148,12 @@ class AppPreferences(val context: Context) { val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null) val encryptedAppPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_APP_PASSPHRASE, null) val initializationVectorAppPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE, null) + val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null) + val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null) val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true) val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false) + val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false) + val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null) val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name) val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.name) @@ -274,8 +278,12 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase" private const val SHARED_PREFS_ENCRYPTED_APP_PASSPHRASE = "EncryptedAppPassphrase" private const val SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE = "InitializationVectorAppPassphrase" + private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase" + private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase" private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt" private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" + private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct" + private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName" private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme" private const val SHARED_PREFS_SYSTEM_DARK_THEME = "SystemDarkTheme" private const val SHARED_PREFS_THEMES = "Themes" @@ -434,8 +442,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } - suspend fun apiCreateActiveUser(p: Profile): User? { - val r = sendCmd(CC.CreateActiveUser(p)) + suspend fun apiCreateActiveUser(p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { + val r = sendCmd(CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) if (r is CR.ActiveUser) return r.user else if ( r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || @@ -1853,7 +1861,7 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() - class CreateActiveUser(val profile: Profile): CC() + class CreateActiveUser(val profile: Profile?, val sameServers: Boolean, val pastTimestamp: Boolean): CC() class ListUsers: CC() class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC() class ApiHideUser(val userId: Long, val viewPwd: String): CC() @@ -1938,7 +1946,10 @@ sealed class CC { val cmdString: String get() = when (this) { is Console -> cmd is ShowActiveUser -> "/u" - is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}" + is CreateActiveUser -> { + val user = NewUser(profile, sameServers = sameServers, pastTimestamp = pastTimestamp) + "/_create user ${json.encodeToString(user)}" + } is ListUsers -> "/users" is ApiSetActiveUser -> "/_user $userId${maybePwd(viewPwd)}" is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" @@ -2144,6 +2155,13 @@ sealed class CC { } } +@Serializable +data class NewUser( + val profile: Profile?, + val sameServers: Boolean, + val pastTimestamp: Boolean +) + sealed class ChatPagination { class Last(val count: Int): ChatPagination() class After(val chatItemId: Long, val count: Int): ChatPagination() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index c75e490849..787d56416d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -351,7 +351,10 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, ) { contentPadding -> - BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) { + BoxWithConstraints(Modifier + .fillMaxHeight() + .padding(contentPadding) + ) { ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, @@ -615,12 +618,13 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null if (chat.chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null val member = cItem.chatDir.groupMember val showMember = showMemberImage(member, prevItem) - Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) { + Row(Modifier.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp).then(swipeableModifier)) { if (showMember) { val contactId = member.memberContactId if (contactId == null) { @@ -643,7 +647,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) } } else { - Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) { + Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) { ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) } } @@ -651,8 +655,8 @@ fun BoxWithConstraintsScope.ChatItemsList( val sent = cItem.chatDir.sent Box( Modifier.padding( - start = if (sent) 76.dp else 12.dp, - end = if (sent) 12.dp else 76.dp, + start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, ).then(swipeableModifier) ) { ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index fdc4210427..8cc8c96772 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -686,11 +686,22 @@ fun ComposeView( val userIsObserver = rememberUpdatedState(chat.userIsObserver) Column { - contextItemView() - when { - composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {} - composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} - else -> previewView() + if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { + contextItemView() + when { + composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {} + composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} + else -> previewView() + } + } else { + Box { + Box(Modifier.align(Alignment.TopStart).padding(bottom = 69.dp)) { + contextItemView() + } + Box(Modifier.align(Alignment.BottomStart)) { + previewView() + } + } } Row( modifier = Modifier.padding(end = 8.dp), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt index 9c6babe65e..7244ce41aa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt @@ -6,6 +6,10 @@ 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.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -25,99 +29,135 @@ fun ComposeVoiceView( cancelEnabled: Boolean, cancelVoice: () -> Unit ) { - BoxWithConstraints(Modifier - .fillMaxWidth() - ) { - val audioPlaying = rememberSaveable { mutableStateOf(false) } - val progress = rememberSaveable { mutableStateOf(0) } - val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) } - val progressBarWidth = remember { Animatable(0f) } - LaunchedEffect(recordedDurationMs, finishedRecording) { - snapshotFlow { progress.value } - .distinctUntilChanged() - .collect { - val startTime = when { - finishedRecording -> progress.value - else -> recordedDurationMs - } - val endTime = when { - finishedRecording -> duration.value - audioPlaying.value -> recordedDurationMs - else -> MAX_VOICE_MILLIS_FOR_SENDING - } - val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp - progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec()) - } - } - Spacer( + val progress = rememberSaveable { mutableStateOf(0) } + val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) } + val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage + Box { + Box( Modifier - .requiredWidth(progressBarWidth.value.dp) - .padding(top = 58.dp) - .height(3.dp) - .background(MaterialTheme.colors.primary) - ) - val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage - Row( - Modifier - .height(60.dp) - .fillMaxWidth() - .padding(top = 8.dp) - .background(sentColor), - verticalAlignment = Alignment.CenterVertically + .fillMaxWidth().padding(top = 22.dp) ) { - IconButton( - onClick = { - if (!audioPlaying.value) { - AudioPlayer.play(filePath, audioPlaying, progress, duration, false) - } else { - AudioPlayer.pause(audioPlaying, progress) - } - }, - enabled = finishedRecording) { - Icon( - if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled), - stringResource(R.string.icon_descr_file), - Modifier - .padding(start = 4.dp, end = 2.dp) - .size(36.dp), - tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - val numberInText = remember(recordedDurationMs, progress.value) { - derivedStateOf { - when { - finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000 - finishedRecording -> progress.value / 1000 - else -> recordedDurationMs / 1000 - } - } - } - Text( - durationText(numberInText.value), - fontSize = 18.sp, - color = MaterialTheme.colors.secondary, - ) - Spacer(Modifier.weight(1f)) - if (cancelEnabled) { + val audioPlaying = rememberSaveable { mutableStateOf(false) } + Row( + Modifier + .height(60.dp) + .fillMaxWidth() + .background(sentColor) + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { IconButton( onClick = { - AudioPlayer.stop(filePath) - cancelVoice() + if (!audioPlaying.value) { + AudioPlayer.play(filePath, audioPlaying, progress, duration, false) + } else { + AudioPlayer.pause(audioPlaying, progress) + } }, - modifier = Modifier.padding(0.dp) + enabled = finishedRecording ) { Icon( - painterResource(R.drawable.ic_close), - contentDescription = stringResource(R.string.icon_descr_cancel_file_preview), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) + if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled), + stringResource(R.string.icon_descr_file), + Modifier + .padding(start = 4.dp, end = 2.dp) + .size(36.dp), + tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary ) } + val numberInText = remember(recordedDurationMs, progress.value) { + derivedStateOf { + when { + finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000 + finishedRecording -> progress.value / 1000 + else -> recordedDurationMs / 1000 + } + } + } + Text( + durationText(numberInText.value), + fontSize = 18.sp, + color = MaterialTheme.colors.secondary, + ) + Spacer(Modifier.weight(1f)) + if (cancelEnabled) { + IconButton( + onClick = { + AudioPlayer.stop(filePath) + cancelVoice() + }, + modifier = Modifier.padding(0.dp) + ) { + Icon( + painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.icon_descr_cancel_file_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } } } + + if (finishedRecording) { + FinishedRecordingSlider(sentColor, progress, duration, filePath) + } else { + RecordingInProgressSlider(recordedDurationMs) + } } } +@Composable +fun FinishedRecordingSlider(backgroundColor: Color, progress: MutableState, duration: MutableState, filePath: String) { + val dp4 = with(LocalDensity.current) { 4.dp.toPx() } + val dp10 = with(LocalDensity.current) { 10.dp.toPx() } + val primary = MaterialTheme.colors.primary + val inactiveTrackColor = MaterialTheme.colors.primary.mixWith( + backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha), + 0.24f) + Slider( + progress.value.toFloat(), + onValueChange = { AudioPlayer.seekTo(it.toInt(), progress, filePath) }, + Modifier + .fillMaxWidth() + .drawBehind { + drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + }, + colors = SliderDefaults.colors(inactiveTrackColor = inactiveTrackColor), + valueRange = 0f..duration.value.toFloat() + ) +} + +@Composable +fun RecordingInProgressSlider(recordedDurationMs: Int) { + val thumbPosition = remember { Animatable(0f) } + val recDuration = rememberUpdatedState(recordedDurationMs) + LaunchedEffect(Unit) { + snapshotFlow { recDuration.value } + .distinctUntilChanged() + .collect { + thumbPosition.animateTo(it.toFloat(), audioProgressBarAnimationSpec()) + } + } + val dp4 = with(LocalDensity.current) { 4.dp.toPx() } + val dp10 = with(LocalDensity.current) { 10.dp.toPx() } + val primary = MaterialTheme.colors.primary + val inactiveTrackColor = Color.Transparent + Slider( + thumbPosition.value, + onValueChange = {}, + Modifier + .fillMaxWidth() + .drawBehind { + drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + }, + colors = SliderDefaults.colors(disabledInactiveTrackColor = inactiveTrackColor, disabledActiveTrackColor = primary, thumbColor = Color.Transparent, disabledThumbColor = Color.Transparent), + enabled = false, + valueRange = 0f..MAX_VOICE_MILLIS_FOR_SENDING.toFloat() + ) +} + @Preview @Composable fun PreviewComposeAudioView() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index d8acbe6c49..f5ca6d7596 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.chat.item +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -9,20 +10,19 @@ 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.clip -import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.* import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.flow.distinctUntilChanged // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901 @@ -38,7 +38,7 @@ fun CIVoiceView( longClick: () -> Unit, ) { Row( - Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp), + Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp), verticalAlignment = Alignment.CenterVertically ) { if (file != null) { @@ -64,7 +64,9 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) { + AudioPlayer.seekTo(it, progress, filePath) + } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick) val metaReserve = if (edited) @@ -90,18 +92,67 @@ private fun VoiceLayout( timedMessagesTTL: Int?, play: () -> Unit, pause: () -> Unit, - longClick: () -> Unit + longClick: () -> Unit, + onProgressChanged: (Int) -> Unit, + ) { + @Composable + fun RowScope.Slider(backgroundColor: Color, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { + var movedManuallyTo by rememberSaveable(file.fileId) { mutableStateOf(-1) } + if (audioPlaying.value || progress.value > 0 || movedManuallyTo == progress.value) { + val dp4 = with(LocalDensity.current) { 4.dp.toPx() } + val dp10 = with(LocalDensity.current) { 10.dp.toPx() } + val primary = MaterialTheme.colors.primary + val inactiveTrackColor = + MaterialTheme.colors.primary.mixWith( + backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha), + 0.24f) + val width = with(LocalDensity.current) { LocalView.current.width.toDp() } + val colors = SliderDefaults.colors( + inactiveTrackColor = inactiveTrackColor + ) + Slider( + progress.value.toFloat(), + onValueChange = { + onProgressChanged(it.toInt()) + movedManuallyTo = it.toInt() + }, + Modifier + .size(width, 48.dp) + .weight(1f) + .padding(padding) + .drawBehind { + drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + }, + valueRange = 0f..duration.value.toFloat(), + colors = colors + ) + LaunchedEffect(Unit) { + snapshotFlow { audioPlaying.value } + .distinctUntilChanged() + .collect { + movedManuallyTo = -1 + } + } + } + } when { hasText -> { + val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage + val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Spacer(Modifier.width(6.dp)) VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) - DurationText(text, PaddingValues(start = 12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + DurationText(text, PaddingValues(start = 12.dp)) + Slider(if (ci.chatDir.sent) sentColor else receivedColor) + } } sent -> { Row { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { Spacer(Modifier.height(56.dp)) + Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp)) DurationText(text, PaddingValues(end = 12.dp)) } Column { @@ -120,8 +171,9 @@ private fun VoiceLayout( CIMetaView(ci, timedMessagesTTL) } } - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { DurationText(text, PaddingValues(start = 12.dp)) + Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) Spacer(Modifier.height(56.dp)) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index 2a74524b3c..a9cfc1b906 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -64,7 +64,6 @@ fun DatabaseView( importArchiveAlert(m, context, uri, appFilesCountAndSize, progressIndicator) } } - val chatDbDeleted = remember { m.chatDbDeleted } LaunchedEffect(m.chatRunning) { runChat.value = m.chatRunning.value ?: true } @@ -83,7 +82,6 @@ fun DatabaseView( chatArchiveName, chatArchiveTime, chatLastStart, - chatDbDeleted.value, m.controller.appPrefs.privacyFullBackup, appFilesCountAndSize, chatItemTTL, @@ -134,7 +132,6 @@ fun DatabaseLayout( chatArchiveName: MutableState, chatArchiveTime: MutableState, chatLastStart: MutableState, - chatDbDeleted: Boolean, privacyFullBackup: SharedPreference, appFilesCountAndSize: MutableState>, chatItemTTL: MutableState, @@ -173,7 +170,7 @@ fun DatabaseLayout( SectionDividerSpaced(maxTopPadding = true) SectionView(stringResource(R.string.run_chat_section)) { - RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert) + RunChatSetting(runChat, stopped, startChat, stopChatAlert) } SectionDividerSpaced() @@ -330,7 +327,6 @@ private fun TtlOptions(current: State, enabled: State, onS fun RunChatSetting( runChat: Boolean, stopped: Boolean, - chatDbDeleted: Boolean, startChat: () -> Unit, stopChatAlert: () -> Unit ) { @@ -341,7 +337,6 @@ fun RunChatSetting( iconColor = if (stopped) Color.Red else MaterialTheme.colors.primary, ) { DefaultSwitch( - enabled = !chatDbDeleted, checked = runChat, onCheckedChange = { runChatSwitch -> if (runChatSwitch) { @@ -371,9 +366,14 @@ private fun startChat(m: ChatModel, runChat: MutableState, chatLastSta ModalManager.shared.closeModals() return@withApi } - m.controller.apiStartChat() - runChat.value = true - m.chatRunning.value = true + if (m.currentUser.value == null) { + ModalManager.shared.closeModals() + return@withApi + } else { + m.controller.apiStartChat() + runChat.value = true + m.chatRunning.value = true + } val ts = Clock.System.now() m.controller.appPrefs.chatLastStart.set(ts) chatLastStart.value = ts @@ -410,7 +410,7 @@ private fun authStopChat(m: ChatModel, runChat: MutableState, context: authenticate( generalGetString(R.string.auth_stop_chat), generalGetString(R.string.auth_log_in_using_credential), - context as FragmentActivity, + activity = context as FragmentActivity, completed = { laResult -> when (laResult) { LAResult.Success, is LAResult.Unavailable -> { @@ -433,18 +433,28 @@ private fun authStopChat(m: ChatModel, runChat: MutableState, context: private fun stopChat(m: ChatModel, runChat: MutableState, context: Context) { withApi { try { - m.controller.apiStopChat() runChat.value = false - m.chatRunning.value = false - SimplexService.safeStopService(context) + stopChatAsync(m) + SimplexService.safeStopService(SimplexApp.context) MessagesFetcherWorker.cancelAll() } catch (e: Error) { runChat.value = true - AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString()) + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_stopping_chat), e.toString()) } } } +suspend fun stopChatAsync(m: ChatModel) { + m.controller.apiStopChat() + m.chatRunning.value = false +} + +suspend fun deleteChatAsync(m: ChatModel) { + m.controller.apiDeleteStorage() + DatabaseUtils.ksDatabasePassword.remove() + m.controller.appPrefs.storeDBPassphrase.set(true) +} + private fun exportArchive( context: Context, m: ChatModel, @@ -619,10 +629,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { progressIndicator.value = true withApi { try { - m.controller.apiDeleteStorage() - m.chatDbDeleted.value = true - DatabaseUtils.ksDatabasePassword.remove() - m.controller.appPrefs.storeDBPassphrase.set(true) + deleteChatAsync(m) operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile)) } @@ -717,7 +724,6 @@ fun PreviewDatabaseLayout() { chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, chatLastStart = remember { mutableStateOf(Clock.System.now()) }, - chatDbDeleted = false, privacyFullBackup = SharedPreference({ true }, {}), appFilesCountAndSize = remember { mutableStateOf(0 to 0L) }, chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt index c82f2773d4..68829da559 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt @@ -18,9 +18,11 @@ object DatabaseUtils { private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword" private const val APP_PASSWORD_ALIAS: String = "appPassword" + private const val SELF_DESTRUCT_PASSWORD_ALIAS: String = "selfDestructPassword" val ksDatabasePassword = KeyStoreItem(DATABASE_PASSWORD_ALIAS, appPreferences.encryptedDBPassphrase, appPreferences.initializationVectorDBPassphrase) val ksAppPassword = KeyStoreItem(APP_PASSWORD_ALIAS, appPreferences.encryptedAppPassphrase, appPreferences.initializationVectorAppPassphrase) + val ksSelfDestructPassword = KeyStoreItem(SELF_DESTRUCT_PASSWORD_ALIAS, appPreferences.encryptedSelfDestructPassphrase, appPreferences.initializationVectorSelfDestructPassphrase) class KeyStoreItem(private val alias: String, val passphrase: SharedPreference, val initVector: SharedPreference) { fun get(): String? { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt index 97ef5e1082..74c8cdedd0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt @@ -28,16 +28,18 @@ data class LocalAuthRequest ( val title: String?, val reason: String, val password: String, + val selfDestruct: Boolean, val completed: (LAResult) -> Unit ) { companion object { - val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "") { } + val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "", selfDestruct = false) { } } } fun authenticate( promptTitle: String, promptSubtitle: String, + selfDestruct: Boolean = false, activity: FragmentActivity, usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(), completed: (LAResult) -> Unit @@ -59,7 +61,7 @@ fun authenticate( completed(LAResult.Error(generalGetString(R.string.authentication_cancelled))) } Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password) { + LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && SimplexApp.context.chatModel.controller.appPrefs.selfDestruct.get()) { close() completed(it) }) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt index ab1e123960..d4b48bdb9d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt @@ -78,8 +78,9 @@ class ModalManager { } fun closeModals() { - while (modalCount.value > 0) closeModal() - passcodeView.value = null + modalViews.clear() + toRemove.clear() + modalCount.value = 0 } @OptIn(ExperimentalAnimationApi::class) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index 7e0e481bc1..f3aa27aa24 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -274,6 +274,13 @@ object AudioPlayer { audioPlaying.value = false } + fun seekTo(ms: Int, pro: MutableState, filePath: String?) { + pro.value = ms + if (this.currentlyPlaying.value?.first == filePath) { + player.seekTo(ms) + } + } + fun duration(filePath: String): Int? { var res: Int? = null kotlin.runCatching { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 39f7d88932..f001719b1f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -23,9 +23,11 @@ import android.view.View import android.view.ViewTreeObserver import android.view.inputmethod.InputMethodManager import androidx.annotation.StringRes +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.* @@ -33,6 +35,7 @@ import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.* import androidx.core.content.FileProvider +import androidx.core.graphics.ColorUtils import androidx.core.text.HtmlCompat import chat.simplex.app.* import chat.simplex.app.R @@ -587,6 +590,9 @@ fun Color.darker(factor: Float = 0.1f): Color = fun Color.lighter(factor: Float = 0.1f): Color = Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha) +fun Color.mixWith(color: Color, alpha: Float): Color = + Color(ColorUtils.blendARGB(color.toArgb(), toArgb(), alpha)) + fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt index 25772c701a..e972ad8620 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt @@ -1,22 +1,80 @@ package chat.simplex.app.views.localauth +import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.res.stringResource -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel +import chat.simplex.app.* +import chat.simplex.app.model.* +import chat.simplex.app.views.database.deleteChatAsync +import chat.simplex.app.views.database.stopChatAsync import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword +import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword +import chat.simplex.app.views.onboarding.OnboardingStage +import kotlinx.coroutines.delay @Composable fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) { val passcode = rememberSaveable { mutableStateOf("") } PasscodeView(passcode, authRequest.title ?: stringResource(R.string.la_enter_app_passcode), authRequest.reason, stringResource(R.string.submit_passcode), submit = { - val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode)) - authRequest.completed(r) + val sdPassword = ksSelfDestructPassword.get() + if (sdPassword == passcode.value && authRequest.selfDestruct) { + deleteStorageAndRestart(m, sdPassword) { r -> + authRequest.completed(r) + } + } else { + val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode)) + authRequest.completed(r) + } }, cancel = { authRequest.completed(LAResult.Error(generalGetString(R.string.authentication_cancelled))) }) } + +private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (LAResult) -> Unit) { + withBGApi { + try { + stopChatAsync(m) + deleteChatAsync(m) + ksAppPassword.set(password) + ksSelfDestructPassword.remove() + m.controller.ntfManager.cancelAllNotifications() + val selfDestructPref = m.controller.appPrefs.selfDestruct + val displayNamePref = m.controller.appPrefs.selfDestructDisplayName + val displayName = displayNamePref.get() + selfDestructPref.set(false) + displayNamePref.set(null) + m.chatDbChanged.value = true + m.chatDbStatus.value = null + try { + SimplexApp.context.initChatController(startChat = true) + } catch (e: Exception) { + Log.d(TAG, "initializeChat ${e.stackTraceToString()}") + } + m.chatDbChanged.value = false + if (m.currentUser.value != null) { + return@withBGApi + } + var profile: Profile? = null + if (!displayName.isNullOrEmpty()) { + profile = Profile(displayName = displayName, fullName = "") + } + val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true) + m.currentUser.value = createdUser + m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) + m.onboardingStage.value = OnboardingStage.OnboardingComplete + if (createdUser != null) { + m.controller.startChat(createdUser) + } + ModalManager.shared.closeModals() + AlertManager.shared.hideAlert() + completed(LAResult.Success) + } catch (e: Exception) { + completed(LAResult.Error(generalGetString(R.string.incorrect_passcode))) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/localauth/SetAppPasscodeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/localauth/SetAppPasscodeView.kt index cb6c6368d0..d7214a4143 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/localauth/SetAppPasscodeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/localauth/SetAppPasscodeView.kt @@ -4,11 +4,15 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import chat.simplex.app.R +import chat.simplex.app.views.helpers.DatabaseUtils import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword import chat.simplex.app.views.helpers.generalGetString @Composable fun SetAppPasscodeView( + passcodeKeychain: DatabaseUtils.KeyStoreItem = ksAppPassword, + title: String = generalGetString(R.string.new_passcode), + reason: String? = null, submit: () -> Unit, cancel: () -> Unit, close: () -> Unit @@ -23,7 +27,7 @@ fun SetAppPasscodeView( close() cancel() } - PasscodeView(passcode, title = title, submitLabel = submitLabel, submitEnabled = submitEnabled, submit = submit) { + PasscodeView(passcode, title = title, reason = reason, submitLabel = submitLabel, submitEnabled = submitEnabled, submit = submit) { close() cancel() } @@ -36,7 +40,7 @@ fun SetAppPasscodeView( submitEnabled = { pwd -> pwd == enteredPassword } ) { if (passcode.value == enteredPassword) { - ksAppPassword.set(passcode.value) + passcodeKeychain.set(passcode.value) enteredPassword = "" passcode.value = "" close() @@ -44,7 +48,7 @@ fun SetAppPasscodeView( } } } else { - SetPasswordView(generalGetString(R.string.new_passcode), generalGetString(R.string.save_verb)) { + SetPasswordView(title, generalGetString(R.string.save_verb)) { enteredPassword = passcode.value passcode.value = "" confirming = true diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index 997a9f9e54..ae38a01520 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -126,6 +126,30 @@ fun SharedPreferenceToggleWithIcon( } } +@Composable +fun SharedPreferenceToggleWithIcon( + text: String, + icon: Painter, + onClickInfo: () -> Unit, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(text, Modifier.padding(end = 4.dp)) + Icon( + icon, + null, + Modifier.clickable(onClick = onClickInfo), + tint = MaterialTheme.colors.primary + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} + @Composable fun SharedPreferenceRadioButton(text: String, prefState: MutableState, preference: SharedPreference, value: T) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt index d2efb8416e..020d1d300c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt @@ -6,9 +6,8 @@ import SectionItemView import SectionTextFooter import SectionView import android.view.WindowManager +import androidx.compose.foundation.* 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.ui.Alignment @@ -17,12 +16,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.ProfileNameField import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword +import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword +import chat.simplex.app.views.isValidDisplayName import chat.simplex.app.views.localauth.SetAppPasscodeView +import chat.simplex.app.views.onboarding.ReadableText enum class LAMode { SYSTEM, @@ -111,6 +116,9 @@ fun SimplexLockView( val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay } val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } } val activity = LocalContext.current as FragmentActivity + val selfDestructPref = remember { chatModel.controller.appPrefs.selfDestruct } + 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) @@ -123,6 +131,11 @@ fun SimplexLockView( laUnavailableInstructionAlert() } + fun resetSelfDestruct() { + selfDestructPref.set(false) + ksSelfDestructPassword.remove() + } + fun toggleLAMode(toLAMode: LAMode) { authenticate( if (toLAMode == LAMode.SYSTEM) { @@ -130,7 +143,7 @@ fun SimplexLockView( } else { generalGetString(R.string.chat_lock) }, - generalGetString(R.string.change_lock_mode), activity + generalGetString(R.string.change_lock_mode), activity = activity ) { laResult -> when (laResult) { is LAResult.Error -> { @@ -140,16 +153,15 @@ fun SimplexLockView( LAResult.Success -> { when (toLAMode) { LAMode.SYSTEM -> { - authenticate(generalGetString(R.string.auth_enable_simplex_lock), promptSubtitle = "", activity, toLAMode) { laResult -> + authenticate(generalGetString(R.string.auth_enable_simplex_lock), promptSubtitle = "", activity = activity, usingLAMode = toLAMode) { laResult -> when (laResult) { LAResult.Success -> { currentLAMode.set(toLAMode) ksAppPassword.remove() + resetSelfDestruct() laTurnedOnAlert() } - is LAResult.Unavailable, is LAResult.Error -> { - laFailedAlert() - } + is LAResult.Unavailable, is LAResult.Error -> laFailedAlert() is LAResult.Failed -> { /* Can be called multiple times on every failure */ } } } @@ -164,7 +176,7 @@ fun SimplexLockView( passcodeAlert(generalGetString(R.string.passcode_set)) }, cancel = {}, - close + close = close ) } } @@ -176,8 +188,27 @@ fun SimplexLockView( } } + fun toggleSelfDestruct(selfDestruct: SharedPreference) { + authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.change_self_destruct_mode), activity = activity) { laResult -> + when (laResult) { + is LAResult.Error -> laFailedAlert() + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + LAResult.Success -> { + if (!selfDestruct.get()) { + ModalManager.shared.showCustomModal { close -> + EnableSelfDestruct(selfDestruct, close) + } + } else { + resetSelfDestruct() + } + } + is LAResult.Unavailable -> disableUnavailableLA() + } + } + } + fun changeLAPassword() { - authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.la_change_app_passcode), activity) { laResult -> + authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.la_change_app_passcode), activity = activity) { laResult -> when (laResult) { LAResult.Success -> { ModalManager.shared.showCustomModal { close -> @@ -187,7 +218,32 @@ fun SimplexLockView( passcodeAlert(generalGetString(R.string.passcode_changed)) }, cancel = { passcodeAlert(generalGetString(R.string.passcode_not_changed)) - }, close + }, close = close + ) + } + } + } + is LAResult.Error -> laFailedAlert() + is LAResult.Failed -> {} + is LAResult.Unavailable -> disableUnavailableLA() + } + } + } + + fun changeSelfDestructPassword() { + authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.change_self_destruct_passcode), activity = activity) { laResult -> + when (laResult) { + LAResult.Success -> { + ModalManager.shared.showCustomModal { close -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + passcodeKeychain = ksSelfDestructPassword, + submit = { + selfDestructPasscodeAlert(generalGetString(R.string.self_destruct_passcode_changed)) + }, cancel = { + passcodeAlert(generalGetString(R.string.passcode_not_changed)) + }, + close = close ) } } @@ -223,7 +279,8 @@ fun SimplexLockView( }, cancel = { resetLAEnabled(false) - }, close + }, + close = close ) } } @@ -250,11 +307,85 @@ fun SimplexLockView( } } } + if (performLA.value && laMode.value == LAMode.PASSCODE) { + SectionDividerSpaced() + SectionView(stringResource(R.string.self_destruct_passcode).uppercase()) { + val openInfo = { + ModalManager.shared.showModal { + SelfDestructInfoView() + } + } + SettingsActionItemWithContent(null, null, click = openInfo) { + SharedPreferenceToggleWithIcon( + stringResource(R.string.enable_self_destruct), + painterResource(R.drawable.ic_info), + openInfo, + remember { selfDestructPref.state }.value + ) { + toggleSelfDestruct(selfDestructPref) + } + } + + if (remember { selfDestructPref.state }.value) { + Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) { + Text( + stringResource(R.string.self_destruct_new_display_name), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(selfDestructDisplayName, "", ::isValidDisplayName) + LaunchedEffect(selfDestructDisplayName.value) { + val new = selfDestructDisplayName.value + if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) { + selfDestructDisplayNamePref.set(new) + } + } + } + SectionItemView({ changeSelfDestructPassword() }) { + Text(stringResource(R.string.change_self_destruct_passcode)) + } + } + } + } } SectionBottomSpacer() } } +@Composable +private fun SelfDestructInfoView() { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), + ) { + AppBarTitle(stringResource(R.string.self_destruct), withPadding = false) + ReadableText(stringResource(R.string.if_you_enter_self_destruct_code)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TextListItem("1.", stringResource(R.string.all_app_data_will_be_cleared)) + TextListItem("2.", stringResource(R.string.app_passcode_replaced_with_self_destruct)) + TextListItem("3.", stringResource(R.string.empty_chat_profile_is_created)) + } + SectionBottomSpacer() + } +} + +@Composable +private fun EnableSelfDestruct( + selfDestruct: SharedPreference, + close: () -> Unit +) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + passcodeKeychain = ksSelfDestructPassword, title = generalGetString(R.string.set_passcode), reason = generalGetString(R.string.enabled_self_destruct_passcode), + submit = { + selfDestruct.set(true) + selfDestructPasscodeAlert(generalGetString(R.string.self_destruct_passcode_enabled)) + }, + cancel = {}, + close = close + ) + } +} + @Composable private fun EnableLock(performLA: MutableState, onCheckedChange: (Boolean) -> Unit) { SectionItemView { @@ -300,6 +431,14 @@ private fun LockDelaySelector(state: State, onSelected: (Int) -> Unit) { ) } +@Composable +private fun TextListItem(n: String, text: String) { + Box { + Text(n) + Text(text, Modifier.padding(start = 20.dp)) + } +} + private fun laDelayText(t: Int): String { val m = t / 60 val s = t % 60 @@ -319,3 +458,7 @@ private fun passcodeAlert(title: String) { text = generalGetString(R.string.la_please_remember_to_store_password) ) } + +private fun selfDestructPasscodeAlert(title: String) { + AlertManager.shared.showAlertMsg(title, generalGetString(R.string.if_you_enter_passcode_data_removed)) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 65482116a0..1875dc9cac 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -483,7 +483,7 @@ private fun runAuth(title: String, desc: String, context: Context, onFinish: (su authenticate( title, desc, - context as FragmentActivity, + activity = context as FragmentActivity, completed = { laResult -> onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable) } diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index e8628988d0..ed8189604f 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -808,7 +808,7 @@ Lock mode Lock after Submit - Confirm Passcode + Confirm passcode Incorrect passcode New Passcode Authentication cancelled @@ -818,6 +818,21 @@ Passcode changed! Passcode not changed! Change lock mode + Self-destruct + Enable self-destruct passcode + Change self-destruct mode + Change self-destruct passcode + Self-destruct passcode enabled! + Self-destruct passcode changed! + Self-destruct passcode + Enable self-destruct + New display name: + If you enter your self-destruct passcode while opening the app: + All app data is deleted. + App passcode is replaced with self-destruct passcode. + An empty chat profile with the provided name is created, and the app opens as usual. + If you enter this passcode when opening the app, all app data will be irreversibly removed! + Set passcode YOU diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f616a9929c..97b073cb48 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -125,8 +125,8 @@ func apiGetActiveUser() throws -> User? { } } -func apiCreateActiveUser(_ p: Profile?) throws -> User { - let r = chatSendCmdSync(.createActiveUser(profile: p)) +func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false) throws -> User { + let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp)) if case let .activeUser(user) = r { return user } throw r } @@ -295,6 +295,12 @@ func loadChat(chat: Chat, search: String = "") { } } +func apiGetChatItemInfo(itemId: Int64) async throws -> ChatItemInfo { + let r = await chatSendCmd(.apiGetChatItemInfo(itemId: itemId)) + if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo } + throw r +} + func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? { let chatModel = ChatModel.shared let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d5ed8a67ff..ac9b7236cf 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -327,7 +327,11 @@ func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorSch } func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color { - ci.chatDir.sent + ciDirFrameColor(chatItemSent: ci.chatDir.sent, colorScheme: colorScheme) +} + +func ciDirFrameColor(chatItemSent: Bool, colorScheme: ColorScheme) -> Color { + chatItemSent ? (colorScheme == .light ? sentColorLight : sentColorDark) : Color(uiColor: .tertiarySystemGroupedBackground) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift new file mode 100644 index 0000000000..748b6a83c0 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -0,0 +1,147 @@ +// +// ChatItemInfoView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 09.05.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChatItemInfoView: View { + @Environment(\.colorScheme) var colorScheme + var chatItemSent: Bool + @Binding var chatItemInfo: ChatItemInfo? + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + + var body: some View { + if let chatItemInfo = chatItemInfo { + NavigationView { + itemInfoView(chatItemInfo) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShareSheet(items: [itemInfoShareText(chatItemInfo)]) } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + } + } else { + Text("No message details") + } + } + + @ViewBuilder private func itemInfoView(_ chatItemInfo: ChatItemInfo) -> some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Message details") + .font(.largeTitle) + .bold() + .padding(.bottom) + + let maxWidth = (g.size.width - 32) * 0.84 + if developerTools { + infoRow("Database ID", "\(chatItemInfo.chatItemId)") + } + infoRow("Sent at", localTimestamp(chatItemInfo.itemTs)) + if !chatItemSent { + infoRow("Received at", localTimestamp(chatItemInfo.createdAt)) + } + + if !chatItemInfo.itemVersions.isEmpty { + Divider() + .padding(.top) + + Text("Edit history") + .font(.title) + .padding(.bottom, 4) + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(Array(chatItemInfo.itemVersions.enumerated()), id: \.element.chatItemVersionId) { index, itemVersion in + itemVersionView(itemVersion, maxWidth, current: index == 0) + } + } + } + } + } + .padding() + .frame(maxHeight: .infinity, alignment: .top) + } + } + + @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { + let uiMenu: Binding = Binding( + get: { UIMenu(title: "", children: itemVersionMenu(itemVersion)) }, + set: { _ in } + ) + VStack(alignment: .leading, spacing: 4) { + messageText(itemVersion.msgContent.text, parseSimpleXMarkdown(itemVersion.msgContent.text), nil) + .allowsHitTesting(false) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(ciDirFrameColor(chatItemSent: chatItemSent, colorScheme: colorScheme)) + .cornerRadius(18) + .uiKitContextMenu(menu: uiMenu) + Text( + localTimestamp(itemVersion.itemVersionTs) + + (current + ? (" (" + NSLocalizedString("Current", comment: "designation of the current version of the message") + ")") + : "") + ) + .foregroundStyle(.secondary) + .font(.caption) + .padding(.horizontal, 12) + } + .frame(maxWidth: maxWidth, alignment: .leading) + } + + func itemVersionMenu(_ itemVersion: ChatItemVersion) -> [UIAction] {[ + UIAction( + title: NSLocalizedString("Share", comment: "chat item action"), + image: UIImage(systemName: "square.and.arrow.up") + ) { _ in + showShareSheet(items: [itemVersion.msgContent.text]) + }, + UIAction( + title: NSLocalizedString("Copy", comment: "chat item action"), + image: UIImage(systemName: "doc.on.doc") + ) { _ in + UIPasteboard.general.string = itemVersion.msgContent.text + } + ]} + + func itemInfoShareText(_ chatItemInfo: ChatItemInfo) -> String { + var shareText = "" + let nl = "\n" + shareText += "Message details" + nl + nl + if developerTools { + shareText += "Database ID: \(chatItemInfo.chatItemId)" + nl + } + shareText += "Sent at: \(localTimestamp(chatItemInfo.itemTs))" + nl + if !chatItemSent { + shareText += "Received at: \(localTimestamp(chatItemInfo.createdAt))" + nl + } + if !chatItemInfo.itemVersions.isEmpty { + shareText += nl + "Edit history" + nl + nl + for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() { + shareText += localTimestamp(itemVersion.itemVersionTs) + (index == 0 ? " (Current)" : "") + ":" + nl + shareText += itemVersion.msgContent.text + nl + nl + } + } + return shareText.trimmingCharacters(in: .newlines) + } +} + +func localTimestamp(_ date: Date) -> String { + let localDateFormatter = DateFormatter() + localDateFormatter.dateStyle = .medium + localDateFormatter.timeStyle = .medium + return localDateFormatter.string(from: date) +} + +struct ChatItemInfoView_Previews: PreviewProvider { + static var previews: some View { + ChatItemInfoView(chatItemSent: true, chatItemInfo: Binding.constant(nil)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index b008d420a8..55f8fa5c63 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -445,6 +445,8 @@ struct ChatView: View { @Binding var showDeleteMessage: Bool @State private var revealed = false + @State private var showChatItemInfoSheet: Bool = false + @State private var chatItemInfo: ChatItemInfo? var body: some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading @@ -467,6 +469,11 @@ struct ChatView: View { } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) + .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { + chatItemInfo = nil + }) { + ChatItemInfoView(chatItemSent: ci.chatDir.sent, chatItemInfo: $chatItemInfo) + } } private func menu(live: Bool) -> [UIAction] { @@ -491,6 +498,7 @@ struct ChatView: View { if ci.meta.editable && !mc.isVoice && !live { menu.append(editAction()) } + menu.append(viewInfoUIAction()) if revealed { menu.append(hideUIAction()) } @@ -589,6 +597,25 @@ struct ChatView: View { } } + private func viewInfoUIAction() -> UIAction { + UIAction( + title: NSLocalizedString("View details", comment: "chat item action"), + image: UIImage(systemName: "info") + ) { _ in + Task { + do { + let ciInfo = try await apiGetChatItemInfo(itemId: ci.id) + await MainActor.run { + chatItemInfo = ciInfo + } + } catch let error { + logger.error("apiGetChatItemInfo error: \(responseError(error))") + } + await MainActor.run { showChatItemInfoSheet = true } + } + } + } + private func cancelFileUIAction(_ fileId: Int64, _ cancelAction: CancelAction) -> UIAction { return UIAction( title: cancelAction.uiAction, diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index c06ed66f17..711ae826c6 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -104,31 +104,33 @@ private class CustomUITextField: UITextView, UITextViewDelegate { } func textViewDidChange(_ textView: UITextView) { - var images: [UploadContent] = [] - var rangeDiff = 0 - let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText) - textView.attributedText.enumerateAttribute( - NSAttributedString.Key.attachment, - in: NSRange(location: 0, length: textView.attributedText.length), - options: [], - using: { value, range, _ in - if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents { - do { - images.append(.animatedImage(image: try UIImage(gifData: attachment))) - } catch { - if let img = (value as? NSTextAttachment)?.image { - images.append(.simpleImage(image: img)) + if textView.markedTextRange == nil { + var images: [UploadContent] = [] + var rangeDiff = 0 + let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText) + textView.attributedText.enumerateAttribute( + NSAttributedString.Key.attachment, + in: NSRange(location: 0, length: textView.attributedText.length), + options: [], + using: { value, range, _ in + if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents { + do { + images.append(.animatedImage(image: try UIImage(gifData: attachment))) + } catch { + if let img = (value as? NSTextAttachment)?.image { + images.append(.simpleImage(image: img)) + } } + newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "") + rangeDiff += range.length } - newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "") - rangeDiff += range.length } + ) + if textView.attributedText != newAttributedText { + textView.attributedText = newAttributedText } - ) - if textView.attributedText != newAttributedText { - textView.attributedText = newAttributedText + onTextChanged(textView.text, images) } - onTextChanged(textView.text, images) } func textViewDidBeginEditing(_ textView: UITextView) { diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 8200a2c0f5..59b13e45b3 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -58,7 +58,7 @@ struct LocalAuthView: View { if let displayName = displayName, displayName != "" { profile = Profile(displayName: displayName, fullName: "") } - m.currentUser = try apiCreateActiveUser(profile) + m.currentUser = try apiCreateActiveUser(profile, pastTimestamp: true) onboardingStageDefault.set(.onboardingComplete) m.onboardingStage = .onboardingComplete try startChat() diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index a6f12cb275..982768710a 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -120,8 +120,8 @@ struct SimplexLockView: View { case laUnavailableTurningOffAlert case laPasscodeSetAlert case laPasscodeChangedAlert - case laSeldDestructPasscodeSetAlert - case laSeldDestructPasscodeChangedAlert + case laSelfDestructPasscodeSetAlert + case laSelfDestructPasscodeChangedAlert case laPasscodeNotChangedAlert var id: Self { self } @@ -238,8 +238,8 @@ struct SimplexLockView: View { case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert() case .laPasscodeSetAlert: return passcodeAlert("Passcode set!") case .laPasscodeChangedAlert: return passcodeAlert("Passcode changed!") - case .laSeldDestructPasscodeSetAlert: return selfDestructPasscodeAlert("Self-destruct passcode enabled!") - case .laSeldDestructPasscodeChangedAlert: return selfDestructPasscodeAlert("Self-destruct passcode changed!") + case .laSelfDestructPasscodeSetAlert: return selfDestructPasscodeAlert("Self-destruct passcode enabled!") + case .laSelfDestructPasscodeChangedAlert: return selfDestructPasscodeAlert("Self-destruct passcode changed!") case .laPasscodeNotChangedAlert: return mkAlert(title: "Passcode not changed!") } } @@ -272,13 +272,13 @@ struct SimplexLockView: View { case .enableSelfDestruct: SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, title: "Set passcode", reason: NSLocalizedString("Enable self-destruct passcode", comment: "set passcode view")) { updateSelfDestruct() - showLAAlert(.laSeldDestructPasscodeSetAlert) + showLAAlert(.laSelfDestructPasscodeSetAlert) } cancel: { revertSelfDestruct() } case .changeSelfDestructPasscode: SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, reason: NSLocalizedString("Change self-destruct passcode", comment: "set passcode view")) { - showLAAlert(.laSeldDestructPasscodeChangedAlert) + showLAAlert(.laSelfDestructPasscodeChangedAlert) } cancel: { showLAAlert(.laPasscodeNotChangedAlert) } @@ -296,17 +296,17 @@ struct SimplexLockView: View { private func selfDestructInfoView() -> some View { VStack(alignment: .leading) { - Text("Self-desctruct") + Text("Self-destruct") .font(.largeTitle) .bold() .padding(.vertical) ScrollView { VStack(alignment: .leading) { Group { - Text("If you enter your self-desctruct passcode while opening the app:") + Text("If you enter your self-destruct passcode while opening the app:") VStack(spacing: 8) { textListItem("1.", "All app data is deleted.") - textListItem("2.", "App passcode is replaced with self-desctruct passcode.") + textListItem("2.", "App passcode is replaced with self-destruct passcode.") textListItem("3.", "An empty chat profile with the provided name is created, and the app opens as usual.") } } @@ -338,6 +338,7 @@ struct SimplexLockView: View { switch laResult { case .success: _ = kcAppPassword.remove() + resetSelfDestruct() laAlert = .laTurnedOnAlert case .failed, .unavailable: currentLAMode = .passcode diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8371053644..de0fde24a1 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -172,6 +172,7 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; @@ -442,6 +443,7 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; @@ -548,6 +550,7 @@ 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */, 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */, 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, + 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, ); path = Chat; sourceTree = ""; @@ -1058,6 +1061,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 561bbc48d0..eb21b347d8 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -14,7 +14,7 @@ let jsonEncoder = getJSONEncoder() public enum ChatCommand { case showActiveUser - case createActiveUser(profile: Profile?) + case createActiveUser(profile: Profile?, sameServers: Bool, pastTimestamp: Bool) case listUsers case apiSetActiveUser(userId: Int64, viewPwd: String?) case apiHideUser(userId: Int64, viewPwd: String) @@ -36,6 +36,7 @@ public enum ChatCommand { case apiStorageEncryption(config: DBEncryptionConfig) case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) + case apiGetChatItemInfo(itemId: Int64) case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) @@ -110,11 +111,9 @@ public enum ChatCommand { get { switch self { case .showActiveUser: return "/u" - case let .createActiveUser(profile): - if let profile = profile { - return "/create user \(profile.displayName) \(profile.fullName)" - } - return "/create user" + case let .createActiveUser(profile, sameServers, pastTimestamp): + let user = NewUser(profile: profile, sameServers: sameServers, pastTimestamp: pastTimestamp) + return "/_create user \(encodeJSON(user))" case .listUsers: return "/users" case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" @@ -141,6 +140,7 @@ public enum ChatCommand { case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") + case let .apiGetChatItemInfo(itemId): return "/_get item info \(itemId)" case let .apiSendMessage(type, id, file, quotedItemId, mc, live): let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) return "/_send \(ref(type, id)) live=\(onOff(live)) json \(msg)" @@ -247,6 +247,7 @@ public enum ChatCommand { case .apiStorageEncryption: return "apiStorageEncryption" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" + case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessage: return "apiSendMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" @@ -385,6 +386,7 @@ public enum ChatResponse: Decodable, Error { case chatSuspended case apiChats(user: User, chats: [ChatData]) case apiChat(user: User, chat: ChatData) + case chatItemInfo(user: User, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case userProtoServers(user: User, servers: UserProtoServers) case serverTestResult(user: User, testServer: String, testFailure: ProtocolTestFailure?) case chatItemTTL(user: User, chatItemTTL: Int64?) @@ -501,6 +503,7 @@ public enum ChatResponse: Decodable, Error { case .chatSuspended: return "chatSuspended" case .apiChats: return "apiChats" case .apiChat: return "apiChat" + case .chatItemInfo: return "chatItemInfo" case .userProtoServers: return "userProtoServers" case .serverTestResult: return "serverTestResult" case .chatItemTTL: return "chatItemTTL" @@ -616,6 +619,7 @@ public enum ChatResponse: Decodable, Error { case .chatSuspended: return noDetails case let .apiChats(u, chats): return withUser(u, String(describing: chats)) case let .apiChat(u, chat): return withUser(u, String(describing: chat)) + case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) @@ -729,6 +733,12 @@ public enum ChatResponse: Decodable, Error { } } +struct NewUser: Encodable { + var profile: Profile? + var sameServers: Bool + var pastTimestamp: Bool +} + public enum ChatPagination { case last(count: Int) case after(chatItemId: Int64, count: Int) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f82bb844bc..492758fa04 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2857,3 +2857,18 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable { return lhs.comparisonValue < rhs.comparisonValue } } + +public struct ChatItemInfo: Decodable { + public var chatItemId: Int64 + public var itemTs: Date + public var createdAt: Date + public var updatedAt: Date + public var itemVersions: [ChatItemVersion] +} + +public struct ChatItemVersion: Decodable { + public var chatItemVersionId: Int64 + public var msgContent: MsgContent + public var itemVersionTs: Date + public var createdAt: Date +} diff --git a/cabal.project b/cabal.project index 5930246437..d4c50d9e35 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8954f39425d971025e5e3df1a1628281dab61a3c + tag: ce64c91d5a6e4cbc6cfae6b4a5b312224931f10c source-repository-package type: git diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 0000000000..6b06a18fd2 --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,25 @@ +# App color themes + +Mobile apps allow to configure, export and import color themes. Currently this is only supported in Android app. + +You can contribute your themes to the repository by creating a pull request. + +## How to contribute a theme + +1. Once you have configured your theme in the app, export it to a file and give it a descriptive name – e.g., `example.theme` + +2. Export your app database, and import a [sample chat database](./themes/simplex-chat.sample.zip). + +3. Make three screenshots - the list of conversations with opened profile picker, conversation and privacy settings. + +4. Create PR that includes these files and amends this THEMES.md file, following the example below. + +5. Restore your database from the backup. + +## Color themes + +### SimpleX - included dark blue theme + +Download [SimpleX theme](./themes/example.theme) + +          diff --git a/docs/themes/example-chats.png b/docs/themes/example-chats.png new file mode 100644 index 0000000000..15527729e6 Binary files /dev/null and b/docs/themes/example-chats.png differ diff --git a/docs/themes/example-conversation.png b/docs/themes/example-conversation.png new file mode 100644 index 0000000000..3d40293957 Binary files /dev/null and b/docs/themes/example-conversation.png differ diff --git a/docs/themes/example-settings.png b/docs/themes/example-settings.png new file mode 100644 index 0000000000..3b57c44cf9 Binary files /dev/null and b/docs/themes/example-settings.png differ diff --git a/docs/themes/example.theme b/docs/themes/example.theme new file mode 100644 index 0000000000..29c48c0f4d --- /dev/null +++ b/docs/themes/example.theme @@ -0,0 +1,11 @@ +base: "SIMPLEX" +colors: + accent: "#ff70f0f9" + accentVariant: "#ff1298a5" + secondary: "#ff8b8786" + secondaryVariant: "#ff2c464d" + background: "#ff111528" + menus: "#ff121c37" + title: "#ff267be5" + sentMessage: "#1e45b8ff" + receivedMessage: "#20b1b0b5" \ No newline at end of file diff --git a/docs/themes/simplex-chat.sample.zip b/docs/themes/simplex-chat.sample.zip new file mode 100644 index 0000000000..048bfd9684 Binary files /dev/null and b/docs/themes/simplex-chat.sample.zip differ diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 12b2c4eec0..c2cf8dbda9 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8954f39425d971025e5e3df1a1628281dab61a3c" = "0m670s43m1ym51firdzxj77k49bi8qq5gwxc7w50nv8r2yl62ckd"; + "https://github.com/simplex-chat/simplexmq.git"."ce64c91d5a6e4cbc6cfae6b4a5b312224931f10c" = "0iz4hyh1s469vd9nry7wbxbqhc7p1z3qvlyawfayzna1jm0c1mcq"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e049efcc1c..91a2b12db0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -215,23 +215,25 @@ cfgServers = \case startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) startChatController subConns enableExpireCIs startXFTPWorkers = do asks smpAgent >>= resumeAgentClient - users <- fromRight [] <$> runExceptT (withStore' getUsers) - restoreCalls + users <- timeItToView "startChatController, getUsers" $ fromRight [] <$> runExceptT (withStore' getUsers) + timeItToView "startChatController, restoreCalls" $ restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) where start s users = do - a1 <- async $ race_ notificationSubscriber agentSubscriber + a1 <- timeItToView "startChatController, a1" $ async $ race_ notificationSubscriber agentSubscriber a2 <- - if subConns - then Just <$> async (subscribeUsers users) - else pure Nothing + timeItToView "startChatController, a2" $ + if subConns + then Just <$> async (subscribeUsers users) + else pure Nothing atomically . writeTVar s $ Just (a1, a2) when startXFTPWorkers $ do - startXFTP - void $ forkIO $ startFilesToReceive users - startCleanupManager - when enableExpireCIs $ startExpireCIs users + timeItToView "startChatController, startXFTP" $ startXFTP + timeItToView "startChatController, forkIO startFilesToReceive" $ void $ forkIO $ startFilesToReceive users + timeItToView "startChatController, startCleanupManager" $ startCleanupManager + when enableExpireCIs $ + timeItToView "startChatController, startExpireCIs" $ startExpireCIs users pure a1 startXFTP = do tmp <- readTVarIO =<< asks tempDirectory @@ -311,7 +313,7 @@ execChatCommand s = do parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -toView :: ChatMonad m => ChatResponse -> m () +toView :: ChatMonad' m => ChatResponse -> m () toView event = do q <- asks outputQ atomically $ writeTBQueue q (Nothing, event) @@ -354,7 +356,7 @@ processChatCommand = \case storeServers user servers = unless (null servers) $ withStore $ \db -> overwriteProtocolServers db user servers - coupleDaysAgo t = (`addUTCTime` t) . fromInteger . (+ (2 * day)) <$> randomRIO (0, day) + coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withStore' getUsersInfo APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do @@ -470,7 +472,7 @@ processChatCommand = \case let CIMeta {itemTs, createdAt, updatedAt} = meta ciInfo = ChatItemInfo {chatItemId = itemId, itemTs, createdAt, updatedAt, itemVersions} pure $ CRChatItemInfo user chatItem ciInfo - APISendMessage (ChatRef cType chatId) live (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of + APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgNew_ @@ -479,7 +481,7 @@ processChatCommand = \case then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) else do (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct - timed_ <- sndContactCITimed live ct + timed_ <- sndContactCITimed live ct itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) case ft_ of @@ -539,7 +541,7 @@ processChatCommand = \case then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFVoice)) else do (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms) - timed_ <- sndGroupCITimed live gInfo + timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership msg@SndMessage {sharedMsgId} <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) mapM_ (sendGroupFileInline ms sharedMsgId) ft_ @@ -651,14 +653,19 @@ processChatCommand = \case CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive}, content = ciContent} -> do case (ciContent, itemSharedMsgId) of (CISndMsgContent oldMC, Just itemSharedMId) -> do - (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) - ci' <- withStore' $ \db -> do - currentTs <- liftIO getCurrentTime - addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) - updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId - startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' - setActive $ ActiveC c - pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') + let changed = mc /= oldMC + if changed || fromMaybe False itemLive + then do + (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + ci' <- withStore' $ \db -> do + currentTs <- liftIO getCurrentTime + when changed $ + addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) + updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId + startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' + setActive $ ActiveC c + pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') + else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do @@ -669,14 +676,19 @@ processChatCommand = \case CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive}, content = ciContent} -> do case (ciContent, itemSharedMsgId) of (CISndMsgContent oldMC, Just itemSharedMId) -> do - SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) - ci' <- withStore' $ \db -> do - currentTs <- liftIO getCurrentTime - addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) - updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId - startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - setActive $ ActiveG gName - pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') + let changed = mc /= oldMC + if changed || fromMaybe False itemLive + then do + SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + ci' <- withStore' $ \db -> do + currentTs <- liftIO getCurrentTime + when changed $ + addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) + updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId + startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' + setActive $ ActiveG gName + pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') + else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTContactRequest -> pure $ chatCmdError (Just user) "not supported" @@ -1199,7 +1211,7 @@ processChatCommand = \case contactId <- withStore $ \db -> getContactIdByName db user cName quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg let mc = MCText msg - processChatCommand . APISendMessage (ChatRef CTDirect contactId) False $ ComposedMessage Nothing (Just quotedItemId) mc + processChatCommand . APISendMessage (ChatRef CTDirect contactId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg @@ -1397,7 +1409,7 @@ processChatCommand = \case groupId <- withStore $ \db -> getGroupIdByName db user gName quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName quotedMsg let mc = MCText msg - processChatCommand . APISendMessage (ChatRef CTGroup groupId) False $ ComposedMessage Nothing (Just quotedItemId) mc + processChatCommand . APISendMessage (ChatRef CTGroup groupId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc LastChats count_ -> withUser' $ \user -> do chats <- withStore' $ \db -> getChatPreviews db user False pure $ CRChats $ maybe id take count_ chats @@ -1429,7 +1441,7 @@ processChatCommand = \case asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName - processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCFile "") + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "") SendImage chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName filePath <- toFSFilePath f @@ -1437,7 +1449,7 @@ processChatCommand = \case fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" @@ -1777,17 +1789,18 @@ processChatCommand = \case sendTextMessage chatName msg live = withUser $ \user -> do chatRef <- getChatRef user chatName let mc = MCText msg - processChatCommand . APISendMessage chatRef live $ ComposedMessage Nothing Nothing mc - sndContactCITimed :: Bool -> Contact -> m (Maybe CITimed) - sndContactCITimed live = mapM (sndCITimed_ live) . contactTimedTTL - sndGroupCITimed :: Bool -> GroupInfo -> m (Maybe CITimed) - sndGroupCITimed live = mapM (sndCITimed_ live) . groupTimedTTL - sndCITimed_ :: Bool -> Int -> m CITimed - sndCITimed_ live ttl = - CITimed ttl - <$> if live - then pure Nothing - else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime + processChatCommand . APISendMessage chatRef live Nothing $ ComposedMessage Nothing Nothing mc + sndContactCITimed :: Bool -> Contact -> Maybe Int -> m (Maybe CITimed) + sndContactCITimed live = sndCITimed_ live . contactTimedTTL + sndGroupCITimed :: Bool -> GroupInfo -> Maybe Int -> m (Maybe CITimed) + sndGroupCITimed live = sndCITimed_ live . groupTimedTTL + sndCITimed_ :: Bool -> Maybe (Maybe Int) -> Maybe Int -> m (Maybe CITimed) + sndCITimed_ live chatTTL itemTTL = + forM (chatTTL >>= (itemTTL <|>)) $ \ttl -> + CITimed ttl + <$> if live + then pure Nothing + else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime drgRandomBytes :: Int -> m ByteString drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n) privateGetUser :: UserId -> m User @@ -3324,12 +3337,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do updateRcvChatItem = do cci <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId case cci of - CChatItem SMDRcv ci@ChatItem {content = CIRcvMsgContent oldMC} -> do - ci' <- withStore' $ \db -> do - addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateDirectChatItem' db user contactId ci content live $ Just msgId - toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' + CChatItem SMDRcv ci@ChatItem {meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> do + let changed = mc /= oldMC + if changed || fromMaybe False itemLive + then do + ci' <- withStore' $ \db -> do + when changed $ + addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) + updateDirectChatItem' db user contactId ci content live $ Just msgId + toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' + else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> messageError "x.msg.update: contact attempted invalid message update" messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m () @@ -3393,16 +3411,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do updateRcvChatItem = do cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId case cci of - CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', content = CIRcvMsgContent oldMC} -> do + CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> if sameMemberId memberId m' then do - ci' <- withStore' $ \db -> do - addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateGroupChatItem db user groupId ci content live $ Just msgId - toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g - startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - else messageError "x.msg.update: group member attempted to update a message of another member" -- shouldn't happen now that query includes group member id + let changed = mc /= oldMC + if changed || fromMaybe False itemLive + then do + ci' <- withStore' $ \db -> do + when changed $ + addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) + updateGroupChatItem db user groupId ci content live $ Just msgId + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') + setActive $ ActiveG g + startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' + else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) + else messageError "x.msg.update: group member attempted to update a message of another member" _ -> messageError "x.msg.update: group member attempted invalid message update" groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> m () @@ -4629,7 +4652,7 @@ chatCommandP = "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> A.decimal), - "/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))), + "/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal), @@ -4823,6 +4846,7 @@ chatCommandP = quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space refChar c = c > ' ' && c /= '#' && c /= '@' liveMessageP = " live=" *> onOffP <|> pure False + sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing onOffP = ("on" $> True) <|> ("off" $> False) profileNames = (,) <$> displayName <*> fullNameP newUserP = do @@ -4902,3 +4926,15 @@ chatCommandP = adminContactReq :: ConnReqContact adminContactReq = either error id $ strDecode "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" + +timeItToView :: ChatMonad' m => String -> m a -> m a +timeItToView s action = do + t1 <- liftIO getCurrentTime + a <- action + t2 <- liftIO getCurrentTime + let diff = diffInMillis t2 t1 + toView $ CRTimedAction s diff + pure a + +diffInMillis :: UTCTime -> UTCTime -> Int64 +diffInMillis a b = (`div` 1000000000) $ diffInPicos a b diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 88f62b6791..90cc8583ca 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -214,7 +214,7 @@ data ChatCommand | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatItemId - | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, composedMessage :: ComposedMessage} + | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessage :: ComposedMessage} | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId @@ -397,6 +397,7 @@ data ChatResponse | CRNewChatItem {user :: User, chatItem :: AChatItem} | CRChatItemStatusUpdated {user :: User, chatItem :: AChatItem} | CRChatItemUpdated {user :: User, chatItem :: AChatItem} + | CRChatItemNotChanged {user :: User, chatItem :: AChatItem} | CRChatItemDeleted {user :: User, deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool} | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent User MsgContent Int ZonedTime @@ -519,6 +520,7 @@ data ChatResponse | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRChatError {user_ :: Maybe User, chatError :: ChatError} + | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show, Generic) logResponseToFile :: ChatResponse -> Bool diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index fa29d40544..b699cbbc06 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -344,7 +344,7 @@ instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOpt data CITimed = CITimed { ttl :: Int, -- seconds - deleteAt :: Maybe UTCTime + deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read } deriving (Show, Generic) @@ -353,16 +353,16 @@ instance ToJSON CITimed where toEncoding = J.genericToEncoding J.defaultOptions ttl' :: CITimed -> Int ttl' CITimed {ttl} = ttl -contactTimedTTL :: Contact -> Maybe Int +contactTimedTTL :: Contact -> Maybe (Maybe Int) contactTimedTTL Contact {mergedPreferences = ContactUserPreferences {timedMessages = ContactUserPreference {enabled, userPreference}}} - | forUser enabled && forContact enabled = ttl + | forUser enabled && forContact enabled = Just ttl | otherwise = Nothing where TimedMessagesPreference {ttl} = preference (userPreference :: ContactUserPref TimedMessagesPreference) -groupTimedTTL :: GroupInfo -> Maybe Int +groupTimedTTL :: GroupInfo -> Maybe (Maybe Int) groupTimedTTL GroupInfo {fullGroupPreferences = FullGroupPreferences {timedMessages = TimedMessagesGroupPreference {enable, ttl}}} - | enable == FEOn = Just ttl + | enable == FEOn = Just $ Just ttl | otherwise = Nothing rcvContactCITimed :: Contact -> Maybe Int -> Maybe CITimed @@ -371,7 +371,7 @@ rcvContactCITimed = rcvCITimed_ . contactTimedTTL rcvGroupCITimed :: GroupInfo -> Maybe Int -> Maybe CITimed rcvGroupCITimed = rcvCITimed_ . groupTimedTTL -rcvCITimed_ :: Maybe Int -> Maybe Int -> Maybe CITimed +rcvCITimed_ :: Maybe (Maybe Int) -> Maybe Int -> Maybe CITimed rcvCITimed_ chatTTL itemTTL = (`CITimed` Nothing) <$> (chatTTL >> itemTTL) data CIQuote (c :: ChatType) = CIQuote diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 313a0bf63b..e64d70aa38 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -89,6 +89,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u _ -> ttyUser u [] CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts + CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts testView CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t @@ -246,6 +247,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel e CRChatError u e -> ttyUser' u $ viewChatError logLevel e + CRTimedAction _ _ -> [] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser} ss @@ -477,6 +479,11 @@ hideLive :: CIMeta с d -> [StyledString] -> [StyledString] hideLive CIMeta {itemLive = Just True} _ = [] hideLive _ s = s +viewItemNotChanged :: AChatItem -> [StyledString] +viewItemNotChanged (AChatItem _ msgDir _ _) = case msgDir of + SMDSnd -> ["message didn't change"] + SMDRcv -> [] + viewItemDelete :: ChatInfo c -> ChatItem c d -> Maybe AChatItem -> Bool -> Bool -> CurrentTime -> Bool -> [StyledString] viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} toItem byUser timed ts testView | timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView] diff --git a/stack.yaml b/stack.yaml index 63d47f90d1..b73cca7459 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 8954f39425d971025e5e3df1a1628281dab61a3c + commit: ce64c91d5a6e4cbc6cfae6b4a5b312224931f10c - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 66893d0f92..96400585c3 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -34,6 +34,7 @@ chatDirectTests = do it "direct message edit history" testDirectMessageEditHistory it "direct message delete" testDirectMessageDelete it "direct live message" testDirectLiveMessage + it "direct timed message" testDirectTimedMessage it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact it "should send multiline message" testMultilineMessage describe "SMP servers" $ do @@ -228,6 +229,9 @@ testDirectMessageUpdate = alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((1, "hello 🙂"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))]) bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "hello 🙂"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂"))]) + alice ##> ("/_update item @2 " <> itemId 1 <> " text hello 🙂") + alice <## "message didn't change" + alice ##> ("/_update item @2 " <> itemId 1 <> " text hey 👋") alice <# "@bob [edited] hey 👋" bob <# "alice> [edited] hey 👋" @@ -440,6 +444,32 @@ testDirectLiveMessage = bob .<## ": hello 2" bob .<## ":" +testDirectTimedMessage :: HasCallStack => FilePath -> IO () +testDirectTimedMessage = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice ##> "/_send @2 ttl=1 text hello!" + alice <# "@bob hello!" + bob <# "alice> hello!" + alice <## "timed message deleted: hello!" + bob <## "timed message deleted: hello!" + + alice ##> "/_send @2 live=off ttl=1 text hey" + alice <# "@bob hey" + bob <# "alice> hey" + alice <## "timed message deleted: hey" + bob <## "timed message deleted: hey" + + alice ##> "/_send @2 ttl=default text hello" + alice <# "@bob hello" + bob <# "alice> hello" + + alice ##> "/_send @2 live=off text hi" + alice <# "@bob hi" + bob <# "alice> hi" + testRepeatAuthErrorsDisableContact :: HasCallStack => FilePath -> IO () testRepeatAuthErrorsDisableContact = testChat2 aliceProfile bobProfile $ \alice bob -> do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e2cf4e2e61..79af235cd6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -820,6 +820,9 @@ testGroupMessageUpdate = (cath <# "#team alice> hello!") msgItemId1 <- lastItemId alice + alice ##> ("/_update item #1 " <> msgItemId1 <> " text hello!") + alice <## "message didn't change" + alice ##> ("/_update item #1 " <> msgItemId1 <> " text hey 👋") alice <# "#team [edited] hey 👋" concurrently_ @@ -1044,6 +1047,7 @@ testGroupLiveMessage = bob <# "#team alice> [LIVE ended] hello there" cath <# "#team alice> [LIVE ended] hello there" -- empty live message is also sent instantly + threadDelay 1000000 alice `send` "/live #team" msgItemId2 <- lastItemId alice bob <#. "#team alice> [LIVE started]" @@ -1058,13 +1062,13 @@ testGroupLiveMessage = alice <## "message history:" alice .<## ": hello 2" alice .<## ":" - -- bobItemId <- lastItemId bob - -- bob ##> ("/_get item info " <> bobItemId) - -- bob <##. "sent at: " - -- bob <##. "received at: " - -- bob <## "message history:" - -- bob .<## ": hello 2" - -- bob .<## ":" + bobItemId <- lastItemId bob + bob ##> ("/_get item info " <> bobItemId) + bob <##. "sent at: " + bob <##. "received at: " + bob <## "message history:" + bob .<## ": hello 2" + bob .<## ":" testUpdateGroupProfile :: HasCallStack => FilePath -> IO () testUpdateGroupProfile = diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index efe14e9545..92119aa8fb 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -15,7 +15,7 @@ mobileTests :: SpecWith FilePath mobileTests = do describe "mobile API" $ do it "start new chat without user" testChatApiNoUser - it "start new chat with existing user" testChatApi + xit "start new chat with existing user" testChatApi noActiveUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON)