Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2024-04-19 21:18:33 +01:00
175 changed files with 6190 additions and 952 deletions
@@ -48,12 +48,7 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
@@ -89,11 +89,12 @@ class MainActivity: FragmentActivity() {
}
override fun onBackPressed() {
if (
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
) {
val canFinishActivity = (
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
) && SimplexApp.context.chatModel.sharedContent.value !is SharedContent.Forward
if (canFinishActivity) {
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
super.onBackPressed()
}
@@ -104,9 +105,15 @@ class MainActivity: FragmentActivity() {
AppLock.laFailed.value = true
}
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
val sharedContent = chatModel.sharedContent.value
// Drop shared content
SimplexApp.context.chatModel.sharedContent.value = null
finish()
chatModel.sharedContent.value = null
if (sharedContent is SharedContent.Forward) {
chatModel.chatId.value = sharedContent.fromChatInfo.id
}
if (canFinishActivity) {
finish()
}
}
}
}
@@ -16,8 +16,7 @@ import androidx.work.*
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.updatingChatsMutex
@@ -291,6 +290,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
activeCallDestroyWebView()
}
override fun androidRestartNetworkObserver() {
NetworkObserver.shared.restartNetworkObserver()
}
@SuppressLint("SourceLockedOrientationActivity")
@Composable
override fun androidLockPortraitOrientation() {
+24
View File
@@ -83,6 +83,30 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization") apply false
}
// https://raymondctc.medium.com/configuring-your-sourcecompatibility-targetcompatibility-and-kotlinoptions-jvmtarget-all-at-once-66bf2198145f
val jvmVersion: Provider<String> = providers.gradleProperty("kotlin.jvm.target")
configure(subprojects) {
// Apply compileOptions to subprojects
plugins.withType<com.android.build.gradle.BasePlugin>().configureEach {
extensions.findByType<com.android.build.gradle.BaseExtension>()?.apply {
jvmVersion.map { JavaVersion.toVersion(it) }.orNull?.let {
compileOptions {
sourceCompatibility = it
targetCompatibility = it
}
}
}
}
// Apply kotlinOptions.jvmTarget to subprojects
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
if (jvmVersion.isPresent) jvmTarget = jvmVersion.get()
}
}
}
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}
+1 -7
View File
@@ -12,9 +12,7 @@ version = extra["android.version_name"] as String
kotlin {
androidTarget()
jvm("desktop") {
jvmToolchain(11)
}
jvm("desktop")
applyDefaultHierarchyTemplate()
sourceSets {
all {
@@ -119,10 +117,6 @@ android {
}
testOptions.targetSdk = 33
lint.targetSdk = 33
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
val isAndroid = gradle.startParameter.taskNames.find {
val lower = it.lowercase()
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="chat.simplex.common">
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
@@ -0,0 +1,104 @@
package chat.simplex.common.helpers
import android.net.*
import android.util.Log
import androidx.core.content.getSystemService
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.UserNetworkInfo
import chat.simplex.common.model.UserNetworkType
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withBGApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
class NetworkObserver {
private var prevInfo: UserNetworkInfo? = null
// When having both mobile and Wi-Fi networks enabled with Wi-Fi being active, then disabling Wi-Fi, network reports its offline (which is true)
// but since it will be online after switching to mobile, there is no need to inform backend about such temporary change.
// But if it will not be online after some seconds, report it and apply required measures
private var noNetworkJob = Job() as Job
private val networkCallback = object: ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = networkCapabilitiesChanged(networkCapabilities)
override fun onLost(network: Network) = networkLost()
}
private val connectivityManager: ConnectivityManager? = androidAppContext.getSystemService()
fun restartNetworkObserver() {
if (connectivityManager == null) {
Log.e(TAG, "Connectivity manager is unavailable, network observer is disabled")
val info = UserNetworkInfo(
networkType = UserNetworkType.OTHER,
online = true,
)
prevInfo = info
setNetworkInfo(info)
return
}
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (e: Exception) {
// do nothing
}
val initialCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
if (initialCapabilities != null) {
networkCapabilitiesChanged(initialCapabilities)
} else {
networkLost()
}
try {
connectivityManager.registerDefaultNetworkCallback(networkCallback)
} catch (e: Exception) {
Log.e(TAG, "Error registering network callback: ${e.stackTraceToString()}")
}
}
private fun networkCapabilitiesChanged(capabilities: NetworkCapabilities) {
connectivityManager ?: return
val info = UserNetworkInfo(
networkType = networkTypeFromCapabilities(capabilities),
online = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
)
if (prevInfo != info) {
prevInfo = info
setNetworkInfo(info)
}
}
private fun networkLost() {
Log.d(TAG, "Network changed: lost")
val none = UserNetworkInfo(networkType = UserNetworkType.NONE, false)
prevInfo = none
setNetworkInfo(none)
}
private fun setNetworkInfo(info: UserNetworkInfo) {
Log.d(TAG, "Network changed: $info")
noNetworkJob.cancel()
if (info.online) {
withBGApi {
if (controller.hasChatCtrl() && controller.apiSetNetworkInfo(info)) {
chatModel.networkInfo.value = info
}
}
} else {
noNetworkJob = withBGApi {
delay(3000)
if (controller.hasChatCtrl() && controller.apiSetNetworkInfo(info)) {
chatModel.networkInfo.value = info
}
}
}
}
private fun networkTypeFromCapabilities(capabilities: NetworkCapabilities): UserNetworkType = when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> UserNetworkType.ETHERNET
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> UserNetworkType.WIFI
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> UserNetworkType.CELLULAR
else -> UserNetworkType.OTHER
}
companion object {
val shared = NetworkObserver()
}
}
@@ -2,17 +2,16 @@ package chat.simplex.common.helpers
import android.media.*
import android.net.Uri
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.*
import androidx.core.content.ContextCompat
import chat.simplex.common.R
import chat.simplex.common.platform.SoundPlayerInterface
import chat.simplex.common.platform.androidAppContext
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withApi
import kotlinx.coroutines.*
object SoundPlayer: SoundPlayerInterface {
private var player: MediaPlayer? = null
var playing = false
private var playing = false
override fun start(scope: CoroutineScope, sound: Boolean) {
player?.reset()
@@ -32,7 +31,7 @@ object SoundPlayer: SoundPlayerInterface {
scope.launch {
while (playing) {
if (sound) player?.start()
vibrator?.vibrate(effect)
vibrator?.vibrateApiVersionAware(effect)
delay(3500)
}
}
@@ -43,3 +42,82 @@ object SoundPlayer: SoundPlayerInterface {
player?.stop()
}
}
object CallSoundsPlayer: CallSoundsPlayerInterface {
private var player: MediaPlayer? = null
private var playingJob: Job? = null
private fun start(soundPath: String, delay: Long, scope: CoroutineScope) {
playingJob?.cancel()
player?.reset()
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING)
.build()
)
setDataSource(androidAppContext, Uri.parse(soundPath))
prepare()
}
if (delay < 1000) {
player?.isLooping = true
player?.start()
return
}
playingJob = scope.launch {
while (isActive) {
player?.start()
delay(delay)
}
}
}
override fun startConnectingCallSound(scope: CoroutineScope) {
// Taken from https://github.com/TelegramOrg/Telegram-Android
// https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
start("android.resource://" + androidAppContext.packageName + "/" + R.raw.connecting_call, 0, scope)
}
override fun startInCallSound(scope: CoroutineScope) {
start("android.resource://" + androidAppContext.packageName + "/" + R.raw.in_call, 2000, scope)
}
override fun vibrate(times: Int) {
val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java)
val effect = VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)
vibrator?.vibrateApiVersionAware(effect)
repeat(times - 1) {
withApi {
delay(50)
vibrator?.vibrateApiVersionAware(effect)
}
}
}
override fun stop() {
playingJob?.cancel()
player?.stop()
}
}
private fun Vibrator.vibrateApiVersionAware(effect: VibrationEffect) {
if (Build.VERSION.SDK_INT >= 33) {
vibrate(
effect,
VibrationAttributes.Builder()
.setUsage(VibrationAttributes.USAGE_ALARM)
.build()
)
} else if (Build.VERSION.SDK_INT >= 29) {
vibrate(
effect,
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
)
} else {
vibrate(effect)
}
}
@@ -296,6 +296,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer
actual typealias CallSoundsPlayer = chat.simplex.common.helpers.CallSoundsPlayer
class CryptoMediaSource(val data: ByteArray) : MediaDataSource() {
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
@@ -69,6 +69,8 @@ fun activeCallDestroyWebView() = withApi {
@SuppressLint("SourceLockedOrientationActivity")
@Composable
actual fun ActiveCallView() {
val call = remember { chatModel.activeCall }.value
val scope = rememberCoroutineScope()
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
val proximityLock = remember {
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
@@ -78,6 +80,13 @@ actual fun ActiveCallView() {
null
}
}
val wasConnected = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(call) {
if (call?.callState == CallState.Connected && !wasConnected.value) {
CallSoundsPlayer.vibrate(2)
wasConnected.value = true
}
}
DisposableEffect(Unit) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
var btDeviceCount = 0
@@ -107,6 +116,10 @@ actual fun ActiveCallView() {
}
am.registerAudioDeviceCallback(audioCallback, null)
onDispose {
CallSoundsPlayer.stop()
if (wasConnected.value) {
CallSoundsPlayer.vibrate()
}
dropAudioManagerOverrides()
am.unregisterAudioDeviceCallback(audioCallback)
if (proximityLock?.isHeld == true) {
@@ -122,8 +135,6 @@ actual fun ActiveCallView() {
if (proximityLock?.isHeld == false) proximityLock.acquire()
}
}
val scope = rememberCoroutineScope()
val call = chatModel.activeCall.value
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
@@ -136,6 +147,9 @@ actual fun ActiveCallView() {
val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
setCallSound(call.soundSpeaker, audioViaBluetooth)
CallSoundsPlayer.startConnectingCallSound(scope)
activeCallWaitDeliveryReceipt(scope)
}
is WCallResponse.Offer -> withBGApi {
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
@@ -144,6 +158,7 @@ actual fun ActiveCallView() {
is WCallResponse.Answer -> withBGApi {
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
CallSoundsPlayer.stop()
}
is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
@@ -6,7 +6,6 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import chat.simplex.common.helpers.toUri
import chat.simplex.common.model.CIFile
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.ModalManager
@@ -15,7 +14,6 @@ import coil.compose.rememberAsyncImagePainter
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import java.net.URI
@Composable
actual fun SimpleAndAnimatedImageView(
@@ -43,6 +41,7 @@ actual fun SimpleAndAnimatedImageView(
}
private val imageLoader = ImageLoader.Builder(androidAppContext)
.networkObserverEnabled(false)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
@@ -3,7 +3,7 @@ package chat.simplex.common.views.chat.item
import android.os.Build
import android.view.View
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.BitmapPainter
@@ -11,8 +11,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.isVisible
import chat.simplex.common.helpers.toUri
import chat.simplex.common.platform.VideoPlayer
import chat.simplex.common.platform.androidAppContext
import chat.simplex.res.MR
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
@@ -23,21 +23,11 @@ import coil.size.Size
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
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
// I'm using a new private 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)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
Image(
rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(),
@@ -73,3 +63,14 @@ actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: (
modifier
)
}
private val imageLoader = ImageLoader.Builder(androidAppContext)
.networkObserverEnabled(false)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
@@ -111,13 +111,14 @@ object ChatModel {
var draft = mutableStateOf(null as ComposeState?)
var draftChatId = mutableStateOf(null as String?)
// working with external intents
// working with external intents or internal forwarding of chat items
val sharedContent = mutableStateOf(null as SharedContent?)
val filesToDelete = mutableSetOf<File>()
val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) }
val clipboardHasText = mutableStateOf(false)
val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true))
val updatingChatsMutex: Mutex = Mutex()
val changingActiveUserMutex: Mutex = Mutex()
@@ -804,6 +805,24 @@ data class Chat(
val id: String get() = chatInfo.id
fun groupFeatureEnabled(feature: GroupFeature): Boolean =
if (chatInfo is ChatInfo.Group) {
val groupInfo = chatInfo.groupInfo
val p = groupInfo.fullGroupPreferences
when (feature) {
GroupFeature.TimedMessages -> p.timedMessages.on
GroupFeature.DirectMessages -> p.directMessages.on(groupInfo.membership)
GroupFeature.FullDelete -> p.fullDelete.on
GroupFeature.Reactions -> p.reactions.on
GroupFeature.Voice -> p.voice.on(groupInfo.membership)
GroupFeature.Files -> p.files.on(groupInfo.membership)
GroupFeature.SimplexLinks -> p.simplexLinks.on(groupInfo.membership)
GroupFeature.History -> p.history.on
}
} else {
true
}
@Serializable
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false)
@@ -1239,7 +1258,7 @@ data class GroupInfo (
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
ChatFeature.Reactions -> fullGroupPreferences.reactions.on
ChatFeature.Voice -> fullGroupPreferences.voice.on
ChatFeature.Voice -> fullGroupPreferences.voice.on(membership)
ChatFeature.Calls -> false
}
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
@@ -1734,7 +1753,7 @@ data class ChatItem (
val allowAddReaction: Boolean get() =
meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3)
private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null
@@ -1883,14 +1902,16 @@ data class ChatItem (
status: CIStatus = CIStatus.SndNew(),
quotedItem: CIQuote? = null,
file: CIFile? = null,
itemForwarded: CIForwardedFrom? = null,
itemDeleted: CIDeleted? = null,
itemEdited: Boolean = false,
itemTimed: CITimed? = null,
deletable: Boolean = true,
editable: Boolean = true
) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemTimed, editable),
meta = CIMeta.getSample(id, ts, text, status, itemForwarded, itemDeleted, itemEdited, itemTimed, deletable, editable),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem,
reactions = listOf(),
@@ -1974,10 +1995,12 @@ data class ChatItem (
itemStatus = CIStatus.RcvRead(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemForwarded = null,
itemDeleted = null,
itemEdited = false,
itemTimed = null,
itemLive = false,
deletable = false,
editable = false
),
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
@@ -1995,10 +2018,12 @@ data class ChatItem (
itemStatus = CIStatus.RcvRead(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemForwarded = null,
itemDeleted = null,
itemEdited = false,
itemTimed = null,
itemLive = true,
deletable = false,
editable = false
),
content = CIContent.SndMsgContent(MsgContent.MCText("")),
@@ -2095,10 +2120,12 @@ data class CIMeta (
val itemStatus: CIStatus,
val createdAt: Instant,
val updatedAt: Instant,
val itemForwarded: CIForwardedFrom?,
val itemDeleted: CIDeleted?,
val itemEdited: Boolean,
val itemTimed: CITimed?,
val itemLive: Boolean?,
val deletable: Boolean,
val editable: Boolean
) {
val timestampText: String get() = getTimestampText(itemTs)
@@ -2118,7 +2145,8 @@ data class CIMeta (
companion object {
fun getSample(
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
itemDeleted: CIDeleted? = null, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
itemForwarded: CIForwardedFrom? = null, itemDeleted: CIDeleted? = null, itemEdited: Boolean = false,
itemTimed: CITimed? = null, itemLive: Boolean = false, deletable: Boolean = true, editable: Boolean = true
): CIMeta =
CIMeta(
itemId = id,
@@ -2127,10 +2155,12 @@ data class CIMeta (
itemStatus = status,
createdAt = ts,
updatedAt = ts,
itemForwarded = itemForwarded,
itemDeleted = itemDeleted,
itemEdited = itemEdited,
itemTimed = itemTimed,
itemLive = itemLive,
deletable = deletable,
editable = editable
)
@@ -2143,10 +2173,12 @@ data class CIMeta (
itemStatus = CIStatus.SndNew(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemForwarded = null,
itemDeleted = null,
itemEdited = false,
itemTimed = null,
itemLive = false,
deletable = false,
editable = false
)
}
@@ -2257,6 +2289,37 @@ sealed class CIDeleted {
@Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted()
}
@Serializable
enum class MsgDirection {
@SerialName("rcv") Rcv,
@SerialName("snd") Snd;
}
@Serializable
sealed class CIForwardedFrom {
@Serializable @SerialName("unknown") object Unknown: CIForwardedFrom()
@Serializable @SerialName("contact") class Contact(override val chatName: String, val msgDir: MsgDirection, val contactId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom()
@Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom()
open val chatName: String
get() = when (this) {
Unknown -> ""
is Contact -> chatName
is Group -> chatName
}
fun text(chatType: ChatType): String =
if (chatType == ChatType.Local) {
if (chatName.isEmpty()) {
generalGetString(MR.strings.saved_description)
} else {
generalGetString(MR.strings.saved_from_description).format(chatName)
}
} else {
generalGetString(MR.strings.forwarded_description)
}
}
@Serializable
enum class CIDeleteMode(val deleteMode: String) {
@SerialName("internal") cidmInternal("internal"),
@@ -2292,8 +2355,8 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null }
@@ -2325,8 +2388,8 @@ sealed class CIContent: ItemContent {
is SndChatFeature -> featureText(feature, enabled.text, param)
is RcvChatPreference -> preferenceText(feature, allowed, param)
is SndChatPreference -> preferenceText(feature, allowed, param)
is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param)
is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param)
is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_)
is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_)
is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}"
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}"
is SndModerated -> generalGetString(MR.strings.moderated_description)
@@ -2363,11 +2426,23 @@ sealed class CIContent: ItemContent {
private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short)
fun featureText(feature: Feature, enabled: String, param: Int?): String =
if (feature.hasParam) {
fun featureText(feature: Feature, enabled: String, param: Int?, role: GroupMemberRole? = null): String =
(if (feature.hasParam) {
"${feature.text}: ${timeText(param)}"
} else {
"${feature.text}: $enabled"
}) + (
if (feature.hasRole && role != null)
" (${roleText(role)})"
else
""
)
private fun roleText(role: GroupMemberRole?): String =
when (role) {
GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners)
GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins)
else -> generalGetString(MR.strings.feature_roles_all_members)
}
fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
@@ -3251,7 +3326,8 @@ sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
@Serializable
class ChatItemInfo(
val itemVersions: List<ChatItemVersion>,
val memberDeliveryStatuses: List<MemberDeliveryStatus>?
val memberDeliveryStatuses: List<MemberDeliveryStatus>?,
val forwardedFromChatItem: AChatItem?
)
@Serializable
@@ -20,6 +20,7 @@ import com.charleskorn.kaml.YamlConfiguration
import chat.simplex.res.MR
import com.russhwolf.settings.Settings
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@@ -350,11 +351,15 @@ object ChatController {
var ctrl: ChatCtrl? = -1
val appPrefs: AppPreferences by lazy { AppPreferences() }
val messagesChannel: Channel<APIResponse> = Channel()
val chatModel = ChatModel
private var receiverStarted = false
var lastMsgReceivedTimestamp: Long = System.currentTimeMillis()
private set
fun hasChatCtrl() = ctrl != -1L && ctrl != null
private suspend fun currentUserId(funcName: String): Long = changingActiveUserMutex.withLock {
val userId = chatModel.currentUser.value?.userId
if (userId == null) {
@@ -481,6 +486,7 @@ object ChatController {
if (msg != null) {
val finishedWithoutTimeout = withTimeoutOrNull(60_000L) {
processReceivedMsg(msg)
messagesChannel.trySend(msg)
}
if (finishedWithoutTimeout == null) {
Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType)
@@ -732,12 +738,16 @@ object ChatController {
suspend fun apiSendMessage(rh: Long?, 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)
return processSendMessageCmd(rh, cmd)
}
private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): AChatItem? {
val r = sendCmd(rh, cmd)
return when (r) {
is CR.NewChatItem -> r.chatItem
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiSendMessage", generalGetString(MR.strings.error_sending_message), r)
apiErrorAlert("processSendMessageCmd", generalGetString(MR.strings.error_sending_message), r)
}
null
}
@@ -765,6 +775,13 @@ object ChatController {
}
}
suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long): ChatItem? {
val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId)
return processSendMessageCmd(rh, cmd)?.chatItem
}
suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? {
val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live))
if (r is CR.ChatItemUpdated) return r.chatItem
@@ -875,6 +892,9 @@ object ChatController {
}
}
suspend fun apiSetNetworkInfo(networkInfo: UserNetworkInfo): Boolean =
sendCommandOkResp(null, CC.APISetNetworkInfo(networkInfo))
suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean =
sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings))
@@ -1962,7 +1982,6 @@ object ChatController {
}
is CR.SndFileCompleteXFTP -> {
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
}
is CR.SndFileError -> {
if (r.chatItem_ != null) {
@@ -2119,7 +2138,7 @@ object ChatController {
if (active(r.user)) {
chatModel.updateContact(rhId, r.contact)
}
is CR.ChatCmdError -> when {
is CR.ChatRespError -> when {
r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> {
chatModel.processedCriticalError.newError(r.chatError.agentError, r.chatError.agentError.offerRestart)
}
@@ -2387,6 +2406,7 @@ sealed class 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()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long): CC()
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
@@ -2409,6 +2429,7 @@ sealed class CC {
class APIGetChatItemTTL(val userId: Long): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC()
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC()
class APIContactInfo(val contactId: Long): CC()
@@ -2529,6 +2550,7 @@ sealed class CC {
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiForwardChatItem -> "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId"
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
@@ -2551,6 +2573,7 @@ sealed class CC {
is APIGetChatItemTTL -> "/_ttl $userId"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}"
is APIContactInfo -> "/_info @$contactId"
@@ -2666,6 +2689,7 @@ sealed class CC {
is ApiDeleteChatItem -> "apiDeleteChatItem"
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
is ApiChatItemReaction -> "apiChatItemReaction"
is ApiForwardChatItem -> "apiForwardChatItem"
is ApiNewGroup -> "apiNewGroup"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
@@ -2688,6 +2712,7 @@ sealed class CC {
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "apiSetNetworkConfig"
is APIGetNetworkConfig -> "apiGetNetworkConfig"
is APISetNetworkInfo -> "apiSetNetworkInfo"
is APISetChatSettings -> "apiSetChatSettings"
is ApiSetMemberSettings -> "apiSetMemberSettings"
is APIContactInfo -> "apiContactInfo"
@@ -3035,7 +3060,7 @@ data class NetCfg(
sessionMode = TransportSessionMode.User,
tcpConnectTimeout = 20_000_000,
tcpTimeout = 15_000_000,
tcpTimeoutPerKb = 45_000,
tcpTimeoutPerKb = 10_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 1200_000_000,
smpPingCount = 3
@@ -3049,7 +3074,7 @@ data class NetCfg(
sessionMode = TransportSessionMode.User,
tcpConnectTimeout = 30_000_000,
tcpTimeout = 20_000_000,
tcpTimeoutPerKb = 60_000,
tcpTimeoutPerKb = 15_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 1200_000_000,
smpPingCount = 3
@@ -3418,6 +3443,7 @@ interface Feature {
@Composable
fun iconFilled(): Painter
val hasParam: Boolean
val hasRole: Boolean
}
@Serializable
@@ -3437,6 +3463,7 @@ enum class ChatFeature: Feature {
TimedMessages -> true
else -> false
}
override val hasRole: Boolean = false
override val text: String
get() = when(this) {
@@ -3537,6 +3564,7 @@ enum class GroupFeature: Feature {
@SerialName("reactions") Reactions,
@SerialName("voice") Voice,
@SerialName("files") Files,
@SerialName("simplexLinks") SimplexLinks,
@SerialName("history") History;
override val hasParam: Boolean get() = when(this) {
@@ -3544,6 +3572,18 @@ enum class GroupFeature: Feature {
else -> false
}
override val hasRole: Boolean
get() = when (this) {
TimedMessages -> false
DirectMessages -> true
FullDelete -> false
Reactions -> false
Voice -> true
Files -> true
SimplexLinks -> true
History -> false
}
override val text: String
get() = when(this) {
TimedMessages -> generalGetString(MR.strings.timed_messages)
@@ -3552,6 +3592,7 @@ enum class GroupFeature: Feature {
Reactions -> generalGetString(MR.strings.message_reactions)
Voice -> generalGetString(MR.strings.voice_messages)
Files -> generalGetString(MR.strings.files_and_media)
SimplexLinks -> generalGetString(MR.strings.simplex_links)
History -> generalGetString(MR.strings.recent_history)
}
@@ -3563,6 +3604,7 @@ enum class GroupFeature: Feature {
Reactions -> painterResource(MR.images.ic_add_reaction)
Voice -> painterResource(MR.images.ic_keyboard_voice)
Files -> painterResource(MR.images.ic_draft)
SimplexLinks -> painterResource(MR.images.ic_link)
History -> painterResource(MR.images.ic_schedule)
}
@@ -3574,6 +3616,7 @@ enum class GroupFeature: Feature {
Reactions -> painterResource(MR.images.ic_add_reaction_filled)
Voice -> painterResource(MR.images.ic_keyboard_voice_filled)
Files -> painterResource(MR.images.ic_draft_filled)
SimplexLinks -> painterResource(MR.images.ic_link)
History -> painterResource(MR.images.ic_schedule_filled)
}
@@ -3604,6 +3647,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_files)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_files)
}
SimplexLinks -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_simplex_links)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_simplex_links)
}
History -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_recent_history)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_recent_history)
@@ -3635,6 +3682,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_files)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.files_are_prohibited_in_group)
}
SimplexLinks -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_simplex_links)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.simplex_links_are_prohibited_in_group)
}
History -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.recent_history_is_sent_to_new_members)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.recent_history_is_not_sent_to_new_members)
@@ -3748,11 +3799,12 @@ enum class FeatureAllowed {
@Serializable
data class FullGroupPreferences(
val timedMessages: TimedMessagesGroupPreference,
val directMessages: GroupPreference,
val directMessages: RoleGroupPreference,
val fullDelete: GroupPreference,
val reactions: GroupPreference,
val voice: GroupPreference,
val files: GroupPreference,
val voice: RoleGroupPreference,
val files: RoleGroupPreference,
val simplexLinks: RoleGroupPreference,
val history: GroupPreference,
) {
fun toGroupPreferences(): GroupPreferences =
@@ -3763,17 +3815,19 @@ data class FullGroupPreferences(
reactions = reactions,
voice = voice,
files = files,
simplexLinks = simplexLinks,
history = history
)
companion object {
val sampleData = FullGroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
directMessages = RoleGroupPreference(GroupFeatureEnabled.OFF, role = null),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
reactions = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON),
files = GroupPreference(GroupFeatureEnabled.ON),
voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
history = GroupPreference(GroupFeatureEnabled.ON),
)
}
@@ -3782,21 +3836,23 @@ data class FullGroupPreferences(
@Serializable
data class GroupPreferences(
val timedMessages: TimedMessagesGroupPreference? = null,
val directMessages: GroupPreference? = null,
val directMessages: RoleGroupPreference? = null,
val fullDelete: GroupPreference? = null,
val reactions: GroupPreference? = null,
val voice: GroupPreference? = null,
val files: GroupPreference? = null,
val voice: RoleGroupPreference? = null,
val files: RoleGroupPreference? = null,
val simplexLinks: RoleGroupPreference? = null,
val history: GroupPreference? = null,
) {
companion object {
val sampleData = GroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
directMessages = RoleGroupPreference(GroupFeatureEnabled.OFF, role = null),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
reactions = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON),
files = GroupPreference(GroupFeatureEnabled.ON),
voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null),
history = GroupPreference(GroupFeatureEnabled.ON),
)
}
@@ -3807,6 +3863,26 @@ data class GroupPreference(
val enable: GroupFeatureEnabled
) {
val on: Boolean get() = enable == GroupFeatureEnabled.ON
fun enabled(role: GroupMemberRole?, m: GroupMember?): GroupFeatureEnabled =
when (enable) {
GroupFeatureEnabled.OFF -> GroupFeatureEnabled.OFF
GroupFeatureEnabled.ON ->
if (role != null && m != null) {
if (m.memberRole >= role) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF
} else {
GroupFeatureEnabled.ON
}
}
}
@Serializable
data class RoleGroupPreference(
val enable: GroupFeatureEnabled,
val role: GroupMemberRole? = null,
) {
fun on(m: GroupMember): Boolean =
enable == GroupFeatureEnabled.ON && m.memberRole >= (role ?: GroupMemberRole.Observer)
}
@Serializable
@@ -4760,6 +4836,7 @@ sealed class ChatErrorType {
is FallbackToSMPProhibited -> "fallbackToSMPProhibited"
is InlineFileProhibited -> "inlineFileProhibited"
is InvalidQuote -> "invalidQuote"
is InvalidForward -> "invalidForward"
is InvalidChatItemUpdate -> "invalidChatItemUpdate"
is InvalidChatItemDelete -> "invalidChatItemDelete"
is HasCurrentCall -> "hasCurrentCall"
@@ -4838,6 +4915,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("fallbackToSMPProhibited") class FallbackToSMPProhibited(val fileId: Long): ChatErrorType()
@Serializable @SerialName("inlineFileProhibited") class InlineFileProhibited(val fileId: Long): ChatErrorType()
@Serializable @SerialName("invalidQuote") object InvalidQuote: ChatErrorType()
@Serializable @SerialName("invalidForward") object InvalidForward: ChatErrorType()
@Serializable @SerialName("invalidChatItemUpdate") object InvalidChatItemUpdate: ChatErrorType()
@Serializable @SerialName("invalidChatItemDelete") object InvalidChatItemDelete: ChatErrorType()
@Serializable @SerialName("hasCurrentCall") object HasCurrentCall: ChatErrorType()
@@ -5527,3 +5605,26 @@ enum class AppSettingsLockScreenCalls {
}
}
}
@Serializable
data class UserNetworkInfo(
val networkType: UserNetworkType,
val online: Boolean,
)
enum class UserNetworkType {
@SerialName("none") NONE,
@SerialName("cellular") CELLULAR,
@SerialName("wifi") WIFI,
@SerialName("ethernet") ETHERNET,
@SerialName("other") OTHER;
val text: String
get() = when (this) {
NONE -> generalGetString(MR.strings.network_type_no_network_connection)
CELLULAR -> generalGetString(MR.strings.network_type_cellular)
WIFI -> generalGetString(MR.strings.network_type_network_wifi)
ETHERNET -> generalGetString(MR.strings.network_type_ethernet)
OTHER -> generalGetString(MR.strings.network_type_other)
}
}
@@ -9,7 +9,6 @@ import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import java.io.File
import java.nio.ByteBuffer
@@ -88,6 +87,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
Log.d(TAG, "Unable to migrate successfully: $res")
return
}
platform.androidRestartNetworkObserver()
controller.apiSetTempFolder(coreTmpDir.absolutePath)
controller.apiSetFilesFolder(appFilesDir.absolutePath)
if (appPlatform.isDesktop) {
@@ -23,6 +23,7 @@ interface PlatformInterface {
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
fun androidRestartNetworkObserver() {}
@Composable fun androidLockPortraitOrientation() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
@Composable fun desktopScrollBarComponents(): Triple<Animatable<Float, AnimationVector1D>, Modifier, MutableState<Job>> = remember { Triple(Animatable(0f), Modifier, mutableStateOf(Job())) }
@@ -39,4 +39,13 @@ interface SoundPlayerInterface {
fun stop()
}
interface CallSoundsPlayerInterface {
fun startConnectingCallSound(scope: CoroutineScope)
fun startInCallSound(scope: CoroutineScope)
fun stop()
fun vibrate(times: Int = 1)
}
expect object SoundPlayer: SoundPlayerInterface
expect object CallSoundsPlayer: CallSoundsPlayerInterface
@@ -85,6 +85,7 @@ fun TerminalLayout(
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
@@ -1,6 +1,26 @@
package chat.simplex.common.views.call
import androidx.compose.runtime.Composable
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import kotlinx.coroutines.*
@Composable
expect fun ActiveCallView()
fun activeCallWaitDeliveryReceipt(scope: CoroutineScope) = scope.launch(Dispatchers.Default) {
for (apiResp in controller.messagesChannel) {
val call = chatModel.activeCall.value
if (call == null || call.callState > CallState.InvitationSent) break
val msg = apiResp.resp
if (apiResp.remoteHostId == call.remoteHostId &&
msg is CR.ChatItemStatusUpdated &&
msg.chatItem.chatInfo.id == call.contact.id &&
msg.chatItem.chatItem.content is CIContent.SndCall &&
msg.chatItem.chatItem.meta.itemStatus is CIStatus.SndRcvd) {
CallSoundsPlayer.startInCallSound(scope)
break
}
}
}
@@ -187,10 +187,11 @@ data class ConnectionState(
)
// the servers are expected in this format:
// stun:stun.simplex.im:443?transport=tcp
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
// stuns:stun.simplex.im:443?transport=tcp
// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp
fun parseRTCIceServer(str: String): RTCIceServer? {
var s = replaceScheme(str, "stun:")
s = replaceScheme(s, "stuns:")
s = replaceScheme(s, "turn:")
s = replaceScheme(s, "turns:")
val u = runCatching { URI(s) }.getOrNull()
@@ -198,7 +199,7 @@ fun parseRTCIceServer(str: String): RTCIceServer? {
val scheme = u.scheme
val host = u.host
val port = u.port
if (u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns")) {
if (u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns")) {
val userInfo = u.userInfo?.split(":")
val query = if (u.query == null || u.query == "") "" else "?${u.query}"
return RTCIceServer(
@@ -13,9 +13,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -29,6 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
@@ -36,10 +36,11 @@ sealed class CIInfoTab {
class Delivery(val memberDeliveryStatuses: List<MemberDeliveryStatus>): CIInfoTab()
object History: CIInfoTab()
class Quote(val quotedItem: CIQuote): CIInfoTab()
class Forwarded(val forwardedFromChatItem: AChatItem): CIInfoTab()
}
@Composable
fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
val sent = ci.chatDir.sent
val appColors = CurrentColors.collectAsState().value.appColors
val uriHandler = LocalUriHandler.current
@@ -151,6 +152,70 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
}
}
val local = when (ci.chatDir) {
is CIDirection.LocalSnd -> true
is CIDirection.LocalRcv -> true
else -> false
}
@Composable
fun ForwardedFromSender(forwardedFromItem: AChatItem) {
@Composable
fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) {
Text(
text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
fontStyle = fontStyle,
color = color,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
ChatInfoImage(forwardedFromItem.chatInfo, size = 57.dp)
Column(
modifier = Modifier
.padding(start = 15.dp)
.weight(1F)
) {
if (forwardedFromItem.chatItem.chatDir.sent) {
ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic)
Spacer(Modifier.height(7.dp))
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
} else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) {
ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName)
Spacer(Modifier.height(7.dp))
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
} else {
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground)
}
}
}
}
@Composable
fun ForwardedFromView(forwardedFromItem: AChatItem) {
Column {
SectionItemView(
click = {
withBGApi {
openChat(chatRh, forwardedFromItem.chatInfo, chatModel)
ModalManager.end.closeModals()
}
},
padding = PaddingValues(start = 17.dp, end = DEFAULT_PADDING)
) {
ForwardedFromSender(forwardedFromItem)
}
if (!local) {
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 41.dp, end = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING_HALF))
Text(stringResource(MR.strings.recipients_can_not_see_who_message_from), Modifier.padding(horizontal = DEFAULT_PADDING), fontSize = 12.sp, color = MaterialTheme.colors.secondary)
}
}
}
@Composable
fun Details() {
AppBarTitle(stringResource(if (ci.localNote) MR.strings.saved_message_title else if (sent) MR.strings.sent_message else MR.strings.received_message))
@@ -188,7 +253,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val versions = ciInfo.itemVersions
if (versions.isNotEmpty()) {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
@@ -213,7 +278,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.in_reply_to), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
QuotedMsgView(qi)
@@ -222,6 +287,22 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
}
}
@Composable
fun ForwardedFromTab(forwardedFromItem: AChatItem) {
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
SectionView {
Text(stringResource(if (local) MR.strings.saved_from_chat_item_info_title else MR.strings.forwarded_from_chat_item_info_title),
style = MaterialTheme.typography.h2,
modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING))
ForwardedFromView(forwardedFromItem)
}
SectionBottomSpacer()
}
}
@Composable
fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) {
SectionItemView(
@@ -271,7 +352,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val mss = membersStatuses(chatModel, memberDeliveryStatuses)
if (mss.isNotEmpty()) {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
@@ -297,6 +378,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
is CIInfoTab.Delivery -> stringResource(MR.strings.delivery)
is CIInfoTab.History -> stringResource(MR.strings.edit_history)
is CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to)
is CIInfoTab.Forwarded -> stringResource(if (local) MR.strings.saved_chat_item_info_tab else MR.strings.forwarded_chat_item_info_tab)
}
}
@@ -305,6 +387,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
is CIInfoTab.Delivery -> MR.images.ic_double_check
is CIInfoTab.History -> MR.images.ic_history
is CIInfoTab.Quote -> MR.images.ic_reply
is CIInfoTab.Forwarded -> MR.images.ic_forward
}
}
@@ -316,6 +399,9 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
if (ci.quotedItem != null) {
numTabs += 1
}
if (ciInfo.forwardedFromChatItem != null) {
numTabs += 1
}
return numTabs
}
@@ -326,11 +412,6 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
LaunchedEffect(ciInfo) {
if (ciInfo.memberDeliveryStatuses != null) {
selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)
}
}
Column(Modifier.weight(1f)) {
when (val sel = selection.value) {
is CIInfoTab.Delivery -> {
@@ -344,6 +425,10 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
is CIInfoTab.Quote -> {
QuoteTab(sel.quotedItem)
}
is CIInfoTab.Forwarded -> {
ForwardedFromTab(sel.forwardedFromChatItem)
}
}
}
val availableTabs = mutableListOf<CIInfoTab>()
@@ -354,6 +439,19 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
if (ci.quotedItem != null) {
availableTabs.add(CIInfoTab.Quote(ci.quotedItem))
}
if (ciInfo.forwardedFromChatItem != null) {
availableTabs.add(CIInfoTab.Forwarded(ciInfo.forwardedFromChatItem))
}
if (availableTabs.none { it.javaClass == selection.value.javaClass }) {
selection.value = availableTabs.first()
}
LaunchedEffect(ciInfo) {
if (ciInfo.forwardedFromChatItem != null && selection.value is CIInfoTab.Forwarded) {
selection.value = CIInfoTab.Forwarded(ciInfo.forwardedFromChatItem)
} else if (ciInfo.memberDeliveryStatuses != null) {
selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)
}
}
TabRow(
selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
backgroundColor = Color.Transparent,
@@ -52,9 +52,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
val draft = chatModel.draft.value
val sharedContent = chatModel.sharedContent.value
mutableStateOf(
if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) {
chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
if (chatModel.draftChatId.value == chatId && draft != null && (sharedContent !is SharedContent.Forward || sharedContent.fromChatInfo.id == chatId)) {
draft
} else {
ComposeState(useLinkPreviews = useLinkPreviews)
}
@@ -408,7 +410,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
}
}) { close ->
ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
KeyChangeEffect(chatModel.chatId.value) {
close()
}
@@ -956,13 +958,13 @@ fun BoxWithConstraintsScope.ChatItemsList(
tryOrShowError("${cItem.id}ChatItem", error = {
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
}) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
ChatItemView(chat.remoteHostId, chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
}
}
@Composable
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) {
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val member = cItem.chatDir.groupMember
@@ -1090,9 +1092,9 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
.collect {
try {
if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.size)) {
listState.animateScrollToItem(0)
if (appPlatform.isAndroid) listState.animateScrollToItem(0) else listState.scrollToItem(0)
} else {
listState.animateScrollBy(scrollDistance)
if (appPlatform.isAndroid) listState.animateScrollBy(scrollDistance) else listState.scrollBy(scrollDistance)
}
} catch (e: CancellationException) {
/**
@@ -1392,11 +1394,12 @@ private fun providerForGallery(
return null
}
var initialIndex = Int.MAX_VALUE / 2
// Pager has a bug with overflowing when total pages is around Int.MAX_VALUE. Using smaller value
var initialIndex = 10000 / 2
var initialChatId = cItemId
return object: ImageGalleryProvider {
override val initialIndex: Int = initialIndex
override val totalMediaSize = mutableStateOf(Int.MAX_VALUE)
override val totalMediaSize = mutableStateOf(10000)
override fun getMedia(index: Int): ProviderMedia? {
val internalIndex = initialIndex - index
val item = item(internalIndex, initialChatId)?.second ?: return null
@@ -1,6 +1,7 @@
@file:UseSerializers(UriSerializer::class)
package chat.simplex.common.views.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
@@ -11,6 +12,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
@@ -18,8 +21,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.filesToDelete
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.Indigo
import chat.simplex.common.ui.theme.isSystemInDarkTheme
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
@@ -44,6 +46,7 @@ sealed class ComposeContextItem {
@Serializable object NoContextItem: ComposeContextItem()
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class ForwardingItem(val chatItem: ChatItem, val fromChatInfo: ChatInfo): ComposeContextItem()
}
@Serializable
@@ -77,13 +80,18 @@ data class ComposeState(
is ComposeContextItem.EditingItem -> true
else -> false
}
val forwarding: Boolean
get() = when (contextItem) {
is ComposeContextItem.ForwardingItem -> true
else -> false
}
val sendEnabled: () -> Boolean
get() = {
val hasContent = when (preview) {
is ComposePreview.MediaPreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty() || liveMessage != null
else -> message.isNotEmpty() || forwarding || liveMessage != null
}
hasContent && !inProgress
}
@@ -107,7 +115,7 @@ data class ComposeState(
val attachmentDisabled: Boolean
get() {
if (editing || liveMessage != null || inProgress) return true
if (editing || forwarding || liveMessage != null || inProgress) return true
return when (preview) {
ComposePreview.NoPreview -> false
is ComposePreview.CLinkPreview -> false
@@ -115,6 +123,15 @@ data class ComposeState(
}
}
val attachmentPreview: Boolean
get() = when (preview) {
ComposePreview.NoPreview -> false
is ComposePreview.CLinkPreview -> false
is ComposePreview.MediaPreview -> preview.content.isNotEmpty()
is ComposePreview.VoicePreview -> false
is ComposePreview.FilePreview -> true
}
val empty: Boolean
get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
@@ -243,10 +260,23 @@ fun ComposeView(
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit
) {
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
fun parseMessage(msg: String): Pair<String?, Boolean> {
if (msg.isBlank()) return null to false
val parsedMsg = parseToMarkdown(msg) ?: return null to false
val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink }
return link?.text to simplexLink
}
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
// default value parsed because of draft
val hasSimplexLink = rememberSaveable { mutableStateOf(parseMessage(composeState.value.message).second) }
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
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)
@@ -255,15 +285,6 @@ fun ComposeView(
AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } }
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
fun parseMessage(msg: String): String? {
val parsedMsg = parseToMarkdown(msg)
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
return link?.text
}
fun loadLinkPreview(url: String, wait: Long? = null) {
if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
@@ -283,7 +304,9 @@ fun ComposeView(
fun showLinkPreview(s: String) {
prevLinkUrl.value = linkUrl.value
linkUrl.value = parseMessage(s)
val parsed = parseMessage(s)
linkUrl.value = parsed.first
hasSimplexLink.value = parsed.second
val url = linkUrl.value
if (url != null) {
if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) {
@@ -338,6 +361,7 @@ fun ComposeView(
is SharedContent.Media -> shared.uris.map { it.toString() }
is SharedContent.File -> listOf(shared.uri.toString())
is SharedContent.Text -> emptyList()
is SharedContent.Forward -> emptyList()
}
// When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing
chatModel.filesToDelete.removeAll { file ->
@@ -384,10 +408,25 @@ fun ComposeView(
composeState.value = composeState.value.copy(inProgress = true)
}
suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo): ChatItem? {
val chatItem = controller.apiForwardChatItem(
rh = rhId,
toChatType = chat.chatInfo.chatType,
toChatId = chat.chatInfo.apiId,
fromChatType = fromChatInfo.chatType,
fromChatId = fromChatInfo.apiId,
itemId = forwardedItem.id
)
if (chatItem != null) {
chatModel.addChatItem(rhId, chat.chatInfo, chatItem)
}
return chatItem
}
fun checkLinkPreview(): MsgContent {
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(msgText)
val url = parseMessage(msgText).first
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(msgText, preview = lp)
@@ -443,11 +482,18 @@ fun ComposeView(
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
sending()
}
clearCurrentDraft()
if (!cs.forwarding || chatModel.draft.value?.forwarding == true) {
clearCurrentDraft()
}
if (chat.nextSendGrpInv) {
sendMemberContactInvitation()
sent = null
} else if (cs.contextItem is ComposeContextItem.ForwardingItem) {
sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo)
if (cs.message.isNotEmpty()) {
sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = null)
}
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem
sent = updateMessage(ei, chat, live)
@@ -546,7 +592,15 @@ fun ComposeView(
sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl)
}
}
val wasForwarding = cs.forwarding
val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItem)?.fromChatInfo?.id
clearState(live)
val draft = chatModel.draft.value
if (wasForwarding && chatModel.draftChatId.value == chat.chatInfo.id && forwardingFromChatId != chat.chatInfo.id && draft != null) {
composeState.value = draft
} else {
clearCurrentDraft()
}
return sent
}
@@ -563,8 +617,16 @@ fun ComposeView(
} else {
textStyle.value = smallFont
if (composeState.value.linkPreviewAllowed) {
if (s.isNotEmpty()) showLinkPreview(s)
else resetLinkPreview()
if (s.isNotEmpty()) {
showLinkPreview(s)
} else {
resetLinkPreview()
hasSimplexLink.value = false
}
} else if (s.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) {
hasSimplexLink.value = parseMessage(s).second
} else {
hasSimplexLink.value = false
}
}
}
@@ -700,6 +762,16 @@ fun ComposeView(
}
}
@Composable
fun MsgNotAllowedView(reason: String, icon: Painter) {
val color = CurrentColors.collectAsState().value.appColors.receivedMessage
Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = MaterialTheme.colors.secondary)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
Text(reason, fontStyle = FontStyle.Italic)
}
}
@Composable
fun contextItemView() {
when (val contextItem = composeState.value.contextItem) {
@@ -710,6 +782,9 @@ fun ComposeView(
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) {
clearState()
}
is ComposeContextItem.ForwardingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_forward), showSender = false) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
}
}
}
@@ -729,6 +804,10 @@ fun ComposeView(
is SharedContent.Text -> onMessageChange(shared.text)
is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text)
is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text)
is SharedContent.Forward -> composeState.value = composeState.value.copy(
contextItem = ComposeContextItem.ForwardingItem(shared.chatItem, shared.fromChatInfo),
preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview
)
null -> {}
}
chatModel.sharedContent.value = null
@@ -743,7 +822,17 @@ fun ComposeView(
if (nextSendGrpInv.value) {
ComposeContextInvitingContactMemberView()
}
val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)
val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files)
val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) {
if (simplexLinkProhibited) {
MsgNotAllowedView(generalGetString(MR.strings.simplex_links_not_allowed), icon = painterResource(MR.images.ic_link))
} else if (fileProhibited) {
MsgNotAllowedView(generalGetString(MR.strings.files_and_media_not_allowed), icon = painterResource(MR.images.ic_draft))
} else if (voiceProhibited) {
MsgNotAllowedView(generalGetString(MR.strings.voice_messages_not_allowed), icon = painterResource(MR.images.ic_mic))
}
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
@@ -764,7 +853,7 @@ fun ComposeView(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
val attachmentClicked = if (isGroupAndProhibitedFiles) {
{
AlertManager.shared.showAlertMsg(
@@ -858,6 +947,17 @@ fun ComposeView(
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
onDispose {
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
}
}
}
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
@@ -871,6 +971,7 @@ fun ComposeView(
chat.chatInfo is ChatInfo.Direct,
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
sendMsgEnabled = sendMsgEnabled.value,
sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited),
nextSendGrpInv = nextSendGrpInv.value,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
@@ -4,26 +4,29 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
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.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.*
import androidx.compose.ui.text.*
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.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import kotlinx.datetime.Clock
@Composable
fun ContextItemView(
contextItem: ChatItem,
contextIcon: Painter,
showSender: Boolean = true,
cancelContextItem: () -> Unit
) {
val sent = contextItem.chatDir.sent
@@ -31,16 +34,47 @@ fun ContextItemView(
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@Composable
fun msgContentView(lines: Int) {
fun MessageText(attachment: ImageResource?, lines: Int) {
val inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = if (attachment != null) {
remember(contextItem.id) {
val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = {
appendInlineContent(id = "attachmentIcon")
append(" ")
}
val inlineContent = mapOf(
"attachmentIcon" to InlineTextContent(
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(attachment), null, tint = MaterialTheme.colors.secondary)
}
)
inlineContentBuilder to inlineContent
}
} else null
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = null,
toggleSecrets = false,
maxLines = lines,
inlineContent = inlineContent,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
}
fun attachment(): ImageResource? =
when (contextItem.content.msgContent) {
is MsgContent.MCFile -> MR.images.ic_draft_filled
is MsgContent.MCImage -> MR.images.ic_image
is MsgContent.MCVoice -> MR.images.ic_play_arrow_filled
else -> null
}
@Composable
fun ContextMsgPreview(lines: Int) {
MessageText(remember(contextItem.id) { attachment() }, lines)
}
Row(
Modifier
.padding(top = 8.dp)
@@ -64,7 +98,7 @@ fun ContextItemView(
tint = MaterialTheme.colors.secondary,
)
val sender = contextItem.memberDisplayName
if (sender != null) {
if (showSender && sender != null) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -73,10 +107,10 @@ fun ContextItemView(
sender,
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
msgContentView(lines = 2)
ContextMsgPreview(lines = 2)
}
} else {
msgContentView(lines = 3)
ContextMsgPreview(lines = 3)
}
}
IconButton(onClick = cancelContextItem) {
@@ -39,6 +39,7 @@ fun SendMsgView(
isDirectChat: Boolean,
liveMessageAlertShown: SharedPreference<Boolean>,
sendMsgEnabled: Boolean,
sendButtonEnabled: Boolean,
nextSendGrpInv: Boolean,
needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean,
@@ -71,11 +72,12 @@ fun SendMsgView(
}
}
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() ||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
cs.endLiveDisabled
cs.endLiveDisabled ||
!sendButtonEnabled
PlatformTextField(composeState, sendMsgEnabled, sendMsgButtonDisabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) {
if (!cs.inProgress) {
sendMessage(null)
@@ -155,7 +157,7 @@ fun SendMsgView(
fun MenuItems(): List<@Composable () -> Unit> {
val menuItems = mutableListOf<@Composable () -> Unit>()
if (cs.liveMessage == null && !cs.editing && !nextSendGrpInv || sendMsgEnabled) {
if (cs.liveMessage == null && !cs.editing && !cs.forwarding && !nextSendGrpInv || sendMsgEnabled) {
if (
cs.preview !is ComposePreview.VoicePreview &&
cs.contextItem is ComposeContextItem.NoContextItem &&
@@ -430,7 +432,7 @@ private fun SendMsgButton(
.padding(4.dp)
.alpha(alpha.value)
.clip(CircleShape)
.background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary)
.background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary.copy(alpha = 0.75f))
.padding(3.dp)
)
}
@@ -552,6 +554,7 @@ fun PreviewSendMsgView() {
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
@@ -586,6 +589,7 @@ fun PreviewSendMsgViewEditing() {
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
@@ -620,6 +624,7 @@ fun PreviewSendMsgViewInProgress() {
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
@@ -309,7 +309,7 @@ fun GroupMemberInfoLayout(
SectionView {
if (contactId != null && knownDirectChat(contactId) != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) {
@@ -335,7 +335,7 @@ fun GroupMemberInfoLayout(
val clipboard = LocalClipboardManager.current
ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) }
if (contactId != null) {
if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) {
if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) })
}
} else {
@@ -6,7 +6,6 @@ import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -20,13 +19,20 @@ import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
private val featureRoles: List<Pair<GroupMemberRole?, String>> = listOf(
null to generalGetString(MR.strings.feature_roles_all_members),
GroupMemberRole.Admin to generalGetString(MR.strings.feature_roles_admins),
GroupMemberRole.Owner to generalGetString(MR.strings.feature_roles_owners)
)
@Composable
fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit,) {
fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit) {
val groupInfo = remember { derivedStateOf {
val ch = m.getChat(chatId)
val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo
if (g == null || ch?.remoteHostId != rhId) null else g
if (g == null || ch.remoteHostId != rhId) null else g
}}
val gInfo = groupInfo.value ?: return
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
@@ -81,7 +87,7 @@ private fun GroupPreferencesLayout(
val onTTLUpdated = { ttl: Int? ->
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl)))
}
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
FeatureSection(GroupFeature.TimedMessages, timedMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ ->
if (enable == GroupFeatureEnabled.ON) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
} else {
@@ -90,33 +96,45 @@ private fun GroupPreferencesLayout(
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
val directMessagesRole = remember(preferences) { mutableStateOf(preferences.directMessages.role) }
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, directMessagesRole, groupInfo, preferences, onTTLUpdated) { enable, role ->
applyPrefs(preferences.copy(directMessages = RoleGroupPreference(enable = enable, role)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, null, groupInfo, preferences, onTTLUpdated) { enable, _ ->
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = enable)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) }
FeatureSection(GroupFeature.Reactions, allowReactions, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(reactions = GroupPreference(enable = it)))
FeatureSection(GroupFeature.Reactions, allowReactions, null, groupInfo, preferences, onTTLUpdated) { enable, _ ->
applyPrefs(preferences.copy(reactions = GroupPreference(enable = enable)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
val voiceRole = remember(preferences) { mutableStateOf(preferences.voice.role) }
FeatureSection(GroupFeature.Voice, allowVoice, voiceRole, groupInfo, preferences, onTTLUpdated) { enable, role ->
applyPrefs(preferences.copy(voice = RoleGroupPreference(enable = enable, role)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) }
FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(files = GroupPreference(enable = it)))
val filesRole = remember(preferences) { mutableStateOf(preferences.files.role) }
FeatureSection(GroupFeature.Files, allowFiles, filesRole, groupInfo, preferences, onTTLUpdated) { enable, role ->
applyPrefs(preferences.copy(files = RoleGroupPreference(enable = enable, role)))
}
// TODO enable simplexLinks preference in 5.8
// SectionDividerSpaced(true, maxBottomPadding = false)
// val allowSimplexLinks = remember(preferences) { mutableStateOf(preferences.simplexLinks.enable) }
// val simplexLinksRole = remember(preferences) { mutableStateOf(preferences.simplexLinks.role) }
// FeatureSection(GroupFeature.SimplexLinks, allowSimplexLinks, simplexLinksRole, groupInfo, preferences, onTTLUpdated) { enable, role ->
// applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role)))
// }
SectionDividerSpaced(true, maxBottomPadding = false)
val enableHistory = remember(preferences) { mutableStateOf(preferences.history.enable) }
FeatureSection(GroupFeature.History, enableHistory, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(history = GroupPreference(enable = it)))
FeatureSection(GroupFeature.History, enableHistory, null, groupInfo, preferences, onTTLUpdated) { enable, _ ->
applyPrefs(preferences.copy(history = GroupPreference(enable = enable)))
}
if (groupInfo.canEdit) {
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
@@ -134,10 +152,11 @@ private fun GroupPreferencesLayout(
private fun FeatureSection(
feature: GroupFeature,
enableFeature: State<GroupFeatureEnabled>,
enableForRole: State<GroupMemberRole?>? = null,
groupInfo: GroupInfo,
preferences: FullGroupPreferences,
onTTLUpdated: (Int?) -> Unit,
onSelected: (GroupFeatureEnabled) -> Unit
onSelected: (GroupFeatureEnabled, GroupMemberRole?) -> Unit
) {
SectionView {
val on = enableFeature.value == GroupFeatureEnabled.ON
@@ -151,7 +170,7 @@ private fun FeatureSection(
iconTint,
enableFeature.value == GroupFeatureEnabled.ON,
) { checked ->
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value)
}
if (timedOn) {
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
@@ -165,6 +184,18 @@ private fun FeatureSection(
onSelected = onTTLUpdated
)
}
if (enableFeature.value == GroupFeatureEnabled.ON && enableForRole != null) {
ExposedDropDownSettingRow(
generalGetString(MR.strings.feature_enabled_for),
featureRoles,
enableForRole,
// remove in v5.8
enabled = remember { mutableStateOf(false) },
onSelected = { value ->
onSelected(enableFeature.value, value)
}
)
}
} else {
InfoRow(
feature.text,
@@ -175,6 +206,14 @@ private fun FeatureSection(
if (timedOn) {
InfoRow(generalGetString(MR.strings.delete_after), timeText(preferences.timedMessages.ttl))
}
if (enableFeature.value == GroupFeatureEnabled.ON && enableForRole != null) {
InfoRow(generalGetString(MR.strings.feature_enabled_for), featureRoles.firstOrNull { it.first == enableForRole.value }?.second ?: generalGetString(MR.strings.feature_roles_all_members), textColor = MaterialTheme.colors.secondary)
}
}
}
KeyChangeEffect(enableFeature.value) {
if (enableFeature.value == GroupFeatureEnabled.OFF) {
onSelected(enableFeature.value, null)
}
}
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
@@ -16,6 +16,7 @@ import chat.simplex.common.platform.onRightClick
@Composable
fun CIChatFeatureView(
chatInfo: ChatInfo,
chatItem: ChatItem,
feature: Feature,
iconColor: Color,
@@ -23,7 +24,7 @@ fun CIChatFeatureView(
revealed: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
) {
val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList()
val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList()
Box(
Modifier
.combinedClickable(
@@ -70,7 +71,7 @@ private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): Feat
)
@Composable
private fun mergedFeatures(chatItem: ChatItem): List<FeatureInfo>? {
private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List<FeatureInfo>? {
val m = ChatModel
val fs: ArrayList<FeatureInfo> = arrayListOf()
val icons: MutableSet<PainterBox> = mutableSetOf()
@@ -78,7 +79,7 @@ private fun mergedFeatures(chatItem: ChatItem): List<FeatureInfo>? {
if (i != null) {
val reversedChatItems = m.chatItems.asReversed()
while (i < reversedChatItems.size) {
val f = featureInfo(reversedChatItems[i]) ?: break
val f = featureInfo(reversedChatItems[i], chatInfo) ?: break
if (!icons.contains(f.icon)) {
fs.add(0, f)
icons.add(f.icon)
@@ -90,12 +91,12 @@ private fun mergedFeatures(chatItem: ChatItem): List<FeatureInfo>? {
}
@Composable
private fun featureInfo(ci: ChatItem): FeatureInfo? =
private fun featureInfo(ci: ChatItem, chatInfo: ChatInfo): FeatureInfo? =
when (ci.content) {
is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name)
is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name)
is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name)
is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name)
is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, ci.content.param, ci.content.groupFeature.name)
is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, ci.content.param, ci.content.groupFeature.name)
else -> null
}
@@ -59,18 +59,11 @@ fun CIFileView(
}
}
fun fileSizeValid(): Boolean {
if (file != null) {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
fun fileAction() {
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
if (fileSizeValid(file)) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
@@ -165,7 +158,7 @@ fun CIFileView(
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.RcvInvitation ->
if (fileSizeValid())
if (fileSizeValid(file))
fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary)
else
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
@@ -216,6 +209,8 @@ fun CIFileView(
}
}
fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol)
@Composable
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false, ciFile) { to: URI? ->
@@ -19,6 +19,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
@@ -40,6 +41,7 @@ fun chatEventText(eventText: String, ts: String): AnnotatedString =
@Composable
fun ChatItemView(
rhId: Long?,
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
@@ -195,12 +197,16 @@ fun ChatItemView(
}
val clipboard = LocalClipboardManager.current
val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests }
val copyAndShareAllowed = when {
cItem.content.text.isNotEmpty() -> true
fun fileForwardingAllowed() = when {
cItem.file != null && chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded -> true
getLoadedFilePath(cItem.file) != null -> true
else -> false
}
val copyAndShareAllowed = when {
cItem.content.text.isNotEmpty() -> true
fileForwardingAllowed() -> true
else -> false
}
if (copyAndShareAllowed) {
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
@@ -227,8 +233,19 @@ fun ChatItemView(
showMenu.value = false
})
}
if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false && cItem.file?.loaded == true))) {
if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) {
ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = {
withBGApi {
Log.d(TAG, "ChatItemView downloadFileAction")
val user = chatModel.currentUser.value
if (user != null) {
controller.receiveFile(rhId, user, cItem.file.fileId)
}
}
showMenu.value = false
})
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = {
@@ -236,6 +253,16 @@ fun ChatItemView(
showMenu.value = false
})
}
if (cItem.meta.itemDeleted == null &&
(cItem.file == null || fileForwardingAllowed()) &&
!cItem.isLiveDummy && !live
) {
ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = {
chatModel.chatId.value = null
chatModel.sharedContent.value = SharedContent.Forward(cItem, cInfo)
showMenu.value = false
})
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
if (revealed.value) {
HideItemAction(revealed, showMenu)
@@ -304,7 +331,7 @@ fun ChatItemView(
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem, cInfo.timedMessagesTTL)
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
@@ -442,11 +469,11 @@ fun ChatItemView(
MsgContentItemDropdownMenu()
}
is CIContent.RcvChatFeature -> {
CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndChatFeature -> {
CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvChatPreference -> {
@@ -454,23 +481,23 @@ fun ChatItemView(
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> {
CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu)
CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupFeature -> {
CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu)
CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupFeature -> {
CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu)
CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvChatFeatureRejected -> {
CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu)
CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupFeatureRejected -> {
CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu)
CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndModerated -> DeletedItem()
@@ -724,7 +751,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
if (chatItem.meta.editable && !chatItem.localNote) {
if (chatItem.meta.deletable && !chatItem.localNote) {
Spacer(Modifier.padding(horizontal = 4.dp))
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
@@ -782,6 +809,7 @@ expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager)
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
rhId = null,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
@@ -818,6 +846,7 @@ fun PreviewChatItemView() {
fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
rhId = null,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
@@ -87,15 +87,16 @@ fun FramedItemView(
}
@Composable
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null) {
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Row(
Modifier
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
.fillMaxWidth()
.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
@@ -184,7 +185,7 @@ fun FramedItemView(
}
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) &&
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null && ci.meta.itemForwarded == null
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@@ -219,7 +220,11 @@ fun FramedItemView(
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(MR.strings.live), false)
}
ci.quotedItem?.let { ciQuoteView(it) }
if (ci.quotedItem != null) {
ciQuoteView(ci.quotedItem)
} else if (ci.meta.itemForwarded != null) {
FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
}
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
Column(
@@ -68,7 +68,7 @@ fun MarkdownText (
senderBold: Boolean = false,
modifier: Modifier = Modifier,
linkMode: SimplexLinkMode,
inlineContent: Map<String, InlineTextContent>? = null,
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
onLinkLongClick: (link: String) -> Unit = {}
) {
val textLayoutDirection = remember (text) {
@@ -119,6 +119,7 @@ fun MarkdownText (
}
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
if (text is String) append(text)
else if (text is AnnotatedString) append(text)
@@ -127,10 +128,11 @@ fun MarkdownText (
}
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf())
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
} else {
var hasAnnotations = false
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
for ((i, ft) in formattedText.withIndex()) {
if (ft.format == null) append(ft.text)
@@ -210,7 +212,7 @@ fun MarkdownText (
}
)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
}
}
}
@@ -90,7 +90,7 @@ fun ChatPreviewView(
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
fun messageDraft(draft: ComposeState): Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>> {
fun attachment(): Pair<ImageResource, String?>? =
when (draft.preview) {
is ComposePreview.FilePreview -> MR.images.ic_draft_filled to draft.preview.fileName
@@ -100,7 +100,7 @@ fun ChatPreviewView(
}
val attachment = attachment()
val text = buildAnnotatedString {
val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = {
appendInlineContent(id = "editIcon")
append(" ")
if (attachment != null) {
@@ -110,7 +110,6 @@ fun ChatPreviewView(
}
append(" ")
}
append(draft.message)
}
val inlineContent: Map<String, InlineTextContent> = mapOf(
"editIcon" to InlineTextContent(
@@ -124,7 +123,7 @@ fun ChatPreviewView(
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(MR.images.ic_edit_note), null, tint = MaterialTheme.colors.secondary)
}
)
return text to inlineContent
return inlineContentBuilder to inlineContent
}
@Composable
@@ -169,7 +168,7 @@ fun ChatPreviewView(
if (ci != null) {
if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) {
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft) }
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci.meta) to null
}
@@ -13,9 +13,8 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.Chat
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@@ -74,7 +73,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
val navButton: @Composable RowScope.() -> Unit = {
when {
showSearch -> NavigationButtonBack(hideSearchOnBack)
users.size > 1 || chatModel.remoteHosts.isNotEmpty() -> {
(users.size > 1 || chatModel.remoteHosts.isNotEmpty()) && remember { chatModel.sharedContent }.value !is SharedContent.Forward -> {
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
@@ -82,7 +81,14 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
userPickerState.value = AnimatedViewState.VISIBLE
}
}
else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null })
else -> NavigationButtonBack(onButtonClicked = {
val sharedContent = chatModel.sharedContent.value
// Drop shared content
chatModel.sharedContent.value = null
if (sharedContent is SharedContent.Forward) {
chatModel.chatId.value = sharedContent.fromChatInfo.id
}
})
}
}
if (chatModel.chats.size >= 8) {
@@ -118,7 +124,8 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
is SharedContent.Text -> stringResource(MR.strings.share_message)
is SharedContent.Media -> stringResource(MR.strings.share_image)
is SharedContent.File -> stringResource(MR.strings.share_file)
else -> stringResource(MR.strings.share_message)
is SharedContent.Forward -> stringResource(MR.strings.forward_message)
null -> stringResource(MR.strings.share_message)
},
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
@@ -135,12 +142,14 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
@Composable
private fun ShareList(chatModel: ChatModel, search: String) {
val filter: (Chat) -> Boolean = { chat: Chat ->
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
}
val chats by remember(search) {
derivedStateOf {
if (search.isEmpty()) chatModel.chats.toList().filter { it.chatInfo.ready } else chatModel.chats.toList().filter { it.chatInfo.ready }.filter(filter)
val sorted = chatModel.chats.toList().sortedByDescending { it.chatInfo is ChatInfo.Local }
if (search.isEmpty()) {
sorted.filter { it.chatInfo.ready }
} else {
sorted.filter { it.chatInfo.ready && it.chatInfo.chatViewName.lowercase().contains(search.lowercase()) }
}
}
}
LazyColumnWithScrollBar(
@@ -2,6 +2,8 @@
package chat.simplex.common.views.helpers
import androidx.compose.runtime.saveable.Saver
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.model.ChatItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
@@ -13,6 +15,7 @@ sealed class SharedContent {
data class Text(val text: String): SharedContent()
data class Media(val text: String, val uris: List<URI>): SharedContent()
data class File(val text: String, val uri: URI): SharedContent()
data class Forward(val chatItem: ChatItem, val fromChatInfo: ChatInfo): SharedContent()
}
enum class AnimatedViewState {
@@ -254,12 +254,12 @@ fun TextIconSpaced(extraPadding: Boolean = false) {
}
@Composable
fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null) {
fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground) {
SectionItemViewSpaceBetween {
Row {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: MaterialTheme.colors.secondary)
Text(title)
Text(title, color = textColor)
}
Text(value, color = MaterialTheme.colors.secondary)
}
@@ -15,7 +15,6 @@ import chat.simplex.res.MR
import com.charleskorn.kaml.decodeFromStream
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import java.io.*
import java.net.URI
@@ -38,9 +38,7 @@ import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
@Composable
fun ConnectMobileView() {
@@ -269,12 +267,20 @@ fun AddingMobileDevice(showTitle: Boolean, staleQrCode: MutableState<Boolean>, c
var cachedR by remember { mutableStateOf<CR.RemoteHostStarted?>(null) }
val customAddress = rememberSaveable { mutableStateOf<RemoteCtrlAddress?>(null) }
val customPort = rememberSaveable { mutableStateOf<Int?>(null) }
var userChangedAddress by rememberSaveable { mutableStateOf(false) }
var userChangedPort by rememberSaveable { mutableStateOf(false) }
val startRemoteHost = suspend {
if (customAddress.value != cachedR.address && cachedR != null) {
userChangedAddress = true
}
if (customPort.value != cachedR.port && cachedR != null) {
userChangedPort = true
}
val r = chatModel.controller.startRemoteHost(
rhId = null,
multicast = controller.appPrefs.offerRemoteMulticast.get(),
address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_,
port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_
address = if (customAddress.value != null && userChangedAddress) customAddress.value else cachedR.rh?.bindAddress_,
port = if (customPort.value != null && userChangedPort) customPort.value else cachedR.rh?.bindPort_
)
if (r != null) {
cachedR = r
@@ -343,12 +349,20 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState
var cachedR by remember { mutableStateOf<CR.RemoteHostStarted?>(null) }
val customAddress = rememberSaveable { mutableStateOf<RemoteCtrlAddress?>(null) }
val customPort = rememberSaveable { mutableStateOf<Int?>(null) }
var userChangedAddress by rememberSaveable { mutableStateOf(false) }
var userChangedPort by rememberSaveable { mutableStateOf(false) }
val startRemoteHost = suspend {
if (customAddress.value != cachedR.address && cachedR != null) {
userChangedAddress = true
}
if (customPort.value != cachedR.port && cachedR != null) {
userChangedPort = true
}
val r = chatModel.controller.startRemoteHost(
rhId = rh.remoteHostId,
multicast = controller.appPrefs.offerRemoteMulticast.get(),
address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_ ?: rh.bindAddress_,
port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_ ?: rh.bindPort_
address = if (customAddress.value != null && userChangedAddress) customAddress.value else cachedR.rh?.bindAddress_ ?: rh.bindAddress_,
port = if (customPort.value != null && userChangedPort) customPort.value else cachedR.rh?.bindPort_ ?: rh.bindPort_
)
if (r != null) {
cachedR = r
@@ -167,7 +167,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
// can't be higher than 130ms to avoid overflow on 32bit systems
TimeoutSettingRow(
stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb,
listOf(15_000, 30_000, 45_000, 60_000, 90_000, 120_000), secondsLabel
listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel
)
}
SectionItemView {
@@ -2,6 +2,7 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionItemWithValue
import SectionView
@@ -21,12 +22,12 @@ 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.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatModel
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ClickableText
import chat.simplex.common.views.helpers.*
@@ -190,6 +191,16 @@ fun NetworkAndServersView() {
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
}
if (appPlatform.isAndroid) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.settings_section_title_network_connection).uppercase()) {
val info = remember { chatModel.networkInfo }.value
SettingsActionItemWithContent(icon = null, info.networkType.text) {
Icon(painterResource(MR.images.ic_circle_filled), stringResource(MR.strings.icon_descr_server_status_connected), tint = if (info.online) Color.Green else MaterialTheme.colors.error)
}
}
}
SectionBottomSpacer()
}
}
@@ -46,6 +46,9 @@
<string name="invalid_message_format">invalid message format</string>
<string name="live">LIVE</string>
<string name="moderated_description">moderated</string>
<string name="forwarded_description">forwarded</string>
<string name="saved_description">saved</string>
<string name="saved_from_description">saved from %s</string>
<string name="invalid_chat">invalid chat</string>
<string name="invalid_data">invalid data</string>
<string name="error_showing_message">error showing message</string>
@@ -267,6 +270,11 @@
<string name="edit_history">History</string>
<string name="no_history">No history</string>
<string name="in_reply_to">In reply to</string>
<string name="saved_chat_item_info_tab">Saved</string>
<string name="forwarded_chat_item_info_tab">Forwarded</string>
<string name="saved_from_chat_item_info_title">Saved from</string>
<string name="forwarded_from_chat_item_info_title">Forwarded from</string>
<string name="recipients_can_not_see_who_message_from">Recipient(s) can\'t see who this message is from.</string>
<string name="delivery">Delivery</string>
<string name="no_info_on_delivery">No delivery information</string>
<string name="delete_verb">Delete</string>
@@ -294,6 +302,8 @@
<string name="revoke_file__title">Revoke file?</string>
<string name="revoke_file__message">File will be deleted from servers.</string>
<string name="revoke_file__confirm">Revoke</string>
<string name="forward_chat_item">Forward</string>
<string name="download_file">Download</string>
<!-- CIMetaView.kt -->
<string name="icon_descr_edited">edited</string>
@@ -328,6 +338,7 @@
<string name="share_message">Share message…</string>
<string name="share_image">Share media…</string>
<string name="share_file">Share file…</string>
<string name="forward_message">Forward message…</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Attach</string>
@@ -347,6 +358,9 @@
<string name="files_and_media_prohibited">Files and media prohibited!</string>
<string name="only_owners_can_enable_files_and_media">Only group owners can enable files and media.</string>
<string name="compose_send_direct_message_to_connect">Send direct message to connect</string>
<string name="simplex_links_not_allowed">SimpleX links not allowed</string>
<string name="files_and_media_not_allowed">Files and media not allowed</string>
<string name="voice_messages_not_allowed">Voice messages not allowed</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Image</string>
@@ -1017,6 +1031,7 @@
<string name="settings_section_title_themes">THEMES</string>
<string name="settings_section_title_messages">MESSAGES AND FILES</string>
<string name="settings_section_title_calls">CALLS</string>
<string name="settings_section_title_network_connection">Network connection</string>
<string name="settings_section_title_incognito">Incognito mode</string>
<string name="settings_section_title_experimenta">EXPERIMENTAL</string>
<string name="settings_section_title_use_from_desktop">Use from desktop</string>
@@ -1530,6 +1545,7 @@
<string name="message_reactions">Message reactions</string>
<string name="voice_messages">Voice messages</string>
<string name="files_and_media">Files and media</string>
<string name="simplex_links">SimpleX links</string>
<string name="recent_history">Visible history</string>
<string name="audio_video_calls">Audio/video calls</string>
<string name="available_in_v51">\nAvailable in v5.1</string>
@@ -1587,6 +1603,8 @@
<string name="prohibit_message_reactions_group">Prohibit messages reactions.</string>
<string name="allow_to_send_files">Allow to send files and media.</string>
<string name="prohibit_sending_files">Prohibit sending files and media.</string>
<string name="allow_to_send_simplex_links">Allow to send SimpleX links.</string>
<string name="prohibit_sending_simplex_links">Prohibit sending SimpleX links</string>
<string name="enable_sending_recent_history">Send up to 100 last messages to new members.</string>
<string name="disable_sending_recent_history">Do not send history to new members.</string>
<string name="group_members_can_send_disappearing">Group members can send disappearing messages.</string>
@@ -1601,6 +1619,8 @@
<string name="message_reactions_are_prohibited">Message reactions are prohibited in this group.</string>
<string name="group_members_can_send_files">Group members can send files and media.</string>
<string name="files_are_prohibited_in_group">Files and media are prohibited in this group.</string>
<string name="group_members_can_send_simplex_links">Group members can send SimpleX links.</string>
<string name="simplex_links_are_prohibited_in_group">SimpleX links are prohibited in this group.</string>
<string name="recent_history_is_sent_to_new_members">Up to 100 last messages are sent to new members.</string>
<string name="recent_history_is_not_sent_to_new_members">History is not sent to new members.</string>
<string name="delete_after">Delete after</string>
@@ -1623,6 +1643,10 @@
<string name="feature_offered_item">offered %s</string>
<string name="feature_offered_item_with_param">offered %s: %2s</string>
<string name="feature_cancelled_item">cancelled %s</string>
<string name="feature_roles_all_members">all members</string>
<string name="feature_roles_admins">admins</string>
<string name="feature_roles_owners">owners</string>
<string name="feature_enabled_for">Enabled for</string>
<!-- WhatsNewView.kt -->
<string name="whats_new">What\'s new</string>
@@ -1925,4 +1949,11 @@
<string name="migrate_from_device_check_connection_and_try_again">Check your internet connection and try again</string>
<string name="migrate_from_device_archive_will_be_deleted"><![CDATA[<b>Warning</b>: the archive will be deleted.]]></string>
<string name="migrate_from_device_error_verifying_passphrase">Error verifying passphrase:</string>
<!-- NetworkObserver.kt -->
<string name="network_type_no_network_connection">No network connection</string>
<string name="network_type_cellular">Cellular</string>
<string name="network_type_network_wifi">WiFi</string>
<string name="network_type_ethernet">Wired ethernet</string>
<string name="network_type_other">Other</string>
</resources>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path transform="scale(-1,1) translate(-960,0)" d="m236.5-495.5 142.5 143q8.5 8.5 8.25 20.25T378.5-312q-9 8.5-21 8.5t-20.5-9L145.5-504q-9-8.5-9-20t9-20.5L338-737q9-9 20.75-8.75T379.5-737q8.5 8.5 8.5 20.5t-8.5 20.5l-143 143h403q84 0 139.75 56T835-357.5V-234q0 12.5-8.25 20.75T806.5-205q-12.5 0-20.75-8.25T777.5-234v-123.5q0-60-39-99t-99-39h-403Z"/></svg>

After

Width:  |  Height:  |  Size: 441 B

@@ -36,9 +36,10 @@ var localizedState = "";
var localizedDescription = "";
const processCommand = (function () {
const defaultIceServers = [
{ urls: ["stuns:stun.simplex.im:443"] },
{ urls: ["stun:stun.simplex.im:443"] },
{ urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
{ urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
//{urls: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"},
{ urls: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj" },
];
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
return {
@@ -110,7 +111,17 @@ const processCommand = (function () {
});
}
async function initializeCall(config, mediaType, aesKey) {
const pc = new RTCPeerConnection(config.peerConnectionConfig);
var _a;
let pc;
try {
pc = new RTCPeerConnection(config.peerConnectionConfig);
}
catch (e) {
console.log("Error while constructing RTCPeerConnection, will try without 'stuns' specified: " + e);
const withoutStuns = (_a = config.peerConnectionConfig.iceServers) === null || _a === void 0 ? void 0 : _a.filter((elem) => typeof elem.urls === "string" ? !elem.urls.startsWith("stuns:") : !elem.urls.some((url) => url.startsWith("stuns:")));
config.peerConnectionConfig.iceServers = withoutStuns;
pc = new RTCPeerConnection(config.peerConnectionConfig);
}
const remoteStream = new MediaStream();
const localCamera = VideoCamera.User;
const localStream = await getLocalMediaStream(mediaType, localCamera);
@@ -375,6 +386,9 @@ const processCommand = (function () {
// setupVideoElement(videos.remote)
videos.local.srcObject = call.localStream;
videos.remote.srcObject = call.remoteStream;
// Without doing it manually Firefox shows black screen but video can be played in Picture-in-Picture
videos.local.play();
videos.remote.play();
}
async function setupEncryptionWorker(call) {
if (call.aesKey) {
@@ -447,7 +461,9 @@ const processCommand = (function () {
codecs.splice(selectedCodecIndex, 1);
codecs.unshift(selectedCodec);
for (const t of call.connection.getTransceivers()) {
if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") {
// Firefox doesn't have this function implemented:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1396922
if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video" && t.setCodecPreferences) {
t.setCodecPreferences(codecs);
}
}
@@ -470,8 +486,22 @@ const processCommand = (function () {
}
return;
}
for (const t of call.localStream.getTracks())
t.stop();
if (!call.screenShareEnabled) {
for (const t of call.localStream.getTracks())
t.stop();
}
else {
// Don't stop audio track if switching to screenshare
for (const t of call.localStream.getVideoTracks())
t.stop();
// Replace new track from screenshare with old track from recording device
for (const t of localStream.getAudioTracks()) {
t.stop();
localStream.removeTrack(t);
}
for (const t of call.localStream.getAudioTracks())
localStream.addTrack(t);
}
call.localCamera = camera;
const audioTracks = localStream.getAudioTracks();
const videoTracks = localStream.getVideoTracks();
@@ -485,6 +515,7 @@ const processCommand = (function () {
replaceTracks(pc, videoTracks);
call.localStream = localStream;
videos.local.srcObject = localStream;
videos.local.play();
}
function replaceTracks(pc, tracks) {
if (!tracks.length)
@@ -530,7 +561,9 @@ const processCommand = (function () {
//},
//aspectRatio: 1.33,
},
audio: true,
audio: false,
// This works with Chrome, Edge, Opera, but not with Firefox and Safari
// systemAudio: "include"
};
return navigator.mediaDevices.getDisplayMedia(constraints);
}
@@ -10,7 +10,6 @@
<video
id="remote-video-stream"
class="inline"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
onclick="javascript:toggleRemoteVideoFitFill()"
@@ -19,7 +18,6 @@
id="local-video-stream"
class="inline"
muted
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
@@ -213,7 +213,7 @@ actual object SoundPlayer: SoundPlayerInterface {
override fun start(scope: CoroutineScope, sound: Boolean) {
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
tmpFile.deleteOnExit()
SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) }
SoundPlayer::class.java.getResource("/media/ring_once.mp3")!!.openStream()!!.use { it.copyTo(tmpFile.outputStream()) }
playing = true
scope.launch {
while (playing && sound) {
@@ -228,3 +228,37 @@ actual object SoundPlayer: SoundPlayerInterface {
AudioPlayer.stop()
}
}
actual object CallSoundsPlayer: CallSoundsPlayerInterface {
private var playingJob: Job? = null
private fun start(soundPath: String, delay: Long, scope: CoroutineScope) {
playingJob?.cancel()
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
tmpFile.deleteOnExit()
SoundPlayer::class.java.getResource(soundPath)!!.openStream()!!.use { it.copyTo(tmpFile.outputStream()) }
playingJob = scope.launch {
while (isActive) {
AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true)
delay(delay)
}
}
}
override fun startConnectingCallSound(scope: CoroutineScope) {
// Taken from https://github.com/TelegramOrg/Telegram-Android
// https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
start("/media/connecting_call.mp3", 3000, scope)
}
override fun startInCallSound(scope: CoroutineScope) {
start("/media/in_call.mp3", 5000, scope)
}
override fun vibrate(times: Int) {}
override fun stop() {
playingJob?.cancel()
AudioPlayer.stop()
}
}
@@ -1,30 +1,15 @@
package chat.simplex.common.views.call
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.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
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.chat.item.ItemAction
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import org.nanohttpd.protocols.http.IHTTPSession
import org.nanohttpd.protocols.http.response.Response
@@ -45,6 +30,7 @@ actual fun ActiveCallView() {
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
}
BackHandler(onBack = endCall)
val scope = rememberCoroutineScope()
WebRTCController(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCController: $apiMsg")
val call = chatModel.activeCall.value
@@ -56,6 +42,8 @@ actual fun ActiveCallView() {
val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
CallSoundsPlayer.startConnectingCallSound(scope)
activeCallWaitDeliveryReceipt(scope)
}
is WCallResponse.Offer -> withBGApi {
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
@@ -64,6 +52,7 @@ actual fun ActiveCallView() {
is WCallResponse.Answer -> withBGApi {
chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates)
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
CallSoundsPlayer.stop()
}
is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
@@ -121,6 +110,7 @@ actual fun ActiveCallView() {
// After the first call, End command gets added to the list which prevents making another calls
chatModel.callCommand.removeAll { it is WCallCommand.End }
onDispose {
CallSoundsPlayer.stop()
chatModel.activeCallViewIsVisible.value = false
chatModel.callCommand.clear()
}
+2 -5
View File
@@ -12,10 +12,7 @@ version = extra["desktop.version_name"] as String
kotlin {
jvm {
jvmToolchain(11)
withJava()
}
jvm()
sourceSets {
val jvmMain by getting {
dependencies {
@@ -151,7 +148,7 @@ cmake {
tasks.named("clean") {
dependsOn("cmakeClean")
}
tasks.named("compileJava") {
tasks.named("compileKotlinJvm") {
dependsOn("cmakeBuildAndCopy")
}
afterEvaluate {
+5 -4
View File
@@ -24,12 +24,13 @@ android.nonTransitiveRClass=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=5.6
android.version_code=191
android.version_name=5.7-beta.0
android.version_code=196
desktop.version_name=5.6
desktop.version_code=35
desktop.version_name=5.7-beta.0
desktop.version_code=37
kotlin.version=1.9.23
gradle.plugin.version=8.2.0