Merge branch 'master' into users
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 85
|
||||
versionName "4.4.1-beta.0"
|
||||
versionCode 86
|
||||
versionName "4.4.1-beta.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
|
||||
@@ -180,7 +180,11 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
chatItems.add(cItem)
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +259,18 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
|
||||
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
|
||||
chatItems.add(cItem)
|
||||
return cItem
|
||||
}
|
||||
|
||||
fun removeLiveDummy() {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
|
||||
val markedRead = markItemsReadInCurrentChat(cInfo, range)
|
||||
// update preview
|
||||
@@ -1278,7 +1294,8 @@ data class ChatItem (
|
||||
}
|
||||
|
||||
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
|
||||
|
||||
const val TEMP_LIVE_CHAT_ITEM_ID = -2L
|
||||
|
||||
val deletedItemDummy: ChatItem
|
||||
get() = ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
@@ -1300,6 +1317,26 @@ data class ChatItem (
|
||||
file = null
|
||||
)
|
||||
|
||||
fun liveDummy(direct: Boolean): ChatItem = ChatItem(
|
||||
chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(),
|
||||
meta = CIMeta(
|
||||
itemId = TEMP_LIVE_CHAT_ITEM_ID,
|
||||
itemTs = Clock.System.now(),
|
||||
itemText = "",
|
||||
itemStatus = CIStatus.RcvRead(),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
itemDeleted = false,
|
||||
itemEdited = false,
|
||||
itemTimed = null,
|
||||
itemLive = true,
|
||||
editable = false
|
||||
),
|
||||
content = CIContent.SndMsgContent(MsgContent.MCText("")),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
|
||||
fun invalidJSON(json: String): ChatItem =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectSnd(),
|
||||
|
||||
@@ -147,8 +147,8 @@ fun TerminalLayout(
|
||||
sendMessage = sendCommand,
|
||||
sendLiveMessage = null,
|
||||
updateLiveMessage = null,
|
||||
::onMessageChange,
|
||||
textStyle
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -568,7 +568,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
scope.launch {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ sealed class ComposeContextItem {
|
||||
data class LiveMessage(
|
||||
val chatItem: ChatItem,
|
||||
val typedMsg: String,
|
||||
val sentMsg: String
|
||||
val sentMsg: String,
|
||||
val sent: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -103,6 +104,9 @@ data class ComposeState(
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
val endLiveDisabled: Boolean
|
||||
get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
|
||||
|
||||
val linkPreviewAllowed: Boolean
|
||||
get() =
|
||||
when (preview) {
|
||||
@@ -352,6 +356,7 @@ fun ComposeView(
|
||||
chosenContent.value = emptyList()
|
||||
chosenAudio.value = null
|
||||
chosenFile.value = null
|
||||
chatModel.removeLiveDummy()
|
||||
}
|
||||
|
||||
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
|
||||
@@ -430,7 +435,7 @@ fun ComposeView(
|
||||
if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||
val ei = cs.contextItem.chatItem
|
||||
sent = updateMessage(ei, cInfo, live)
|
||||
} else if (liveMessage != null) {
|
||||
} else if (liveMessage != null && liveMessage.sent) {
|
||||
sent = updateMessage(liveMessage.chatItem, cInfo, live)
|
||||
} else {
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
@@ -569,13 +574,16 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
suspend fun sendLiveMessage() {
|
||||
val typedMsg = composeState.value.message
|
||||
val sentMsg = truncateToWords(typedMsg)
|
||||
if (composeState.value.liveMessage == null) {
|
||||
val ci = sendMessageAsync(sentMsg, live = true)
|
||||
val cs = composeState.value
|
||||
val typedMsg = cs.message
|
||||
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
|
||||
val ci = sendMessageAsync(typedMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
|
||||
}
|
||||
} else if (cs.liveMessage == null) {
|
||||
val cItem = chatModel.addLiveDummy(chat.chatInfo)
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +600,7 @@ fun ComposeView(
|
||||
if (sentMsg != null) {
|
||||
val ci = sendMessageAsync(sentMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
|
||||
}
|
||||
} else if (liveMessage.typedMsg != typedMsg) {
|
||||
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
|
||||
@@ -701,9 +709,13 @@ fun ComposeView(
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
if (orientation == activity.resources.configuration.orientation && composeState.value.liveMessage != null) {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
val cs = composeState.value
|
||||
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
}
|
||||
chatModel.removeLiveDummy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -723,6 +735,10 @@ fun ComposeView(
|
||||
},
|
||||
sendLiveMessage = ::sendLiveMessage,
|
||||
updateLiveMessage = ::updateLiveMessage,
|
||||
cancelLiveMessage = {
|
||||
composeState.value = composeState.value.copy(liveMessage = null)
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
@@ -63,14 +62,15 @@ fun SendMsgView(
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
sendMessage: () -> Unit,
|
||||
sendLiveMessage: ( suspend () -> Unit)? = null,
|
||||
sendLiveMessage: (suspend () -> Unit)? = null,
|
||||
updateLiveMessage: (suspend () -> Unit)? = null,
|
||||
cancelLiveMessage: (() -> Unit)? = null,
|
||||
onMessageChange: (String) -> Unit,
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 8.dp)) {
|
||||
val cs = composeState.value
|
||||
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
@@ -109,7 +109,10 @@ fun SendMsgView(
|
||||
else ->
|
||||
RecordVoiceView(recState, stopRecOnNextClick)
|
||||
}
|
||||
if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) {
|
||||
if (sendLiveMessage != null
|
||||
&& updateLiveMessage != null
|
||||
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||
&& cs.contextItem is ComposeContextItem.NoContextItem) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StartLiveMessageButton {
|
||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||
@@ -119,15 +122,24 @@ fun SendMsgView(
|
||||
}
|
||||
}
|
||||
}
|
||||
cs.liveMessage?.sent == false && cs.message.isEmpty() -> {
|
||||
CancelLiveMessageButton {
|
||||
cancelLiveMessage?.invoke()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val cs = composeState.value
|
||||
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (composeState.value.liveMessage == null &&
|
||||
val disabled = !cs.sendEnabled() ||
|
||||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||
cs.endLiveDisabled
|
||||
if (cs.liveMessage == null &&
|
||||
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
|
||||
cs.contextItem is ComposeContextItem.NoContextItem &&
|
||||
sendLiveMessage != null && updateLiveMessage != null
|
||||
) {
|
||||
var showDropdown by rememberSaveable { mutableStateOf(false) }
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true }
|
||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdown,
|
||||
@@ -144,7 +156,7 @@ fun SendMsgView(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage)
|
||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +178,6 @@ private fun NativeKeyboard(
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||
@@ -187,6 +198,7 @@ private fun NativeKeyboard(
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
@@ -339,7 +351,6 @@ private fun LockToCurrentOrientationUntilDispose() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun StopRecordButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
@@ -374,9 +385,24 @@ private fun ProgressIndicator() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendTextButton(
|
||||
private fun CancelLiveMessageButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
stringResource(R.string.icon_descr_cancel_live_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendMsgButton(
|
||||
icon: ImageVector,
|
||||
backgroundColor: Color,
|
||||
sizeDp: Animatable<Float, AnimationVector1D>,
|
||||
alpha: Animatable<Float, AnimationVector1D>,
|
||||
enabled: Boolean,
|
||||
@@ -405,7 +431,7 @@ private fun SendTextButton(
|
||||
.padding(4.dp)
|
||||
.alpha(alpha.value)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight)
|
||||
.padding(3.dp)
|
||||
)
|
||||
}
|
||||
@@ -552,7 +578,7 @@ fun PreviewSendMsgViewEditing() {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
showVoiceRecordIcon = false,
|
||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
|
||||
@@ -54,6 +54,7 @@ fun ChatItemView(
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
val live = composeState.value.liveMessage != null
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -97,7 +98,7 @@ fun ChatItemView(
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
if (!cItem.meta.itemDeleted) {
|
||||
if (!cItem.meta.itemDeleted && !live) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
@@ -133,7 +134,7 @@ fun ChatItemView(
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
|
||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
@@ -149,7 +150,9 @@ fun ChatItemView(
|
||||
}
|
||||
)
|
||||
}
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
if (!(live && cItem.meta.isLive)) {
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
<string name="live_message">Live message!</string>
|
||||
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
|
||||
<string name="send_verb">Send</string>
|
||||
<string name="icon_descr_cancel_live_message">Cancel live message</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Back</string>
|
||||
|
||||
@@ -219,7 +219,7 @@ final class ChatModel: ObservableObject {
|
||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
let ci = reversedChatItems[i]
|
||||
withAnimation(.default) {
|
||||
withAnimation {
|
||||
self.reversedChatItems[i] = cItem
|
||||
self.reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
@@ -230,9 +230,18 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { reversedChatItems.insert(cItem, at: 0) }
|
||||
withAnimation(itemAnimation()) {
|
||||
reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func itemAnimation() -> Animation? {
|
||||
switch cItem.chatDir {
|
||||
case .directSnd, .groupSnd: return cItem.meta.isLive ? nil : .default
|
||||
default: return .default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
@@ -274,6 +283,28 @@ final class ChatModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||
withAnimation {
|
||||
reversedChatItems.insert(cItem, at: 0)
|
||||
}
|
||||
return cItem
|
||||
}
|
||||
|
||||
func removeLiveDummy(animated: Bool = true) {
|
||||
if hasLiveDummy {
|
||||
if animated {
|
||||
withAnimation { _ = reversedChatItems.removeFirst() }
|
||||
} else {
|
||||
_ = reversedChatItems.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasLiveDummy: Bool {
|
||||
reversedChatItems.first?.isLiveDummy == true
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
// update preview
|
||||
_updateChat(cInfo.id) { chat in
|
||||
|
||||
@@ -49,8 +49,9 @@ func saveAnimImage(_ image: UIImage) -> String? {
|
||||
}
|
||||
|
||||
func saveImage(_ uiImage: UIImage) -> String? {
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) {
|
||||
let ext = imageHasAlpha(uiImage) ? "png" : "jpg"
|
||||
let hasAlpha = imageHasAlpha(uiImage)
|
||||
let ext = hasAlpha ? "png" : "jpg"
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
|
||||
let fileName = generateNewFileName("IMG", ext)
|
||||
return saveFile(imageDataResized, fileName)
|
||||
}
|
||||
@@ -67,19 +68,18 @@ func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
} else if size.height > side {
|
||||
origin.y -= (size.height - side) / 2
|
||||
}
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
|
||||
}
|
||||
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
|
||||
var img = image
|
||||
let usePng = imageHasAlpha(image)
|
||||
var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
var dataSize = data?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha)
|
||||
data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
dataSize = data?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToDataSize final \(dataSize)")
|
||||
@@ -88,45 +88,61 @@ func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
|
||||
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
let hasAlpha = imageHasAlpha(image)
|
||||
var str = compressImageStr(img, hasAlpha: hasAlpha)
|
||||
var dataSize = str?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
str = compressImageStr(img)
|
||||
img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha)
|
||||
str = compressImageStr(img, hasAlpha: hasAlpha)
|
||||
dataSize = str?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToStrSize final \(dataSize)")
|
||||
return str
|
||||
}
|
||||
|
||||
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
|
||||
let ext = imageHasAlpha(image) ? "png" : "jpg"
|
||||
if let data = imageHasAlpha(image) ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
|
||||
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
|
||||
let ext = hasAlpha ? "png" : "jpg"
|
||||
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
|
||||
return "data:image/\(ext);base64,\(data.base64EncodedString())"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
|
||||
private func reduceSize(_ image: UIImage, ratio: CGFloat, hasAlpha: Bool) -> UIImage {
|
||||
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
|
||||
let bounds = CGRect(origin: .zero, size: newSize)
|
||||
return resizeImage(image, newBounds: bounds, drawIn: bounds)
|
||||
return resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha)
|
||||
}
|
||||
|
||||
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
|
||||
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0
|
||||
format.opaque = !imageHasAlpha(image)
|
||||
format.opaque = !hasAlpha
|
||||
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
|
||||
image.draw(in: drawIn)
|
||||
}
|
||||
}
|
||||
|
||||
func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
let alpha = img.cgImage?.alphaInfo
|
||||
return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly
|
||||
if let cgImage = img.cgImage {
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
||||
if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) {
|
||||
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
||||
if let data = context.data {
|
||||
let data = data.assumingMemoryBound(to: UInt8.self)
|
||||
let size = cgImage.width * cgImage.height
|
||||
var i = 0
|
||||
while i < size {
|
||||
if data[i] < 255 { return true }
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func saveFileFromURL(_ url: URL) -> String? {
|
||||
|
||||
@@ -442,9 +442,13 @@ struct ChatView: View {
|
||||
|
||||
var body: some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
|
||||
let uiMenu: Binding<UIMenu> = Binding(
|
||||
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
|
||||
set: { _ in }
|
||||
)
|
||||
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
|
||||
.uiKitContextMenu(actions: menu())
|
||||
.uiKitContextMenu(menu: uiMenu)
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal)
|
||||
@@ -459,10 +463,10 @@ struct ChatView: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
}
|
||||
|
||||
private func menu() -> [UIAction] {
|
||||
private func menu(live: Bool) -> [UIAction] {
|
||||
var menu: [UIAction] = []
|
||||
if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed {
|
||||
if !ci.meta.itemDeleted {
|
||||
if !ci.meta.itemDeleted && !ci.isLiveDummy && !live {
|
||||
menu.append(replyUIAction())
|
||||
}
|
||||
menu.append(shareUIAction())
|
||||
@@ -478,13 +482,15 @@ struct ChatView: View {
|
||||
menu.append(saveFileAction(filePath))
|
||||
}
|
||||
}
|
||||
if ci.meta.editable && !mc.isVoice {
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
menu.append(editAction())
|
||||
}
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
}
|
||||
menu.append(deleteUIAction())
|
||||
if !live || !ci.meta.isLive {
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
} else if ci.meta.itemDeleted {
|
||||
menu.append(revealUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
|
||||
@@ -34,7 +34,7 @@ enum VoiceMessageRecordingState {
|
||||
struct LiveMessage {
|
||||
var chatItem: ChatItem
|
||||
var typedMsg: String
|
||||
var sentMsg: String
|
||||
var sentMsg: String?
|
||||
}
|
||||
|
||||
struct ComposeState {
|
||||
@@ -96,6 +96,13 @@ struct ComposeState {
|
||||
}
|
||||
}
|
||||
|
||||
var quoting: Bool {
|
||||
switch contextItem {
|
||||
case .quotedItem: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews: return true
|
||||
@@ -105,6 +112,10 @@ struct ComposeState {
|
||||
}
|
||||
}
|
||||
|
||||
var endLiveDisabled: Bool {
|
||||
liveMessage != nil && message.isEmpty && noPreview && !quoting
|
||||
}
|
||||
|
||||
var linkPreviewAllowed: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews: return false
|
||||
@@ -232,9 +243,9 @@ struct ComposeView: View {
|
||||
VStack(spacing: 0) {
|
||||
contextItemView()
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
case (true, .filePreview): EmptyView()
|
||||
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
||||
default: previewView()
|
||||
case (true, .filePreview): EmptyView()
|
||||
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
||||
default: previewView()
|
||||
}
|
||||
HStack (alignment: .bottom) {
|
||||
Button {
|
||||
@@ -255,6 +266,10 @@ struct ComposeView: View {
|
||||
},
|
||||
sendLiveMessage: sendLiveMessage,
|
||||
updateLiveMessage: updateLiveMessage,
|
||||
cancelLiveMessage: {
|
||||
composeState.liveMessage = nil
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||
startVoiceMessageRecording: {
|
||||
@@ -371,10 +386,11 @@ struct ComposeView: View {
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
cancelVoiceMessageRecording(fileName)
|
||||
}
|
||||
if composeState.liveMessage != nil {
|
||||
if composeState.liveMessage != nil && (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
}
|
||||
chatModel.removeLiveDummy(animated: false)
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||
if !startingRecording {
|
||||
@@ -395,11 +411,17 @@ struct ComposeView: View {
|
||||
|
||||
private func sendLiveMessage() async {
|
||||
let typedMsg = composeState.message
|
||||
let sentMsg = truncateToWords(typedMsg)
|
||||
if composeState.liveMessage == nil,
|
||||
let ci = await sendMessageAsync(sentMsg, live: true) {
|
||||
let lm = composeState.liveMessage
|
||||
if (composeState.sendEnabled || composeState.quoting)
|
||||
&& (lm == nil || lm?.sentMsg == nil),
|
||||
let ci = await sendMessageAsync(typedMsg, live: true) {
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg))
|
||||
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg))
|
||||
}
|
||||
} else if lm == nil {
|
||||
let cItem = chatModel.addLiveDummy(chat.chatInfo)
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: cItem, typedMsg: typedMsg, sentMsg: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +446,7 @@ struct ComposeView: View {
|
||||
|
||||
private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? {
|
||||
let s = t != lm.typedMsg ? truncateToWords(t) : t
|
||||
return s != lm.sentMsg ? s : nil
|
||||
return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil
|
||||
}
|
||||
|
||||
private func truncateToWords(_ s: String) -> String {
|
||||
@@ -512,7 +534,7 @@ struct ComposeView: View {
|
||||
}
|
||||
if case let .editingItem(ci) = composeState.contextItem {
|
||||
sent = await updateMessage(ci, live: live)
|
||||
} else if let liveMessage = liveMessage {
|
||||
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
||||
sent = await updateMessage(liveMessage.chatItem, live: live)
|
||||
} else {
|
||||
var quoted: Int64? = nil
|
||||
@@ -609,6 +631,7 @@ struct ComposeView: View {
|
||||
live: live
|
||||
) {
|
||||
await MainActor.run {
|
||||
chatModel.removeLiveDummy(animated: false)
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private let liveMsgInterval: UInt64 = 3000_000000
|
||||
|
||||
struct SendMessageView: View {
|
||||
@Binding var composeState: ComposeState
|
||||
var sendMessage: () -> Void
|
||||
var sendLiveMessage: (() async -> Void)? = nil
|
||||
var updateLiveMessage: (() async -> Void)? = nil
|
||||
var cancelLiveMessage: (() -> Void)? = nil
|
||||
var showVoiceMessageButton: Bool = true
|
||||
var voiceMessageAllowed: Bool = true
|
||||
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
||||
@@ -97,12 +100,18 @@ struct SendMessageView: View {
|
||||
} else {
|
||||
voiceMessageNotAllowedButton()
|
||||
}
|
||||
if let send = sendLiveMessage, let update = updateLiveMessage {
|
||||
if let send = sendLiveMessage,
|
||||
let update = updateLiveMessage,
|
||||
case .noContextItem = composeState.contextItem {
|
||||
startLiveMessageButton(send: send, update: update)
|
||||
}
|
||||
}
|
||||
} else if vmrs == .recording && !holdingVMR {
|
||||
finishVoiceMessageRecordingButton()
|
||||
} else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty {
|
||||
cancelLiveMessageButton {
|
||||
cancelLiveMessage?()
|
||||
}
|
||||
} else {
|
||||
sendMessageButton()
|
||||
}
|
||||
@@ -129,11 +138,13 @@ struct SendMessageView: View {
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
composeState.disabled ||
|
||||
(!voiceMessageAllowed && composeState.voicePreview)
|
||||
(!voiceMessageAllowed && composeState.voicePreview) ||
|
||||
composeState.endLiveDisabled
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
|
||||
if composeState.liveMessage == nil,
|
||||
case .noContextItem = composeState.contextItem,
|
||||
!composeState.voicePreview && !composeState.editing,
|
||||
let send = sendLiveMessage,
|
||||
let update = updateLiveMessage {
|
||||
@@ -220,6 +231,20 @@ struct SendMessageView: View {
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func cancelLiveMessageButton(cancel: @escaping () -> Void) -> some View {
|
||||
return Button {
|
||||
cancel()
|
||||
} label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 15, height: 15)
|
||||
}
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .horizontal], 4)
|
||||
}
|
||||
|
||||
private func startLiveMessageButton(send: @escaping () async -> Void, update: @escaping () async -> Void) -> some View {
|
||||
return Button {
|
||||
switch composeState.preview {
|
||||
@@ -271,9 +296,12 @@ struct SendMessageView: View {
|
||||
sendButtonOpacity = 1
|
||||
}
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { t in
|
||||
if composeState.liveMessage == nil { t.invalidate() }
|
||||
Task { await update() }
|
||||
Task {
|
||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||
while composeState.liveMessage != nil {
|
||||
await update()
|
||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View {
|
||||
func uiKitContextMenu(menu: Binding<UIMenu>) -> some View {
|
||||
self.overlay(Color(uiColor: .systemBackground))
|
||||
.overlay(
|
||||
InteractionView(content: self, menu: UIMenu(title: title, children: actions))
|
||||
InteractionView(content: self, menu: menu)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ private struct InteractionConfig<Content: View> {
|
||||
|
||||
private struct InteractionView<Content: View>: UIViewRepresentable {
|
||||
let content: Content
|
||||
let menu: UIMenu
|
||||
@Binding var menu: UIMenu
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
|
||||
@@ -1305,7 +1305,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 111;
|
||||
CURRENT_PROJECT_VERSION = 112;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1347,7 +1347,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 111;
|
||||
CURRENT_PROJECT_VERSION = 112;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1426,7 +1426,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 111;
|
||||
CURRENT_PROJECT_VERSION = 112;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1456,7 +1456,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 111;
|
||||
CURRENT_PROJECT_VERSION = 112;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
||||
@@ -1670,6 +1670,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
public var file: CIFile?
|
||||
|
||||
public var viewTimestamp = Date.now
|
||||
public var isLiveDummy: Bool = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case chatDir, meta, content, formattedText, quotedItem, file
|
||||
@@ -1862,6 +1863,29 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
public static func liveDummy(_ chatType: ChatType) -> ChatItem {
|
||||
var item = ChatItem(
|
||||
chatDir: chatType == ChatType.direct ? CIDirection.directSnd : CIDirection.groupSnd,
|
||||
meta: CIMeta(
|
||||
itemId: -2,
|
||||
itemTs: .now,
|
||||
itemText: "",
|
||||
itemStatus: .rcvRead,
|
||||
createdAt: .now,
|
||||
updatedAt: .now,
|
||||
itemDeleted: false,
|
||||
itemEdited: false,
|
||||
itemLive: true,
|
||||
editable: false
|
||||
),
|
||||
content: .sndMsgContent(msgContent: .text("")),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
item.isLiveDummy = true
|
||||
return item
|
||||
}
|
||||
|
||||
public static func invalidJSON(_ json: String) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: CIDirection.directSnd,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 4.4.0
|
||||
version: 4.4.1
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
|
||||
@@ -7,7 +7,7 @@ function readlink() {
|
||||
}
|
||||
|
||||
if [ -z ${1} ]; then
|
||||
echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something"
|
||||
echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something/{master,stable}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -22,12 +22,12 @@ output_dir="$root_dir/apps/android/app/src/main/cpp/libs/$output_arch/"
|
||||
|
||||
mkdir -p "$output_dir" 2> /dev/null
|
||||
|
||||
curl --location -o libsupport.zip $job_repo/simplex-chat/$arch-android:lib:support.x86_64-linux/latest/download/1 && \
|
||||
curl --location -o libsupport.zip $job_repo/$arch-android:lib:support.x86_64-linux/latest/download/1 && \
|
||||
unzip -o libsupport.zip && \
|
||||
mv libsupport.so "$output_dir" && \
|
||||
rm libsupport.zip
|
||||
|
||||
curl --location -o libsimplex.zip $job_repo/simplex-chat/$arch-android:lib:simplex-chat.x86_64-linux/latest/download/1 && \
|
||||
curl --location -o libsimplex.zip $job_repo/$arch-android:lib:simplex-chat.x86_64-linux/latest/download/1 && \
|
||||
unzip -o libsimplex.zip && \
|
||||
mv libsimplex.so "$output_dir" && \
|
||||
rm libsimplex.zip
|
||||
|
||||
@@ -7,7 +7,7 @@ function readlink() {
|
||||
}
|
||||
|
||||
if [ -z ${1} ]; then
|
||||
echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something"
|
||||
echo "Job repo is unset. Provide it via first argument like: $(readlink $0)/download_libs_aarch64.sh https://something.com/job/something/{master,stable}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -15,10 +15,10 @@ job_repo=$1
|
||||
|
||||
root_dir="$(dirname $(dirname $(readlink $0)))"
|
||||
|
||||
curl --location -o ~/Downloads/pkg-ios-aarch64-swift-json.zip $job_repo/simplex-chat/aarch64-darwin-ios:lib:simplex-chat.aarch64-darwin/latest/download/1 && \
|
||||
curl --location -o ~/Downloads/pkg-ios-aarch64-swift-json.zip $job_repo/aarch64-darwin-ios:lib:simplex-chat.aarch64-darwin/latest/download/1 && \
|
||||
unzip -o ~/Downloads/pkg-ios-aarch64-swift-json.zip -d ~/Downloads/pkg-ios-aarch64-swift-json
|
||||
|
||||
curl --location -o ~/Downloads/pkg-ios-x86_64-swift-json.zip $job_repo/simplex-chat/x86_64-darwin-ios:lib:simplex-chat.x86_64-darwin/latest/download/1 && \
|
||||
curl --location -o ~/Downloads/pkg-ios-x86_64-swift-json.zip $job_repo/x86_64-darwin-ios:lib:simplex-chat.x86_64-darwin/latest/download/1 && \
|
||||
unzip -o ~/Downloads/pkg-ios-x86_64-swift-json.zip -d ~/Downloads/pkg-ios-x86_64-swift-json
|
||||
|
||||
sh $root_dir/scripts/ios/prepare-x86_64.sh
|
||||
|
||||
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 4.4.0
|
||||
version: 4.4.1
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
|
||||
@@ -228,13 +228,21 @@ restoreCalls user = do
|
||||
calls <- asks currentCalls
|
||||
atomically $ writeTVar calls callsMap
|
||||
|
||||
stopChatController :: MonadUnliftIO m => ChatController -> m ()
|
||||
stopChatController ChatController {smpAgent, agentAsync = s, expireCIs} = do
|
||||
stopChatController :: forall m. MonadUnliftIO m => ChatController -> m ()
|
||||
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIs} = do
|
||||
disconnectAgentClient smpAgent
|
||||
readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2)
|
||||
closeFiles sndFiles
|
||||
closeFiles rcvFiles
|
||||
atomically $ do
|
||||
writeTVar expireCIs False
|
||||
writeTVar s Nothing
|
||||
where
|
||||
closeFiles :: TVar (Map Int64 Handle) -> m ()
|
||||
closeFiles files = do
|
||||
fs <- readTVarIO files
|
||||
mapM_ hClose fs
|
||||
atomically $ writeTVar files M.empty
|
||||
|
||||
execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse
|
||||
execChatCommand s = case parseChatCommand s of
|
||||
@@ -1831,7 +1839,7 @@ subscribeUserConnections agentBatchSubscribe user = do
|
||||
pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> m ()
|
||||
pendingConnSubsToView rs = toView . CRPendingSubSummary . map (uncurry PendingSubStatus) . resultsFor rs
|
||||
withStore_ :: (DB.Connection -> User -> IO [a]) -> m [a]
|
||||
withStore_ a = withStore' (`a` user) `catchError` \_ -> pure []
|
||||
withStore_ a = withStore' (`a` user) `catchError` \e -> toView (CRChatError (Just user) e) >> pure []
|
||||
filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)]
|
||||
filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_)
|
||||
resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)]
|
||||
@@ -3357,8 +3365,7 @@ getFileHandle fileId filePath files ioMode = do
|
||||
maybe (newHandle fs) pure h_
|
||||
where
|
||||
newHandle fs = do
|
||||
-- TODO handle errors
|
||||
h <- liftIO (openFile filePath ioMode)
|
||||
h <- liftIO (openFile filePath ioMode) `E.catch` (throwChatError . CEFileInternal . (show :: E.SomeException -> String))
|
||||
atomically . modifyTVar fs $ M.insert fileId h
|
||||
pure h
|
||||
|
||||
|
||||
@@ -1292,7 +1292,7 @@ getLiveSndFileTransfers db User {userId} = do
|
||||
FROM files f
|
||||
JOIN snd_files s USING (file_id)
|
||||
WHERE f.user_id = ? AND s.file_status IN (?, ?, ?) AND s.file_inline IS NULL
|
||||
AND created_at > ?
|
||||
AND s.created_at > ?
|
||||
|]
|
||||
(userId, FSNew, FSAccepted, FSConnected, cutoffTs)
|
||||
concatMap (filter liveTransfer) . rights <$> mapM (getSndFileTransfers_ db userId) fileIds
|
||||
@@ -1312,7 +1312,7 @@ getLiveRcvFileTransfers db user@User {userId} = do
|
||||
FROM files f
|
||||
JOIN rcv_files r USING (file_id)
|
||||
WHERE f.user_id = ? AND r.file_status IN (?, ?) AND r.rcv_file_inline IS NULL
|
||||
AND created_at > ?
|
||||
AND r.created_at > ?
|
||||
|]
|
||||
(userId, FSAccepted, FSConnected, cutoffTs)
|
||||
rights <$> mapM (runExceptT . getRcvFileTransfer db user) fileIds
|
||||
|
||||
@@ -159,10 +159,12 @@ chatTests = do
|
||||
-- it "v1 to v2" testFullAsyncV1toV2
|
||||
-- it "v2 to v1" testFullAsyncV2toV1
|
||||
describe "async sending and receiving files" $ do
|
||||
it "send and receive file, sender restarts" testAsyncFileTransferSenderRestarts
|
||||
it "send and receive file, receiver restarts" testAsyncFileTransferReceiverRestarts
|
||||
xdescribe "send and receive file, fully asynchronous" $ do
|
||||
it "v2" testAsyncFileTransfer
|
||||
it "v1" testAsyncFileTransferV1
|
||||
xit "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer
|
||||
it "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer
|
||||
describe "webrtc calls api" $ do
|
||||
it "negotiate call" testNegotiateCall
|
||||
describe "maintenance mode" $ do
|
||||
@@ -4013,6 +4015,34 @@ testFullAsyncV2toV1 = withTmpFiles $ do
|
||||
withNewBob = withNewTestChat "bob" bobProfile
|
||||
withBob = withTestChat "bob"
|
||||
|
||||
testAsyncFileTransferSenderRestarts :: IO ()
|
||||
testAsyncFileTransferSenderRestarts = withTmpFiles $ do
|
||||
withNewTestChat "bob" bobProfile $ \bob -> do
|
||||
withNewTestChat "alice" aliceProfile $ \alice -> do
|
||||
connectUsers alice bob
|
||||
startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes"
|
||||
threadDelay 100000
|
||||
withTestChatContactConnected "alice" $ \alice -> do
|
||||
alice <## "completed sending file 1 (test_1MB.pdf) to bob"
|
||||
bob <## "completed receiving file 1 (test_1MB.pdf) from alice"
|
||||
src <- B.readFile "./tests/fixtures/test_1MB.pdf"
|
||||
dest <- B.readFile "./tests/tmp/test_1MB.pdf"
|
||||
dest `shouldBe` src
|
||||
|
||||
testAsyncFileTransferReceiverRestarts :: IO ()
|
||||
testAsyncFileTransferReceiverRestarts = withTmpFiles $ do
|
||||
withNewTestChat "alice" aliceProfile $ \alice -> do
|
||||
withNewTestChat "bob" bobProfile $ \bob -> do
|
||||
connectUsers alice bob
|
||||
startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes"
|
||||
threadDelay 100000
|
||||
withTestChatContactConnected "bob" $ \bob -> do
|
||||
alice <## "completed sending file 1 (test_1MB.pdf) to bob"
|
||||
bob <## "completed receiving file 1 (test_1MB.pdf) from alice"
|
||||
src <- B.readFile "./tests/fixtures/test_1MB.pdf"
|
||||
dest <- B.readFile "./tests/tmp/test_1MB.pdf"
|
||||
dest `shouldBe` src
|
||||
|
||||
testAsyncFileTransfer :: IO ()
|
||||
testAsyncFileTransfer = withTmpFiles $ do
|
||||
withNewTestChat "alice" aliceProfile $ \alice ->
|
||||
|
||||
Reference in New Issue
Block a user