Merge remote-tracking branch 'origin/master' into av/desktop-windows-build

This commit is contained in:
Avently
2023-09-16 06:04:38 +03:00
432 changed files with 38280 additions and 10330 deletions
+106 -3
View File
@@ -39,7 +39,7 @@ kotlin {
api("org.jetbrains.kotlinx:kotlinx-datetime:0.3.2")
api("com.russhwolf:multiplatform-settings:1.0.0")
api("com.charleskorn.kaml:kaml:0.43.0")
api("dev.icerock.moko:resources-compose:0.22.3")
api("dev.icerock.moko:resources-compose:0.23.0")
api("org.jetbrains.compose.ui:ui-text:${rootProject.extra["compose.version"] as String}")
implementation("org.jetbrains.compose.components:components-animatedimage:${rootProject.extra["compose.version"] as String}")
//Barcode
@@ -48,7 +48,7 @@ kotlin {
// Link Previews
implementation("org.jsoup:jsoup:1.13.1")
// Resources
implementation("dev.icerock.moko:resources:0.22.3")
implementation("dev.icerock.moko:resources:0.23.0")
}
}
val commonTest by getting {
@@ -62,7 +62,7 @@ kotlin {
val work_version = "2.7.1"
implementation("androidx.work:work-runtime-ktx:$work_version")
implementation("com.google.accompanist:accompanist-insets:0.23.0")
implementation("dev.icerock.moko:resources:0.22.3")
implementation("dev.icerock.moko:resources:0.23.0")
// Video support
implementation("com.google.android.exoplayer:exoplayer:2.17.1")
@@ -114,6 +114,15 @@ android {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
val isAndroid = gradle.startParameter.taskNames.find {
val lower = it.toLowerCase()
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
} != null
if (isAndroid) {
// This is not needed on Android but can't be moved to desktopMain because MR lib don't support this.
// No other ways to exclude a file work but it's large and should be excluded
kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf")
}
}
multiplatformResources {
@@ -128,3 +137,97 @@ buildConfig {
buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"")
}
}
afterEvaluate {
tasks.named("generateMRcommonMain") {
dependsOn("adjustFormatting")
}
tasks.create("adjustFormatting") {
doLast {
val debug = false
val stringRegex = Regex(".*<string .*</string>.*")
val startStringRegex = Regex("<string [^>]*>")
val endStringRegex = Regex("</string>[ ]*")
val endTagRegex = Regex("</")
val anyHtmlRegex = Regex("[^>]*>.*(<|>).*</string>|[^>]*>.*(&lt;|&gt;).*</string>")
val correctHtmlRegex = Regex("[^>]*>.*<b>.*</b>.*</string>|[^>]*>.*<i>.*</i>.*</string>|[^>]*>.*<u>.*</u>.*</string>|[^>]*>.*<font[^>]*>.*</font>.*</string>")
fun String.removeCDATA(): String =
if (contains("<![CDATA")) {
replace("<![CDATA[", "").replace("]]></string>", "</string>")
} else {
this
}
fun String.addCDATA(filepath: String): String {
//return this
if (anyHtmlRegex.matches(this)) {
val countOfStartTag = count { it == '<' }
val countOfEndTag = count { it == '>' }
if (countOfStartTag != countOfEndTag || countOfStartTag != endTagRegex.findAll(this).count() * 2 || !correctHtmlRegex.matches(this)) {
if (debug) {
println("Wrong string:")
println(this)
println("in $filepath")
println(" ")
} else {
throw Exception("Wrong string: $this \nin $filepath")
}
}
val res = replace(startStringRegex) { it.value + "<![CDATA[" }.replace(endStringRegex) { "]]>" + it.value }
if (debug) {
println("Changed string:")
println(this)
println(res)
println(" ")
}
return res
}
if (debug) {
println("Correct string:")
println(this)
println(" ")
}
return this
}
val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$")
kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath) }.asFileTree.forEach { file ->
val initialLines = ArrayList<String>()
val finalLines = ArrayList<String>()
file.useLines { lines ->
val multiline = ArrayList<String>()
lines.forEach { line ->
initialLines.add(line)
if (stringRegex.matches(line)) {
finalLines.add(line.removeCDATA().addCDATA(file.absolutePath))
} else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) {
multiline.add(line)
} else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) {
multiline.add(line)
finalLines.addAll(multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n"))
multiline.clear()
} else if (multiline.isNotEmpty()) {
multiline.add(line)
} else {
finalLines.add(line)
}
}
if (multiline.isNotEmpty()) {
throw Exception("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}")
}
}
if (!debug && finalLines != initialLines) {
file.writer().use {
finalLines.forEachIndexed { index, line ->
it.write(line)
if (index != finalLines.lastIndex) {
it.write("\n")
}
}
}
}
}
}
}
}
@@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db"
actual val databaseExportDir: File = androidAppContext.cacheDir
actual fun desktopOpenDatabaseDir() {}
@Composable
actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher {
val launcher = rememberLauncherForActivityResult(
@@ -62,7 +62,7 @@ actual fun cropToSquare(image: ImageBitmap): ImageBitmap {
}
actual fun compressImageStr(bitmap: ImageBitmap): String {
val usePng = bitmap.hasAlpha
val usePng = bitmap.hasAlpha()
val ext = if (usePng) "png" else "jpg"
return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP)
}
@@ -89,6 +89,8 @@ actual fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSiz
actual fun GrayU8.toImageBitmap(): ImageBitmap = ConvertBitmap.grayToBitmap(this, Bitmap.Config.RGB_565).asImageBitmap()
actual fun ImageBitmap.hasAlpha(): Boolean = hasAlpha
actual fun ImageBitmap.addLogo(): ImageBitmap = asAndroidBitmap().applyCanvas {
val radius = (width * 0.16f) / 2
val paint = android.graphics.Paint()
@@ -1,6 +1,5 @@
package chat.simplex.common.platform
import android.app.Application
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
@@ -8,10 +7,10 @@ import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import androidx.compose.runtime.*
import chat.simplex.res.MR
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import chat.simplex.common.platform.AudioPlayer.duration
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import java.io.*
@@ -134,20 +133,25 @@ actual object AudioPlayer: AudioPlayerInterface {
}
// Returns real duration of the track
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath)
if (!File(absoluteFilePath).exists()) {
Log.e(TAG, "No such file: ${fileSource.filePath}")
return null
}
VideoPlayer.stopAll()
RecorderInterface.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
if (current == null || current.first != fileSource.filePath) {
stopListener()
player.reset()
runCatching {
player.setDataSource(filePath)
if (fileSource.cryptoArgs != null) {
player.setDataSource(CryptoMediaSource(readCryptoFile(absoluteFilePath, fileSource.cryptoArgs)))
} else {
player.setDataSource(absoluteFilePath)
}
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
@@ -162,7 +166,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
if (seek != null) player.seekTo(seek)
player.start()
currentlyPlaying.value = filePath to onProgressUpdate
currentlyPlaying.value = fileSource.filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && player.isPlaying) {
@@ -229,7 +233,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
override fun play(
filePath: String?,
fileSource: CryptoFile,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
@@ -238,7 +242,7 @@ actual object AudioPlayer: AudioPlayerInterface {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
val realDuration = start(fileSource, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
@@ -268,10 +272,10 @@ actual object AudioPlayer: AudioPlayerInterface {
}
}
override fun duration(filePath: String): Int? {
override fun duration(unencryptedFilePath: String): Int? {
var res: Int? = null
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.setDataSource(unencryptedFilePath)
helperPlayer.prepare()
helperPlayer.start()
helperPlayer.stop()
@@ -283,3 +287,21 @@ actual object AudioPlayer: AudioPlayerInterface {
}
actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer
class CryptoMediaSource(val data: ByteArray) : MediaDataSource() {
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position >= data.size) return -1
val endPosition: Int = (position + size).toInt()
var sizeLeft: Int = size
if (endPosition > data.size) {
sizeLeft -= endPosition - data.size
}
System.arraycopy(data, position.toInt(), buffer, offset, sizeLeft)
return sizeLeft
}
override fun getSize(): Long = data.size.toLong()
override fun close() {}
}
@@ -39,14 +39,14 @@ private val sharedPreferencesThemes: SharedPreferences by lazy { androidAppConte
actual val settings: Settings by lazy { SharedPreferencesSettings(sharedPreferences) }
actual val settingsThemes: Settings by lazy { SharedPreferencesSettings(sharedPreferencesThemes) }
actual fun screenOrientation(): ScreenOrientation = when (mainActivity.get()?.resources?.configuration?.orientation) {
Configuration.ORIENTATION_PORTRAIT -> ScreenOrientation.PORTRAIT
Configuration.ORIENTATION_LANDSCAPE -> ScreenOrientation.LANDSCAPE
else -> ScreenOrientation.UNDEFINED
actual fun windowOrientation(): WindowOrientation = when (mainActivity.get()?.resources?.configuration?.orientation) {
Configuration.ORIENTATION_PORTRAIT -> WindowOrientation.PORTRAIT
Configuration.ORIENTATION_LANDSCAPE -> WindowOrientation.LANDSCAPE
else -> WindowOrientation.UNDEFINED
}
@Composable
actual fun screenWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun desktopExpandWindowToWidth(width: Dp) {}
@@ -8,13 +8,15 @@ import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import chat.simplex.common.helpers.toUri
import chat.simplex.common.model.CIFile
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.views.helpers.getAppFileUri
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import java.io.BufferedOutputStream
import java.io.File
import chat.simplex.res.MR
import java.io.ByteArrayOutputStream
actual fun ClipboardManager.shareText(text: String) {
val sendIntent: Intent = Intent().apply {
@@ -28,9 +30,17 @@ actual fun ClipboardManager.shareText(text: String) {
androidAppContext.startActivity(shareIntent)
}
actual fun shareFile(text: String, filePath: String) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val ext = filePath.substringAfterLast(".")
actual fun shareFile(text: String, fileSource: CryptoFile) {
val uri = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit()
ChatModel.filesToDelete.add(tmpFile)
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath)
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI()
} else {
getAppFileUri(fileSource.filePath)
}
val ext = fileSource.filePath.substringAfterLast(".")
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
@@ -84,8 +94,16 @@ fun saveImage(ciFile: CIFile?) {
uri?.let {
androidAppContext.contentResolver.openOutputStream(uri)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
if (ciFile.fileSource?.cryptoArgs != null) {
createTmpFileAndDelete { tmpFile ->
decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
tmpFile.inputStream().use { it.copyTo(outputStream) }
}
outputStream.close()
} else {
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
}
showToast(generalGetString(MR.strings.image_saved))
}
}
@@ -11,3 +11,5 @@ actual val Inter: FontFamily = FontFamily(
Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium),
Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light)
)
actual val EmojiFont: FontFamily = FontFamily.Default
@@ -12,7 +12,9 @@ import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
import android.view.ViewGroup
import android.webkit.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@@ -33,11 +35,10 @@ import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.ProfileImage
import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.Contact
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.icerock.moko.resources.StringResource
@@ -267,7 +268,9 @@ private fun ActiveCallOverlayLayout(
when (call.peerMedia ?: call.localMedia) {
CallMediaType.Video -> {
CallInfoView(call, alignment = Alignment.Start)
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
DisabledBackgroundCallsButton()
}
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
ToggleAudioButton(call, toggleAudio)
Spacer(Modifier.size(40.dp))
@@ -293,7 +296,9 @@ private fun ActiveCallOverlayLayout(
ProfileImage(size = 192.dp, image = call.contact.profile.image)
CallInfoView(call, alignment = Alignment.CenterHorizontally)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
DisabledBackgroundCallsButton()
}
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss) {
@@ -358,6 +363,31 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
}
}
@Composable
private fun DisabledBackgroundCallsButton() {
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
if (show) {
Row(
Modifier
.padding(bottom = 24.dp)
.clickable {
withBGApi {
show = !platform.androidAskToAllowBackgroundCalls()
}
}
.background(WarningOrange.copy(0.3f), RoundedCornerShape(50))
.padding(start = 14.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(MR.strings.system_restricted_background_in_call_title), color = WarningOrange)
Spacer(Modifier.width(8.dp))
IconButton(onClick = { show = false }, Modifier.size(24.dp)) {
Icon(painterResource(MR.images.ic_close), null, tint = WarningOrange)
}
}
}
}
//@Composable
//fun CallViewDebug(close: () -> Unit) {
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
@@ -19,7 +19,7 @@ import java.net.URI
@Composable
actual fun SimpleAndAnimatedImageView(
uri: URI,
data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
@@ -27,7 +27,7 @@ actual fun SimpleAndAnimatedImageView(
) {
val context = LocalContext.current
val imagePainter = rememberAsyncImagePainter(
ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(),
ImageRequest.Builder(context).data(data = data).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
)
@@ -2,22 +2,31 @@ package chat.simplex.common.views.chat.item
import android.Manifest
import android.os.Build
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.MsgContent
import chat.simplex.common.platform.FileChooserLauncher
import chat.simplex.common.platform.saveImage
import chat.simplex.common.views.helpers.SharedContent
import chat.simplex.common.views.helpers.withApi
import chat.simplex.res.MR
import com.google.accompanist.permissions.rememberPermissionState
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun ReactionIcon(text: String, fontSize: TextUnit) {
Text(text, fontSize = fontSize)
}
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
@@ -0,0 +1,10 @@
package chat.simplex.common.views.chat.item
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@Composable
actual fun EmojiText(text: String) {
val s = text.trim()
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
}
@@ -26,7 +26,7 @@ import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) {
actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
val imageLoader = ImageLoader.Builder(LocalContext.current)
@@ -40,7 +40,7 @@ actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageB
.build()
Image(
rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current).data(data = uri.toUri()).size(Size.ORIGINAL).build(),
ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
),
@@ -0,0 +1,106 @@
package chat.simplex.common.views.database
import SectionItemView
import SectionTextFooter
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.SimplexGreen
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView(minHeight = minHeight) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled),
stringResource(MR.strings.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(MR.strings.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
@Composable
actual fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
) {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs))
}
} else {
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
}
actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.encrypt_database_question),
text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_database_passphrase_question),
text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
actual fun removePassphraseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.remove_passphrase_from_keychain),
text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(MR.strings.remove_passphrase),
onConfirm = onConfirm,
destructive = true,
)
}
@@ -1,6 +1,5 @@
package chat.simplex.common.views.helpers
import android.app.Application
import android.content.res.Resources
import android.graphics.*
import android.graphics.Typeface
@@ -12,11 +11,8 @@ import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.BaselineShift
@@ -159,17 +155,18 @@ actual fun getAppFileUri(fileName: String): URI =
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI()
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file)
return if (filePath != null) {
return if (filePath != null && file != null) {
try {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r")
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
parcelFileDescriptor?.close()
image.asImageBitmap()
val data = if (file.fileSource?.cryptoArgs != null) {
readCryptoFile(getAppFilePath(file.fileSource.filePath), file.fileSource.cryptoArgs)
} else {
File(getAppFilePath(file.fileName)).readBytes()
}
decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
null
}
} else {
@@ -178,17 +175,17 @@ actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
BitmapFactory.decodeByteArray(data, 0, data.size)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
BitmapFactory.decodeByteArray(data, 0, data.size)
}
}
@@ -254,6 +251,26 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma
}?.asImageBitmap()
}
actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? {
return if (Build.VERSION.SDK_INT >= 31) {
val source = ImageDecoder.createSource(data)
try {
ImageDecoder.decodeBitmap(source)
} catch (e: android.graphics.ImageDecoder.DecodeException) {
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
if (withAlertOnException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.image_decoding_exception_title),
text = generalGetString(MR.strings.image_decoding_exception_desc)
)
}
null
}
} else {
BitmapFactory.decodeByteArray(data, 0, data.size)
}?.asImageBitmap()
}
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? {
return if (Build.VERSION.SDK_INT >= 28) {
val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri())
@@ -13,7 +13,8 @@ actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
cameraPermissionState.launchPermissionRequest()
}
ConnectContactLayout(
chatModelIncognito = chatModel.incognito.value,
close
chatModel = chatModel,
incognitoPref = chatModel.controller.appPrefs.incognito,
close = close
)
}
@@ -1,5 +1,6 @@
#include <jni.h>
//#include <string.h>
#include <string.h>
#include <stdint.h>
//#include <stdlib.h>
//#include <android/log.h>
@@ -45,6 +46,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_write_file(const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path);
extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
@@ -115,3 +120,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity));
(*env)->ReleaseStringUTFChars(env, path, _path);
return res;
}
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) {
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE);
const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE);
jbyte *res = chat_read_file(_path, _key, _nonce);
(*env)->ReleaseStringUTFChars(env, path, _path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
jint status = (jint)res[0];
jbyteArray arr;
if (status == 0) {
union {
uint32_t w;
uint8_t b[4];
} len;
len.b[0] = (uint8_t)res[1];
len.b[1] = (uint8_t)res[2];
len.b[2] = (uint8_t)res[3];
len.b[3] = (uint8_t)res[4];
arr = (*env)->NewByteArray(env, len.w);
(*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5);
} else {
int len = strlen(res + 1); // + 1 offset here is to not include status byte
arr = (*env)->NewByteArray(env, len);
(*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
}
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
(*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"),
status);
(*env)->SetObjectArrayElement(env, ret, 0, statusObj);
(*env)->SetObjectArrayElement(env, ret, 1, arr);
return ret;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) {
const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) {
const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE);
const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE);
const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
@@ -1,6 +1,7 @@
#include <jni.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
// from the RTS
void hs_init(int * argc, char **argv[]);
@@ -20,7 +21,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_write_file(const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path);
extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
// As a reference: https://stackoverflow.com/a/60002045
jstring decode_to_utf8_string(JNIEnv *env, char *string) {
@@ -128,3 +132,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
const char *_path = encode_to_utf8_chars(env, path);
jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity));
(*env)->ReleaseStringUTFChars(env, path, _path);
return res;
}
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) {
const char *_path = encode_to_utf8_chars(env, path);
const char *_key = encode_to_utf8_chars(env, key);
const char *_nonce = encode_to_utf8_chars(env, nonce);
jbyte *res = chat_read_file(_path, _key, _nonce);
(*env)->ReleaseStringUTFChars(env, path, _path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
jint status = (jint)res[0];
jbyteArray arr;
if (status == 0) {
union {
uint32_t w;
uint8_t b[4];
} len;
len.b[0] = (uint8_t)res[1];
len.b[1] = (uint8_t)res[2];
len.b[2] = (uint8_t)res[3];
len.b[3] = (uint8_t)res[4];
arr = (*env)->NewByteArray(env, len.w);
(*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5);
} else {
int len = strlen(res + 1); // + 1 offset here is to not include status byte
arr = (*env)->NewByteArray(env, len);
(*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
}
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
(*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"),
status);
(*env)->SetObjectArrayElement(env, ret, 0, statusObj);
(*env)->SetObjectArrayElement(env, ret, 1, arr);
return ret;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) {
const char *_from_path = encode_to_utf8_chars(env, from_path);
const char *_to_path = encode_to_utf8_chars(env, to_path);
jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) {
const char *_from_path = encode_to_utf8_chars(env, from_path);
const char *_key = encode_to_utf8_chars(env, key);
const char *_nonce = encode_to_utf8_chars(env, nonce);
const char *_to_path = encode_to_utf8_chars(env, to_path);
jstring res = decode_to_utf8_string(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
@@ -32,8 +32,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.*
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
@@ -64,7 +63,7 @@ fun MainScreen() {
if (
!chatModel.controller.appPrefs.laNoticeShown.get()
&& showAdvertiseLAAlert
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
&& chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete
&& chatModel.chats.isNotEmpty()
&& chatModel.activeCallInvitation.value == null
) {
@@ -102,7 +101,10 @@ fun MainScreen() {
}
Box {
val onboarding = chatModel.onboardingStage.value
var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) }
LaunchedEffect(Unit) {
snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it }
}
val userCreated = chatModel.userCreated.value
var showInitializationView by remember { mutableStateOf(false) }
when {
@@ -112,7 +114,7 @@ fun MainScreen() {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
onboarding == null || userCreated == null -> SplashView()
remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
Box {
showAdvertiseLAAlert = true
@@ -134,6 +136,7 @@ fun MainScreen() {
}
}
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
@@ -194,24 +197,25 @@ fun AndroidScreen(settingsState: SettingsViewState) {
StartPartOfScreen(settingsState)
}
val scope = rememberCoroutineScope()
val onComposed: () -> Unit = {
val onComposed: suspend (chatId: String?) -> Unit = { chatId ->
// coroutine, scope and join() because:
// - it should be run from coroutine to wait until this function finishes
// - without using scope.launch it throws CancellationException when changing user
// - join allows to wait until completion
scope.launch {
offset.animateTo(
if (chatModel.chatId.value == null) 0f else maxWidth.value,
if (chatId == null) 0f else maxWidth.value,
chatListAnimationSpec()
)
if (offset.value == 0f) {
currentChatId = null
}
}
}.join()
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it != null) currentChatId = it
else onComposed()
if (it == null) onComposed(null)
currentChatId = it
}
}
}
@@ -311,7 +315,6 @@ fun DesktopScreen(settingsState: SettingsViewState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
}
ModalManager.fullscreen.showInView()
ModalManager.fullscreen.showPasscodeInView()
}
}
@@ -13,6 +13,7 @@ import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.platform.chatController
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@@ -38,7 +39,6 @@ import kotlin.time.*
@Stable
object ChatModel {
val controller: ChatController = ChatController
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
val setDeliveryReceipts = mutableStateOf(false)
val currentUser = mutableStateOf<User?>(null)
val users = mutableStateListOf<UserInfo>()
@@ -80,7 +80,7 @@ object ChatModel {
}
val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
val incognito by lazy { mutableStateOf(ChatController.appPrefs.incognito.get()) }
val showChatPreviews by lazy { mutableStateOf(ChatController.appPrefs.privacyShowChatPreviews.get()) }
// current WebRTC call
val callManager = CallManager(this)
@@ -407,18 +407,18 @@ object ChatModel {
)
}
fun increaseUnreadCounter(user: User) {
fun increaseUnreadCounter(user: UserLike) {
changeUnreadCounter(user, 1)
}
fun decreaseUnreadCounter(user: User, by: Int = 1) {
fun decreaseUnreadCounter(user: UserLike, by: Int = 1) {
changeUnreadCounter(user, -by)
}
private fun changeUnreadCounter(user: User, by: Int) {
private fun changeUnreadCounter(user: UserLike, by: Int) {
val i = users.indexOfFirst { it.user.userId == user.userId }
if (i != -1) {
users[i] = UserInfo(user, users[i].unreadCount + by)
users[i] = UserInfo(users[i].user, users[i].unreadCount + by)
}
}
@@ -500,17 +500,17 @@ enum class ChatType(val type: String) {
@Serializable
data class User(
val userId: Long,
override val userId: Long,
val userContactId: Long,
val localDisplayName: String,
val profile: LocalProfile,
val fullPreferences: FullChatPreferences,
val activeUser: Boolean,
val showNtfs: Boolean,
override val activeUser: Boolean,
override val showNtfs: Boolean,
val sendRcptsContacts: Boolean,
val sendRcptsSmallGroups: Boolean,
val viewPwdHash: UserPwdHash?
): NamedChat {
): NamedChat, UserLike {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
override val image: String? get() = profile.image
@@ -518,8 +518,6 @@ data class User(
val hidden: Boolean = viewPwdHash != null
val showNotifications: Boolean = activeUser || showNtfs
val addressShared: Boolean = profile.contactLink != null
companion object {
@@ -538,6 +536,22 @@ data class User(
}
}
@Serializable
data class UserRef(
override val userId: Long,
val localDisplayName: String,
override val activeUser: Boolean,
override val showNtfs: Boolean
): UserLike {}
interface UserLike {
val userId: Long
val activeUser: Boolean
val showNtfs: Boolean
val showNotifications: Boolean get() = activeUser || showNtfs
}
@Serializable
data class UserPwdHash(
val hash: String,
@@ -932,6 +946,14 @@ data class LocalProfile(
}
}
@Serializable
data class UserProfileUpdateSummary(
val notChanged: Int,
val updateSuccesses: Int,
val updateFailures: Int,
val changedContacts: List<Contact>
)
@Serializable
class Group (
val groupInfo: GroupInfo,
@@ -1373,6 +1395,13 @@ data class ChatItem (
private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null
val encryptLocalFile: Boolean
get() = file?.fileProtocol == FileProtocol.XFTP &&
content.msgContent !is MsgContent.MCVideo &&
chatController.appPrefs.privacyEncryptLocalFiles.get()
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
else null
@@ -1386,6 +1415,18 @@ data class ChatItem (
else -> false
}
val memberConnected: GroupMember? get() =
when (chatDir) {
is CIDirection.GroupRcv -> when (content) {
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberConnected -> chatDir.groupMember
else -> null
}
else -> null
}
else -> null
}
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember>? {
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
val m = chatInfo.groupInfo.membership
@@ -1741,26 +1782,15 @@ sealed class CIStatus {
is CIStatus.Invalid -> MR.images.ic_question_mark to metaColor
}
val statusText: String get() = when (this) {
is SndNew -> generalGetString(MR.strings.item_status_snd_new_text)
is SndSent -> generalGetString(MR.strings.item_status_snd_sent_text)
is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_text)
is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_text)
is SndError -> generalGetString(MR.strings.item_status_snd_error_text)
is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_text)
is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_text)
is Invalid -> "Invalid status"
}
val statusDescription: String get() = when (this) {
is SndNew -> generalGetString(MR.strings.item_status_snd_new_desc)
is SndSent -> generalGetString(MR.strings.item_status_snd_sent_desc)
is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_desc)
is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_auth_desc)
is SndError -> String.format(generalGetString(MR.strings.item_status_snd_error_unexpected_desc), this.agentError)
is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_desc)
is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_desc)
is Invalid -> this.text
val statusInto: Pair<String, String>? get() = when (this) {
is SndNew -> null
is SndSent -> null
is SndRcvd -> null
is SndErrorAuth -> generalGetString(MR.strings.message_delivery_error_title) to generalGetString(MR.strings.message_delivery_error_desc)
is SndError -> generalGetString(MR.strings.message_delivery_error_title) to (generalGetString(MR.strings.unknown_error) + ": $agentError")
is RcvNew -> null
is RcvRead -> null
is Invalid -> "Invalid status" to this.text
}
}
@@ -1850,6 +1880,19 @@ sealed class CIContent: ItemContent {
is InvalidJSON -> "invalid data"
}
val showMemberName: Boolean get() =
when (this) {
is RcvMsgContent -> true
is RcvDeleted -> true
is RcvCall -> true
is RcvIntegrityError -> true
is RcvDecryptionError -> true
is RcvGroupInvitation -> true
is RcvModerated -> true
is InvalidJSON -> true
else -> false
}
companion object {
fun featureText(feature: Feature, enabled: String, param: Int?): String =
if (feature.hasParam) {
@@ -1989,7 +2032,7 @@ class CIFile(
val fileId: Long,
val fileName: String,
val fileSize: Long,
val filePath: String? = null,
val fileSource: CryptoFile? = null,
val fileStatus: CIFileStatus,
val fileProtocol: FileProtocol
) {
@@ -2037,10 +2080,27 @@ class CIFile(
filePath: String? = "test.txt",
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
): CIFile =
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
}
}
@Serializable
data class CryptoFile(
val filePath: String,
val cryptoArgs: CryptoFileArgs?
) {
val isAbsolutePath: Boolean
get() = File(filePath).isAbsolute
companion object {
fun plain(f: String): CryptoFile = CryptoFile(f, null)
}
}
@Serializable
data class CryptoFileArgs(val fileKey: String, val fileNonce: String)
class CancelAction(
val uiActionId: StringResource,
val alert: AlertInfo
@@ -0,0 +1,59 @@
package chat.simplex.common.model
import chat.simplex.common.platform.*
import kotlinx.serialization.*
import java.nio.ByteBuffer
@Serializable
sealed class WriteFileResult {
@Serializable @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult()
@Serializable @SerialName("error") data class Error(val writeError: String): WriteFileResult()
}
/*
fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
val str = chatWriteFile(path, data)
return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) {
is WriteFileResult.Result -> d.cryptoArgs
is WriteFileResult.Error -> throw Exception(d.writeError)
}
}
* */
fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
val buffer = ByteBuffer.allocateDirect(data.size)
buffer.put(data)
buffer.rewind()
val str = chatWriteFile(path, buffer)
return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) {
is WriteFileResult.Result -> d.cryptoArgs
is WriteFileResult.Error -> throw Exception(d.writeError)
}
}
fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray {
val res: Array<Any> = chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce)
val status = (res[0] as Integer).toInt()
val arr = res[1] as ByteArray
if (status == 0) {
return arr
} else {
throw Exception(String(arr))
}
}
fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs {
val str = chatEncryptFile(fromPath, toPath)
val d = json.decodeFromString(WriteFileResult.serializer(), str)
return when (d) {
is WriteFileResult.Result -> d.cryptoArgs
is WriteFileResult.Error -> throw Exception(d.writeError)
}
}
fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) {
val err = chatDecryptFile(fromPath, cryptoArgs.fileKey, cryptoArgs.fileNonce, toPath)
if (err != "") {
throw Exception(err)
}
}
@@ -5,7 +5,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
@@ -91,8 +90,10 @@ class AppPreferences {
},
set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
)
val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true)
val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true)
val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false)
val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, false)
val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
@@ -245,7 +246,10 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews"
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet"
private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
@@ -331,7 +335,6 @@ object ChatController {
if (justStarted) {
chatModel.currentUser.value = user
chatModel.userCreated.value = true
apiSetIncognito(chatModel.incognito.value)
getUserChatData()
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
@@ -402,19 +405,15 @@ object ChatController {
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
if (cmd !is CC.ApiParseMarkdown) {
chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
}
chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
val json = chatSendCmd(ctrl, c)
val r = APIResponse.decodeStr(json)
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d(TAG, "sendCmd response json $json")
}
if (r.resp !is CR.ParsedMarkdown) {
chatModel.addTerminalItem(TerminalItem.resp(r.resp))
}
chatModel.addTerminalItem(TerminalItem.resp(r.resp))
r.resp
}
}
@@ -546,12 +545,6 @@ object ChatController {
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
}
suspend fun apiSetIncognito(incognito: Boolean) {
val r = sendCmd(CC.SetIncognito(incognito))
if (r is CR.CmdOk) return
throw Exception("failed to set incognito: ${r.responseType} ${r.details}")
}
suspend fun apiExportArchive(config: ArchiveConfig) {
val r = sendCmd(CC.ApiExportArchive(config))
if (r is CR.CmdOk) return
@@ -594,7 +587,7 @@ object ChatController {
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl)
val r = sendCmd(cmd)
return when (r) {
@@ -819,14 +812,14 @@ object ChatController {
suspend fun apiAddContact(): String? {
suspend fun apiAddContact(incognito: Boolean): Pair<String, PendingContactConnection>? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiAddContact: no current user")
return null
}
val r = sendCmd(CC.APIAddContact(userId))
val r = sendCmd(CC.APIAddContact(userId, incognito))
return when (r) {
is CR.Invitation -> r.connReqInvitation
is CR.Invitation -> r.connReqInvitation to r.connection
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r)
@@ -836,12 +829,19 @@ object ChatController {
}
}
suspend fun apiConnect(connReq: String): Boolean {
suspend fun apiSetConnectionIncognito(connId: Long, incognito: Boolean): PendingContactConnection? {
val r = sendCmd(CC.ApiSetConnectionIncognito(connId, incognito))
if (r is CR.ConnectionIncognitoUpdated) return r.toConnection
Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiConnect: no current user")
return false
}
val r = sendCmd(CC.APIConnect(userId, connReq))
val r = sendCmd(CC.APIConnect(userId, incognito, connReq))
when {
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
r is CR.ContactAlreadyExists -> {
@@ -911,11 +911,11 @@ object ChatController {
return null
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
suspend fun apiUpdateProfile(profile: Profile): Pair<Profile, List<Contact>>? {
val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null }
val r = sendCmd(CC.ApiUpdateProfile(userId, profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
if (r is CR.UserProfileNoChange) return profile to emptyList()
if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
return null
}
@@ -998,8 +998,8 @@ object ChatController {
return null
}
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
suspend fun apiAcceptContactRequest(incognito: Boolean, contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(incognito, contactReqId))
return when {
r is CR.AcceptingContactRequest -> r.contact
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
@@ -1080,8 +1080,8 @@ object ChatController {
return false
}
suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId, inline))
suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline))
return when (r) {
is CR.RcvFileAccepted -> r.chatItem
is CR.RcvFileAcceptedSndCancelled -> {
@@ -1290,13 +1290,6 @@ object ChatController {
}
}
suspend fun apiParseMarkdown(text: String): List<FormattedText>? {
val r = sendCmd(CC.ApiParseMarkdown(text))
if (r is CR.ParsedMarkdown) return r.formattedText
Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}")
return null
}
private fun networkErrorAlert(r: CR): Boolean {
return when {
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
@@ -1421,7 +1414,7 @@ object ChatController {
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withApi { receiveFile(r.user, file.fileId, auto = true) }
withApi { receiveFile(r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) }
}
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
@@ -1644,7 +1637,7 @@ object ChatController {
}
}
private fun active(user: User): Boolean = user.userId == chatModel.currentUser.value?.userId
private fun active(user: UserLike): Boolean = user.userId == chatModel.currentUser.value?.userId
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
val call = chatModel.activeCall.value
@@ -1655,8 +1648,8 @@ object ChatController {
}
}
suspend fun receiveFile(user: User, fileId: Long, auto: Boolean = false) {
val chatItem = apiReceiveFile(fileId, auto = auto)
suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) {
val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto)
if (chatItem != null) {
chatItemSimpleUpdate(user, chatItem)
}
@@ -1669,7 +1662,7 @@ object ChatController {
}
}
private suspend fun chatItemSimpleUpdate(user: User, aChatItem: AChatItem) {
private suspend fun chatItemSimpleUpdate(user: UserLike, aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) }
@@ -1805,7 +1798,6 @@ sealed class CC {
class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC()
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
class SetIncognito(val incognito: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC()
@@ -1813,7 +1805,7 @@ sealed class CC {
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: CryptoFile?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
@@ -1850,14 +1842,14 @@ sealed class CC {
class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC()
class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC()
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
class APIAddContact(val userId: Long): CC()
class APIConnect(val userId: Long, val connReq: String): CC()
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
class ApiParseMarkdown(val text: String): CC()
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
class ApiCreateMyAddress(val userId: Long): CC()
@@ -1872,11 +1864,11 @@ sealed class CC {
class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC()
class ApiEndCall(val contact: Contact): CC()
class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC()
class ApiAcceptContact(val contactReqId: Long): CC()
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val inline: Boolean?): CC()
class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
class ShowVersion(): CC()
@@ -1908,7 +1900,6 @@ sealed class CC {
is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder"
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
is SetIncognito -> "/incognito ${onOff(incognito)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete"
@@ -1956,14 +1947,14 @@ sealed class CC {
is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId"
is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else ""
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
is APIAddContact -> "/_connect $userId"
is APIConnect -> "/_connect $userId $connReq"
is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}"
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
is ApiParseMarkdown -> "/_parse $text"
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
is ApiCreateMyAddress -> "/_address $userId"
@@ -1971,7 +1962,7 @@ sealed class CC {
is ApiShowMyAddress -> "/_show_address $userId"
is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}"
is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}"
is ApiAcceptContact -> "/_accept $contactReqId"
is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}"
is ApiRejectCall -> "/_call reject @${contact.apiId}"
@@ -1982,7 +1973,7 @@ sealed class CC {
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}"
is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}")
is CancelFile -> "/fcancel $fileId"
is ShowVersion -> "/version"
}
@@ -2006,7 +1997,6 @@ sealed class CC {
is SetTempFolder -> "setTempFolder"
is SetFilesFolder -> "setFilesFolder"
is ApiSetXFTPConfig -> "apiSetXFTPConfig"
is SetIncognito -> "setIncognito"
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage"
@@ -2052,13 +2042,13 @@ sealed class CC {
is APIVerifyContact -> "apiVerifyContact"
is APIVerifyGroupMember -> "apiVerifyGroupMember"
is APIAddContact -> "apiAddContact"
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
is APIConnect -> "apiConnect"
is ApiDeleteChat -> "apiDeleteChat"
is ApiClearChat -> "apiClearChat"
is ApiListContacts -> "apiListContacts"
is ApiUpdateProfile -> "apiUpdateProfile"
is ApiSetContactPrefs -> "apiSetContactPrefs"
is ApiParseMarkdown -> "apiParseMarkdown"
is ApiSetContactAlias -> "apiSetContactAlias"
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
is ApiCreateMyAddress -> "apiCreateMyAddress"
@@ -2145,7 +2135,7 @@ sealed class ChatPagination {
}
@Serializable
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent)
@Serializable
class XFTPFileConfig(val minFileSize: Long)
@@ -3160,7 +3150,7 @@ class APIResponse(val resp: CR, val corr: String? = null) {
val type = resp["type"]?.jsonPrimitive?.content ?: "invalid"
try {
if (type == "apiChats") {
val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
val chats: List<Chat> = resp["chats"]!!.jsonArray.map {
parseChatData(it)
}
@@ -3169,7 +3159,7 @@ class APIResponse(val resp: CR, val corr: String? = null) {
corr = data["corr"]?.toString()
)
} else if (type == "apiChat") {
val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
val chat = parseChatData(resp["chat"]!!)
return APIResponse(
resp = CR.ApiChat(user, chat),
@@ -3177,11 +3167,18 @@ class APIResponse(val resp: CR, val corr: String? = null) {
)
} else if (type == "chatCmdError") {
val userObject = resp["user_"]?.jsonObject
val user = runCatching<User?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
return APIResponse(
resp = CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))),
corr = data["corr"]?.toString()
)
} else if (type == "chatError") {
val userObject = resp["user_"]?.jsonObject
val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
return APIResponse(
resp = CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))),
corr = data["corr"]?.toString()
)
}
} catch (e: Exception) {
Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString())
@@ -3225,124 +3222,124 @@ sealed class CR {
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
@Serializable @SerialName("apiChats") class ApiChats(val user: User, val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val user: User, val chat: Chat): CR()
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: User, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: User, val servers: UserProtocolServers): CR()
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: User, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: User, val chatItemTTL: Long? = null): CR()
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat): CR()
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR()
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR()
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val user: User, val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats? = null): CR()
@Serializable @SerialName("contactSwitchStarted") class ContactSwitchStarted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberSwitchStarted") class GroupMemberSwitchStarted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("contactSwitchAborted") class ContactSwitchAborted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberSwitchAborted") class GroupMemberSwitchAborted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("contactSwitch") class ContactSwitch(val user: User, val contact: Contact, val switchProgress: SwitchProgress): CR()
@Serializable @SerialName("groupMemberSwitch") class GroupMemberSwitch(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val switchProgress: SwitchProgress): CR()
@Serializable @SerialName("contactRatchetSyncStarted") class ContactRatchetSyncStarted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberRatchetSyncStarted") class GroupMemberRatchetSyncStarted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("contactRatchetSync") class ContactRatchetSync(val user: User, val contact: Contact, val ratchetSyncProgress: RatchetSyncProgress): CR()
@Serializable @SerialName("groupMemberRatchetSync") class GroupMemberRatchetSync(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val ratchetSyncProgress: RatchetSyncProgress): CR()
@Serializable @SerialName("contactVerificationReset") class ContactVerificationReset(val user: User, val contact: Contact): CR()
@Serializable @SerialName("groupMemberVerificationReset") class GroupMemberVerificationReset(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR()
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: User): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: User): CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: User, val contact: Contact): CR()
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: User, val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: User, val contact: Contact): CR()
@Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats? = null): CR()
@Serializable @SerialName("contactSwitchStarted") class ContactSwitchStarted(val user: UserRef, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberSwitchStarted") class GroupMemberSwitchStarted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("contactSwitchAborted") class ContactSwitchAborted(val user: UserRef, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberSwitchAborted") class GroupMemberSwitchAborted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("contactSwitch") class ContactSwitch(val user: UserRef, val contact: Contact, val switchProgress: SwitchProgress): CR()
@Serializable @SerialName("groupMemberSwitch") class GroupMemberSwitch(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val switchProgress: SwitchProgress): CR()
@Serializable @SerialName("contactRatchetSyncStarted") class ContactRatchetSyncStarted(val user: UserRef, val contact: Contact, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("groupMemberRatchetSyncStarted") class GroupMemberRatchetSyncStarted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR()
@Serializable @SerialName("contactRatchetSync") class ContactRatchetSync(val user: UserRef, val contact: Contact, val ratchetSyncProgress: RatchetSyncProgress): CR()
@Serializable @SerialName("groupMemberRatchetSync") class GroupMemberRatchetSync(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val ratchetSyncProgress: RatchetSyncProgress): CR()
@Serializable @SerialName("contactVerificationReset") class ContactVerificationReset(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("groupMemberVerificationReset") class GroupMemberVerificationReset(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): CR()
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile): CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
@Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR()
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: User, val toContact: Contact): CR()
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: User, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: User, val fromContact: Contact, val toContact: Contact): CR()
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR()
@Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val user: User, val contactLink: UserContactLinkRec): CR()
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted(val user: User): CR()
@Serializable @SerialName("contactConnected") class ContactConnected(val user: User, val contact: Contact, val userCustomProfile: Profile? = null): CR()
@Serializable @SerialName("contactConnecting") class ContactConnecting(val user: User, val contact: Contact): CR()
@Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val user: User, val contactRequest: UserContactRequest): CR()
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: User, val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: User): CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val user: User, val toContact: Contact): CR()
@Serializable @SerialName("contactConnected") class ContactConnected(val user: UserRef, val contact: Contact, val userCustomProfile: Profile? = null): CR()
@Serializable @SerialName("contactConnecting") class ContactConnecting(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val user: UserRef, val contactRequest: UserContactRequest): CR()
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List<ContactRef>): CR()
@Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List<ContactRef>): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val user: User, val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: User, val contactSubscriptions: List<ContactSubStatus>): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: User, val group: GroupInfo): CR()
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: User, val memberSubErrors: List<MemberSubError>): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val user: User, val group: GroupInfo): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List<ContactSubStatus>): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR()
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List<MemberSubError>): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: User, val added: Boolean, val reaction: ACIReaction): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
@Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List<Contact>): CR()
@Serializable @SerialName("newChatItem") class NewChatItem(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: UserRef, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
@Serializable @SerialName("contactsList") class ContactsList(val user: UserRef, val contacts: List<Contact>): CR()
// group events
@Serializable @SerialName("groupCreated") class GroupCreated(val user: User, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: User, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: User, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupMembers") class GroupMembers(val user: User, val group: Group): CR()
@Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: User, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: User, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberRole") class MemberRole(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("deletedMember") class DeletedMember(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR()
@Serializable @SerialName("leftMember") class LeftMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupDeleted") class GroupDeleted(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("contactsMerged") class ContactsMerged(val user: User, val intoContact: Contact, val mergedContact: Contact): CR()
@Serializable @SerialName("groupInvitation") class GroupInvitation(val user: User, val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: User, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR()
@Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR()
@Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("contactsMerged") class ContactsMerged(val user: UserRef, val intoContact: Contact, val mergedContact: Contact): CR()
@Serializable @SerialName("groupInvitation") class GroupInvitation(val user: UserRef, val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val user: UserRef, val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupLink") class GroupLink(val user: UserRef, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR()
@Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR()
// receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: User, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: User, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: User, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: User, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR()
@Serializable @SerialName("rcvFileError") class RcvFileError(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR()
@Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem: AChatItem): CR()
// sending file events
@Serializable @SerialName("sndFileStart") class SndFileStart(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR()
@Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR()
@Serializable @SerialName("sndFileError") class SndFileError(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("sndFileStart") class SndFileStart(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR()
@Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR()
@Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem: AChatItem): CR()
// call events
@Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR()
@Serializable @SerialName("callOffer") class CallOffer(val user: User, val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callAnswer") class CallAnswer(val user: User, val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: User, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val user: User, val contact: Contact): CR()
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: User, val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: User, val connection: PendingContactConnection): CR()
@Serializable @SerialName("callOffer") class CallOffer(val user: UserRef, val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: User?, val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val user_: User?, val chatError: ChatError): CR()
@Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR()
@Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List<ArchiveError>): CR()
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -3378,6 +3375,7 @@ sealed class CR {
is GroupMemberCode -> "groupMemberCode"
is ConnectionVerified -> "connectionVerified"
is Invitation -> "invitation"
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
is ContactAlreadyExists -> "contactAlreadyExists"
@@ -3463,7 +3461,6 @@ sealed class CR {
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted"
is VersionInfo -> "versionInfo"
is ParsedMarkdown -> "apiParsedMarkdown"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
@@ -3503,6 +3500,7 @@ sealed class CR {
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, connReqInvitation)
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is SentConfirmation -> withUser(user, noDetails())
is SentInvitation -> withUser(user, noDetails())
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
@@ -3515,7 +3513,6 @@ sealed class CR {
is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")
is ParsedMarkdown -> json.encodeToString(formattedText)
is UserContactLink -> withUser(user, contactLink.responseDetails)
is UserContactLinkUpdated -> withUser(user, contactLink.responseDetails)
is UserContactLinkCreated -> withUser(user, connReqContact)
@@ -3601,7 +3598,7 @@ sealed class CR {
fun noDetails(): String ="${responseType}: " + generalGetString(MR.strings.no_details)
private fun withUser(u: User?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s
private fun withUser(u: UserLike?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s
}
fun chatError(r: CR): ChatErrorType? {
@@ -3822,6 +3819,7 @@ sealed class ChatErrorType {
is ServerProtocol -> "serverProtocol"
is AgentCommandError -> "agentCommandError"
is InvalidFileDescription -> "invalidFileDescription"
is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited"
is InternalError -> "internalError"
is CEException -> "exception $message"
}
@@ -3895,6 +3893,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("serverProtocol") object ServerProtocol: ChatErrorType()
@Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType()
@Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType()
@Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType()
@Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType()
@Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType()
}
@@ -3912,6 +3911,7 @@ sealed class StoreError {
is UserNotFoundByContactRequestId -> "userNotFoundByContactRequestId"
is ContactNotFound -> "contactNotFound"
is ContactNotFoundByName -> "contactNotFoundByName"
is ContactNotFoundByMemberId -> "contactNotFoundByMemberId"
is ContactNotReady -> "contactNotReady"
is DuplicateContactLink -> "duplicateContactLink"
is UserContactLinkNotFound -> "userContactLinkNotFound"
@@ -3939,6 +3939,7 @@ sealed class StoreError {
is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP"
is ConnectionNotFound -> "connectionNotFound"
is ConnectionNotFoundById -> "connectionNotFoundById"
is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId"
is PendingConnectionNotFound -> "pendingConnectionNotFound"
is IntroNotFound -> "introNotFound"
is UniqueID -> "uniqueID"
@@ -3967,6 +3968,7 @@ sealed class StoreError {
@Serializable @SerialName("userNotFoundByContactRequestId") class UserNotFoundByContactRequestId(val contactRequestId: Long): StoreError()
@Serializable @SerialName("contactNotFound") class ContactNotFound(val contactId: Long): StoreError()
@Serializable @SerialName("contactNotFoundByName") class ContactNotFoundByName(val contactName: String): StoreError()
@Serializable @SerialName("contactNotFoundByMemberId") class ContactNotFoundByMemberId(val groupMemberId: Long): StoreError()
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contactName: String): StoreError()
@Serializable @SerialName("duplicateContactLink") object DuplicateContactLink: StoreError()
@Serializable @SerialName("userContactLinkNotFound") object UserContactLinkNotFound: StoreError()
@@ -3994,6 +3996,7 @@ sealed class StoreError {
@Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError()
@Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError()
@Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError()
@Serializable @SerialName("connectionNotFoundByMemberId") class ConnectionNotFoundByMemberId(val groupMemberId: Long): StoreError()
@Serializable @SerialName("pendingConnectionNotFound") class PendingConnectionNotFound(val connId: Long): StoreError()
@Serializable @SerialName("introNotFound") object IntroNotFound: StoreError()
@Serializable @SerialName("uniqueID") object UniqueID: StoreError()
@@ -1,8 +1,9 @@
package chat.simplex.common.platform
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.DefaultTheme
import java.io.File
import java.util.*
enum class AppPlatform {
@@ -4,6 +4,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import kotlinx.serialization.decodeFromString
import java.nio.ByteBuffer
// ghc's rts
external fun initHS()
@@ -19,6 +20,10 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String
external fun chatWriteFile(path: String, buffer: ByteBuffer): String
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
external fun chatEncryptFile(fromPath: String, toPath: String): String
external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String
val chatModel: ChatModel
get() = chatController.chatModel
@@ -50,17 +55,16 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (user == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
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) {
appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
OnboardingStage.Step3_CreateSimpleXAddress
} else {
savedOnboardingStage
}
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) {
})
if (appPreferences.onboardingStage.get() == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) {
chatModel.setDeliveryReceipts.value = true
}
chatController.startChat(user)
@@ -2,6 +2,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import chat.simplex.common.model.CIFile
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import java.io.*
@@ -23,6 +24,8 @@ expect val agentDatabaseFileName: String
* */
expect val databaseExportDir: File
expect fun desktopOpenDatabaseDir()
fun copyFileToFile(from: File, to: URI, finally: () -> Unit) {
try {
to.outputStream().use { stream ->
@@ -60,14 +63,25 @@ fun getAppFilePath(fileName: String): String {
}
fun getLoadedFilePath(file: CIFile?): String? {
return if (file?.filePath != null && file.loaded) {
val filePath = getAppFilePath(file.filePath)
val f = file?.fileSource?.filePath
return if (f != null && file.loaded) {
val filePath = getAppFilePath(f)
if (File(filePath).exists()) filePath else null
} else {
null
}
}
fun getLoadedFileSource(file: CIFile?): CryptoFile? {
val f = file?.fileSource?.filePath
return if (f != null && file.loaded) {
val filePath = getAppFilePath(f)
if (File(filePath).exists()) file.fileSource else null
} else {
null
}
}
/**
* [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function
* */
@@ -15,6 +15,7 @@ expect fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOut
expect fun GrayU8.toImageBitmap(): ImageBitmap
expect fun ImageBitmap.hasAlpha(): Boolean
expect fun ImageBitmap.addLogo(): ImageBitmap
expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap
@@ -10,34 +10,38 @@ import chat.simplex.res.MR
import kotlinx.coroutines.delay
enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
ACCEPT_CONTACT_REQUEST,
ACCEPT_CONTACT_REQUEST_INCOGNITO
}
lateinit var ntfManager: NtfManager
abstract class NtfManager {
fun notifyContactConnected(user: User, contact: Contact) = displayNotification(
fun notifyContactConnected(user: UserLike, contact: Contact) = displayNotification(
user = user,
chatId = contact.id,
displayName = contact.displayName,
msgText = generalGetString(MR.strings.notification_contact_connected)
)
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) = displayNotification(
fun notifyContactRequestReceived(user: UserLike, cInfo: ChatInfo.ContactRequest) = displayNotification(
user = user,
chatId = cInfo.id,
displayName = cInfo.displayName,
msgText = generalGetString(MR.strings.notification_new_contact_request),
image = cInfo.image,
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, cInfo.id) })
listOf(
NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, incognito = false, cInfo.id) },
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO to { acceptContactRequestAction(user.userId, incognito = true, cInfo.id) }
)
)
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
fun notifyMessageReceived(user: UserLike, cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun acceptContactRequestAction(userId: Long?, chatId: ChatId) {
fun acceptContactRequestAction(userId: Long?, incognito: Boolean, chatId: ChatId) {
val isCurrentUser = ChatModel.currentUser.value?.userId == userId
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
(ChatModel.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
@@ -45,7 +49,7 @@ abstract class NtfManager {
null
}
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
acceptContactRequest(apiId, cInfo, isCurrentUser, ChatModel)
acceptContactRequest(incognito, apiId, cInfo, isCurrentUser, ChatModel)
cancelNotificationsForChat(chatId)
}
@@ -85,7 +89,7 @@ abstract class NtfManager {
abstract fun notifyCallInvitation(invitation: RcvCallInvitation)
abstract fun hasNotificationsForChat(chatId: String): Boolean
abstract fun cancelNotificationsForChat(chatId: String)
abstract fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<Pair<NotificationAction, () -> Unit>> = emptyList())
abstract fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<Pair<NotificationAction, () -> Unit>> = emptyList())
abstract fun cancelCallNotification()
abstract fun cancelAllNotifications()
// Android only
@@ -96,7 +100,7 @@ abstract class NtfManager {
if (chatModel.chatRunning.value == null) {
val step = 50L
for (i in 0..(timeout / step)) {
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
if (chatModel.chatRunning.value == true || chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.Step1_SimpleXInfo) {
break
}
delay(step)
@@ -9,6 +9,8 @@ interface PlatformInterface {
fun androidChatStartedAfterBeingOff() {}
fun androidChatStopped() {}
fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
}
/**
* Multiplatform project has separate directories per platform + common directory that contains directories per platform + common for all of them.
@@ -1,7 +1,7 @@
package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import kotlinx.coroutines.CoroutineScope
interface RecorderInterface {
@@ -14,11 +14,11 @@ interface RecorderInterface {
fun stop(): Int
}
expect class RecorderNative: RecorderInterface
expect class RecorderNative(): RecorderInterface
interface AudioPlayerInterface {
fun play(
filePath: String?,
fileSource: CryptoFile,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
@@ -29,7 +29,7 @@ interface AudioPlayerInterface {
fun stop(fileName: String?)
fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>)
fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?)
fun duration(filePath: String): Int?
fun duration(unencryptedFilePath: String): Int?
}
expect object AudioPlayer: AudioPlayerInterface
@@ -19,14 +19,14 @@ expect fun isInNightMode(): Boolean
expect val settings: Settings
expect val settingsThemes: Settings
enum class ScreenOrientation {
enum class WindowOrientation {
UNDEFINED, PORTRAIT, LANDSCAPE
}
expect fun screenOrientation(): ScreenOrientation
expect fun windowOrientation(): WindowOrientation
@Composable
expect fun screenWidth(): Dp
expect fun windowWidth(): Dp
expect fun desktopExpandWindowToWidth(width: Dp)
@@ -2,8 +2,9 @@ package chat.simplex.common.platform
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import chat.simplex.common.model.CryptoFile
expect fun UriHandler.sendEmail(subject: String, body: CharSequence)
expect fun ClipboardManager.shareText(text: String)
expect fun shareFile(text: String, filePath: String)
expect fun shareFile(text: String, fileSource: CryptoFile)
@@ -12,6 +12,7 @@ import chat.simplex.common.views.helpers.generalGetString
// https://github.com/rsms/inter
// I place it here because IDEA shows an error (but still works anyway) when this declaration inside Type.kt
expect val Inter: FontFamily
expect val EmojiFont: FontFamily
object ThemeManager {
private val appPrefs: AppPreferences = ChatController.appPrefs
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.Profile
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.navigationBarsWithImePadding
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -88,14 +89,20 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
icon = painterResource(MR.images.ic_arrow_back_ios_new),
textDecoration = TextDecoration.None,
fontWeight = FontWeight.Medium
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }
}
Spacer(Modifier.fillMaxWidth().weight(1f))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
val createModifier: Modifier
val createColor: Color
if (enabled) {
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
createModifier = Modifier.clickable {
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
createProfileInProfiles(chatModel, displayName.value, fullName.value, close)
} else {
createProfileOnboarding(chatModel, displayName.value, fullName.value, close)
}
}.padding(8.dp)
createColor = MaterialTheme.colors.primary
} else {
createModifier = Modifier.padding(8.dp)
@@ -116,7 +123,7 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
}
}
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null)
@@ -125,16 +132,32 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c
if (chatModel.users.isEmpty()) {
chatModel.controller.startChat(user)
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
chatModel.onboardingStage.value = OnboardingStage.Step3_CreateSimpleXAddress
} else {
val users = chatModel.controller.listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.controller.getUserChatData()
close()
}
}
}
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
withApi {
chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null)
) ?: return@withApi
val onboardingStage = chatModel.controller.appPrefs.onboardingStage
if (chatModel.users.isEmpty()) {
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) {
OnboardingStage.Step2_5_SetupDatabasePassphrase
} else {
OnboardingStage.Step3_CreateSimpleXAddress
})
} else {
// the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen,
// this will get it unstuck.
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
onboardingStage.set(OnboardingStage.OnboardingComplete)
close()
}
}
@@ -3,6 +3,7 @@ package chat.simplex.common.views.call
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.views.helpers.withBGApi
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import kotlinx.datetime.Clock
import kotlin.time.Duration.Companion.minutes
@@ -28,6 +29,7 @@ class CallManager(val chatModel: ChatModel) {
if (appPlatform.isDesktop) {
return showInDevelopingAlert()
}
val call = chatModel.activeCall.value
if (call == null) {
justAcceptIncomingCall(invitation = invitation)
@@ -50,7 +52,7 @@ class CallManager(val chatModel: ChatModel) {
contact = invitation.contact,
callState = CallState.InvitationAccepted,
localMedia = invitation.callType.media,
sharedKey = invitation.sharedKey
sharedKey = invitation.sharedKey,
)
showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
@@ -5,6 +5,7 @@ import InfoRowEllipsis
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionTextFooter
import SectionView
@@ -23,6 +24,7 @@ import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -37,6 +39,7 @@ import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
@Composable
@@ -50,15 +53,16 @@ fun ChatInfoView(
close: () -> Unit,
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val currentUser = chatModel.currentUser.value
val connStats = remember { mutableStateOf(connectionStats) }
val contact = rememberUpdatedState(contact).value
val chat = remember(contact.id) { chatModel.chats.firstOrNull { it.id == contact.id } }
val currentUser = remember { chatModel.currentUser }.value
val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null && currentUser != null) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) {
mutableStateOf(chatModel.contactNetworkStatus(contact))
}
val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
ChatInfoLayout(
chat,
contact,
@@ -202,7 +206,10 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() ->
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
@@ -238,7 +245,7 @@ fun ChatInfoLayout(
currentUser: User,
sendReceipts: State<SendReceipts>,
setSendReceipts: (SendReceipts) -> Unit,
connStats: MutableState<ConnectionStats?>,
connStats: State<ConnectionStats?>,
contactNetworkStatus: NetworkStatus,
customUserProfile: Profile?,
localAlias: String,
@@ -255,10 +262,15 @@ fun ChatInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
KeyChangeEffect(chat.id) {
scope.launch { scrollState.scrollTo(0) }
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
) {
Row(
Modifier.fillMaxWidth(),
@@ -267,11 +279,14 @@ fun ChatInfoLayout(
ChatInfoHeader(chat.chatInfo, contact)
}
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
InfoRow(generalGetString(MR.strings.incognito_random_profile), customUserProfile.chatViewName)
SectionItemViewSpaceBetween {
Text(generalGetString(MR.strings.incognito_random_profile))
Text(customUserProfile.chatViewName, color = Indigo)
}
}
SectionDividerSpaced()
}
@@ -389,13 +404,17 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
@Composable
fun LocalAliasEditor(
chatId: String,
initialValue: String,
center: Boolean = true,
leadingIcon: Boolean = false,
focus: Boolean = false,
updateValue: (String) -> Unit
) {
var value by rememberSaveable { mutableStateOf(initialValue) }
val state = remember(chatId) {
mutableStateOf(TextFieldValue(initialValue))
}
var updatedValueAtLeastOnce = remember { false }
val modifier = if (center)
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
else
@@ -403,7 +422,7 @@ fun LocalAliasEditor(
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) {
DefaultBasicTextField(
modifier,
value,
state,
{
Text(
generalGetString(MR.strings.text_field_set_contact_placeholder),
@@ -416,23 +435,27 @@ fun LocalAliasEditor(
} else null,
color = MaterialTheme.colors.secondary,
focus = focus,
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
textStyle = TextStyle.Default.copy(textAlign = if (state.value.text.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(state.value.text) })
) {
value = it
state.value = it
updatedValueAtLeastOnce = true
}
}
LaunchedEffect(Unit) {
snapshotFlow { value }
LaunchedEffect(chatId) {
var prevValue = state.value
snapshotFlow { state.value }
.distinctUntilChanged()
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
.conflate() // get the latest value
.filter { it == value } // don't process old ones
.filter { it == state.value && it != prevValue } // don't process old ones
.collect {
updateValue(value)
updateValue(it.text)
prevValue = it
}
}
DisposableEffect(Unit) {
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
DisposableEffect(chatId) {
onDispose { if (updatedValueAtLeastOnce) updateValue(state.value.text) } // just in case snapshotFlow will be canceled when user presses Back too fast
}
}
@@ -234,18 +234,17 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
)
Spacer(Modifier.fillMaxWidth().weight(1f))
val statusIcon = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary)
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(20.dp))
.clickable {
AlertManager.shared.showAlertMsg(
title = status.statusText,
text = status.statusDescription
)
},
contentAlignment = Alignment.Center
) {
var modifier = Modifier.size(36.dp).clip(RoundedCornerShape(20.dp))
val info = status.statusInto
if (info != null) {
modifier = modifier.clickable {
AlertManager.shared.showAlertMsg(
title = info.first,
text = info.second
)
}
}
Box(modifier, contentAlignment = Alignment.Center) {
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
Icon(
@@ -434,22 +433,6 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem
val t = qi.text
shareText.add(if (t != "") t else generalGetString(MR.strings.item_info_no_text))
}
val mdss = chatItemInfo.memberDeliveryStatuses
if (mdss != null) {
val mss = membersStatuses(chatModel, mdss)
if (mss.isNotEmpty()) {
shareText.add("")
shareText.add("## " + generalGetString(MR.strings.delivery))
shareText.add("")
mss.forEach { (member, status) ->
shareText.add(String.format(
generalGetString(MR.strings.recipient_colon_delivery_status),
member.chatViewName,
status.statusDescription
))
}
}
}
val versions = chatItemInfo.itemVersions
if (versions.isNotEmpty()) {
shareText.add("")
@@ -17,11 +17,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
@@ -43,7 +43,7 @@ import java.net.URI
import kotlin.math.sign
@Composable
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: String) -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
@@ -66,12 +66,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (activeChat.value?.id != chatModel.chatId.value && chatModel.chatId.value != null) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatModel.chatId.value!!)
}
.filter { it != null && activeChat.value?.id != it }
.collect { chatId ->
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId!!)
markUnreadChatAsRead(activeChat, chatModel)
}
}
@@ -91,20 +90,21 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
}
.distinctUntilChanged()
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
.filter { it != null && it?.chatInfo != activeChat.value?.chatInfo }
.collect { activeChat.value = it }
}
}
val view = LocalMultiplatformView()
if (activeChat.value == null || user == null) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
} else {
val chat = activeChat.value!!
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
}
}
val clipboard = LocalClipboardManager.current
@@ -127,34 +127,51 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
chatModelIncognito = chatModel.incognito.value,
back = {
hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null
},
info = {
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
hideKeyboard(view)
withApi {
// The idea is to preload information before showing a modal because large groups can take time to load all members
var preloadedContactInfo: Pair<ConnectionStats, Profile?>? = null
var preloadedCode: String? = null
var preloadedLink: Pair<String, GroupMemberRole>? = null
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
}
preloadedContactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
preloadedCode = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId).second
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
var groupLink = link?.first
var groupLinkMemberRole = link?.second
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
groupLink = it.first;
groupLinkMemberRole = it.second
preloadedLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
}
ModalManager.end.showModalCloseable(true) { close ->
val chat = remember { activeChat }.value
if (chat?.chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId).second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
setGroupMembers((chat.chatInfo as ChatInfo.Group).groupInfo, chatModel)
link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatModel, link?.first, link?.second, {
link = it
preloadedLink = it
}, close)
}
}
@@ -227,8 +244,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
}
}
},
receiveFile = { fileId ->
withApi { chatModel.controller.receiveFile(user, fileId) }
receiveFile = { fileId, encrypted ->
withApi { chatModel.controller.receiveFile(user, fileId, encrypted) }
},
cancelFile = { fileId ->
withApi { chatModel.controller.cancelFile(user, fileId) }
@@ -240,11 +257,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
if (appPlatform.isDesktop) {
return@out showInDevelopingAlert()
}
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Capabilities
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Capabilities
}
}
},
acceptCall = { contact ->
@@ -379,13 +398,12 @@ fun ChatLayout(
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
back: () -> Unit,
info: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
@@ -403,14 +421,15 @@ fun ChatLayout(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
onComposed: () -> Unit,
onComposed: suspend (chatId: String) -> Unit,
) {
val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
Box(
Modifier
.fillMaxWidth()
.desktopOnExternalDrag(
enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value,
enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value,
onFiles = { paths ->
val uris = paths.map { URI.create(it) }
val groups = uris.groupBy { isImage(it) }
@@ -465,7 +484,7 @@ fun ChatLayout(
) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed,
@@ -634,11 +653,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
@@ -653,7 +671,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
showItemDetails: (ChatInfo, ChatItem) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
onComposed: suspend (chatId: String) -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
@@ -684,13 +702,13 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
}
LaunchedEffect(Unit) {
LaunchedEffect(chat.id) {
var stopListening = false
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.filter { !stopListening }
.collect {
onComposed()
onComposed(chat.id)
stopListening = true
}
}
@@ -700,7 +718,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
)
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
itemsIndexed(reversedChatItems, key = { _, item -> item.id }) { i, cItem ->
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
@@ -742,27 +760,73 @@ fun BoxWithConstraintsScope.ChatItemsList(
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 = if (voiceWithTransparentBack) 12.dp else 66.dp).then(swipeableModifier)) {
if (showMember) {
Box(
val nextItem = if (i - 1 >= 0) reversedChatItems[i - 1] else null
fun getConnectedMemberNames(): List<String> {
val ns = mutableListOf<String>()
var idx = i
while (idx < reversedChatItems.size) {
val m = reversedChatItems[idx].memberConnected
if (m != null) {
ns.add(m.displayName)
} else {
break
}
idx++
}
return ns
}
if (cItem.memberConnected != null && nextItem?.memberConnected != null) {
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
Box(Modifier.size(0.dp)) {}
} else {
val member = cItem.chatDir.groupMember
if (showMemberImage(member, prevItem)) {
Column(
Modifier
.clip(CircleShape)
.clickable {
showMemberInfo(chat.chatInfo.groupInfo, member)
.padding(top = 8.dp)
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start
) {
if (cItem.content.showMemberName) {
Text(
member.displayName,
Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp),
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
}
Row(
swipeableModifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
Modifier
.clip(CircleShape)
.clickable {
showMemberInfo(chat.chatInfo.groupInfo, member)
}
) {
MemberImage(member)
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames)
}
}
} else {
Row(
Modifier
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
.then(swipeableModifier)
) {
MemberImage(member)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames)
}
Spacer(Modifier.size(4.dp))
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
}
} else {
Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 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, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
}
}
@@ -778,7 +842,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
if (cItem.isRcvNew) {
if (cItem.isRcvNew && chat.id == ChatModel.chatId.value) {
LaunchedEffect(cItem.id) {
scope.launch {
delay(600)
@@ -816,7 +880,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect {
try {
if (listState.firstVisibleItemIndex == 0) {
if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.size)) {
listState.animateScrollToItem(0)
} else {
listState.animateScrollBy(scrollDistance)
@@ -910,17 +974,19 @@ fun BoxWithConstraintsScope.FloatingButtons(
onLongClick = { showDropDown.value = true }
)
DefaultDropdownMenu(showDropDown, offset = DpOffset(maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
ItemAction(
generalGetString(MR.strings.mark_read),
painterResource(MR.images.ic_check),
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown.value = false
})
Box {
DefaultDropdownMenu(showDropDown, offset = DpOffset(this@FloatingButtons.maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
ItemAction(
generalGetString(MR.strings.mark_read),
painterResource(MR.images.ic_check),
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown.value = false
})
}
}
}
@@ -955,9 +1021,11 @@ fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
(prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
}
val MEMBER_IMAGE_SIZE: Dp = 38.dp
@Composable
fun MemberImage(member: GroupMember) {
ProfileImage(38.dp, member.memberProfile.image)
ProfileImage(MEMBER_IMAGE_SIZE, member.memberProfile.image)
}
@Composable
@@ -1049,7 +1117,7 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
}
sealed class ProviderMedia {
data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia()
data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia()
data class Video(val uri: URI, val preview: String): ProviderMedia()
}
@@ -1087,11 +1155,11 @@ private fun providerForGallery(
val item = item(internalIndex, initialChatId)?.second ?: return null
return when (item.content.msgContent) {
is MsgContent.MCImage -> {
val imageBitmap: ImageBitmap? = getLoadedImage(item.file)
val res = getLoadedImage(item.file)
val filePath = getLoadedFilePath(item.file)
if (imageBitmap != null && filePath != null) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
ProviderMedia.Image(uri, imageBitmap)
if (res != null && filePath != null) {
val (imageBitmap: ImageBitmap, data: ByteArray) = res
ProviderMedia.Image(data, imageBitmap)
} else null
}
is MsgContent.MCVideo -> {
@@ -1184,13 +1252,12 @@ fun PreviewChatLayout() {
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
startCall = {},
@@ -1252,13 +1319,12 @@ fun PreviewGroupChatLayout() {
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
startCall = {},
@@ -16,6 +16,8 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.Indigo
import chat.simplex.common.ui.theme.isSystemInDarkTheme
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
@@ -112,7 +114,7 @@ data class ComposeState(
}
val empty: Boolean
get() = message.isEmpty() && preview is ComposePreview.NoPreview
get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
@@ -230,6 +232,7 @@ fun ComposeView(
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val saveLastDraft = chatModel.controller.appPrefs.privacySaveLastDraft.get()
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
@@ -240,7 +243,7 @@ fun ComposeView(
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
fun parseMessage(msg: String): String? {
val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
val parsedMsg = parseToMarkdown(msg)
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
return link?.text
}
@@ -283,6 +286,20 @@ fun ComposeView(
cancelledLinks.clear()
}
fun clearPrevDraft(prevChatId: String?) {
if (chatModel.draftChatId.value == prevChatId) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
}
fun clearCurrentDraft() {
if (chatModel.draftChatId.value == chat.id) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
}
fun clearState(live: Boolean = false) {
if (live) {
composeState.value = composeState.value.copy(inProgress = false)
@@ -300,7 +317,7 @@ fun ComposeView(
chatModel.filesToDelete.clear()
}
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? {
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
@@ -314,7 +331,7 @@ fun ComposeView(
chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem.chatItem
}
if (file != null) removeFile(file)
if (file != null) removeFile(file.filePath)
return null
}
@@ -378,6 +395,7 @@ fun ComposeView(
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
sending()
}
clearCurrentDraft()
if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem
@@ -386,16 +404,16 @@ fun ComposeView(
sent = updateMessage(liveMessage.chatItem, cInfo, live)
} else {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
val files: ArrayList<CryptoFile> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.MediaPreview -> {
preview.content.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(it.uri)
is UploadContent.Video -> saveFileFromUri(it.uri)
is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false)
}
if (file != null) {
files.add(file)
@@ -411,15 +429,21 @@ fun ComposeView(
val tmpFile = File(preview.voice)
AudioPlayer.stop(tmpFile.absolutePath)
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, "")))
withContext(Dispatchers.IO) {
Files.move(tmpFile.toPath(), actualFile.toPath())
}
files.add(actualFile.name)
files.add(withContext(Dispatchers.IO) {
if (chatController.appPrefs.privacyEncryptLocalFiles.get()) {
val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath)
tmpFile.delete()
CryptoFile(actualFile.name, args)
} else {
Files.move(tmpFile.toPath(), actualFile.toPath())
CryptoFile.plain(actualFile.name)
}
})
deleteUnusedFiles()
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
}
is ComposePreview.FilePreview -> {
val file = saveFileFromUri(preview.uri)
val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
@@ -705,13 +729,6 @@ fun ComposeView(
}
}
fun clearCurrentDraft() {
if (chatModel.draftChatId.value == chat.id) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
}
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
if (!chat.userCanSend) {
clearCurrentDraft()
@@ -719,29 +736,38 @@ fun ComposeView(
}
}
DisposableEffectOnGone {
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearCurrentDraft()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (composeState.value.inProgress) {
clearCurrentDraft()
} else if (!composeState.value.empty) {
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearCurrentDraft()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
if (chat.chatInfo.incognito)
if (isSystemInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
else MaterialTheme.colors.primary
SendMsgView(
composeState,
showVoiceRecordIcon = true,
@@ -753,6 +779,7 @@ fun ComposeView(
allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = userIsObserver.value,
userCanSend = userCanSend.value,
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
sendMessage = { ttl ->
@@ -17,6 +17,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.model.durationText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -52,7 +53,7 @@ fun ComposeVoiceView(
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
AudioPlayer.play(CryptoFile.plain(filePath), audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
@@ -11,7 +11,9 @@ import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.model.*
@@ -27,6 +29,17 @@ fun ContextItemView(
val sent = contextItem.chatDir.sent
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@Composable
fun msgContentView(lines: Int) {
MarkdownText(
contextItem.text, contextItem.formattedText,
maxLines = lines,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
}
Row(
Modifier
.padding(top = 8.dp)
@@ -49,12 +62,21 @@ fun ContextItemView(
contentDescription = stringResource(MR.strings.icon_descr_context),
tint = MaterialTheme.colors.secondary,
)
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
val sender = contextItem.memberDisplayName
if (sender != null) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
sender,
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
msgContentView(lines = 2)
}
} else {
msgContentView(lines = 3)
}
}
IconButton(onClick = cancelContextItem) {
Icon(
@@ -41,6 +41,7 @@ fun SendMsgView(
allowedVoiceByPrefs: Boolean,
userIsObserver: Boolean,
userCanSend: Boolean,
sendButtonColor: Color = MaterialTheme.colors.primary,
allowVoiceToContact: () -> Unit,
timedMessageAllowed: Boolean = false,
customDisappearingMessageTimePref: SharedPreference<Int>? = null,
@@ -64,12 +65,22 @@ fun SendMsgView(
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview)
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(composeState.value.inProgress) {
progressByTimeout = if (composeState.value.inProgress) {
delay(500)
composeState.value.inProgress
} else {
false
}
}
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
PlatformTextField(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage) {
sendMessage(null)
if (!cs.inProgress) {
sendMessage(null)
}
}
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) {
@@ -98,7 +109,7 @@ fun SendMsgView(
}
}
when {
showProgress -> ProgressIndicator()
progressByTimeout -> ProgressIndicator()
showVoiceButton -> {
Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) }
@@ -184,12 +195,12 @@ fun SendMsgView(
val menuItems = MenuItems()
if (menuItems.isNotEmpty()) {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true }
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) { showDropdown.value = true }
DefaultDropdownMenu(showDropdown) {
menuItems.forEach { composable -> composable() }
}
} else {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage)
}
}
}
@@ -439,6 +450,7 @@ private fun SendMsgButton(
icon: Painter,
sizeDp: Animatable<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
sendButtonColor: Color,
enabled: Boolean,
sendMessage: (Int?) -> Unit,
onLongClick: (() -> Unit)? = null
@@ -466,7 +478,7 @@ private fun SendMsgButton(
.padding(4.dp)
.alpha(alpha.value)
.clip(CircleShape)
.background(if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary)
.background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary)
.padding(3.dp)
)
}
@@ -20,17 +20,16 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.InfoAboutIncognito
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.group.GroupPreferencesView
import chat.simplex.res.MR
@Composable
@@ -41,7 +40,6 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
BackHandler(onBack = close)
AddGroupMembersLayout(
chatModel.incognito.value,
groupInfo = groupInfo,
creatingGroup = creatingGroup,
contactsToAdd = getContactsToAdd(chatModel, searchText.value.text),
@@ -73,6 +71,9 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
close = close,
)
KeyChangeEffect(chatModel.chatId.value) {
close()
}
}
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
@@ -92,7 +93,6 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
@Composable
fun AddGroupMembersLayout(
chatModelIncognito: Boolean,
groupInfo: GroupInfo,
creatingGroup: Boolean,
contactsToAdd: List<Contact>,
@@ -107,19 +107,31 @@ fun AddGroupMembersLayout(
removeContact: (Long) -> Unit,
close: () -> Unit,
) {
@Composable fun profileText() {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
painterResource(MR.images.ic_info),
null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier.padding(end = 10.dp).size(20.dp)
)
Text(generalGetString(MR.strings.group_main_profile_sent), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2)
}
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(MR.strings.button_add_members))
InfoAboutIncognito(
chatModelIncognito,
false,
generalGetString(MR.strings.group_unsupported_incognito_main_profile_sent),
generalGetString(MR.strings.group_main_profile_sent),
true
)
profileText()
Spacer(Modifier.size(DEFAULT_PADDING))
Row(
Modifier.fillMaxWidth(),
@@ -164,7 +176,7 @@ fun AddGroupMembersLayout(
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.select_contacts)) {
SectionItemView(padding = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText, selectedContacts.size)
SearchRowView(searchText)
}
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
@@ -175,8 +187,7 @@ fun AddGroupMembersLayout(
@Composable
private fun SearchRowView(
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) },
selectedContactsSize: Int
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
) {
Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) {
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.secondary)
@@ -185,11 +196,6 @@ private fun SearchRowView(
SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) {
searchText.value = searchText.value.copy(it)
}
val view = LocalMultiplatformView()
LaunchedEffect(selectedContactsSize) {
searchText.value = searchText.value.copy("")
hideKeyboard(view)
}
}
@Composable
@@ -350,7 +356,6 @@ fun showProhibitedToInviteIncognitoAlertDialog() {
fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
chatModelIncognito = false,
groupInfo = GroupInfo.sampleData,
creatingGroup = false,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
@@ -8,8 +8,8 @@ import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@@ -33,11 +33,12 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val currentUser = chatModel.currentUser.value
@@ -132,7 +133,10 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
@@ -177,79 +181,92 @@ fun GroupChatInfoLayout(
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
) {
Column(
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
KeyChangeEffect(chat.id) {
scope.launch { listState.scrollToItem(0) }
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = remember(members) { derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim()) } } }
LazyColumn(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
state = listState
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
}
if (groupInfo.groupProfile.description != null || groupInfo.canEdit) {
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
SendReceiptsOptionDisabled()
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
}
if (groupInfo.groupProfile.description != null || groupInfo.canEdit) {
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
GroupLinkButton(manageGroupLink)
}
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
AddMembersButton(tint, onAddMembersClick)
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = remember { derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim()) } } }
if (members.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
SendReceiptsOptionDisabled()
}
}
SectionItemView(minHeight = 54.dp) {
MemberRow(groupInfo.membership, user = true)
}
MembersList(filteredMembers.value, showMemberInfo)
}
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
DeleteGroupButton(deleteGroup)
}
if (groupInfo.membership.memberCurrent) {
LeaveGroupButton(leaveGroup)
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName)
InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString())
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
} else {
GroupLinkButton(manageGroupLink)
}
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
AddMembersButton(tint, onAddMembersClick)
}
if (members.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
}
}
SectionItemView(minHeight = 54.dp) {
MemberRow(groupInfo.membership, user = true)
}
}
}
SectionBottomSpacer()
items(filteredMembers.value) { member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
}
}
item {
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
DeleteGroupButton(deleteGroup)
}
if (groupInfo.membership.memberCurrent) {
LeaveGroupButton(leaveGroup)
}
}
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName)
InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString())
}
}
SectionBottomSpacer()
}
}
}
@@ -330,18 +347,6 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick
)
}
@Composable
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
}
}
}
}
@Composable
private fun MemberRow(member: GroupMember, user: Boolean = false) {
Row(
@@ -23,7 +23,7 @@ import chat.simplex.common.views.newchat.QRCode
import chat.simplex.res.MR
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
var creatingLink by rememberSaveable { mutableStateOf(false) }
@@ -34,7 +34,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
onGroupLinkUpdated(link)
}
creatingLink = false
}
@@ -60,7 +60,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
onGroupLinkUpdated(link)
}
}
}
@@ -75,7 +75,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
onGroupLinkUpdated(null to null)
onGroupLinkUpdated(null)
}
}
},
@@ -3,6 +3,7 @@ package chat.simplex.common.views.chat.group
import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
@@ -445,19 +446,42 @@ private fun updateMemberRoleDialog(
}
fun connectViaMemberAddressAlert(connReqUri: String) {
AlertManager.shared.showAlertDialog(
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.connect_via_member_address_alert_title),
text = generalGetString(MR.strings.connect_via_member_address_alert_desc),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting")
connectViaUri(chatModel, linkType, uri)
text = AnnotatedString(generalGetString(MR.strings.connect_via_member_address_alert_desc)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting")
connectViaUri(chatModel, linkType, uri, incognito = false)
}
}
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting incognito")
connectViaUri(chatModel, linkType, uri, incognito = true)
}
}
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
},
}
)
}
@@ -8,43 +8,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.ui.theme.*
@Composable
fun CIEventView(ci: ChatItem) {
@Composable
fun chatEventTextView(text: AnnotatedString) {
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
fun CIEventView(text: AnnotatedString) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
chatEventTextView(
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}.plus(chatEventText(ci))
)
} else {
chatEventTextView(chatEventText(ci))
}
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
}
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
fun chatEventText(ci: ChatItem): AnnotatedString =
buildAnnotatedString {
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
@@ -52,8 +28,6 @@ fun chatEventText(ci: ChatItem): AnnotatedString =
@Composable
fun CIEventViewPreview() {
SimpleXTheme {
CIEventView(
ChatItem.getGroupEventSample()
)
CIEventView(buildAnnotatedString { append("event happened") })
}
}
@@ -28,7 +28,7 @@ import java.net.URI
fun CIFileView(
file: CIFile?,
edited: Boolean,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
@@ -71,7 +71,8 @@ fun CIFileView(
when (file.fileStatus) {
is CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
receiveFile(file.fileId)
val encrypted = file.fileProtocol == FileProtocol.XFTP && chatController.appPrefs.privacyEncryptLocalFiles.get()
receiveFile(file.fileId, encrypted)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -184,9 +185,9 @@ fun CIFileView(
) {
fileIndicator()
val metaReserve = if (edited)
" "
" "
else
" "
" "
if (file != null) {
Column {
Text(
@@ -211,7 +212,15 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false, ciFile) { to: URI? ->
val filePath = getLoadedFilePath(ciFile)
if (filePath != null && to != null) {
copyFileToFile(File(filePath), to) {}
if (ciFile?.fileSource?.cryptoArgs != null) {
createTmpFileAndDelete { tmpFile ->
decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
copyFileToFile(tmpFile, to) {}
tmpFile.delete()
}
} else {
copyFileToFile(File(filePath), to) {}
}
}
}
@@ -29,9 +29,11 @@ import java.net.URI
fun CIImageView(
image: String,
file: CIFile?,
encryptLocalFile: Boolean,
metaColor: Color,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
@Composable
fun progressIndicator() {
@@ -48,7 +50,7 @@ fun CIImageView(
icon,
stringResource(stringId),
Modifier.fillMaxSize(),
tint = Color.White
tint = metaColor
)
}
@@ -132,27 +134,31 @@ fun CIImageView(
return false
}
fun imageAndFilePath(file: CIFile?): Pair<ImageBitmap?, String?> {
val imageBitmap: ImageBitmap? = getLoadedImage(file)
val filePath = getLoadedFilePath(file)
return imageBitmap to filePath
fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? {
val res = getLoadedImage(file)
if (res != null) {
val (imageBitmap: ImageBitmap, data: ByteArray) = res
val filePath = getLoadedFilePath(file)!!
return Triple(imageBitmap, data, filePath)
}
return null
}
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd
) {
val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
if (imageBitmap != null && filePath != null) {
val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) }
SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
val res = remember(file) { imageAndFilePath(file) }
if (res != null) {
val (imageBitmap, data, _) = res
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
} else {
imageView(base64ToBitmap(image), onClick = {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
if (fileSizeValid()) {
receiveFile(file.fileId)
receiveFile(file.fileId, encryptLocalFile)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -186,7 +192,7 @@ fun CIImageView(
@Composable
expect fun SimpleAndAnimatedImageView(
uri: URI,
data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
@@ -44,14 +44,14 @@ fun CIMetaView(
modifier = Modifier.padding(start = 3.dp)
)
} else {
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor)
CIMetaText(chatItem.meta, timedMessagesTTL, encrypted = chatItem.encryptedFile, metaColor, paleMetaColor)
}
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) {
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, color: Color, paleColor: Color) {
if (meta.itemEdited) {
StatusIconText(painterResource(MR.images.ic_edit), color)
Spacer(Modifier.width(3.dp))
@@ -77,11 +77,15 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Col
StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent)
Spacer(Modifier.width(4.dp))
}
if (encrypted != null) {
StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color)
Spacer(Modifier.width(4.dp))
}
Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
// the conditions in this function should match CIMetaText
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?): String {
val iconSpace = " "
var res = ""
if (meta.itemEdited) res += iconSpace
@@ -95,6 +99,9 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
res += iconSpace
}
if (encrypted != null) {
res += iconSpace
}
return res + meta.timestampText
}
@@ -32,7 +32,6 @@ fun CIRcvDecryptionError(
syncMemberConnection: (GroupInfo, GroupMember) -> Unit,
findModelChat: (String) -> Chat?,
findModelMember: (String) -> GroupMember?,
showMember: Boolean
) {
LaunchedEffect(Unit) {
if (cInfo is ChatInfo.Direct) {
@@ -46,7 +45,6 @@ fun CIRcvDecryptionError(
fun BasicDecryptionErrorItem() {
DecryptionErrorItem(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.decryption_error),
@@ -64,7 +62,6 @@ fun CIRcvDecryptionError(
if (modelContactStats.ratchetSyncAllowed) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.fix_connection_question),
@@ -78,7 +75,6 @@ fun CIRcvDecryptionError(
} else if (!modelContactStats.ratchetSyncSupported) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.fix_connection_not_supported_by_contact),
@@ -103,7 +99,6 @@ fun CIRcvDecryptionError(
if (modelMemberStats.ratchetSyncAllowed) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.fix_connection_question),
@@ -117,7 +112,6 @@ fun CIRcvDecryptionError(
} else if (!modelMemberStats.ratchetSyncSupported) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.fix_connection_not_supported_by_group_member),
@@ -140,7 +134,6 @@ fun CIRcvDecryptionError(
@Composable
fun DecryptionErrorItemFixButton(
ci: ChatItem,
showMember: Boolean,
onClick: () -> Unit,
syncSupported: Boolean
) {
@@ -159,7 +152,6 @@ fun DecryptionErrorItemFixButton(
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
@@ -174,7 +166,7 @@ fun DecryptionErrorItemFixButton(
Text(
buildAnnotatedString {
append(generalGetString(MR.strings.fix_connection))
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
withStyle(reserveTimestampStyle) { append(" ") } // for icon
},
color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
@@ -189,7 +181,6 @@ fun DecryptionErrorItemFixButton(
@Composable
fun DecryptionErrorItem(
ci: ChatItem,
showMember: Boolean,
onClick: () -> Unit
) {
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@@ -204,9 +195,8 @@ fun DecryptionErrorItem(
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
@@ -31,7 +31,7 @@ fun CIVideoView(
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
@@ -54,7 +54,7 @@ fun CIVideoView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
receiveFileIfValidSize(file, receiveFile)
receiveFileIfValidSize(file, encrypted = false, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
FileProtocol.XFTP ->
@@ -80,7 +80,7 @@ fun CIVideoView(
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, encrypted = false, receiveFile) }
}
}
}
@@ -301,9 +301,9 @@ private fun fileSizeValid(file: CIFile?): Boolean {
return false
}
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
private fun receiveFileIfValidSize(file: CIFile, encrypted: Boolean, receiveFile: (Long, Boolean) -> Unit) {
if (fileSizeValid(file)) {
receiveFile(file.fileId)
receiveFile(file.fileId, encrypted)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -20,8 +20,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.getLoadedFilePath
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -37,21 +36,24 @@ fun CIVoiceView(
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
) {
Row(
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) {
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
val f = file.fileSource?.filePath
val fileSource = remember(f, file.fileStatus) { getLoadedFileSource(file) }
var brokenAudio by rememberSaveable(f) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(f) { mutableStateOf(false) }
val progress = rememberSaveable(f) { mutableStateOf(0) }
val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) }
val play = {
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
brokenAudio = !audioPlaying.value
if (fileSource != null) {
AudioPlayer.play(fileSource, audioPlaying, progress, duration, true)
brokenAudio = !audioPlaying.value
}
}
val pause = {
AudioPlayer.pause(audioPlaying, progress)
@@ -66,7 +68,7 @@ fun CIVoiceView(
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) {
AudioPlayer.seekTo(it, progress, filePath)
AudioPlayer.seekTo(it, progress, fileSource?.filePath)
}
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile)
@@ -94,7 +96,7 @@ private fun VoiceLayout(
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
onProgressChanged: (Int) -> Unit,
) {
@Composable
@@ -248,7 +250,7 @@ private fun VoiceMsgIndicator(
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
) {
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
@@ -268,7 +270,7 @@ private fun VoiceMsgIndicator(
}
} else {
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick)
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, chatController.appPrefs.privacyEncryptLocalFiles.get()) }, {}, longClick = longClick)
} else if (file?.fileStatus is CIFileStatus.RcvTransfer
|| file?.fileStatus is CIFileStatus.RcvAccepted
) {
@@ -13,12 +13,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
@@ -30,17 +29,26 @@ import kotlinx.datetime.Clock
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
fun chatEventText(ci: ChatItem): AnnotatedString =
chatEventText(ci.content.text, ci.timestampText)
fun chatEventText(eventText: String, ts: String): AnnotatedString =
buildAnnotatedString {
withStyle(chatEventStyle) { append("$eventText $ts") }
}
@Composable
fun ChatItemView(
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
@@ -54,6 +62,7 @@ fun ChatItemView(
findModelMember: (String) -> GroupMember?,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
getConnectedMemberNames: (() -> List<String>)? = null,
) {
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
@@ -84,7 +93,7 @@ fun ChatItemView(
@Composable
fun ChatItemReactions() {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
cItem.reactions.forEach { r ->
var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp))
if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) {
@@ -93,13 +102,15 @@ fun ChatItemView(
}
}
Row(modifier.padding(2.dp)) {
Text(r.reaction.text, fontSize = 12.sp)
ReactionIcon(r.reaction.text, fontSize = 12.sp)
if (r.totalReacted > 1) {
Spacer(Modifier.width(4.dp))
Text("${r.totalReacted}",
Text(
"${r.totalReacted}",
fontSize = 11.5.sp,
fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal,
color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = if (appPlatform.isAndroid) Modifier else Modifier.padding(top = 4.dp)
)
}
}
@@ -116,7 +127,7 @@ fun ChatItemView(
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
@@ -145,7 +156,7 @@ fun ChatItemView(
}
}
if (rs.isNotEmpty()) {
Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState())) {
Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) {
rs.forEach() { r ->
Box(
Modifier.size(36.dp).clickable {
@@ -154,7 +165,7 @@ fun ChatItemView(
},
contentAlignment = Alignment.Center
) {
Text(r.text)
ReactionIcon(r.text, 12.sp)
}
}
}
@@ -180,9 +191,9 @@ fun ChatItemView(
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
val filePath = getLoadedFilePath(cItem.file)
val fileSource = getLoadedFileSource(cItem.file)
when {
filePath != null -> shareFile(cItem.text, filePath)
fileSource != null -> shareFile(cItem.text, fileSource)
else -> clipboard.shareText(cItem.content.text)
}
showMenu.value = false
@@ -246,7 +257,7 @@ fun ChatItemView(
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
@@ -265,7 +276,7 @@ fun ChatItemView(
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DeletedItemView(cItem, cInfo.timedMessagesTTL)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
@@ -276,9 +287,48 @@ fun ChatItemView(
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
fun eventItemViewText(): AnnotatedString {
val memberDisplayName = cItem.memberDisplayName
return if (memberDisplayName != null) {
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}.plus(chatEventText(cItem))
} else {
chatEventText(cItem)
}
}
@Composable fun EventItemView() {
CIEventView(eventItemViewText())
}
fun membersConnectedText(): String? {
return if (getConnectedMemberNames != null) {
val ns = getConnectedMemberNames()
when {
ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2)
ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2])
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
else -> null
}
} else {
null
}
}
fun membersConnectedItemText(): AnnotatedString {
val t = membersConnectedText()
return if (t != null) {
chatEventText(t, cItem.timestampText)
} else {
eventItemViewText()
}
}
@Composable
fun ModeratedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage)
@@ -292,14 +342,17 @@ fun ChatItemView(
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, showMember = showMember)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText())
else -> EventItemView()
}
is CIContent.SndGroupEventContent -> EventItemView()
is CIContent.RcvConnEventContent -> EventItemView()
is CIContent.SndConnEventContent -> EventItemView()
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
@@ -324,6 +377,9 @@ fun ChatItemView(
}
}
@Composable
expect fun ReactionIcon(text: String, fontSize: TextUnit = TextUnit.Unspecified)
@Composable
expect fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>)
@@ -510,7 +566,7 @@ fun PreviewChatItemView() {
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
acceptCall = { _ -> },
@@ -539,7 +595,7 @@ fun PreviewChatItemViewDeletedContent() {
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
acceptCall = { _ -> },
@@ -16,7 +16,7 @@ import chat.simplex.common.model.ChatItem
import chat.simplex.common.ui.theme.*
@Composable
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
val sent = ci.chatDir.sent
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@@ -30,7 +30,6 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean =
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
@@ -10,9 +10,11 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.MREmojiChar
import chat.simplex.common.ui.theme.EmojiFont
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont)
@Composable
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
@@ -26,10 +28,7 @@ fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
}
@Composable
fun EmojiText(text: String) {
val s = text.trim()
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
}
expect fun EmojiText(text: String)
// https://stackoverflow.com/a/46279500
private const val emojiStr = "^(" +
@@ -20,9 +20,11 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE
import chat.simplex.res.MR
import kotlin.math.min
@@ -32,10 +34,9 @@ fun FramedItemView(
ci: ChatItem,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
) {
@@ -49,17 +50,39 @@ fun FramedItemView(
@Composable
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
@Composable
fun ciQuotedMsgTextView(qi: CIQuote, lines: Int) {
MarkdownText(
qi.text,
qi.formattedText,
maxLines = lines,
overflow = TextOverflow.Ellipsis,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
linkMode = linkMode,
uriHandler = if (appPlatform.isDesktop) uriHandler else null
)
}
@Composable
fun ciQuotedMsgView(qi: CIQuote) {
Box(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart
) {
MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
linkMode = linkMode
)
val sender = qi.sender(membership())
if (sender != null) {
Column(
horizontalAlignment = Alignment.Start
) {
Text(
sender,
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
ciQuotedMsgTextView(qi, lines = 2)
}
} else {
ciQuotedMsgTextView(qi, lines = 3)
}
}
}
@@ -156,7 +179,7 @@ fun FramedItemView(
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler)
}
}
@@ -203,11 +226,11 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
CIImageView(image = mc.image, file = ci.file, ci.encryptLocalFile, metaColor = metaColor, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
}
}
is MsgContent.MCVideo -> {
@@ -215,29 +238,29 @@ fun FramedItemView(
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
}
}
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
}
}
}
@@ -253,7 +276,6 @@ fun FramedItemView(
fun CIMarkdownText(
ci: ChatItem,
chatTTL: Int?,
showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
@@ -261,7 +283,7 @@ fun CIMarkdownText(
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
text, if (text.isEmpty()) emptyList() else ci.formattedText,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
@@ -123,8 +123,8 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
// LALAL
// https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24
if (media is ProviderMedia.Image) {
val (uri: URI, imageBitmap: ImageBitmap) = media
FullScreenImageView(modifier, uri, imageBitmap)
val (data: ByteArray, imageBitmap: ImageBitmap) = media
FullScreenImageView(modifier, data, imageBitmap)
} else if (media is ProviderMedia.Video) {
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
@@ -138,7 +138,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
}
@Composable
expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap)
expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap)
@Composable
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) {
@@ -24,8 +24,8 @@ import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@Composable
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
CIMsgError(ci, timedMessagesTTL, showMember) {
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?) {
CIMsgError(ci, timedMessagesTTL) {
when (msgError) {
is MsgErrorType.MsgSkipped ->
AlertManager.shared.showAlertMsg(
@@ -50,7 +50,7 @@ fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTT
}
@Composable
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false, onClick: () -> Unit) {
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Surface(
Modifier.clickable(onClick = onClick),
@@ -63,7 +63,6 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
@@ -20,7 +20,7 @@ import chat.simplex.res.MR
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Surface(
@@ -47,7 +47,6 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Bool
private fun MarkedDeletedText(text: String) {
Text(
buildAnnotatedString {
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
@@ -1,39 +1,32 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.text.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.*
import java.awt.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
if (chatItem.chatDir is CIDirection.GroupRcv) {
val name = chatItem.chatDir.groupMember.memberProfile.displayName
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
else b.append(name)
b.append(": ")
}
}
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
if (sender != null) {
if (senderBold) b.withStyle(boldFont) { append(sender) }
@@ -83,7 +76,7 @@ fun MarkdownText (
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
} else if (meta != null) {
reserveSpaceForMeta(meta, chatTTL)
reserveSpaceForMeta(meta, chatTTL, null) // LALAL
} else {
" "
}
@@ -165,7 +158,8 @@ fun MarkdownText (
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
val icon = remember { mutableStateOf(PointerIcon.Default) }
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
@@ -188,6 +182,15 @@ fun MarkdownText (
uriHandler.openVerifiedSimplexUri(annotation.item)
}
},
onHover = { offset ->
icon.value = annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let {
PointerIcon.Hand
} ?: annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let {
PointerIcon.Hand
} ?: PointerIcon.Default
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset).any()
@@ -211,6 +214,7 @@ fun ClickableText(
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
onLongClick: (Int) -> Unit = {},
onHover: (Int) -> Unit = {},
shouldConsumeEvent: (Int) -> Boolean
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -234,6 +238,14 @@ fun ClickableText(
consume
}
)
}.pointerInput(onHover) {
if (appPlatform.isDesktop) {
detectCursorMove { pos ->
layoutResult.value?.let { layoutResult ->
onHover(layoutResult.getOffsetForPosition(pos))
}
}
}
}
BasicText(
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -13,9 +14,12 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.deleteGroupDialog
@@ -23,9 +27,7 @@ import chat.simplex.common.views.chat.group.leaveGroupDialog
import chat.simplex.common.views.chat.item.InvalidJSONView
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.ContactConnectionInfoView
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.ntfManager
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@@ -42,11 +44,12 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
showMenu.value = false
delay(500L)
}
val showChatPreviews = chatModel.showChatPreviews.value
when (chat.chatInfo) {
is ChatInfo.Direct -> {
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -55,7 +58,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -63,7 +66,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
)
is ChatInfo.ContactRequest ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) },
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu,
@@ -101,7 +104,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
if (chatInfo.ready) {
withApi { openChat(chatInfo, chatModel) }
withBGApi { openChat(chatInfo, chatModel) }
} else {
pendingContactAlertDialog(chatInfo, chatModel)
}
@@ -111,7 +114,7 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
}
}
@@ -320,11 +323,20 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
@Composable
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
if (chatModel.incognito.value) stringResource(MR.strings.accept_contact_incognito_button) else stringResource(MR.strings.accept_contact_button),
if (chatModel.incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_check),
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
stringResource(MR.strings.accept_contact_button),
painterResource(MR.images.ic_check),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
acceptContactRequest(incognito = false, chatInfo.apiId, chatInfo, true, chatModel)
showMenu.value = false
}
)
ItemAction(
stringResource(MR.strings.accept_contact_incognito_button),
painterResource(MR.images.ic_theater_comedy),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(incognito = true, chatInfo.apiId, chatInfo, true, chatModel)
showMenu.value = false
}
)
@@ -357,7 +369,12 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
stringResource(MR.strings.delete_verb),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {
if (chatModel.chatId.value == null) {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
}
}
showMenu.value = false
},
color = Color.Red
@@ -430,19 +447,37 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
}
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.accept_connection_request__question),
text = generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified),
confirmText = if (chatModel.incognito.value) generalGetString(MR.strings.accept_contact_incognito_button) else generalGetString(MR.strings.accept_contact_button),
onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) },
dismissText = generalGetString(MR.strings.reject_contact_button),
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(incognito = false, contactRequest.apiId, contactRequest, true, chatModel)
}) {
Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(incognito = true, contactRequest.apiId, contactRequest, true, chatModel)
}) {
Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
rejectContactRequest(contactRequest, chatModel)
}) {
Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
}
}
)
}
fun acceptContactRequest(apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
fun acceptContactRequest(incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(apiId)
val contact = chatModel.controller.apiAcceptContactRequest(incognito, apiId)
if (contact != null && isCurrentUser && contactRequest != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
@@ -457,38 +492,6 @@ fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: Cha
}
}
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(
if (connection.initiated) MR.strings.you_invited_your_contact
else MR.strings.you_accepted_connection
),
text = generalGetString(
if (connection.viaContactUri) MR.strings.you_will_be_connected_when_your_connection_request_is_accepted
else MR.strings.you_will_be_connected_when_your_contacts_device_is_online
),
buttons = {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = {
AlertManager.shared.hideAlert()
deleteContactConnectionAlert(connection, chatModel) {}
}) {
Text(stringResource(MR.strings.delete_verb))
}
Spacer(Modifier.padding(horizontal = 4.dp))
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(MR.strings.ok))
}
}
}
)
}
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.delete_pending_connection__question),
@@ -520,7 +523,10 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
}
}
},
@@ -553,7 +559,10 @@ fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
if (r) {
chatModel.removeChat(groupInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == groupInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(groupInfo.id)
}
}
@@ -660,9 +669,9 @@ fun PreviewChatListNavLinkDirect() {
),
chatStats = Chat.ChatStats()
),
true,
null,
null,
false,
null,
null,
stopped = false,
@@ -700,9 +709,9 @@ fun PreviewChatListNavLinkGroup() {
),
chatStats = Chat.ChatStats()
),
true,
null,
null,
false,
null,
null,
stopped = false,
@@ -727,7 +736,7 @@ fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chatLinkPreview = {
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
ContactRequestView(ChatInfo.ContactRequest.sampleData)
},
click = {},
dropdownMenuItems = null,
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -13,9 +14,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
@@ -58,6 +61,13 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
connectIfOpenedViaUri(url, chatModel)
}
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst()
}
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope()
@@ -221,14 +231,6 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (chatModel.incognito.value) {
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
stringResource(MR.strings.incognito),
tint = Indigo,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
Text(
stringResource(MR.strings.your_chats),
color = MaterialTheme.colors.onBackground,
@@ -317,17 +319,37 @@ fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) {
ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link)
ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link)
}
AlertManager.shared.showAlertDialog(
AlertManager.shared.showAlertDialogButtonsColumn(
title = title,
text = if (linkType == ConnectionLinkType.GROUP)
generalGetString(MR.strings.you_will_join_group)
AnnotatedString(generalGetString(MR.strings.you_will_join_group))
else
generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = {
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, linkType, uri)
AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, linkType, uri, incognito = false)
}
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting incognito")
connectViaUri(chatModel, linkType, uri, incognito = true)
}
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
@@ -31,9 +31,9 @@ import dev.icerock.moko.resources.ImageResource
@Composable
fun ChatPreviewView(
chat: Chat,
showChatPreviews: Boolean,
chatModelDraft: ComposeState?,
chatModelDraftChatId: ChatId?,
chatModelIncognito: Boolean,
currentUserProfileDisplayName: String?,
contactNetworkStatus: NetworkStatus?,
stopped: Boolean,
@@ -138,35 +138,37 @@ fun ChatPreviewView(
}
@Composable
fun chatPreviewText(chatModelIncognito: Boolean) {
fun chatPreviewText() {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
ci.meta.itemDeleted == null -> ci.text to null
else -> generalGetString(MR.strings.marked_deleted_description) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
}
MarkdownText(
text,
formattedText,
sender = when {
if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) {
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
ci.meta.itemDeleted == null -> ci.text to null
else -> generalGetString(MR.strings.marked_deleted_description) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
},
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
)
}
MarkdownText(
text,
formattedText,
sender = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
else -> null
},
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
)
}
} else {
when (cInfo) {
is ChatInfo.Direct ->
@@ -175,7 +177,7 @@ fun ChatPreviewView(
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo))
GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary)
else -> {}
}
@@ -184,6 +186,37 @@ fun ChatPreviewView(
}
}
@Composable
fun chatStatusImage() {
if (cInfo is ChatInfo.Direct) {
val descr = contactNetworkStatus?.statusString
when (contactNetworkStatus) {
is NetworkStatus.Connected ->
IncognitoIcon(chat.chatInfo.incognito)
is NetworkStatus.Error ->
Icon(
painterResource(MR.images.ic_error),
contentDescription = descr,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(19.dp)
)
else ->
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 1.5.dp
)
}
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
}
Row {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 72.dp)
@@ -199,14 +232,14 @@ fun ChatPreviewView(
chatPreviewTitle()
val height = with(LocalDensity.current) { 46.sp.toDp() }
Row(Modifier.heightIn(min = height)) {
chatPreviewText(chatModelIncognito)
chatPreviewText()
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
Box(
contentAlignment = Alignment.TopEnd
) {
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
Text(
ts,
color = MaterialTheme.colors.secondary,
@@ -262,24 +295,33 @@ fun ChatPreviewView(
)
}
}
if (cInfo is ChatInfo.Direct) {
Box(
Modifier.padding(top = 52.dp),
contentAlignment = Alignment.Center
) {
ChatStatusImage(contactNetworkStatus)
}
Box(
Modifier.padding(top = 50.dp),
contentAlignment = Alignment.Center
) {
chatStatusImage()
}
}
}
}
@Composable
private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
fun IncognitoIcon(incognito: Boolean) {
if (incognito) {
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
)
}
}
@Composable
private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
return if (groupInfo.membership.memberIncognito)
String.format(stringResource(MR.strings.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
else if (chatModelIncognito)
String.format(stringResource(MR.strings.group_preview_join_as), currentUserProfileDisplayName ?: "")
else
stringResource(MR.strings.group_preview_you_are_invited)
}
@@ -289,28 +331,6 @@ fun unreadCountStr(n: Int): String {
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(MR.strings.thousand_abbreviation)
}
@Composable
fun ChatStatusImage(s: NetworkStatus?) {
val descr = s?.statusString
if (s is NetworkStatus.Error) {
Icon(
painterResource(MR.images.ic_error),
contentDescription = descr,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(19.dp)
)
} else if (s !is NetworkStatus.Connected) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 1.5.dp
)
}
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
@@ -319,6 +339,6 @@ fun ChatStatusImage(s: NetworkStatus?) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
}
}
@@ -39,16 +39,22 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
val height = with(LocalDensity.current) { 46.sp.toDp() }
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
}
val ts = getTimestampText(contactConnection.updatedAt)
Column(
Modifier.fillMaxHeight(),
Box(
contentAlignment = Alignment.TopEnd
) {
val ts = getTimestampText(contactConnection.updatedAt)
Text(
ts,
color = MaterialTheme.colors.secondary,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
Box(
Modifier.padding(top = 50.dp),
contentAlignment = Alignment.Center
) {
IncognitoIcon(contactConnection.incognito)
}
}
}
}
@@ -18,7 +18,7 @@ import chat.simplex.common.model.getTimestampText
import chat.simplex.res.MR
@Composable
fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
Row {
ChatInfoImage(contactRequest, size = 72.dp)
Column(
@@ -32,7 +32,7 @@ fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.Con
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary
color = MaterialTheme.colors.primary
)
val height = with(LocalDensity.current) { 46.sp.toDp() }
Text(stringResource(MR.strings.contact_wants_to_connect_with_you), Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
@@ -121,14 +121,6 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.incognito.value) {
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
stringResource(MR.strings.incognito),
tint = Indigo,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
}
},
onTitleClick = null,
@@ -93,7 +93,7 @@ fun UserPicker(
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { screenWidth() * density }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Box(Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
@@ -201,7 +201,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
fun UserProfileRow(u: User) {
Row(
Modifier
.widthIn(max = screenWidth() * 0.7f)
.widthIn(max = windowWidth() * 0.7f)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -24,11 +24,13 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.Clock
@@ -60,46 +62,8 @@ fun DatabaseEncryptionView(m: ChatModel) {
initialRandomDBPassphrase,
progressIndicator,
onConfirmEncrypt = {
progressIndicator.value = true
withApi {
try {
prefs.encryptionStartedAt.set(Clock.System.now())
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
prefs.encryptionStartedAt.set(null)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
sqliteError is SQLiteError.ErrorNotADatabase -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.wrong_passphrase_title),
generalGetString(MR.strings.enter_correct_current_passphrase)
)
}
}
error != null -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database),
"failed to set storage encryption: ${error.responseType} ${error.details}"
)
}
}
else -> {
prefs.initialRandomDBPassphrase.set(false)
initialRandomDBPassphrase.value = false
if (useKeychain.value) {
DatabaseUtils.ksDatabasePassword.set(newKey.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
}
}
}
} catch (e: Exception) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString())
}
}
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator)
}
}
)
@@ -142,17 +106,11 @@ fun DatabaseEncryptionLayout(
if (checked) {
setUseKeychain(true, useKeychain, prefs)
} else if (storedKey.value) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.remove_passphrase_from_keychain),
text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(MR.strings.remove_passphrase),
onConfirm = {
DatabaseUtils.ksDatabasePassword.remove()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
},
destructive = true,
)
removePassphraseAlert {
DatabaseUtils.ksDatabasePassword.remove()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
}
} else {
setUseKeychain(false, useKeychain, prefs)
}
@@ -216,37 +174,13 @@ fun DatabaseEncryptionLayout(
}
Column {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
SectionTextFooter(generalGetString(MR.strings.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs))
}
} else {
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
SectionTextFooter(generalGetString(MR.strings.impossible_to_recover_passphrase))
}
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase)
}
SectionBottomSpacer()
}
}
fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.encrypt_database_question),
text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
expect fun encryptDatabaseSavedAlert(onConfirm: () -> Unit)
fun encryptDatabaseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
@@ -258,15 +192,7 @@ fun encryptDatabaseAlert(onConfirm: () -> Unit) {
)
}
fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_database_passphrase_question),
text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
expect fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit)
fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
@@ -278,37 +204,25 @@ fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
)
}
expect fun removePassphraseAlert(onConfirm: () -> Unit)
@Composable
fun SavePassphraseSetting(
expect fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp = TextFieldDefaults.MinHeight,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView(minHeight = minHeight) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled),
stringResource(MR.strings.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(MR.strings.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
)
@Composable
expect fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
)
fun resetFormAfterEncryption(
m: ChatModel,
@@ -442,6 +356,62 @@ fun PassphraseField(
}
}
suspend fun encryptDatabase(
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
initialRandomDBPassphrase: MutableState<Boolean>,
useKeychain: MutableState<Boolean>,
storedKey: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>
): Boolean {
val m = ChatModel
val prefs = ChatController.appPrefs
progressIndicator.value = true
return try {
prefs.encryptionStartedAt.set(Clock.System.now())
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
prefs.encryptionStartedAt.set(null)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
sqliteError is SQLiteError.ErrorNotADatabase -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.wrong_passphrase_title),
generalGetString(MR.strings.enter_correct_current_passphrase)
)
}
false
}
error != null -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database),
"failed to set storage encryption: ${error.responseType} ${error.details}"
)
}
false
}
else -> {
prefs.initialRandomDBPassphrase.set(false)
initialRandomDBPassphrase.value = false
if (useKeychain.value) {
DatabaseUtils.ksDatabasePassword.set(newKey.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
}
true
}
}
} catch (e: Exception) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString())
}
false
}
}
// based on https://generatepasswords.org/how-to-calculate-entropy/
private fun passphraseEntropy(s: String): Double {
var hasDigits = false
@@ -12,6 +12,9 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.AppPreferences
@@ -252,6 +255,11 @@ private fun mtrErrorDescription(err: MTRError): String =
@Composable
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
delay(100L)
focusRequester.requestFocus()
}
PassphraseField(
text,
generalGetString(MR.strings.enter_passphrase),
@@ -259,7 +267,15 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
keyboardActions = KeyboardActions(onDone = if (enabled) {
{ onClick?.invoke() }
} else null
)
),
modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent {
if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
onClick()
true
} else {
false
}
}
)
}
@@ -39,7 +39,6 @@ fun DatabaseView(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val progressIndicator = remember { mutableStateOf(false) }
val runChat = remember { m.chatRunning }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
@@ -60,31 +59,29 @@ fun DatabaseView(
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator)
}
}
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
Box(
Modifier.fillMaxSize(),
) {
DatabaseLayout(
progressIndicator.value,
runChat.value != false,
remember { m.chatRunning }.value != false,
m.chatDbChanged.value,
useKeychain.value,
m.chatDbEncrypted.value,
m.controller.appPrefs.storeDBPassphrase.state.value,
m.controller.appPrefs.initialRandomDBPassphrase,
m.controller.appPrefs.developerTools.state.value,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
chatLastStart,
m.controller.appPrefs.privacyFullBackup,
appFilesCountAndSize,
chatItemTTL,
m.currentUser.value,
m.users,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m, runChat) },
startChat = { startChat(m, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m) },
exportArchive = { exportArchive(m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(appFilesCountAndSize) },
@@ -123,12 +120,13 @@ fun DatabaseLayout(
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
passphraseSaved: Boolean,
initialRandomDBPassphrase: SharedPreference<Boolean>,
developerTools: Boolean,
importArchiveLauncher: FileChooserLauncher,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
privacyFullBackup: SharedPreference<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
currentUser: User?,
@@ -168,20 +166,33 @@ fun DatabaseLayout(
SectionView(stringResource(MR.strings.run_chat_section)) {
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
}
SectionTextFooter(
if (stopped) {
stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(MR.strings.stop_chat_to_enable_database_actions)
}
)
SectionDividerSpaced()
SectionView(stringResource(MR.strings.chat_database_section)) {
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) painterResource(MR.images.ic_lock_open) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
else painterResource(MR.images.ic_lock),
stringResource(MR.strings.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary,
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
disabled = operationsDisabled
)
AppDataBackupPreference(privacyFullBackup, initialRandomDBPassphrase)
SectionDividerSpaced(maxBottomPadding = false)
if (appPlatform.isDesktop && developerTools) {
SettingsActionItem(
painterResource(MR.images.ic_folder_open),
stringResource(MR.strings.open_database_folder),
::desktopOpenDatabaseDir,
disabled = operationsDisabled
)
}
SettingsActionItem(
painterResource(MR.images.ic_ios_share),
stringResource(MR.strings.export_database),
@@ -225,13 +236,6 @@ fun DatabaseLayout(
disabled = operationsDisabled
)
}
SectionTextFooter(
if (stopped) {
stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(MR.strings.stop_chat_to_enable_database_actions)
}
)
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) {
@@ -258,23 +262,6 @@ fun DatabaseLayout(
}
}
@Composable
private fun AppDataBackupPreference(privacyFullBackup: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>) {
SettingsPreferenceItem(
painterResource(MR.images.ic_backup),
iconColor = MaterialTheme.colors.secondary,
pref = privacyFullBackup,
text = stringResource(MR.strings.full_backup)
) {
if (initialRandomDBPassphrase.get()) {
exportProhibitedAlert()
privacyFullBackup.set(false)
} else {
privacyFullBackup.set(it)
}
}
}
private fun setChatItemTTLAlert(
m: ChatModel, selectedChatItemTTL: MutableState<ChatItemTTL>,
progressIndicator: MutableState<Boolean>,
@@ -348,7 +335,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) MR.strings.old_database_archive else MR.strings.new_database_archive)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
private fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
withApi {
try {
if (chatDbChanged.value) {
@@ -365,7 +352,6 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
return@withApi
} else {
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
}
val ts = Clock.System.now()
@@ -373,19 +359,19 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
chatLastStart.value = ts
platform.androidChatStartedAfterBeingOff()
} catch (e: Error) {
runChat.value = false
m.chatRunning.value = false
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.toString())
}
}
}
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean?>) {
private fun stopChatAlert(m: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.stop_chat_question),
text = generalGetString(MR.strings.stop_chat_to_export_import_or_delete_chat_database),
confirmText = generalGetString(MR.strings.stop_chat_confirmation),
onConfirm = { authStopChat(m, runChat) },
onDismiss = { runChat.value = true }
onConfirm = { authStopChat(m) },
onDismiss = { m.chatRunning.value = true }
)
}
@@ -396,7 +382,7 @@ private fun exportProhibitedAlert() {
)
}
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
private fun authStopChat(m: ChatModel) {
if (m.controller.appPrefs.performLA.get()) {
authenticate(
generalGetString(MR.strings.auth_stop_chat),
@@ -404,30 +390,29 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
completed = { laResult ->
when (laResult) {
LAResult.Success, is LAResult.Unavailable -> {
stopChat(m, runChat)
stopChat(m)
}
is LAResult.Error -> {
runChat.value = true
m.chatRunning.value = true
}
is LAResult.Failed -> {
runChat.value = true
m.chatRunning.value = true
}
}
}
)
} else {
stopChat(m, runChat)
stopChat(m)
}
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
private fun stopChat(m: ChatModel) {
withApi {
try {
runChat.value = false
stopChatAsync(m)
platform.androidChatStopped()
} catch (e: Error) {
runChat.value = true
m.chatRunning.value = true
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString())
}
}
@@ -678,12 +663,13 @@ fun PreviewDatabaseLayout() {
chatDbChanged = false,
useKeyChain = false,
chatDbEncrypted = false,
passphraseSaved = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}),
developerTools = true,
importArchiveLauncher = rememberFileChooserLauncher(true) {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
privacyFullBackup = SharedPreference({ true }, {}),
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
currentUser = User.sampleData,
@@ -8,7 +8,9 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
@@ -51,26 +53,31 @@ class AlertManager {
buttons: @Composable () -> Unit,
) {
showAlert {
DefaultDialog(onDismissRequest = ::hideAlert) {
Column(
Modifier
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
.padding(bottom = DEFAULT_PADDING)
) {
AlertDialog(
onDismissRequest = ::hideAlert,
title = {
Text(
title,
Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING),
textAlign = TextAlign.Center,
fontSize = 20.sp
)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) {
Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary)
},
buttons = {
Column(
Modifier
.padding(bottom = DEFAULT_PADDING)
) {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) {
Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary)
}
buttons()
}
buttons()
}
}
}
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
)
}
}
@@ -94,6 +101,10 @@ class AlertManager {
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
horizontalArrangement = Arrangement.SpaceBetween
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
@@ -101,7 +112,7 @@ class AlertManager {
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
}, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
}
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
@@ -154,13 +165,22 @@ class AlertManager {
title = alertTitle(title),
text = alertText(text),
buttons = {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Row(
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
horizontalArrangement = Arrangement.Center
) {
TextButton(onClick = {
hideAlert()
}) { Text(confirmText, color = Color.Unspecified) }
TextButton(
onClick = {
hideAlert()
},
Modifier.focusRequester(focusRequester)
) {
Text(confirmText, color = Color.Unspecified)
}
}
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
@@ -200,7 +220,7 @@ private fun alertText(text: String?): (@Composable () -> Unit)? {
} else {
({
Text(
text,
escapedHtmlToAnnotatedString(text, LocalDensity.current),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 16.sp,
@@ -1,5 +1,7 @@
package chat.simplex.common.views.helpers
import androidx.compose.ui.text.AnnotatedString
interface ValueTitle <T> {
val value: T
val title: String
@@ -8,5 +10,5 @@ interface ValueTitle <T> {
data class ValueTitleDesc <T> (
override val value: T,
override val title: String,
val description: String
val description: AnnotatedString
): ValueTitle<T>
@@ -54,6 +54,12 @@ object DatabaseUtils {
} else {
dbKey = ksDatabasePassword.get() ?: ""
}
} else if (appPlatform.isDesktop && !hasDatabase(dataDir.absolutePath)) {
// In case of database was deleted by hand
dbKey = randomDatabasePassword()
ksDatabasePassword.set(dbKey)
appPreferences.initialRandomDBPassphrase.set(true)
appPreferences.storeDBPassphrase.set(true)
}
return dbKey
}
@@ -32,7 +32,7 @@ import kotlinx.coroutines.launch
@Composable
fun DefaultBasicTextField(
modifier: Modifier,
initialValue: String,
state: MutableState<TextFieldValue>,
placeholder: (@Composable () -> Unit)? = null,
leadingIcon: (@Composable () -> Unit)? = null,
focus: Boolean = false,
@@ -41,11 +41,8 @@ fun DefaultBasicTextField(
selectTextOnFocus: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions: KeyboardActions = KeyboardActions(),
onValueChange: (String) -> Unit,
onValueChange: (TextFieldValue) -> Unit,
) {
val state = remember {
mutableStateOf(TextFieldValue(initialValue))
}
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
@@ -83,8 +80,7 @@ fun DefaultBasicTextField(
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = {
state.value = it
onValueChange(it.text)
onValueChange(it)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = VisualTransformation.None,
@@ -92,6 +92,17 @@ suspend fun PointerInputScope.detectGesture(
}
}
suspend fun PointerInputScope.detectCursorMove(onMove: (Offset) -> Unit = {},) = coroutineScope {
forEachGesture {
awaitPointerEventScope {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Move) {
onMove(event.changes[0].position)
}
}
}
}
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
@@ -97,6 +97,12 @@ class ModalManager(private val placement: ModalPlacement? = null) {
modalCount.value = 0
}
fun closeModalsExceptFirst() {
while (modalCount.value > 1) {
closeModal()
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun showInView() {
@@ -12,7 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.screenWidth
import chat.simplex.common.platform.windowWidth
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
@@ -238,7 +238,7 @@ fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color
@Composable
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
SectionItemViewSpaceBetween(onClick) {
val screenWidthDp = screenWidth()
val screenWidthDp = windowWidth()
Text(title)
Text(
value,
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
@@ -66,11 +67,13 @@ fun SimpleButton(
fun SimpleButtonIconEnded(
text: String,
icon: Painter,
style: TextStyle = MaterialTheme.typography.caption,
color: Color = MaterialTheme.colors.primary,
disabled: Boolean = false,
click: () -> Unit
) {
SimpleButtonFrame(click) {
Text(text, style = MaterialTheme.typography.caption, color = color)
SimpleButtonFrame(click, disabled = disabled) {
Text(text, style = style, color = color)
Icon(
icon, text, tint = color,
modifier = Modifier.padding(start = 8.dp)
@@ -1,7 +1,10 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
@@ -48,19 +51,20 @@ fun TextEditor(
Modifier
.fillMaxWidth()
.padding(contentPadding)
.heightIn(min = 52.dp),
// .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(26.dp)),
.heightIn(min = 52.dp)
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) {
val modifier = modifier
val textFieldModifier = modifier
.fillMaxWidth()
.navigationBarsWithImePadding()
.onFocusChanged { focused = it.isFocused }
.padding(10.dp)
BasicTextField(
value = value.value,
onValueChange = { value.value = it },
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
modifier = if (focusRequester == null) textFieldModifier else textFieldModifier.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
expect fun getAppFileUri(fileName: String): URI
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
expect fun getLoadedImage(file: CIFile?): ImageBitmap?
expect fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
expect fun getFileName(uri: URI): String?
@@ -77,6 +77,8 @@ expect fun getFileSize(uri: URI): Long?
expect fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean = true): ImageBitmap?
expect fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap?
expect fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean = true): Any?
fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverrides? {
@@ -95,29 +97,34 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri
return null
}
fun saveImage(uri: URI): String? {
fun saveImage(uri: URI, encrypted: Boolean): CryptoFile? {
val bitmap = getBitmapFromUri(uri) ?: return null
return saveImage(bitmap)
return saveImage(bitmap, encrypted)
}
fun saveImage(image: ImageBitmap): String? {
fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? {
return try {
val ext = if (image.hasAlpha) "png" else "jpg"
val ext = if (image.hasAlpha()) "png" else "jpg"
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
val fileToSave = generateNewFileName("IMG", ext)
val file = File(getAppFilePath(fileToSave))
val output = FileOutputStream(file)
dataResized.writeTo(output)
output.flush()
output.close()
fileToSave
val destFileName = generateNewFileName("IMG", ext)
val destFile = File(getAppFilePath(destFileName))
if (encrypted) {
val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray())
CryptoFile(destFileName, args)
} else {
val output = FileOutputStream(destFile)
dataResized.writeTo(output)
output.flush()
output.close()
CryptoFile.plain(destFileName)
}
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveImage error: ${e.message}")
Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}")
null
}
}
fun saveAnimImage(uri: URI): String? {
fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val filename = getFileName(uri)?.lowercase()
var ext = when {
@@ -127,15 +134,15 @@ fun saveAnimImage(uri: URI): String? {
}
// Just in case the image has a strange extension
if (ext.length < 3 || ext.length > 4) ext = "gif"
val fileToSave = generateNewFileName("IMG", ext)
val file = File(getAppFilePath(fileToSave))
val output = FileOutputStream(file)
uri.inputStream().use { input ->
output.use { output ->
input?.copyTo(output)
}
val destFileName = generateNewFileName("IMG", ext)
val destFile = File(getAppFilePath(destFileName))
if (encrypted) {
val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readAllBytes() ?: return null)
CryptoFile(destFileName, args)
} else {
Files.copy(uri.inputStream(), destFile.toPath())
CryptoFile.plain(destFileName)
}
fileToSave
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}")
null
@@ -144,25 +151,44 @@ fun saveAnimImage(uri: URI): String? {
expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File?
fun saveFileFromUri(uri: URI): String? {
fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val inputStream = uri.inputStream()
val fileToSave = getFileName(uri)
if (inputStream != null && fileToSave != null) {
return if (inputStream != null && fileToSave != null) {
val destFileName = uniqueCombine(fileToSave)
val destFile = File(getAppFilePath(destFileName))
Files.copy(inputStream, destFile.toPath())
destFileName
if (encrypted) {
createTmpFileAndDelete { tmpFile ->
Files.copy(inputStream, tmpFile.toPath())
val args = encryptCryptoFile(tmpFile.absolutePath, destFile.absolutePath)
CryptoFile(destFileName, args)
}
} else {
Files.copy(inputStream, destFile.toPath())
CryptoFile.plain(destFileName)
}
} else {
Log.e(TAG, "Util.kt saveFileFromUri null inputStream")
null
}
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveFileFromUri error: ${e.message}")
Log.e(TAG, "Util.kt saveFileFromUri error: ${e.stackTraceToString()}")
null
}
}
fun <T> createTmpFileAndDelete(onCreated: (File) -> T): T {
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
tmpFile.deleteOnExit()
ChatModel.filesToDelete.add(tmpFile)
try {
return onCreated(tmpFile)
} finally {
tmpFile.delete()
}
}
fun generateNewFileName(prefix: String, ext: String): String {
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("GMT")
@@ -263,6 +289,17 @@ fun blendARGB(
return Color(r, g, b, a)
}
fun InputStream.toByteArray(): ByteArray =
ByteArrayOutputStream().use { output ->
val b = ByteArray(4096)
var n = read(b)
while (n != -1) {
output.write(b, 0, n);
n = read(b)
}
return output.toByteArray()
}
expect fun ByteArray.toBase64StringForPassphrase(): String
// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string
@@ -306,10 +343,10 @@ fun IntSize.Companion.Saver(): Saver<IntSize, *> = Saver(
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
DisposableEffect(Unit) {
always()
val orientation = screenOrientation()
val orientation = windowOrientation()
onDispose {
whenDispose()
if (orientation == screenOrientation()) {
if (orientation == windowOrientation()) {
whenGone()
}
}
@@ -320,12 +357,53 @@ fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}
fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) {
DisposableEffect(Unit) {
always()
val orientation = screenOrientation()
val orientation = windowOrientation()
onDispose {
whenDispose()
if (orientation != screenOrientation()) {
if (orientation != windowOrientation()) {
whenRotate()
}
}
}
}
/**
* Runs the [block] only after initial value of the [key1] changes, not after initial launch
* */
@Composable
@NonRestartableComposable
fun <T> KeyChangeEffect(
key1: T?,
block: suspend CoroutineScope.(prevKey: T?) -> Unit
) {
var prevKey by remember { mutableStateOf(key1) }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != prevKey) {
block(prevKey)
prevKey = key1
anyChange = true
}
}
}
/**
* Runs the [block] only after initial value of the [key1] or [key2] changes, not after initial launch
* */
@Composable
@NonRestartableComposable
fun KeyChangeEffect(
key1: Any?,
key2: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val initialKey = remember { key1 }
val initialKey2 = remember { key2 }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != initialKey || key2 != initialKey2) {
block()
anyChange = true
}
}
}
@@ -66,7 +66,6 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (
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)
}
@@ -6,10 +6,11 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.input.key.*
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.ScreenOrientation
import chat.simplex.common.platform.screenOrientation
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.common.views.helpers.*
@@ -25,9 +26,43 @@ fun PasscodeView(
submit: () -> Unit,
cancel: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
@Composable
fun Modifier.handleKeyboard(): Modifier {
val numbers = remember {
arrayOf(
Key.Zero, Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine,
Key.NumPad0, Key.NumPad1, Key.NumPad2, Key.NumPad3, Key.NumPad4, Key.NumPad5, Key.NumPad6, Key.NumPad7, Key.NumPad8, Key.NumPad9
)
}
return onPreviewKeyEvent {
if (it.key in numbers && it.type == KeyEventType.KeyDown) {
if (passcode.value.length < 16) {
passcode.value += numbers.indexOf(it.key) % 10
}
true
} else if (it.key == Key.Backspace && it.type == KeyEventType.KeyDown && (it.isCtrlPressed || it.isMetaPressed)) {
passcode.value = ""
true
} else if (it.key == Key.Backspace && it.type == KeyEventType.KeyDown) {
passcode.value = passcode.value.dropLast(1)
true
} else if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) {
if ((submitEnabled?.invoke(passcode.value) != false && passcode.value.length >= 4)) {
submit()
}
true
} else {
false
}
}
}
@Composable
fun VerticalLayout() {
Column(
Modifier.handleKeyboard().focusRequester(focusRequester),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
@@ -38,7 +73,7 @@ fun PasscodeView(
}
}
PasscodeEntry(passcode, true)
Row {
Row(Modifier.heightIn(min = 70.dp), verticalAlignment = Alignment.CenterVertically) {
SimpleButton(generalGetString(MR.strings.cancel_verb), icon = painterResource(MR.images.ic_close), click = cancel)
Spacer(Modifier.size(20.dp))
SimpleButton(submitLabel, icon = painterResource(MR.images.ic_done_filled), disabled = submitEnabled?.invoke(passcode.value) == false || passcode.value.length < 4, click = submit)
@@ -48,9 +83,9 @@ fun PasscodeView(
@Composable
fun HorizontalLayout() {
Row(Modifier.padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.Center) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING).handleKeyboard().focusRequester(focusRequester), horizontalArrangement = Arrangement.Center) {
Column(
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING * 4),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
@@ -64,7 +99,7 @@ fun PasscodeView(
}
Column(
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING * 4),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
@@ -90,9 +125,14 @@ fun PasscodeView(
}
}
if (screenOrientation() == ScreenOrientation.PORTRAIT) {
if (windowOrientation() == WindowOrientation.PORTRAIT || appPlatform.isDesktop) {
VerticalLayout()
} else {
HorizontalLayout()
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
// Disallow to steal a focus by clicking on buttons or using Tab
focusRequester.captureFocus()
}
}
@@ -1,7 +1,6 @@
package chat.simplex.common.views.localauth
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -16,6 +15,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
@Composable
@@ -39,7 +39,7 @@ fun PasscodeEntry(
fun PasscodeView(password: MutableState<String>) {
var showPasscode by rememberSaveable { mutableStateOf(false) }
Text(
if (password.value.isEmpty()) " " else remember(password.value, showPasscode) { splitPassword(showPasscode, password.value) },
if (password.value.isEmpty()) "" else remember(password.value, showPasscode) { splitPassword(showPasscode, password.value) },
Modifier.padding(vertical = 10.dp).clickable { showPasscode = !showPasscode },
style = MaterialTheme.typography.body1
)
@@ -47,7 +47,7 @@ fun PasscodeView(password: MutableState<String>) {
@Composable
private fun BoxWithConstraintsScope.VerticalPasswordGrid(password: MutableState<String>) {
val s = minOf(maxWidth, maxHeight) / 4 - 1.dp
val s = if (appPlatform.isAndroid) minOf(maxWidth, maxHeight) / 4 - 1.dp else minOf(minOf(maxWidth, maxHeight) / 4 - 1.dp, 100.dp)
Column(Modifier.width(IntrinsicSize.Min)) {
DigitsRow(s, 1, 2, 3, password)
Divider()
@@ -1,7 +1,7 @@
package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
@@ -14,20 +14,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.shareText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
@Composable
fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
fun AddContactView(
chatModel: ChatModel,
connReqInvitation: String,
contactConnection: MutableState<PendingContactConnection?>
) {
val clipboard = LocalClipboardManager.current
AddContactLayout(
chatModel = chatModel,
incognitoPref = chatModel.controller.appPrefs.incognito,
connReq = connReqInvitation,
connIncognito = connIncognito,
contactConnection = contactConnection,
share = { clipboard.shareText(connReqInvitation) },
learnMore = {
ModalManager.center.showModal {
@@ -45,57 +51,63 @@ fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
}
@Composable
fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit, learnMore: () -> Unit) {
fun AddContactLayout(
chatModel: ChatModel,
incognitoPref: SharedPreference<Boolean>,
connReq: String,
contactConnection: MutableState<PendingContactConnection?>,
share: () -> Unit,
learnMore: () -> Unit
) {
val incognito = remember { mutableStateOf(incognitoPref.get()) }
LaunchedEffect(incognito.value) {
withApi {
val contactConnVal = contactConnection.value
if (contactConnVal != null) {
chatModel.controller.apiSetConnectionIncognito(contactConnVal.pccConnId, incognito.value)?.let {
contactConnection.value = it
chatModel.updateContactConnection(it)
}
}
}
}
Column(
Modifier
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
) {
AppBarTitle(stringResource(MR.strings.add_contact))
OneTimeLinkProfileText(connIncognito)
SectionSpacer()
SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) {
OneTimeLinkSection(connReq, share, learnMore)
if (connReq.isNotEmpty()) {
QRCode(
connReq, Modifier
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
} else {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterHorizontally),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } }
ShareLinkButton(share)
OneTimeLinkLearnMoreButton(learnMore)
}
SectionTextFooter(sharedProfileInfo(chatModel, incognito.value))
SectionBottomSpacer()
}
}
@Composable
fun OneTimeLinkProfileText(connIncognito: Boolean) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING)) {
InfoAboutIncognito(
connIncognito,
true,
generalGetString(MR.strings.incognito_random_profile_description),
generalGetString(MR.strings.your_profile_will_be_sent)
)
}
}
@Composable
fun ColumnScope.OneTimeLinkSection(connReq: String, share: () -> Unit, learnMore: () -> Unit) {
if (connReq.isNotEmpty()) {
QRCode(
connReq, Modifier
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
} else {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterHorizontally),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
ShareLinkButton(share)
OneTimeLinkLearnMoreButton(learnMore)
}
@Composable
fun ShareLinkButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -117,39 +129,38 @@ fun OneTimeLinkLearnMoreButton(onClick: () -> Unit) {
}
@Composable
fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String, centered: Boolean = false) {
if (chatModelIncognito) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start
) {
Icon(
if (supportedIncognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_info),
stringResource(MR.strings.incognito),
tint = if (supportedIncognito) Indigo else WarningOrange,
modifier = Modifier.padding(end = 10.dp).size(20.dp)
)
Text(onText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2)
}
fun IncognitoToggle(
incognitoPref: SharedPreference<Boolean>,
incognito: MutableState<Boolean>,
onClickInfo: () -> Unit
) {
SettingsActionItemWithContent(
icon = if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy),
text = null,
click = onClickInfo,
iconColor = if (incognito.value) Indigo else MaterialTheme.colors.secondary,
extraPadding = false
) {
SharedPreferenceToggleWithIcon(
stringResource(MR.strings.incognito),
painterResource(MR.images.ic_info),
stopped = false,
onClickInfo = onClickInfo,
preference = incognitoPref,
preferenceState = incognito
)
}
}
fun sharedProfileInfo(
chatModel: ChatModel,
incognito: Boolean
): String {
val name = chatModel.currentUser.value?.displayName ?: ""
return if (incognito) {
generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared)
} else {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start
) {
Icon(
painterResource(MR.images.ic_info),
stringResource(MR.strings.incognito),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.padding(end = 10.dp).size(20.dp)
)
Text(offText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2)
}
String.format(generalGetString(MR.strings.connect__your_profile_will_be_shared), name)
}
}
@@ -162,8 +173,10 @@ fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean
fun PreviewAddContactView() {
SimpleXTheme {
AddContactLayout(
chatModel = ChatModel,
incognitoPref = SharedPreference({ false }, {}),
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
connIncognito = false,
contactConnection = mutableStateOf(PendingContactConnection.getSampleData()),
share = {},
learnMore = {},
)
@@ -2,16 +2,18 @@ package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionDividerSpaced
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
@@ -19,10 +21,10 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.LocalAliasEditor
import chat.simplex.common.views.chatlist.deleteContactConnectionAlert
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.PendingContactConnection
import chat.simplex.common.platform.shareText
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
@Composable
@@ -49,10 +51,10 @@ fun ContactConnectionInfoView(
}
val clipboard = LocalClipboardManager.current
ContactConnectionInfoLayout(
chatModel = chatModel,
connReq = connReqInvitation,
contactConnection,
connIncognito = contactConnection.incognito,
focusAlias,
contactConnection = contactConnection,
focusAlias = focusAlias,
deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) },
onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) },
share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) },
@@ -73,22 +75,43 @@ fun ContactConnectionInfoView(
@Composable
private fun ContactConnectionInfoLayout(
chatModel: ChatModel,
connReq: String?,
contactConnection: PendingContactConnection,
connIncognito: Boolean,
focusAlias: Boolean,
deleteConnection: () -> Unit,
onLocalAliasChanged: (String) -> Unit,
share: () -> Unit,
learnMore: () -> Unit,
) {
@Composable fun incognitoEnabled() {
if (contactConnection.incognito) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_theater_comedy_filled),
text = null,
click = { ModalManager.start.showModal { IncognitoView() } },
iconColor = Indigo,
extraPadding = false
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(MR.strings.incognito), Modifier.padding(end = 4.dp))
Icon(
painterResource(MR.images.ic_info),
null,
tint = MaterialTheme.colors.primary
)
}
}
}
}
Column(
Modifier
.verticalScroll(rememberScrollState()),
) {
AppBarTitle(
stringResource(
if (contactConnection.initiated) MR.strings.you_invited_your_contact
if (contactConnection.initiated) MR.strings.you_invited_a_contact
else MR.strings.you_accepted_connection
)
)
@@ -101,19 +124,27 @@ private fun ContactConnectionInfoLayout(
),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
)
OneTimeLinkProfileText(connIncognito)
if (contactConnection.groupLinkId == null) {
LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
LocalAliasEditor(contactConnection.id, contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
}
SectionView {
if (!connReq.isNullOrEmpty() && contactConnection.initiated) {
OneTimeLinkSection(connReq, share, learnMore)
QRCode(
connReq, Modifier
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
incognitoEnabled()
ShareLinkButton(share)
OneTimeLinkLearnMoreButton(learnMore)
} else {
incognitoEnabled()
OneTimeLinkLearnMoreButton(learnMore)
}
}
SectionTextFooter(sharedProfileInfo(chatModel, contactConnection.incognito))
SectionDividerSpaced(maxBottomPadding = false)
@@ -149,9 +180,9 @@ private fun setContactAlias(contactConnection: PendingContactConnection, localAl
private fun PreviewContactConnectionInfoView() {
SimpleXTheme {
ContactConnectionInfoLayout(
chatModel = ChatModel,
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
PendingContactConnection.getSampleData(),
connIncognito = false,
contactConnection = PendingContactConnection.getSampleData(),
focusAlias = false,
deleteConnection = {},
onLocalAliasChanged = {},
@@ -10,6 +10,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.PendingContactConnection
import chat.simplex.common.views.helpers.ModalManager
import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.views.usersettings.UserAddressView
@@ -23,10 +24,16 @@ enum class CreateLinkTab {
fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
val selection = remember { mutableStateOf(initialSelection) }
val connReqInvitation = rememberSaveable { m.connReqInv }
val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable { mutableStateOf(null) }
val creatingConnReq = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selection.value) {
if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && !creatingConnReq.value) {
createInvitation(m, creatingConnReq, connReqInvitation)
if (
selection.value == CreateLinkTab.ONE_TIME
&& connReqInvitation.value.isNullOrEmpty()
&& contactConnection.value == null
&& !creatingConnReq.value
) {
createInvitation(m, creatingConnReq, connReqInvitation, contactConnection)
}
}
/** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv].
@@ -42,9 +49,12 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
}
val tabTitles = CreateLinkTab.values().map {
when {
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() -> stringResource(MR.strings.create_one_time_link)
it == CreateLinkTab.ONE_TIME -> stringResource(MR.strings.one_time_link)
it == CreateLinkTab.LONG_TERM -> stringResource(MR.strings.your_simplex_contact_address)
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && contactConnection.value == null ->
stringResource(MR.strings.create_one_time_link)
it == CreateLinkTab.ONE_TIME ->
stringResource(MR.strings.one_time_link)
it == CreateLinkTab.LONG_TERM ->
stringResource(MR.strings.your_simplex_contact_address)
else -> ""
}
}
@@ -56,7 +66,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
Column(Modifier.weight(1f)) {
when (selection.value) {
CreateLinkTab.ONE_TIME -> {
AddContactView(connReqInvitation.value ?: "", m.incognito.value)
AddContactView(m, connReqInvitation.value ?: "", contactConnection)
}
CreateLinkTab.LONG_TERM -> {
UserAddressView(m, viaCreateLinkView = true, close = {})
@@ -89,12 +99,18 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
}
}
private fun createInvitation(m: ChatModel, creatingConnReq: MutableState<Boolean>, connReqInvitation: MutableState<String?>) {
private fun createInvitation(
m: ChatModel,
creatingConnReq: MutableState<Boolean>,
connReqInvitation: MutableState<String?>,
contactConnection: MutableState<PendingContactConnection?>
) {
creatingConnReq.value = true
withApi {
val connReq = m.controller.apiAddContact()
if (connReq != null) {
connReqInvitation.value = connReq
val r = m.controller.apiAddContact(incognito = m.controller.appPrefs.incognito.get())
if (r != null) {
connReqInvitation.value = r.first
contactConnection.value = r.second
} else {
creatingConnReq.value = false
}
@@ -97,7 +97,7 @@ private fun NewChatSheetLayout(
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val maxWidth = with(LocalDensity.current) { screenWidth() * density }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Column(
Modifier
.fillMaxSize()
@@ -1,109 +1,132 @@
package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionTextFooter
import androidx.compose.desktop.ui.tooling.preview.Preview
import chat.simplex.common.platform.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.TAG
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.IncognitoView
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import java.net.URI
import java.net.URISyntaxException
@Composable
fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
val connectionLink = remember { mutableStateOf("") }
val clipboard = LocalClipboardManager.current
PasteToConnectLayout(
chatModel.incognito.value,
chatModel = chatModel,
incognitoPref = chatModel.controller.appPrefs.incognito,
connectionLink = connectionLink,
pasteFromClipboard = {
connectionLink.value = clipboard.getText()?.text ?: return@PasteToConnectLayout
},
connectViaLink = { connReqUri ->
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(chatModel, linkType, uri)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_connection_link),
text = generalGetString(MR.strings.this_string_is_not_a_connection_link)
)
}
},
close = close
)
}
@Composable
fun PasteToConnectLayout(
chatModelIncognito: Boolean,
chatModel: ChatModel,
incognitoPref: SharedPreference<Boolean>,
connectionLink: MutableState<String>,
pasteFromClipboard: () -> Unit,
connectViaLink: (String) -> Unit,
close: () -> Unit
) {
val incognito = remember { mutableStateOf(incognitoPref.get()) }
fun connectViaLink(connReqUri: String) {
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(chatModel, linkType, uri, incognito = incognito.value)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_connection_link),
text = generalGetString(MR.strings.this_string_is_not_a_connection_link)
)
} catch (e: URISyntaxException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_connection_link),
text = generalGetString(MR.strings.this_string_is_not_a_connection_link)
)
}
}
Column(
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween,
) {
AppBarTitle(stringResource(MR.strings.connect_via_link), false)
Text(stringResource(MR.strings.paste_connection_link_below_to_connect))
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(MR.strings.incognito_random_profile_from_contact_description),
generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)
)
Box(Modifier.padding(top = DEFAULT_PADDING, bottom = 6.dp)) {
TextEditor(connectionLink, Modifier.height(180.dp), contentPadding = PaddingValues())
TextEditor(
connectionLink,
Modifier.height(180.dp),
contentPadding = PaddingValues(),
placeholder = stringResource(MR.strings.paste_the_link_you_received_to_connect_with_your_contact)
)
}
Row(
Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.Start,
) {
if (connectionLink.value == "") {
SimpleButton(text = stringResource(MR.strings.paste_button), icon = painterResource(MR.images.ic_content_paste)) {
pasteFromClipboard()
}
} else {
SimpleButton(text = stringResource(MR.strings.clear_verb), icon = painterResource(MR.images.ic_close)) {
connectionLink.value = ""
}
}
Spacer(Modifier.weight(1f).fillMaxWidth())
SimpleButton(text = stringResource(MR.strings.connect_button), icon = painterResource(MR.images.ic_link)) {
connectViaLink(connectionLink.value)
}
if (connectionLink.value == "") {
SettingsActionItem(
painterResource(MR.images.ic_content_paste),
stringResource(MR.strings.paste_button),
click = pasteFromClipboard,
)
} else {
SettingsActionItem(
painterResource(MR.images.ic_close),
stringResource(MR.strings.clear_verb),
click = { connectionLink.value = "" },
)
}
Text(annotatedStringResource(MR.strings.you_can_also_connect_by_clicking_the_link))
SettingsActionItem(
painterResource(MR.images.ic_link),
stringResource(MR.strings.connect_button),
click = { connectViaLink(connectionLink.value) },
)
IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } }
SectionTextFooter(
buildAnnotatedString {
append(sharedProfileInfo(chatModel, incognito.value))
append("\n\n")
append(annotatedStringResource(MR.strings.you_can_also_connect_by_clicking_the_link))
}
)
SectionBottomSpacer()
}
}
@@ -117,17 +140,11 @@ fun PasteToConnectLayout(
fun PreviewPasteToConnectTextbox() {
SimpleXTheme {
PasteToConnectLayout(
chatModelIncognito = false,
chatModel = ChatModel,
incognitoPref = SharedPreference({ false }, {}),
connectionLink = remember { mutableStateOf("") },
pasteFromClipboard = {},
connectViaLink = { link ->
try {
println(link)
// withApi { chatModel.controller.apiConnect(link) }
} catch (e: Exception) {
e.printStackTrace()
}
},
close = {}
)
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.stringResource
import boofcv.alg.drawing.FiducialImageEngine
import boofcv.alg.fiducial.qrcode.*
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.*
@@ -37,7 +38,7 @@ fun QRCode(
bitmap = qr,
contentDescription = stringResource(MR.strings.image_descr_qr_code),
Modifier
.widthIn(max = 500.dp)
.widthIn(max = 360.dp)
.then(modifier)
.clickable {
scope.launch {
@@ -45,7 +46,7 @@ fun QRCode(
.let { if (withLogo) it.addLogo() else it }
val file = saveTempImageUncompressed(image, false)
if (file != null) {
shareFile("", file.absolutePath)
shareFile("", CryptoFile.plain(file.absolutePath))
}
}
}
@@ -1,23 +1,23 @@
package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionTextFooter
import androidx.compose.desktop.ui.tooling.preview.Preview
import chat.simplex.common.platform.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.TAG
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.json
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -26,36 +26,6 @@ import java.net.URI
@Composable
expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit)
@Composable
fun QRCodeScanner(close: () -> Unit) {
QRCodeScanner { connReqUri ->
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(ChatModel, linkType, uri)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_QR_code),
text = generalGetString(MR.strings.this_QR_code_is_not_a_link)
)
}
}
}
enum class ConnectionLinkType {
CONTACT, INVITATION, GROUP
}
@@ -93,8 +63,8 @@ fun withUriAction(uri: URI, run: suspend (ConnectionLinkType) -> Unit) {
}
}
suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI): Boolean {
val r = chatModel.controller.apiConnect(uri.toString())
suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI, incognito: Boolean): Boolean {
val r = chatModel.controller.apiConnect(incognito, uri.toString())
if (r) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent),
@@ -110,28 +80,65 @@ suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri:
}
@Composable
fun ConnectContactLayout(chatModelIncognito: Boolean, close: () -> Unit) {
fun ConnectContactLayout(
chatModel: ChatModel,
incognitoPref: SharedPreference<Boolean>,
close: () -> Unit
) {
val incognito = remember { mutableStateOf(incognitoPref.get()) }
@Composable
fun QRCodeScanner(close: () -> Unit) {
QRCodeScanner { connReqUri ->
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(ChatModel, linkType, uri, incognito = incognito.value)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_QR_code),
text = generalGetString(MR.strings.this_QR_code_is_not_a_link)
)
}
}
}
Column(
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.SpaceBetween
) {
AppBarTitle(stringResource(MR.strings.scan_QR_code), false)
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(MR.strings.incognito_random_profile_description),
generalGetString(MR.strings.your_profile_will_be_sent)
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = 12.dp)
) { QRCodeScanner(close) }
Text(
annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
lineHeight = 22.sp
IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } }
SectionTextFooter(
buildAnnotatedString {
append(sharedProfileInfo(chatModel, incognito.value))
append("\n\n")
append(annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link))
}
)
SectionBottomSpacer()
}
}
@@ -150,7 +157,8 @@ fun URI.getQueryParameter(param: String): String? {
fun PreviewConnectContactLayout() {
SimpleXTheme {
ConnectContactLayout(
chatModelIncognito = false,
chatModel = ChatModel,
incognitoPref = SharedPreference({ false }, {}),
close = {},
)
}
@@ -14,8 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.UserContactLinkRec
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -29,6 +28,10 @@ fun CreateSimpleXAddress(m: ChatModel) {
val clipboard = LocalClipboardManager.current
val uriHandler = LocalUriHandler.current
LaunchedEffect(Unit) {
prepareChatBeforeAddressCreation()
}
CreateSimpleXAddressLayout(
userAddress.value,
share = { address: String -> clipboard.shareText(address) },
@@ -63,7 +66,6 @@ fun CreateSimpleXAddress(m: ChatModel) {
OnboardingStage.OnboardingComplete
}
m.controller.appPrefs.onboardingStage.set(next)
m.onboardingStage.value = next
},
)
@@ -172,3 +174,19 @@ private fun ProgressIndicator() {
)
}
}
private fun prepareChatBeforeAddressCreation() {
if (chatModel.users.isNotEmpty()) return
withApi {
val user = chatModel.controller.apiGetActiveUser() ?: return@withApi
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
chatModel.controller.startChat(user)
} else {
val users = chatModel.controller.listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.controller.getUserChatData()
}
}
}
@@ -13,8 +13,7 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.User
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
@@ -22,7 +21,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
@Composable
fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) {
fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>? = null) {
Column(Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
Step2_5_SetupDatabasePassphrase,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,
OnboardingComplete
@@ -41,7 +41,7 @@ fun SetNotificationsMode(m: ChatModel) {
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) {
OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) {
OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, false) {
changeNotificationsMode(currentMode.value, m)
}
}
@@ -0,0 +1,233 @@
package chat.simplex.common.views.onboarding
import SectionBottomSpacer
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun SetupDatabasePassphrase(m: ChatModel) {
val progressIndicator = remember { mutableStateOf(false) }
val prefs = m.controller.appPrefs
val saveInPreferences = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") }
val newKey = rememberSaveable { mutableStateOf("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
}
SetupDatabasePassphraseLayout(
currentKey,
newKey,
confirmNewKey,
progressIndicator,
onConfirmEncrypt = {
withApi {
if (m.chatRunning.value == true) {
// Stop chat if it's started before doing anything
stopChatAsync(m)
}
prefs.storeDBPassphrase.set(false)
val newKeyValue = newKey.value
val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator)
if (success) {
startChat(newKeyValue)
nextStep()
} else {
// Rollback in case of it is finished with error in order to allow to repeat the process again
prefs.storeDBPassphrase.set(true)
}
}
},
nextStep = ::nextStep,
)
if (progressIndicator.value) {
ProgressIndicator()
}
DisposableEffect(Unit) {
onDispose {
if (m.chatRunning.value != true) {
withBGApi {
val user = chatController.apiGetActiveUser()
if (user != null) {
m.controller.startChat(user)
}
}
}
}
}
}
@Composable
private fun SetupDatabasePassphraseLayout(
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
progressIndicator: MutableState<Boolean>,
onConfirmEncrypt: () -> Unit,
nextStep: () -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.setup_database_passphrase))
Spacer(Modifier.weight(1f))
Column(Modifier.width(600.dp)) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
delay(100L)
focusRequester.requestFocus()
}
PassphraseField(
newKey,
generalGetString(MR.strings.new_passphrase),
modifier = Modifier
.padding(horizontal = DEFAULT_PADDING)
.focusRequester(focusRequester)
.onPreviewKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Down)
true
} else {
false
}
},
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (!progressIndicator.value) {
encryptDatabaseAlert(onConfirmEncrypt)
}
}
val disabled = currentKey.value == newKey.value ||
newKey.value != confirmNewKey.value ||
newKey.value.isEmpty() ||
!validKey(currentKey.value) ||
!validKey(newKey.value) ||
progressIndicator.value
PassphraseField(
confirmNewKey,
generalGetString(MR.strings.confirm_new_passphrase),
modifier = Modifier
.padding(horizontal = DEFAULT_PADDING)
.onPreviewKeyEvent {
if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
onClickUpdate()
true
} else {
false
}
},
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) {
SetPassphraseButton(disabled, onClickUpdate)
}
Column {
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
}
Spacer(Modifier.weight(1f))
SkipButton(progressIndicator.value, nextStep)
SectionBottomSpacer()
}
}
@Composable
private fun SetPassphraseButton(disabled: Boolean, onClick: () -> Unit) {
SimpleButtonIconEnded(
stringResource(MR.strings.set_database_passphrase),
painterResource(MR.images.ic_check),
style = MaterialTheme.typography.h2,
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
disabled = disabled,
click = onClick
)
}
@Composable
private fun SkipButton(disabled: Boolean, onClick: () -> Unit) {
SimpleButtonIconEnded(stringResource(MR.strings.use_random_passphrase), painterResource(MR.images.ic_chevron_right), color =
if (disabled) MaterialTheme.colors.secondary else WarningOrange, disabled = disabled, click = onClick)
Text(
stringResource(MR.strings.you_can_change_it_later),
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING * 3),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun ProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
}
private suspend fun startChat(key: String?) {
val m = ChatModel
initChatController(key)
m.chatDbChanged.value = false
m.chatRunning.value = true
}
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
@@ -25,7 +24,7 @@ import dev.icerock.moko.resources.StringResource
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
SimpleXInfoLayout(
user = chatModel.currentUser.value,
onboardingStage = if (onboarding) chatModel.onboardingStage else null,
onboardingStage = if (onboarding) chatModel.controller.appPrefs.onboardingStage else null,
showModal = { modalView -> { if (onboarding) ModalManager.fullscreen.showModal { modalView(chatModel) } else ModalManager.start.showModal { modalView(chatModel) } } },
)
}
@@ -33,7 +32,7 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
@Composable
fun SimpleXInfoLayout(
user: User?,
onboardingStage: MutableState<OnboardingStage?>?,
onboardingStage: SharedPreference<OnboardingStage>?,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
) {
Column(
@@ -100,11 +99,11 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour
}
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null) {
if (user == null) {
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, true, onclick)
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick)
} else {
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, true, onclick)
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick)
}
}
@@ -112,7 +111,6 @@ fun OnboardingActionButton(user: User?, onboardingStage: MutableState<Onboarding
fun OnboardingActionButton(
labelId: StringResource,
onboarding: OnboardingStage?,
onboardingStage: MutableState<OnboardingStage?>,
border: Boolean,
onclick: (() -> Unit)?
) {
@@ -129,7 +127,6 @@ fun OnboardingActionButton(
SimpleButtonFrame(click = {
onclick?.invoke()
onboardingStage.value = onboarding
if (onboarding != null) {
ChatController.appPrefs.onboardingStage.set(onboarding)
}
@@ -201,19 +201,25 @@ object AppearanceScope {
val supportedLanguages = mapOf(
"system" to generalGetString(MR.strings.language_system),
"en" to "English",
"ar" to "العربية",
"bg" to "Български",
"cs" to "Čeština",
"de" to "Deutsch",
"es" to "Español",
"fi" to "Suomi",
"fr" to "Français",
"it" to "Italiano",
"iw" to "עִברִית",
"ja" to "日本語",
"nl" to "Nederlands",
"pl" to "Polski",
"pt-BR" to "Português (Brasil)",
"pt-BR" to "Português, Brasil",
"ru" to "Русский",
"th" to "ภาษาไทย",
"uk" to "Українська",
"zh-CN" to "简体中文"
)
val values by remember { mutableStateOf(supportedLanguages.map { it.key to it.value }) }
val values by remember(ChatController.appPrefs.appLanguage.state.value) { mutableStateOf(supportedLanguages.map { it.key to it.value }) }
ExposedDropDownSettingRow(
generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
values,
@@ -227,7 +233,9 @@ object AppearanceScope {
@Composable
private fun ThemeSelector(state: State<String>, onSelected: (String) -> Unit) {
val darkTheme = chat.simplex.common.ui.theme.isSystemInDarkTheme()
val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third }) }
val values by remember(ChatController.appPrefs.appLanguage.state.value) {
mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third })
}
ExposedDropDownSettingRow(
generalGetString(MR.strings.theme),
values,
@@ -31,7 +31,6 @@ fun IncognitoLayout() {
Text(generalGetString(MR.strings.incognito_info_protects))
Text(generalGetString(MR.strings.incognito_info_allows))
Text(generalGetString(MR.strings.incognito_info_share))
Text(generalGetString(MR.strings.incognito_info_find))
SectionBottomSpacer()
}
}
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
@@ -179,7 +180,13 @@ fun NetworkAndServersView(
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
if (networkUseSocksProxy.value) {
SectionCustomFooter { Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) }
SectionCustomFooter {
Column {
Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported))
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
Text(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
}
}
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
} else {
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
@@ -337,9 +344,9 @@ private fun UseOnionHosts(
val values = remember {
OnionHosts.values().map {
when (it) {
OnionHosts.NEVER -> ValueTitleDesc(OnionHosts.NEVER, generalGetString(MR.strings.network_use_onion_hosts_no), generalGetString(MR.strings.network_use_onion_hosts_no_desc))
OnionHosts.PREFER -> ValueTitleDesc(OnionHosts.PREFER, generalGetString(MR.strings.network_use_onion_hosts_prefer), generalGetString(MR.strings.network_use_onion_hosts_prefer_desc))
OnionHosts.REQUIRED -> ValueTitleDesc(OnionHosts.REQUIRED, generalGetString(MR.strings.network_use_onion_hosts_required), generalGetString(MR.strings.network_use_onion_hosts_required_desc))
OnionHosts.NEVER -> ValueTitleDesc(OnionHosts.NEVER, generalGetString(MR.strings.network_use_onion_hosts_no), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_no_desc)))
OnionHosts.PREFER -> ValueTitleDesc(OnionHosts.PREFER, generalGetString(MR.strings.network_use_onion_hosts_prefer), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_prefer_desc)))
OnionHosts.REQUIRED -> ValueTitleDesc(OnionHosts.REQUIRED, generalGetString(MR.strings.network_use_onion_hosts_required), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_required_desc)))
}
}
}
@@ -368,11 +375,12 @@ private fun SessionModePicker(
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
updateSessionMode: (TransportSessionMode) -> Unit,
) {
val density = LocalDensity.current
val values = remember {
TransportSessionMode.values().map {
when (it) {
TransportSessionMode.User -> ValueTitleDesc(TransportSessionMode.User, generalGetString(MR.strings.network_session_mode_user), generalGetString(MR.strings.network_session_mode_user_description))
TransportSessionMode.Entity -> ValueTitleDesc(TransportSessionMode.Entity, generalGetString(MR.strings.network_session_mode_entity), generalGetString(MR.strings.network_session_mode_entity_description))
TransportSessionMode.User -> ValueTitleDesc(TransportSessionMode.User, generalGetString(MR.strings.network_session_mode_user), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_session_mode_user_description), density))
TransportSessionMode.Entity -> ValueTitleDesc(TransportSessionMode.Entity, generalGetString(MR.strings.network_session_mode_entity), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_session_mode_entity_description), density))
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
@@ -118,21 +119,21 @@ private fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
ValueTitleDesc(
NotificationsMode.OFF,
generalGetString(MR.strings.notifications_mode_off),
generalGetString(MR.strings.notifications_mode_off_desc),
AnnotatedString(generalGetString(MR.strings.notifications_mode_off_desc)),
)
)
res.add(
ValueTitleDesc(
NotificationsMode.PERIODIC,
generalGetString(MR.strings.notifications_mode_periodic),
generalGetString(MR.strings.notifications_mode_periodic_desc),
AnnotatedString(generalGetString(MR.strings.notifications_mode_periodic_desc)),
)
)
res.add(
ValueTitleDesc(
NotificationsMode.SERVICE,
generalGetString(MR.strings.notifications_mode_service),
generalGetString(MR.strings.notifications_mode_service_desc),
AnnotatedString(generalGetString(MR.strings.notifications_mode_service_desc)),
)
)
return res
@@ -145,21 +146,21 @@ fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
ValueTitleDesc(
NotificationPreviewMode.MESSAGE,
generalGetString(MR.strings.notification_preview_mode_message),
generalGetString(MR.strings.notification_preview_mode_message_desc),
AnnotatedString(generalGetString(MR.strings.notification_preview_mode_message_desc)),
)
)
res.add(
ValueTitleDesc(
NotificationPreviewMode.CONTACT,
generalGetString(MR.strings.notification_preview_mode_contact),
generalGetString(MR.strings.notification_preview_mode_contact_desc),
AnnotatedString(generalGetString(MR.strings.notification_preview_mode_contact_desc)),
)
)
res.add(
ValueTitleDesc(
NotificationPreviewMode.HIDDEN,
generalGetString(MR.strings.notification_preview_mode_hidden),
generalGetString(MR.strings.notification_display_mode_hidden_desc),
AnnotatedString(generalGetString(MR.strings.notification_display_mode_hidden_desc)),
)
)
return res
@@ -25,9 +25,11 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) {
fun savePrefs(afterSave: () -> Unit = {}) {
withApi {
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
if (updatedProfile != null) {
val updated = m.controller.apiUpdateProfile(newProfile)
if (updated != null) {
val (updatedProfile, updatedContacts) = updated
m.updateCurrentUser(updatedProfile, preferences)
updatedContacts.forEach(m::updateContact)
currentPreferences = preferences
}
afterSave()

Some files were not shown because too many files have changed in this diff Show More