From 260a758b82ca9e43377551b568406e1d95f6ff7c Mon Sep 17 00:00:00 2001
From: JRoberts <8711996+jr-simplex@users.noreply.github.com>
Date: Wed, 4 May 2022 09:09:59 +0400
Subject: [PATCH 1/3] test: fix race condition caused by small file transfer
(sender cancelled) (#596)
---
tests/ChatTests.hs | 38 +++++++++++++++++++++-----------------
1 file changed, 21 insertions(+), 17 deletions(-)
diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs
index 05e0ea7b6..47689ab19 100644
--- a/tests/ChatTests.hs
+++ b/tests/ChatTests.hs
@@ -1027,20 +1027,20 @@ testFileSndCancel =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
- startFileTransfer alice bob
+ startFileTransfer' alice bob "test_1MB.pdf" "1017.7 KiB / 1042157 bytes"
alice ##> "/fc 1"
concurrentlyN_
[ do
- alice <## "cancelled sending file 1 (test.jpg) to bob"
+ alice <## "cancelled sending file 1 (test_1MB.pdf) to bob"
alice ##> "/fs 1"
- alice <## "sending file 1 (test.jpg) cancelled: bob"
+ alice <## "sending file 1 (test_1MB.pdf) cancelled: bob"
alice <## "file transfer cancelled",
do
- bob <## "alice cancelled sending file 1 (test.jpg)"
+ bob <## "alice cancelled sending file 1 (test_1MB.pdf)"
bob ##> "/fs 1"
- bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg"
+ bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: ./tests/tmp/test_1MB.pdf"
]
- checkPartialTransfer
+ checkPartialTransfer "test_1MB.pdf"
testFileRcvCancel :: IO ()
testFileRcvCancel =
@@ -1062,7 +1062,7 @@ testFileRcvCancel =
alice ##> "/fs 1"
alice <## "sending file 1 (test.jpg) cancelled: bob"
]
- checkPartialTransfer
+ checkPartialTransfer "test.jpg"
testGroupFileTransfer :: IO ()
testGroupFileTransfer =
@@ -1782,21 +1782,25 @@ withTestChatGroup3Connected' :: String -> IO ()
withTestChatGroup3Connected' dbPrefix = withTestChatGroup3Connected dbPrefix $ \_ -> pure ()
startFileTransfer :: TestCC -> TestCC -> IO ()
-startFileTransfer alice bob = do
- alice #> "/f @bob ./tests/fixtures/test.jpg"
+startFileTransfer alice bob =
+ startFileTransfer' alice bob "test.jpg" "136.5 KiB / 139737 bytes"
+
+startFileTransfer' :: TestCC -> TestCC -> String -> String -> IO ()
+startFileTransfer' alice bob fileName fileSize = do
+ alice #> ("/f @bob ./tests/fixtures/" <> fileName)
alice <## "use /fc 1 to cancel sending"
- bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ bob <# ("alice> sends file " <> fileName <> " (" <> fileSize <> ")")
bob <## "use /fr 1 [
/ | ] to receive it"
bob ##> "/fr 1 ./tests/tmp"
- bob <## "saving file 1 from alice to ./tests/tmp/test.jpg"
+ bob <## ("saving file 1 from alice to ./tests/tmp/" <> fileName)
concurrently_
- (bob <## "started receiving file 1 (test.jpg) from alice")
- (alice <## "started sending file 1 (test.jpg) to bob")
+ (bob <## ("started receiving file 1 (" <> fileName <> ") from alice"))
+ (alice <## ("started sending file 1 (" <> fileName <> ") to bob"))
-checkPartialTransfer :: IO ()
-checkPartialTransfer = do
- src <- B.readFile "./tests/fixtures/test.jpg"
- dest <- B.readFile "./tests/tmp/test.jpg"
+checkPartialTransfer :: String -> IO ()
+checkPartialTransfer fileName = do
+ src <- B.readFile $ "./tests/fixtures/" <> fileName
+ dest <- B.readFile $ "./tests/tmp/" <> fileName
B.unpack src `shouldStartWith` B.unpack dest
B.length src > B.length dest `shouldBe` True
From 2c1ad9a6419c9c9002321d873e2d61bbb67a3590 Mon Sep 17 00:00:00 2001
From: JRoberts <8711996+jr-simplex@users.noreply.github.com>
Date: Wed, 4 May 2022 09:10:36 +0400
Subject: [PATCH 2/3] mobile: support receiving files (#584)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
---
.../java/chat/simplex/app/model/ChatModel.kt | 37 ++-
.../java/chat/simplex/app/model/SimpleXAPI.kt | 50 ++--
.../java/chat/simplex/app/ui/theme/Color.kt | 1 +
.../chat/simplex/app/views/chat/ChatView.kt | 30 ++-
.../simplex/app/views/chat/ComposeView.kt | 1 +
.../simplex/app/views/chat/item/CIFileView.kt | 222 ++++++++++++++++++
.../app/views/chat/item/CIImageView.kt | 70 +++++-
.../app/views/chat/item/ChatItemView.kt | 29 ++-
.../app/views/chat/item/FramedItemView.kt | 25 +-
.../chat/simplex/app/views/helpers/Share.kt | 28 +++
.../chat/simplex/app/views/helpers/Util.kt | 2 +
.../app/src/main/res/values-ru/strings.xml | 16 +-
.../app/src/main/res/values/strings.xml | 16 +-
apps/ios/Shared/FileUtils.swift | 4 +-
apps/ios/Shared/Model/Shared/APITypes.swift | 7 +-
apps/ios/Shared/Model/Shared/ChatTypes.swift | 20 +-
apps/ios/Shared/Model/SimpleXAPI.swift | 45 ++--
.../Views/Chat/ChatItem/FramedItemView.swift | 5 +
apps/ios/Shared/Views/Chat/ChatView.swift | 6 +
.../Chat/ComposeMessage/ComposeView.swift | 2 +
.../ios/Shared/Views/Helpers/CIFileView.swift | 177 ++++++++++++++
.../Shared/Views/Helpers/CIImageView.swift | 39 ++-
apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 +
23 files changed, 759 insertions(+), 79 deletions(-)
create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
create mode 100644 apps/ios/Shared/Views/Helpers/CIFileView.swift
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
index b2d03c0c0..b4ed65d1f 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -675,6 +675,21 @@ data class ChatItem (
file = file
)
+ fun getFileMsgContentSample(
+ id: Long = 1,
+ text: String = "",
+ fileName: String = "test.txt",
+ fileSize: Long = 100,
+ fileStatus: CIFileStatus = CIFileStatus.RcvComplete
+ ) =
+ ChatItem(
+ chatDir = CIDirection.DirectRcv(),
+ meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
+ content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
+ quotedItem = null,
+ file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
+ )
+
fun getDeletedContentSampleData(
id: Long = 1,
dir: CIDirection = CIDirection.DirectRcv(),
@@ -858,7 +873,13 @@ class CIFile(
}
companion object {
- fun getSample(fileId: Long, fileName: String, fileSize: Long, filePath: String?, fileStatus: CIFileStatus = CIFileStatus.SndStored): CIFile =
+ fun getSample(
+ fileId: Long = 1,
+ fileName: String = "test.txt",
+ fileSize: Long = 100,
+ filePath: String? = "test.txt",
+ fileStatus: CIFileStatus = CIFileStatus.RcvComplete
+ ): CIFile =
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus)
}
}
@@ -868,6 +889,7 @@ enum class CIFileStatus {
@SerialName("snd_stored") SndStored,
@SerialName("snd_cancelled") SndCancelled,
@SerialName("rcv_invitation") RcvInvitation,
+ @SerialName("rcv_accepted") RcvAccepted,
@SerialName("rcv_transfer") RcvTransfer,
@SerialName("rcv_complete") RcvComplete,
@SerialName("rcv_cancelled") RcvCancelled;
@@ -887,6 +909,9 @@ sealed class MsgContent {
@Serializable(with = MsgContentSerializer::class)
class MCImage(override val text: String, val image: String): MsgContent()
+ @Serializable(with = MsgContentSerializer::class)
+ class MCFile(override val text: String): MsgContent()
+
@Serializable(with = MsgContentSerializer::class)
class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
@@ -894,6 +919,7 @@ sealed class MsgContent {
is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}"
+ is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json"
}
}
@@ -911,6 +937,9 @@ object MsgContentSerializer : KSerializer {
element("text")
element("image")
})
+ element("MCFile", buildClassSerialDescriptor("MCFile") {
+ element("text")
+ })
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
}
@@ -931,6 +960,7 @@ object MsgContentSerializer : KSerializer {
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
MsgContent.MCImage(text, image)
}
+ "file" -> MsgContent.MCFile(text)
else -> MsgContent.MCUnknown(t, text, json)
}
} else {
@@ -961,6 +991,11 @@ object MsgContentSerializer : KSerializer {
put("text", value.text)
put("image", value.image)
}
+ is MsgContent.MCFile ->
+ buildJsonObject {
+ put("type", "file")
+ put("text", value.text)
+ }
is MsgContent.MCUnknown -> value.json
}
encoder.encodeJsonElement(json)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
index 6e6f172f4..c7381861f 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -327,11 +327,11 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return false
}
- suspend fun receiveFile(fileId: Long): Boolean {
+ suspend fun apiReceiveFile(fileId: Long): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId))
- if (r is CR.RcvFileAccepted) return true
+ if (r is CR.RcvFileAccepted) return r.chatItem
Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}")
- return false
+ return null
}
fun apiErrorAlert(method: String, title: String, r: CR) {
@@ -390,8 +390,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
val file = cItem.file
- if (file != null && file.fileSize <= MAX_IMAGE_SIZE) {
- withApi {receiveFile(file.fileId)}
+ if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE) {
+ withApi {
+ val chatItem = apiReceiveFile(file.fileId)
+ if (chatItem != null) {
+ chatItemSimpleUpdate(chatItem)
+ }
+ }
}
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
ntfManager.notifyMessageReceived(cInfo, cItem)
@@ -408,13 +413,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
- is CR.ChatItemUpdated -> {
- val cInfo = r.chatItem.chatInfo
- val cItem = r.chatItem.chatItem
- if (chatModel.upsertChatItem(cInfo, cItem)) {
- ntfManager.notifyMessageReceived(cInfo, cItem)
- }
- }
+ is CR.ChatItemUpdated ->
+ chatItemSimpleUpdate(r.chatItem)
is CR.ChatItemDeleted -> {
val cInfo = r.toChatItem.chatInfo
val cItem = r.toChatItem.chatItem
@@ -425,18 +425,23 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
chatModel.upsertChatItem(cInfo, cItem)
}
}
- is CR.RcvFileComplete -> {
- val cInfo = r.chatItem.chatInfo
- val cItem = r.chatItem.chatItem
- if (chatModel.upsertChatItem(cInfo, cItem)) {
- ntfManager.notifyMessageReceived(cInfo, cItem)
- }
- }
+ is CR.RcvFileStart ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.RcvFileComplete ->
+ chatItemSimpleUpdate(r.chatItem)
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
}
+ private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
+ val cInfo = aChatItem.chatInfo
+ val cItem = aChatItem.chatItem
+ if (chatModel.upsertChatItem(cInfo, cItem)) {
+ ntfManager.notifyMessageReceived(cInfo, cItem)
+ }
+ }
+
fun updateContactsStatus(contactRefs: List, status: Chat.NetworkStatus) {
for (c in contactRefs) {
chatModel.updateNetworkStatus(c.id, status)
@@ -681,7 +686,8 @@ sealed class CR {
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
- @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted: CR()
+ @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR()
@Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR()
@@ -728,6 +734,7 @@ sealed class CR {
is ChatItemUpdated -> "chatItemUpdated"
is ChatItemDeleted -> "chatItemDeleted"
is RcvFileAccepted -> "rcvFileAccepted"
+ is RcvFileStart -> "rcvFileStart"
is RcvFileComplete -> "rcvFileComplete"
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted"
@@ -774,7 +781,8 @@ sealed class CR {
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
- is RcvFileAccepted -> noDetails()
+ is RcvFileAccepted -> json.encodeToString(chatItem)
+ is RcvFileStart -> json.encodeToString(chatItem)
is RcvFileComplete -> json.encodeToString(chatItem)
is NewContactConnection -> json.encodeToString(connection)
is ContactConnectionDeleted -> json.encodeToString(connection)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
index 7aeecca38..2e8d94c47 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
@@ -15,3 +15,4 @@ val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(134, 135, 139, 255)
val ToolbarLight = Color(220, 220, 220, 20)
val ToolbarDark = Color(80, 80, 80, 20)
+val WarningOrange = Color(255, 149, 0, 255)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index 880c423ef..5aa7a9823 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -102,6 +102,16 @@ fun ChatView(chatModel: ChatModel) {
)
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
}
+ },
+ receiveFile = { fileId ->
+ withApi {
+ val chatItem = chatModel.controller.apiReceiveFile(fileId)
+ if (chatItem != null) {
+ val cInfo = chatItem.chatInfo
+ val cItem = chatItem.chatItem
+ chatModel.upsertChatItem(cInfo, cItem)
+ }
+ }
}
)
}
@@ -120,7 +130,8 @@ fun ChatLayout(
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
- deleteMessage: (Long, CIDeleteMode) -> Unit
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit
) {
fun onImageChange(bitmap: Bitmap) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
@@ -153,7 +164,7 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
- ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage)
+ ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile)
}
}
}
@@ -230,7 +241,8 @@ fun ChatItemsList(
composeState: MutableState,
chatItems: List,
openDirectChat: (Long) -> Unit,
- deleteMessage: (Long, CIDeleteMode) -> Unit
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
val keyboardState by getKeyboardState()
@@ -268,11 +280,11 @@ fun ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
- ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage)
+ ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
- ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage)
+ ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
}
} else { // direct message
@@ -283,7 +295,7 @@ fun ChatItemsList(
end = if (sent) 12.dp else 76.dp,
)
) {
- ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage)
+ ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
}
}
@@ -350,7 +362,8 @@ fun PreviewChatLayout() {
back = {},
info = {},
openDirectChat = {},
- deleteMessage = { _, _ -> }
+ deleteMessage = { _, _ -> },
+ receiveFile = {}
)
}
}
@@ -393,7 +406,8 @@ fun PreviewGroupChatLayout() {
back = {},
info = {},
openDirectChat = {},
- deleteMessage = { _, _ -> }
+ deleteMessage = { _, _ -> },
+ receiveFile = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
index aae695d4b..05ab4d284 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
@@ -164,6 +164,7 @@ fun ComposeView(
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
+ is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
new file mode 100644
index 000000000..eaf02c10a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
@@ -0,0 +1,222 @@
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.InsertDriveFile
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.*
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.item.FramedItemView
+import chat.simplex.app.views.helpers.*
+import kotlinx.datetime.Clock
+import kotlin.math.log2
+import kotlin.math.pow
+
+@Composable
+fun CIFileView(
+ file: CIFile?,
+ edited: Boolean,
+ receiveFile: (Long) -> Unit
+) {
+ val context = LocalContext.current
+ val saveFileLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument(),
+ onResult = { destination ->
+ saveFile(context, file, destination)
+ }
+ )
+
+ @Composable
+ fun fileIcon(innerIcon: ImageVector? = null, color: Color = HighOrLowlight) {
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Filled.InsertDriveFile,
+ stringResource(R.string.icon_descr_file),
+ Modifier.fillMaxSize(),
+ tint = color
+ )
+ if (innerIcon != null) {
+ Icon(
+ innerIcon,
+ stringResource(R.string.icon_descr_file),
+ Modifier
+ .size(32.dp)
+ .padding(top = 12.dp),
+ tint = Color.White
+ )
+ }
+ }
+ }
+
+ fun fileSizeValid(): Boolean {
+ if (file != null) {
+ return file.fileSize <= MAX_FILE_SIZE
+ }
+ return false
+ }
+
+ fun fileAction() {
+ if (file != null) {
+ when (file.fileStatus) {
+ CIFileStatus.RcvInvitation -> {
+ if (fileSizeValid()) {
+ receiveFile(file.fileId)
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.contact_sent_large_file), MAX_FILE_SIZE)
+ )
+ }
+ }
+ CIFileStatus.RcvAccepted ->
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.waiting_for_file),
+ String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
+ )
+ CIFileStatus.RcvComplete -> {
+ val filePath = getStoredFilePath(context, file)
+ if (filePath != null) {
+ saveFileLauncher.launch(file.fileName)
+ } else {
+ Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+ }
+ else -> {}
+ }
+ }
+ }
+
+ @Composable
+ fun fileIndicator() {
+ Box(
+ Modifier.size(44.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ if (file != null) {
+ when (file.fileStatus) {
+ CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
+ CIFileStatus.RcvInvitation ->
+ if (fileSizeValid())
+ fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
+ else
+ fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
+ CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
+ CIFileStatus.RcvTransfer ->
+ CircularProgressIndicator(
+ Modifier.size(36.dp),
+ color = HighOrLowlight,
+ strokeWidth = 4.dp
+ )
+ CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
+ else -> fileIcon()
+ }
+ } else {
+ fileIcon()
+ }
+ }
+ }
+
+ fun formatBytes(bytes: Long): String {
+ if (bytes == 0.toLong()) {
+ return "0 bytes"
+ }
+ val bytesDouble = bytes.toDouble()
+ val k = 1000.toDouble()
+ val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
+ val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
+ val size = bytesDouble / k.pow(i)
+ val unit = units[i.toInt()]
+
+ return if (i <= 1) {
+ String.format("%.0f %s", size, unit)
+ } else {
+ String.format("%.2f %s", size, unit)
+ }
+ }
+
+ Row(
+ Modifier
+ .padding(top = 4.dp, bottom = 6.dp, start = 10.dp, end = 12.dp)
+ .clickable(onClick = { fileAction() }),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ fileIndicator()
+ val metaReserve = if (edited)
+ " "
+ else
+ " "
+ if (file != null) {
+ Column(
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ file.fileName,
+ maxLines = 1
+ )
+ Text(
+ formatBytes(file.fileSize) + metaReserve,
+ color = HighOrLowlight,
+ fontSize = 14.sp,
+ maxLines = 1
+ )
+ }
+ } else {
+ Text(metaReserve)
+ }
+ }
+}
+
+class ChatItemProvider: PreviewParameterProvider {
+ private val sentFile = ChatItem(
+ chatDir = CIDirection.DirectSnd(),
+ meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
+ content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
+ quotedItem = null,
+ file = CIFile.getSample(fileStatus = CIFileStatus.SndStored)
+ )
+ private val fileChatItemWtFile = ChatItem(
+ chatDir = CIDirection.DirectRcv(),
+ meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
+ content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
+ quotedItem = null,
+ file = null
+ )
+ override val values = listOf(
+ sentFile,
+ ChatItem.getFileMsgContentSample(),
+ ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
+ ChatItem.getFileMsgContentSample(fileSize = 2000000, fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus = CIFileStatus.RcvInvitation),
+ fileChatItemWtFile
+ ).asSequence()
+}
+
+@Preview
+@Composable
+fun PreviewTextItemViewSnd(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
+ val showMenu = remember { mutableStateOf(false) }
+ SimpleXTheme {
+ FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
index 2f24e9f24..8df4976e8 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
@@ -1,15 +1,21 @@
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Icon
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
-import chat.simplex.app.model.CIFile
+import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
@Composable
@@ -18,12 +24,37 @@ fun CIImageView(
file: CIFile?,
showMenu: MutableState
) {
- Column {
- val context = LocalContext.current
- var imageBitmap: Bitmap? = getStoredImage(context, file)
- if (imageBitmap == null) {
- imageBitmap = base64ToBitmap(image)
+ @Composable
+ fun loadingIndicator() {
+ if (file != null) {
+ Box(
+ Modifier
+ .padding(8.dp)
+ .size(20.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ when (file.fileStatus) {
+ CIFileStatus.RcvAccepted ->
+ Icon(
+ Icons.Outlined.MoreHoriz,
+ stringResource(R.string.icon_descr_waiting_for_image),
+ Modifier.fillMaxSize(),
+ tint = Color.White
+ )
+ CIFileStatus.RcvTransfer ->
+ CircularProgressIndicator(
+ Modifier.size(16.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ else -> {}
+ }
+ }
}
+ }
+
+ @Composable
+ fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
Image(
imageBitmap.asImageBitmap(),
contentDescription = stringResource(R.string.image_descr),
@@ -33,13 +64,30 @@ fun CIImageView(
.width(1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
- onClick = {
- if (getStoredFilePath(context, file) != null) {
- ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
- }
- }
+ onClick = onClick
),
contentScale = ContentScale.FillWidth,
)
}
+
+ Box(contentAlignment = Alignment.TopEnd) {
+ val context = LocalContext.current
+ val imageBitmap: Bitmap? = getStoredImage(context, file)
+ if (imageBitmap != null) {
+ imageView(imageBitmap, onClick = {
+ if (getStoredFilePath(context, file) != null) {
+ ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
+ }
+ })
+ } else {
+ imageView(base64ToBitmap(image), onClick = {
+ if (file != null && file.fileStatus == CIFileStatus.RcvAccepted)
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.waiting_for_image),
+ generalGetString(R.string.image_will_be_received_when_contact_is_online)
+ )
+ })
+ }
+ loadingIndicator()
+ }
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
index d005e1ea3..5e87e6b2f 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
@@ -1,6 +1,8 @@
package chat.simplex.app.views.chat.item
import android.content.Context
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -33,11 +35,19 @@ fun ChatItemView(
cxt: Context,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
- deleteMessage: (Long, CIDeleteMode) -> Unit
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit
) {
+ val context = LocalContext.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
+ val saveFileLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument(),
+ onResult = { destination ->
+ saveFile(context, cItem.file, destination)
+ }
+ )
Box(
modifier = Modifier
.padding(bottom = 4.dp)
@@ -49,7 +59,7 @@ fun ChatItemView(
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
- FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu)
+ FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
} else if (cItem.isDeletedContent) {
DeletedItemView(cItem, showMember = showMember)
@@ -72,6 +82,15 @@ fun ChatItemView(
copyText(cxt, cItem.content.text)
showMenu.value = false
})
+ if (cItem.content.msgContent is MsgContent.MCImage) {
+ val filePath = getStoredFilePath(context, cItem.file)
+ if (filePath != null) {
+ ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
+ saveFileLauncher.launch(cItem.file?.fileName)
+ showMenu.value = false
+ })
+ }
+ }
if (cItem.chatDir.sent && cItem.meta.editable) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem)
@@ -147,7 +166,8 @@ fun PreviewChatItemView() {
),
composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
- deleteMessage = { _, _ -> }
+ deleteMessage = { _, _ -> },
+ receiveFile = {}
)
}
}
@@ -161,7 +181,8 @@ fun PreviewChatItemViewDeletedContent() {
ChatItem.getDeletedContentSampleData(),
composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
- deleteMessage = { _, _ -> }
+ deleteMessage = { _, _ -> },
+ receiveFile = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
index 282b879c2..4a184cc37 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
@@ -1,5 +1,6 @@
package chat.simplex.app.views.chat.item
+import CIFileView
import CIImageView
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -38,7 +39,8 @@ fun FramedItemView(
ci: ChatItem,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
- showMenu: MutableState
+ showMenu: MutableState,
+ receiveFile: (Long) -> Unit
) {
val sent = ci.chatDir.sent
Surface(
@@ -101,6 +103,12 @@ fun FramedItemView(
CIMarkdownText(ci, showMember, uriHandler)
}
}
+ is MsgContent.MCFile -> {
+ CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
+ if (mc.text != "") {
+ CIMarkdownText(ci, showMember, uriHandler)
+ }
+ }
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, uriHandler)
@@ -141,7 +149,8 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -156,7 +165,8 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -175,7 +185,8 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -195,7 +206,8 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -215,7 +227,8 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
index 5cac836f1..ad68516ca 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
@@ -1,7 +1,13 @@
package chat.simplex.app.views.helpers
import android.content.*
+import android.net.Uri
+import android.widget.Toast
import androidx.core.content.ContextCompat
+import chat.simplex.app.R
+import chat.simplex.app.model.CIFile
+import java.io.File
+import java.io.IOException
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
@@ -17,3 +23,25 @@ fun copyText(cxt: Context, text: String) {
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
}
+
+fun saveFile(cxt: Context, ciFile: CIFile?, destination: Uri?) {
+ if (destination != null) {
+ val filePath = getStoredFilePath(cxt, ciFile)
+ if (filePath != null) {
+ val contentResolver = cxt.contentResolver
+ val file = File(filePath)
+ try {
+ val outputStream = contentResolver.openOutputStream(destination)
+ if (outputStream != null) {
+ outputStream.write(file.readBytes())
+ outputStream.close()
+ Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: IOException) {
+ Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
index 20a682488..b4970f12e 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
@@ -204,6 +204,8 @@ private fun spannableStringToAnnotatedString(
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE = 236700
+const val MAX_FILE_SIZE = 1893600
+
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
}
diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml
index 5d42f5fb0..b601587f9 100644
--- a/apps/android/app/src/main/res/values-ru/strings.xml
+++ b/apps/android/app/src/main/res/values-ru/strings.xml
@@ -65,6 +65,7 @@
Ответить
Поделиться
Копировать
+ Сохранить
Редактировать
Удалить
Удалить сообщение?
@@ -91,7 +92,20 @@
Изображение
- удалить превью изображения
+ Удалить превью изображения
+ Ожидание изображения
+ Ожидание изображения
+ Изображение будет получено, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже!
+
+
+ Файл
+ Большой файл!
+ Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s байта).
+ Ожидание файла
+ Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже!
+ Файл сохранен
+ Файл не найден
+ Ошибка сохранения файла
Удалить контакт?
diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml
index 73219eff5..c2a15bee7 100644
--- a/apps/android/app/src/main/res/values/strings.xml
+++ b/apps/android/app/src/main/res/values/strings.xml
@@ -65,6 +65,7 @@
Reply
Share
Copy
+ Save
Edit
Delete
Delete message?
@@ -91,7 +92,20 @@
Image
- cancel image preview
+ Cancel image preview
+ Waiting for image
+ Waiting for image
+ Image will be received when your contact is online, please wait or check later!
+
+
+ File
+ Large file!
+ Your contact sent a file that is larger than currently supported maximum size (%1$s bytes).
+ Waiting for file
+ File will be received when your contact is online, please wait or check later!
+ File saved
+ File not found
+ Error saving file
Delete contact?
diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift
index 5739cc6d5..e6cf6fa14 100644
--- a/apps/ios/Shared/FileUtils.swift
+++ b/apps/ios/Shared/FileUtils.swift
@@ -12,6 +12,8 @@ import SwiftUI
// maximum image file size to be auto-accepted
let maxImageSize = 236700
+let maxFileSize = 1893600
+
func getDocumentsDirectory() -> URL {
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
@@ -24,7 +26,7 @@ func getStoredFilePath(_ file: CIFile?) -> String? {
if let file = file,
file.stored,
let savedFile = file.filePath {
- return getAppFilesDirectory().path + "/" + savedFile
+ return getAppFilesDirectory().appendingPathComponent(savedFile).path
}
return nil
}
diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift
index c813ee23f..65178c737 100644
--- a/apps/ios/Shared/Model/Shared/APITypes.swift
+++ b/apps/ios/Shared/Model/Shared/APITypes.swift
@@ -167,7 +167,8 @@ enum ChatResponse: Decodable, Error {
case chatItemStatusUpdated(chatItem: AChatItem)
case chatItemUpdated(chatItem: AChatItem)
case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
- case rcvFileAccepted
+ case rcvFileAccepted(chatItem: AChatItem)
+ case rcvFileStart(chatItem: AChatItem)
case rcvFileComplete(chatItem: AChatItem)
case ntfTokenStatus(status: NtfTknStatus)
case newContactConnection(connection: PendingContactConnection)
@@ -216,6 +217,7 @@ enum ChatResponse: Decodable, Error {
case .chatItemUpdated: return "chatItemUpdated"
case .chatItemDeleted: return "chatItemDeleted"
case .rcvFileAccepted: return "rcvFileAccepted"
+ case .rcvFileStart: return "rcvFileStart"
case .rcvFileComplete: return "rcvFileComplete"
case .ntfTokenStatus: return "ntfTokenStatus"
case .newContactConnection: return "newContactConnection"
@@ -266,7 +268,8 @@ enum ChatResponse: Decodable, Error {
case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
- case .rcvFileAccepted: return noDetails
+ case let .rcvFileAccepted(chatItem): return String(describing: chatItem)
+ case let .rcvFileStart(chatItem): return String(describing: chatItem)
case let .rcvFileComplete(chatItem): return String(describing: chatItem)
case let .ntfTokenStatus(status): return String(describing: status)
case let .newContactConnection(connection): return String(describing: connection)
diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift
index 0b3e90a6e..e02586d4d 100644
--- a/apps/ios/Shared/Model/Shared/ChatTypes.swift
+++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift
@@ -480,6 +480,16 @@ struct ChatItem: Identifiable, Decodable {
)
}
+ static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
+ ChatItem(
+ chatDir: .directRcv,
+ meta: CIMeta.getSample(id, .now, text, .rcvRead, false, false, false),
+ content: .rcvMsgContent(msgContent: .file(text)),
+ quotedItem: nil,
+ file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
+ )
+ }
+
static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
ChatItem(
chatDir: dir,
@@ -629,7 +639,7 @@ struct CIFile: Decodable {
var filePath: String?
var fileStatus: CIFileStatus
- static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile {
+ static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile {
CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus)
}
@@ -649,6 +659,7 @@ enum CIFileStatus: String, Decodable {
case sndStored = "snd_stored"
case sndCancelled = "snd_cancelled"
case rcvInvitation = "rcv_invitation"
+ case rcvAccepted = "rcv_accepted"
case rcvTransfer = "rcv_transfer"
case rcvComplete = "rcv_complete"
case rcvCancelled = "rcv_cancelled"
@@ -658,6 +669,7 @@ enum MsgContent {
case text(String)
case link(text: String, preview: LinkPreview)
case image(text: String, image: String)
+ case file(String)
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
case unknown(type: String, text: String)
@@ -667,6 +679,7 @@ enum MsgContent {
case let .text(text): return text
case let .link(text, _): return text
case let .image(text, _): return text
+ case let .file(text): return text
case let .unknown(_, text): return text
}
}
@@ -680,6 +693,7 @@ enum MsgContent {
return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}"
case let .image(text: text, image: image):
return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}"
+ case let .file(text): return "json {\"type\":\"file\",\"text\":\(encodeJSON(text))}"
default: return ""
}
}
@@ -690,6 +704,7 @@ enum MsgContent {
case text
case preview
case image
+ case file
}
}
@@ -711,6 +726,9 @@ extension MsgContent: Decodable {
let text = try container.decode(String.self, forKey: CodingKeys.text)
let image = try container.decode(String.self, forKey: CodingKeys.image)
self = .image(text: text, image: image)
+ case "file":
+ let text = try container.decode(String.self, forKey: CodingKeys.text)
+ self = .file(text)
default:
let text = try? container.decode(String.self, forKey: CodingKeys.text)
self = .unknown(type: type, text: text ?? "unknown message format")
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index cd46e4c28..d51f666b6 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -326,9 +326,18 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
}
-func receiveFile(fileId: Int64) async throws {
+func receiveFile(fileId: Int64) async {
+ do {
+ let chatItem = try await apiReceiveFile(fileId: fileId)
+ DispatchQueue.main.async { chatItemSimpleUpdate(chatItem) }
+ } catch let error {
+ logger.error("receiveFile error: \(responseError(error))")
+ }
+}
+
+func apiReceiveFile(fileId: Int64) async throws -> AChatItem {
let r = await chatSendCmd(.receiveFile(fileId: fileId))
- if case .rcvFileAccepted = r { return }
+ if case .rcvFileAccepted(let chatItem) = r { return chatItem }
throw r
}
@@ -470,14 +479,11 @@ func processReceivedMsg(_ res: ChatResponse) {
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
m.addChatItem(cInfo, cItem)
- if let file = cItem.file,
+ if case .image = cItem.content.msgContent,
+ let file = cItem.file,
file.fileSize <= maxImageSize {
Task {
- do {
- try await receiveFile(fileId: file.fileId)
- } catch {
- logger.error("receiveFile error: \(error.localizedDescription)")
- }
+ await receiveFile(fileId: file.fileId)
}
}
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
@@ -499,11 +505,7 @@ func processReceivedMsg(_ res: ChatResponse) {
}
}
case let .chatItemUpdated(aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- if m.upsertChatItem(cInfo, cItem) {
- NtfManager.shared.notifyMessageReceived(cInfo, cItem)
- }
+ chatItemSimpleUpdate(aChatItem)
case let .chatItemDeleted(_, toChatItem):
let cInfo = toChatItem.chatInfo
let cItem = toChatItem.chatItem
@@ -513,18 +515,25 @@ func processReceivedMsg(_ res: ChatResponse) {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
_ = m.upsertChatItem(cInfo, cItem)
}
+ case let .rcvFileStart(aChatItem):
+ chatItemSimpleUpdate(aChatItem)
case let .rcvFileComplete(aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- if m.upsertChatItem(cInfo, cItem) {
- NtfManager.shared.notifyMessageReceived(cInfo, cItem)
- }
+ chatItemSimpleUpdate(aChatItem)
default:
logger.debug("unsupported event: \(res.responseType)")
}
}
}
+func chatItemSimpleUpdate(_ aChatItem: AChatItem) {
+ let m = ChatModel.shared
+ let cInfo = aChatItem.chatInfo
+ let cItem = aChatItem.chatItem
+ if m.upsertChatItem(cInfo, cItem) {
+ NtfManager.shared.notifyMessageReceived(cInfo, cItem)
+ }
+}
+
func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) {
let m = ChatModel.shared
for c in contactRefs {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index 21d7f7cf9..ecca2a577 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -55,6 +55,11 @@ struct FramedItemView: View {
} else {
ciMsgContentView (chatItem, showMember)
}
+ case let .file(text):
+ CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
+ if text != "" {
+ ciMsgContentView (chatItem, showMember)
+ }
case let .link(_, preview):
CILinkView(linkPreview: preview)
ciMsgContentView (chatItem, showMember)
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 708f92512..7daa6d6c6 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -135,6 +135,12 @@ struct ChatView: View {
UIPasteboard.general.string = ci.content.text
}
} label: { Label("Copy", systemImage: "doc.on.doc") }
+ if case .image = ci.content.msgContent,
+ let image = getStoredImage(ci.file) {
+ Button {
+ UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
+ } label: { Label("Save", systemImage: "square.and.arrow.down") }
+ }
if ci.meta.editable {
Button {
withAnimation {
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 43ca22a3d..60fe84872 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -276,6 +276,8 @@ struct ComposeView: View {
return checkLinkPreview()
case .image(_, let image):
return .image(text: composeState.message, image: image)
+ case .file:
+ return .file(composeState.message)
case .unknown(let type, _):
return .unknown(type: type, text: composeState.message)
}
diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift
new file mode 100644
index 000000000..c5c864ada
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift
@@ -0,0 +1,177 @@
+//
+// CIFileView.swift
+// SimpleX
+//
+// Created by JRoberts on 28/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct CIFileView: View {
+ @Environment(\.colorScheme) var colorScheme
+ let file: CIFile?
+ let edited: Bool
+
+ var body: some View {
+ let metaReserve = edited
+ ? " "
+ : " "
+ Button(action: fileAction) {
+ HStack(alignment: .bottom, spacing: 6) {
+ fileIndicator()
+ .padding(.top, 5)
+ .padding(.bottom, 3)
+ if let file = file {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(file.fileName)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(.primary)
+ Text(formatBytes(bytes: file.fileSize) + metaReserve)
+ .font(.caption)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(.secondary)
+ }
+ } else {
+ Text(metaReserve)
+ }
+ }
+ .padding(.top, 4)
+ .padding(.bottom, 6)
+ .padding(.leading, 10)
+ .padding(.trailing, 12)
+ }
+ .disabled(file == nil || (file?.fileStatus != .rcvInvitation && file?.fileStatus != .rcvAccepted && file?.fileStatus != .rcvComplete))
+ }
+
+ func fileSizeValid() -> Bool {
+ if let file = file {
+ return file.fileSize <= maxFileSize
+ }
+ return false
+ }
+
+ func fileAction() {
+ logger.debug("CIFileView processFile")
+ if let file = file {
+ switch (file.fileStatus) {
+ case .rcvInvitation:
+ if fileSizeValid() {
+ Task {
+ logger.debug("CIFileView processFile - in .rcvInvitation, in Task")
+ await receiveFile(fileId: file.fileId)
+ }
+ } else {
+ AlertManager.shared.showAlertMsg(
+ title: "Large file!",
+ message: "Your contact sent a file that is larger than currently supported maximum size (\(maxFileSize) bytes)."
+ )
+ }
+ case .rcvAccepted:
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for file",
+ message: "File will be received when your contact is online, please wait or check later!"
+ )
+ case .rcvComplete:
+ logger.debug("CIFileView processFile - in .rcvComplete")
+ if let filePath = getStoredFilePath(file){
+ let url = URL(fileURLWithPath: filePath)
+ showShareSheet(items: [url])
+ }
+ default: break
+ }
+ }
+ }
+
+ @ViewBuilder func fileIndicator() -> some View {
+ if let file = file {
+ switch file.fileStatus {
+ case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
+ case .rcvInvitation:
+ if fileSizeValid() {
+ fileIcon("arrow.down.doc.fill", color: .accentColor)
+ } else {
+ fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
+ }
+ case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12)
+ case .rcvTransfer: ProgressView().frame(width: 30, height: 30)
+ case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
+ default: fileIcon("doc.fill")
+ }
+ } else {
+ fileIcon("doc.fill")
+ }
+ }
+
+ func fileIcon(_ icon: String, color: Color = .secondary, innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
+ ZStack(alignment: .center) {
+ Image(systemName: icon)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 30, height: 30)
+ .foregroundColor(color)
+ if let innerIcon = innerIcon,
+ let innerIconSize = innerIconSize {
+ Image(systemName: innerIcon)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxHeight: 16)
+ .frame(width: innerIconSize, height: innerIconSize)
+ .foregroundColor(.white)
+ .padding(.top, 12)
+ }
+ }
+ }
+
+ func formatBytes(bytes: Int64) -> String {
+ if (bytes == 0) { return "0 bytes" }
+
+ let bytesDouble = Double(bytes)
+ let k: Double = 1000
+ let units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
+
+ let i = floor(log2(bytesDouble) / log2(k))
+ let size = bytesDouble / pow(k, i)
+ let unit = units[Int(i)]
+
+ if (i <= 1) {
+ return String(format: "%.0f \(unit)", size)
+ } else {
+ return String(format: "%.2f \(unit)", size)
+ }
+ }
+}
+
+struct CIFileView_Previews: PreviewProvider {
+ static var previews: some View {
+ let sentFile = ChatItem(
+ chatDir: .directSnd,
+ meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
+ content: .sndMsgContent(msgContent: .file("")),
+ quotedItem: nil,
+ file: CIFile.getSample(fileStatus: .sndStored)
+ )
+ let fileChatItemWtFile = ChatItem(
+ chatDir: .directRcv,
+ meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
+ content: .rcvMsgContent(msgContent: .file("")),
+ quotedItem: nil,
+ file: nil
+ )
+ Group{
+ ChatItemView(chatItem: sentFile)
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample())
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 2000000, fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: fileChatItemWtFile)
+ }
+ .previewLayout(.fixed(width: 360, height: 360))
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift
index 13d3f6e1a..61518f043 100644
--- a/apps/ios/Shared/Views/Helpers/CIImageView.swift
+++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift
@@ -41,6 +41,14 @@ struct CIImageView: View {
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
+ .onTapGesture {
+ if case .rcvAccepted = file?.fileStatus {
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for image",
+ message: "Image will be received when your contact is online, please wait or check later!"
+ )
+ }
+ }
}
}
}
@@ -48,9 +56,32 @@ struct CIImageView: View {
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75
DispatchQueue.main.async { imgWidth = w }
- return Image(uiImage: img)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: w)
+ return ZStack(alignment: .topTrailing) {
+ Image(uiImage: img)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: w)
+ loadingIndicator()
+ .padding(8)
+ }
+ }
+
+ @ViewBuilder private func loadingIndicator() -> some View {
+ if let file = file {
+ switch file.fileStatus {
+ case .rcvAccepted:
+ Image(systemName: "ellipsis")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 20, height: 20)
+ .foregroundColor(.white)
+ case .rcvTransfer:
+ ProgressView()
+ .progressViewStyle(.circular)
+ .frame(width: 20, height: 20)
+ .tint(.white)
+ default: EmptyView()
+ }
+ }
}
}
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index e5b00c54b..24c3cc472 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -90,6 +90,7 @@
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
+ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
@@ -207,6 +208,7 @@
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; };
+ 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; };
@@ -331,6 +333,7 @@
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
+ 648010AA281ADD15009009B9 /* CIFileView.swift */,
);
path = Helpers;
sourceTree = "";
@@ -625,6 +628,7 @@
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
+ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C764E80279C7276000C6508 /* dummy.m in Sources */,
@@ -864,6 +868,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users";
+ INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Access to Photo Library is needed for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -905,6 +910,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users";
+ INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Access to Photo Library is needed for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
From 70ea803a49e1329f44519fe4cac4f6e718787d4b Mon Sep 17 00:00:00 2001
From: JRoberts <8711996+jr-simplex@users.noreply.github.com>
Date: Wed, 4 May 2022 16:08:40 +0400
Subject: [PATCH 3/3] ios: move image utils to FileUtils (#598)
---
apps/ios/Shared/FileUtils.swift | 75 +++++++++++++++++++
.../Shared/Views/Helpers/ImagePicker.swift | 74 ------------------
2 files changed, 75 insertions(+), 74 deletions(-)
diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift
index e6cf6fa14..b3346293a 100644
--- a/apps/ios/Shared/FileUtils.swift
+++ b/apps/ios/Shared/FileUtils.swift
@@ -37,3 +37,78 @@ func getStoredImage(_ file: CIFile?) -> UIImage? {
}
return nil
}
+
+// image utils
+
+func dropImagePrefix(_ s: String) -> String {
+ dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
+}
+
+private func dropPrefix(_ s: String, _ prefix: String) -> String {
+ s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
+}
+
+func cropToSquare(_ image: UIImage) -> UIImage {
+ let size = image.size
+ let side = min(size.width, size.height)
+ let newSize = CGSize(width: side, height: side)
+ var origin = CGPoint.zero
+ if size.width > side {
+ origin.x -= (size.width - side) / 2
+ } 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))
+}
+
+func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
+ var img = image
+ var data = 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 = img.jpegData(compressionQuality: 0.85)
+ dataSize = data?.count ?? 0
+ }
+ logger.debug("resizeImageToDataSize final \(dataSize)")
+ return data
+}
+
+func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
+ var img = image
+ var str = compressImageStr(img)
+ 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)
+ dataSize = str?.count ?? 0
+ }
+ logger.debug("resizeImageToStrSize final \(dataSize)")
+ return str
+}
+
+func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
+ if let data = image.jpegData(compressionQuality: compressionQuality) {
+ return "data:image/jpg;base64,\(data.base64EncodedString())"
+ }
+ return nil
+}
+
+private func reduceSize(_ image: UIImage, ratio: CGFloat) -> 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)
+}
+
+private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = 1.0
+ format.opaque = true
+ return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
+ image.draw(in: drawIn)
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
index 06c4825c6..8c2b68b8b 100644
--- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift
+++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
@@ -9,80 +9,6 @@
import SwiftUI
import PhotosUI
-func dropPrefix(_ s: String, _ prefix: String) -> String {
- s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
-}
-
-func dropImagePrefix(_ s: String) -> String {
- dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
-}
-
-private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
- let format = UIGraphicsImageRendererFormat()
- format.scale = 1.0
- format.opaque = true
- return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
- image.draw(in: drawIn)
- }
-}
-
-func cropToSquare(_ image: UIImage) -> UIImage {
- let size = image.size
- let side = min(size.width, size.height)
- let newSize = CGSize(width: side, height: side)
- var origin = CGPoint.zero
- if size.width > side {
- origin.x -= (size.width - side) / 2
- } 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))
-}
-
-
-func reduceSize(_ image: UIImage, ratio: CGFloat) -> 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)
-}
-
-func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
- var img = image
- var str = compressImageStr(img)
- 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)
- dataSize = str?.count ?? 0
- }
- logger.debug("resizeImageToStrSize final \(dataSize)")
- return str
-}
-
-func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
- if let data = image.jpegData(compressionQuality: compressionQuality) {
- return "data:image/jpg;base64,\(data.base64EncodedString())"
- }
- return nil
-}
-
-func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
- var img = image
- var data = 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 = img.jpegData(compressionQuality: 0.85)
- dataSize = data?.count ?? 0
- }
- logger.debug("resizeImageToDataSize final \(dataSize)")
- return data
-}
-
enum ImageSource {
case imageLibrary
case camera