diff --git a/README.md b/README.md index 234815ae48..06627e145a 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,14 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/p - ✅ End-to-end encryption using double-ratchet protocol with additional encryption layer. - ✅ Mobile apps v1 for Android and iOS. - ✅ Private instant notifications for Android using background service. -- ✅ Haskell chat bot templates +- ✅ Haskell chat bot templates. +- ✅ v2.0 - supporting images and files in mobile apps. +- 🏗 End-to-end encrypted audio and video calls via the mobile apps. +- 🏗 Automatic chat history deletion. - 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress). -- 🏗 Mobile app v2 - supporting files, images and groups etc. (in progress). - 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress). +- Groups support for mobile apps. - Chat database portability and encryption. -- End-to-end encrypted audio and video calls via the mobile apps. - Web widgets for custom interactivity in the chats. - SMP protocol improvements: - SMP queue redundancy and rotation. diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index b60f2e6840..eb53bce58d 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 26 - versionName "1.6" + versionCode 30 + versionName "2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { @@ -47,6 +47,7 @@ android { freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets" freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi" freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi" + freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi" } externalNativeBuild { cmake { @@ -80,6 +81,7 @@ dependencies { implementation "androidx.compose.material:material-icons-extended:$compose_version" implementation "androidx.navigation:navigation-compose:2.4.1" implementation "com.google.accompanist:accompanist-insets:0.23.0" + implementation 'androidx.webkit:webkit:1.4.0' def work_version = "2.7.1" implementation "androidx.work:work-runtime-ktx:$work_version" @@ -90,9 +92,11 @@ dependencies { implementation "androidx.camera:camera-camera2:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}" implementation "androidx.camera:camera-view:${camerax_version}" + //Barcode - implementation 'com.google.zxing:core:3.4.0' - implementation 'com.google.mlkit:barcode-scanning:17.0.2' + implementation 'org.boofcv:boofcv-android:0.40.1' + implementation 'org.boofcv:boofcv-core:0.40.1' + //Camera Permission implementation "com.google.accompanist:accompanist-permissions:0.23.0" diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 17c4303b20..5062b90ed6 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,33 +1,38 @@ + package="chat.simplex.app"> + - - - - - + + + + + + + + + android:name="SimplexApp" + android:allowBackup="true" + android:icon="@mipmap/icon" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.SimpleX"> + android:name=".MainActivity" + android:launchMode="singleTask" + android:exported="true" + android:label="@string/app_name" + android:windowSoftInputMode="adjustResize" + android:theme="@style/Theme.SimpleX"> @@ -36,10 +41,24 @@ + + + + + + + + + + + + + + + android:resource="@xml/file_paths" /> @@ -57,8 +76,7 @@ android:name=".SimplexService" android:enabled="true" android:exported="false" - android:stopWithTask="false"> - + android:stopWithTask="false"> - + @@ -74,6 +92,6 @@ + android:exported="false" /> diff --git a/apps/android/app/src/main/assets/www/README.md b/apps/android/app/src/main/assets/www/README.md new file mode 100644 index 0000000000..cf012e4d58 --- /dev/null +++ b/apps/android/app/src/main/assets/www/README.md @@ -0,0 +1,3 @@ +# WebView for WebRTC calls in SimpleX Chat + +Do NOT edit call.js here, it is compiled abd copied here from call.ts in packages/simplex-chat-webrtc diff --git a/apps/android/app/src/main/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html new file mode 100644 index 0000000000..fd3019e8eb --- /dev/null +++ b/apps/android/app/src/main/assets/www/call.html @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js new file mode 100644 index 0000000000..e74864b9dd --- /dev/null +++ b/apps/android/app/src/main/assets/www/call.js @@ -0,0 +1,469 @@ +"use strict"; +// Inspired by +// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption +var CallMediaType; +(function (CallMediaType) { + CallMediaType["Audio"] = "audio"; + CallMediaType["Video"] = "video"; +})(CallMediaType || (CallMediaType = {})); +const keyAlgorithm = { + name: "AES-GCM", + length: 256, +}; +const keyUsages = ["encrypt", "decrypt"]; +let activeCall; +const IV_LENGTH = 12; +const initialPlainTextRequired = { + key: 10, + delta: 3, + undefined: 1, +}; +function defaultCallConfig(encodedInsertableStreams) { + return { + peerConnectionConfig: { + iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }], + iceCandidatePoolSize: 10, + encodedInsertableStreams, + }, + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000, + }, + }; +} +async function initializeCall(config, mediaType, aesKey) { + const conn = new RTCPeerConnection(config.peerConnectionConfig); + const remoteStream = new MediaStream(); + const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType)); + await setUpMediaStreams(conn, localStream, remoteStream, aesKey); + conn.addEventListener("connectionstatechange", connectionStateChange); + const iceCandidates = new Promise((resolve, _) => { + let candidates = []; + let resolved = false; + let extrasInterval; + let extrasTimeout; + const delay = setTimeout(() => { + if (!resolved) { + resolveIceCandidates(); + extrasInterval = setInterval(() => { + sendIceCandidates(); + }, config.iceCandidates.extrasInterval); + extrasTimeout = setTimeout(() => { + clearInterval(extrasInterval); + sendIceCandidates(); + }, config.iceCandidates.extrasTimeout); + } + }, config.iceCandidates.delay); + conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c); + conn.onicegatheringstatechange = () => { + if (conn.iceGatheringState == "complete") { + if (resolved) { + if (extrasInterval) + clearInterval(extrasInterval); + if (extrasTimeout) + clearTimeout(extrasTimeout); + sendIceCandidates(); + } + else { + resolveIceCandidates(); + } + } + }; + function resolveIceCandidates() { + if (delay) + clearTimeout(delay); + resolved = true; + const iceCandidates = candidates.map((c) => JSON.stringify(c)); + candidates = []; + resolve(iceCandidates); + } + function sendIceCandidates() { + if (candidates.length === 0) + return; + const iceCandidates = candidates.map((c) => JSON.stringify(c)); + candidates = []; + sendMessageToNative({ resp: { type: "ice", iceCandidates } }); + } + }); + return { connection: conn, iceCandidates, localMedia: mediaType, localStream }; + function connectionStateChange() { + sendMessageToNative({ + resp: { + type: "connection", + state: { + connectionState: conn.connectionState, + iceConnectionState: conn.iceConnectionState, + iceGatheringState: conn.iceGatheringState, + signalingState: conn.signalingState, + }, + }, + }); + if (conn.connectionState == "disconnected" || conn.connectionState == "failed") { + conn.removeEventListener("connectionstatechange", connectionStateChange); + sendMessageToNative({ resp: { type: "ended" } }); + conn.close(); + activeCall = undefined; + resetVideoElements(); + } + } +} +var sendMessageToNative = (msg) => console.log(JSON.stringify(msg)); +async function processCommand(body) { + const { corrId, command } = body; + const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection; + let resp; + try { + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams(); + resp = { type: "capabilities", capabilities: { encryption } }; + break; + case "start": + console.log("starting call"); + if (activeCall) { + resp = { type: "error", message: "start: call already started" }; + } + else if (!supportsInsertableStreams() && command.aesKey) { + resp = { type: "error", message: "start: encryption is not supported" }; + } + else { + const encryption = supportsInsertableStreams(); + const { media, aesKey } = command; + activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, encryption ? aesKey : undefined); + const pc = activeCall.connection; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + // for debugging, returning the command for callee to use + // resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey} + resp = { + type: "offer", + offer: JSON.stringify(offer), + iceCandidates: await activeCall.iceCandidates, + capabilities: { encryption }, + }; + } + break; + case "accept": + if (activeCall) { + resp = { type: "error", message: "accept: call already started" }; + } + else if (!supportsInsertableStreams() && command.aesKey) { + resp = { type: "error", message: "accept: encryption is not supported" }; + } + else { + const offer = JSON.parse(command.offer); + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)); + activeCall = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey); + const pc = activeCall.connection; + await pc.setRemoteDescription(new RTCSessionDescription(offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + addIceCandidates(pc, remoteIceCandidates); + // same as command for caller to use + resp = { + type: "answer", + answer: JSON.stringify(answer), + iceCandidates: await activeCall.iceCandidates, + }; + } + break; + case "answer": + if (!pc) { + resp = { type: "error", message: "answer: call not started" }; + } + else if (!pc.localDescription) { + resp = { type: "error", message: "answer: local description is not set" }; + } + else if (pc.currentRemoteDescription) { + resp = { type: "error", message: "answer: remote description already set" }; + } + else { + const answer = JSON.parse(command.answer); + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)); + await pc.setRemoteDescription(new RTCSessionDescription(answer)); + addIceCandidates(pc, remoteIceCandidates); + resp = { type: "ok" }; + } + break; + case "ice": + if (pc) { + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)); + addIceCandidates(pc, remoteIceCandidates); + resp = { type: "ok" }; + } + else { + resp = { type: "error", message: "ice: call not started" }; + } + break; + case "media": + if (!activeCall) { + resp = { type: "error", message: "media: call not started" }; + } + else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { + resp = { type: "error", message: "media: no video" }; + } + else { + enableMedia(activeCall.localStream, command.media, command.enable); + resp = { type: "ok" }; + } + break; + case "end": + if (pc) { + pc.close(); + activeCall = undefined; + resetVideoElements(); + resp = { type: "ok" }; + } + else { + resp = { type: "error", message: "end: call not started" }; + } + break; + default: + resp = { type: "error", message: "unknown command" }; + break; + } + } + catch (e) { + resp = { type: "error", message: e.message }; + } + const apiResp = { corrId, resp, command }; + sendMessageToNative(apiResp); + return apiResp; +} +function addIceCandidates(conn, iceCandidates) { + for (const c of iceCandidates) { + conn.addIceCandidate(new RTCIceCandidate(c)); + } +} +async function setUpMediaStreams(pc, localStream, remoteStream, aesKey) { + var _a; + const videos = getVideoElements(); + if (!videos) + throw Error("no video elements"); + let key; + if (aesKey) { + const keyData = decodeBase64(encodeAscii(aesKey)); + if (keyData) + key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages); + } + for (const track of localStream.getTracks()) { + pc.addTrack(track, localStream); + } + if (key) { + console.log("set up encryption for sending"); + for (const sender of pc.getSenders()) { + setupPeerTransform(sender, encodeFunction(key)); + } + } + // Pull tracks from remote stream as they arrive add them to remoteStream video + pc.ontrack = (event) => { + if (key) { + console.log("set up decryption for receiving"); + setupPeerTransform(event.receiver, decodeFunction(key)); + } + for (const track of event.streams[0].getTracks()) { + remoteStream.addTrack(track); + } + }; + // We assume VP8 encoding in the decode/encode stages to get the initial + // bytes to pass as plaintext so we enforce that here. + // VP8 is supported by all supports of webrtc. + // Use of VP8 by default may also reduce depacketisation issues. + // We do not encrypt the first couple of bytes of the payload so that the + // video elements can work by determining video keyframes and the opus mode + // being used. This appears to be necessary for any video feed at all. + // For VP8 this is the content described in + // https://tools.ietf.org/html/rfc6386#section-9.1 + // which is 10 bytes for key frames and 3 bytes for delta frames. + // For opus (where encodedFrame.type is not set) this is the TOC byte from + // https://tools.ietf.org/html/rfc6716#section-3.1 + const capabilities = RTCRtpSender.getCapabilities("video"); + if (capabilities) { + const { codecs } = capabilities; + const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8"); + const selectedCodec = codecs[selectedCodecIndex]; + codecs.splice(selectedCodecIndex, 1); + codecs.unshift(selectedCodec); + for (const t of pc.getTransceivers()) { + if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") { + t.setCodecPreferences(codecs); + } + } + } + // setupVideoElement(videos.local) + // setupVideoElement(videos.remote) + videos.local.srcObject = localStream; + videos.remote.srcObject = remoteStream; +} +function callMediaConstraints(mediaType) { + switch (mediaType) { + case CallMediaType.Audio: + return { audio: true, video: false }; + case CallMediaType.Video: + return { + audio: true, + video: { + frameRate: 24, + width: { + min: 480, + ideal: 720, + max: 1280, + }, + aspectRatio: 1.33, + }, + }; + } +} +function supportsInsertableStreams() { + return "createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype; +} +function resetVideoElements() { + const videos = getVideoElements(); + if (!videos) + return; + videos.local.srcObject = null; + videos.remote.srcObject = null; +} +function getVideoElements() { + const local = document.getElementById("local-video-stream"); + const remote = document.getElementById("remote-video-stream"); + if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement)) + return; + return { local, remote }; +} +// function setupVideoElement(video: HTMLElement) { +// // TODO use display: none +// video.style.opacity = "0" +// video.onplaying = () => { +// video.style.opacity = "1" +// } +// } +function enableMedia(s, media, enable) { + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); + for (const t of tracks) + t.enabled = enable; +} +/* Stream Transforms */ +function setupPeerTransform(peer, transform) { + const streams = peer.createEncodedStreams(); + streams.readable.pipeThrough(new TransformStream({ transform })).pipeTo(streams.writable); +} +/* Cryptography */ +function encodeFunction(key) { + return async (frame, controller) => { + const data = new Uint8Array(frame.data); + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0; + const iv = randomIV(); + const initial = data.subarray(0, n); + const plaintext = data.subarray(n, data.byteLength); + try { + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext); + frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer; + controller.enqueue(frame); + } + catch (e) { + console.log(`encryption error ${e}`); + throw e; + } + }; +} +function decodeFunction(key) { + return async (frame, controller) => { + const data = new Uint8Array(frame.data); + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0; + const initial = data.subarray(0, n); + const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH); + const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength); + try { + const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + frame.data = concatN(initial, new Uint8Array(plaintext)).buffer; + controller.enqueue(frame); + } + catch (e) { + console.log(`decryption error ${e}`); + throw e; + } + }; +} +class RTCEncodedVideoFrame { + constructor(type, data) { + this.type = type; + this.data = data; + } +} +function randomIV() { + return crypto.getRandomValues(new Uint8Array(IV_LENGTH)); +} +const char_equal = "=".charCodeAt(0); +function concatN(...bs) { + const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0)); + bs.reduce((offset, b) => { + a.set(b, offset); + return offset + b.byteLength; + }, 0); + return a; +} +function encodeAscii(s) { + const a = new Uint8Array(s.length); + let i = s.length; + while (i--) + a[i] = s.charCodeAt(i); + return a; +} +function decodeAscii(a) { + let s = ""; + for (let i = 0; i < a.length; i++) + s += String.fromCharCode(a[i]); + return s; +} +const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0))); +const base64lookup = new Array(256); +base64chars.forEach((c, i) => (base64lookup[c] = i)); +function encodeBase64(a) { + const len = a.length; + const b64len = Math.ceil(len / 3) * 4; + const b64 = new Uint8Array(b64len); + let j = 0; + for (let i = 0; i < len; i += 3) { + b64[j++] = base64chars[a[i] >> 2]; + b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)]; + b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)]; + b64[j++] = base64chars[a[i + 2] & 63]; + } + if (len % 3) + b64[b64len - 1] = char_equal; + if (len % 3 === 1) + b64[b64len - 2] = char_equal; + return b64; +} +function decodeBase64(b64) { + let len = b64.length; + if (len % 4) + return; + let bLen = (len * 3) / 4; + if (b64[len - 1] === char_equal) { + len--; + bLen--; + if (b64[len - 1] === char_equal) { + len--; + bLen--; + } + } + const bytes = new Uint8Array(bLen); + let i = 0; + let pos = 0; + while (i < len) { + const enc1 = base64lookup[b64[i++]]; + const enc2 = i < len ? base64lookup[b64[i++]] : 0; + const enc3 = i < len ? base64lookup[b64[i++]] : 0; + const enc4 = i < len ? base64lookup[b64[i++]] : 0; + if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined) + return; + bytes[pos++] = (enc1 << 2) | (enc2 >> 4); + bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2); + bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63); + } + return bytes; +} +//# sourceMappingURL=call.js.map \ No newline at end of file diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css new file mode 100644 index 0000000000..7b18648d2d --- /dev/null +++ b/apps/android/app/src/main/assets/www/style.css @@ -0,0 +1,26 @@ +video::-webkit-media-controls { + display: none; +} +html, +body { + padding: 0; + margin: 0; + background-color: black; +} +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + bottom: 0; + right: 0; +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 1b08da4697..4be64a5c68 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -9,25 +9,25 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.AndroidViewModel import androidx.work.* import chat.simplex.app.model.ChatModel import chat.simplex.app.model.NtfManager import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.SplashView -import chat.simplex.app.views.WelcomeView import chat.simplex.app.views.chat.ChatView import chat.simplex.app.views.chatlist.ChatListView import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.connectViaUri import chat.simplex.app.views.newchat.withUriAction +import chat.simplex.app.views.onboarding.* import java.util.concurrent.TimeUnit //import kotlinx.serialization.decodeFromString @@ -39,7 +39,6 @@ class MainActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // testJson() - processIntent(intent, vm.chatModel) setContent { SimpleXTheme { Surface( @@ -54,6 +53,11 @@ class MainActivity: ComponentActivity() { schedulePeriodicServiceRestartWorker() } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + processIntent(intent, vm.chatModel) + } + private fun schedulePeriodicServiceRestartWorker() { val workerVersion = chatController.getAutoRestartWorkerVersion() val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) { @@ -81,12 +85,18 @@ class SimplexViewModel(application: Application): AndroidViewModel(application) @Composable fun MainPage(chatModel: ChatModel) { Box { - when (chatModel.userCreated.value) { - null -> SplashView() - false -> WelcomeView(chatModel) - true -> + val onboarding = chatModel.onboardingStage.value + val userCreated = chatModel.userCreated.value + when { + onboarding == null || userCreated == null -> SplashView() + onboarding == OnboardingStage.OnboardingComplete && userCreated -> if (chatModel.chatId.value == null) ChatListView(chatModel) else ChatView(chatModel) + onboarding == OnboardingStage.Step1_SimpleXInfo -> + Box(Modifier.padding(horizontal = 20.dp)) { + SimpleXInfo(chatModel, onboarding = true) + } + onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) } ModalManager.shared.showInView() AlertManager.shared.showInView() diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 770cb0c643..e560e98053 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.* import chat.simplex.app.model.* import chat.simplex.app.views.helpers.getFilesDirectory import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.onboarding.OnboardingStage import java.io.BufferedReader import java.io.InputStreamReader import java.util.* @@ -46,7 +47,9 @@ class SimplexApp: Application(), LifecycleEventObserver { ProcessLifecycleOwner.get().lifecycle.addObserver(this) withApi { val user = chatController.apiGetActiveUser() - if (user != null) { + if (user == null) { + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } else { chatController.startChat(user) SimplexService.start(applicationContext) chatController.showBackgroundServiceNotice() diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt index 34907f9a48..94ae6a7371 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt @@ -8,6 +8,7 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.work.* import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,7 +66,9 @@ class SimplexService: Service() { withApi { try { val user = chatController.apiGetActiveUser() - if (user != null) { + if (user == null) { + chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } else { Log.w(TAG, "Starting foreground service") chatController.startChat(user) chatController.startReceiver() 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 eec3f1d9ff..8dfab64c3a 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 @@ -10,7 +10,9 @@ import androidx.compose.ui.text.style.TextDecoration import chat.simplex.app.R import chat.simplex.app.ui.theme.SecretColor import chat.simplex.app.ui.theme.SimplexBlue +import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.datetime.* import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -19,23 +21,30 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* class ChatModel(val controller: ChatController) { - var currentUser = mutableStateOf(null) - var userCreated = mutableStateOf(null) - var chats = mutableStateListOf() - var chatId = mutableStateOf(null) - var chatItems = mutableStateListOf() + val onboardingStage = mutableStateOf(null) + val currentUser = mutableStateOf(null) + val userCreated = mutableStateOf(null) + val chats = mutableStateListOf() + val chatId = mutableStateOf(null) + val chatItems = mutableStateListOf() var connReqInvitation: String? = null - var terminalItems = mutableStateListOf() - var userAddress = mutableStateOf(null) - var userSMPServers = mutableStateOf<(List)?>(null) + val terminalItems = mutableStateListOf() + val userAddress = mutableStateOf(null) + val userSMPServers = mutableStateOf<(List)?>(null) // set when app opened from external intent - var clearOverlays = mutableStateOf(false) + val clearOverlays = mutableStateOf(false) // set when app is opened via contact or invitation URI - var appOpenUrl = mutableStateOf(null) - var runServiceInBackground = mutableStateOf(true) + val appOpenUrl = mutableStateOf(null) + val runServiceInBackground = mutableStateOf(true) + + // current WebRTC call + val callInvitations = mutableStateMapOf() + val activeCallInvitation = mutableStateOf(null) + val activeCall = mutableStateOf(null) + val callCommand = mutableStateOf(null) fun updateUserProfile(profile: Profile) { val user = currentUser.value @@ -54,17 +63,20 @@ class ChatModel(val controller: ChatController) { if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo) } - fun updateContact(contact: Contact) { - val cInfo = ChatInfo.Direct(contact) - if (hasChat(contact.id)) { + fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) + + fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact)) + + private fun updateChat(cInfo: ChatInfo) { + if (hasChat(cInfo.id)) { updateChatInfo(cInfo) } else { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf())) } } - fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) { - val i = getChatIndex(contact.id) + fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) { + val i = getChatIndex(id) if (i >= 0) { val chat = chats[i] chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status)) @@ -116,8 +128,8 @@ class ChatModel(val controller: ChatController) { val res: Boolean if (i >= 0) { chat = chats[i] - val pItem = chat.chatItems.last() - if (pItem.id == cItem.id) { + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) } res = false @@ -146,8 +158,8 @@ class ChatModel(val controller: ChatController) { val chat: Chat if (i >= 0) { chat = chats[i] - val pItem = chat.chatItems.last() - if (pItem.id == cItem.id) { + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) } } @@ -168,17 +180,17 @@ class ChatModel(val controller: ChatController) { while (i < chatItems.count()) { val item = chatItems[i] if (item.meta.itemStatus is CIStatus.RcvNew) { - chatItems[i] = item.copy(meta=item.meta.copy(itemStatus = CIStatus.RcvRead())) + chatItems[i] = item.withStatus(CIStatus.RcvRead()) } i += 1 } val chat = chats[chatIdx] + val pItem = chat.chatItems.lastOrNull() chats[chatIdx] = chat.copy( - chatItems = chatItems, + chatItems = if (pItem == null) arrayListOf() else arrayListOf(pItem.withStatus(CIStatus.RcvRead())), chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1) ) } - } // func popChat(_ id: String) { @@ -200,14 +212,8 @@ class ChatModel(val controller: ChatController) { enum class ChatType(val type: String) { Direct("@"), Group("#"), - ContactRequest("<@"); - - val chatTypeName: String get () = - when (this) { - Direct -> "contact" - Group -> "group" - ContactRequest -> "contact request" - } + ContactRequest("<@"), + ContactConnection(":"); } @Serializable @@ -343,6 +349,24 @@ sealed class ChatInfo: SomeChat, NamedChat { val sampleData = ContactRequest(UserContactRequest.sampleData) } } + + @Serializable @SerialName("contactConnection") + class ContactConnection(val contactConnection: PendingContactConnection): ChatInfo() { + override val chatType get() = ChatType.ContactConnection + override val localDisplayName get() = contactConnection.localDisplayName + override val id get() = contactConnection.id + override val apiId get() = contactConnection.apiId + override val ready get() = contactConnection.ready + override val createdAt get() = contactConnection.createdAt + override val displayName get() = contactConnection.displayName + override val fullName get() = contactConnection.fullName + override val image get() = contactConnection.image + + companion object { + fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection = + ContactConnection(PendingContactConnection.getSampleData(status, viaContactUri)) + } + } } @Serializable @@ -357,7 +381,7 @@ class Contact( override val chatType get() = ChatType.Direct override val id get() = "@$contactId" override val apiId get() = contactId - override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" + override val ready get() = activeConn.connStatus == ConnStatus.Ready override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image @@ -373,6 +397,14 @@ class Contact( } } +@Serializable +class ContactRef( + val contactId: Long, + var localDisplayName: String +) { + val id: ChatId get() = "@$contactId" +} + @Serializable class ContactSubStatus( val contact: Contact, @@ -380,9 +412,10 @@ class ContactSubStatus( ) @Serializable -class Connection(val connStatus: String) { +class Connection(val connId: Long, val connStatus: ConnStatus) { + val id: ChatId get() = ":$connId" companion object { - val sampleData = Connection(connStatus = "ready") + val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready) } } @@ -491,7 +524,8 @@ class UserContactRequest ( val contactRequestId: Long, override val localDisplayName: String, val profile: Profile, - override val createdAt: Instant + override val createdAt: Instant, + val updatedAt: Instant ): SomeChat, NamedChat { override val chatType get() = ChatType.ContactRequest override val id get() = "<@$contactRequestId" @@ -506,11 +540,85 @@ class UserContactRequest ( contactRequestId = 1, localDisplayName = "alice", profile = Profile.sampleData, - createdAt = Clock.System.now() + createdAt = Clock.System.now(), + updatedAt = Clock.System.now() ) } } +@Serializable +class PendingContactConnection( + val pccConnId: Long, + val pccAgentConnId: String, + val pccConnStatus: ConnStatus, + val viaContactUri: Boolean, + override val createdAt: Instant, + val updatedAt: Instant +): SomeChat, NamedChat { + override val chatType get() = ChatType.ContactConnection + override val id get () = ":$pccConnId" + override val apiId get() = pccConnId + override val ready get() = false + override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId) + override val displayName: String get() { + val initiated = pccConnStatus.initiated + return if (initiated == null) { + // this should not be in the chat list + generalGetString(R.string.display_name_connection_established) + } else { + generalGetString( + if (initiated && !viaContactUri) R.string.display_name_invited_to_connect + else R.string.display_name_connecting + ) + } + } + override val fullName get() = "" + override val image get() = null + val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri + + val description: String get() { + val initiated = pccConnStatus.initiated + return if (initiated == null) "" else generalGetString( + if (initiated && !viaContactUri) R.string.description_you_shared_one_time_link + else if (viaContactUri ) R.string.description_via_contact_address_link + else R.string.description_via_one_time_link + ) + } + + companion object { + fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): PendingContactConnection = + PendingContactConnection( + pccConnId = 1, + pccAgentConnId = "abcd", + pccConnStatus = status, + viaContactUri = viaContactUri, + createdAt = Clock.System.now(), + updatedAt = Clock.System.now() + ) + } +} + +@Serializable +enum class ConnStatus { + @SerialName("new") New, + @SerialName("joined") Joined, + @SerialName("requested") Requested, + @SerialName("accepted") Accepted, + @SerialName("snd-ready") SndReady, + @SerialName("ready") Ready, + @SerialName("deleted") Deleted; + + val initiated: Boolean? get() = when (this) { + New -> true + Joined -> false + Requested -> true + Accepted -> true + SndReady -> false + Ready -> null + Deleted -> null + } +} + @Serializable class AChatItem ( val chatInfo: ChatInfo, @@ -555,6 +663,15 @@ data class ChatItem ( else -> false } + val isCall: Boolean get() = + when (content) { + is CIContent.SndCall -> true + is CIContent.RcvCall -> true + else -> false + } + + fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status)) + companion object { fun getSampleData( id: Long = 1, @@ -576,6 +693,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(), @@ -595,26 +727,16 @@ data class ChatItem ( @Serializable sealed class CIDirection { - abstract val sent: Boolean + @Serializable @SerialName("directSnd") class DirectSnd: CIDirection() + @Serializable @SerialName("directRcv") class DirectRcv: CIDirection() + @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection() + @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection() - @Serializable @SerialName("directSnd") - class DirectSnd: CIDirection() { - override val sent get() = true - } - - @Serializable @SerialName("directRcv") - class DirectRcv: CIDirection() { - override val sent get() = false - } - - @Serializable @SerialName("groupSnd") - class GroupSnd: CIDirection() { - override val sent get() = true - } - - @Serializable @SerialName("groupRcv") - class GroupRcv(val groupMember: GroupMember): CIDirection() { - override val sent get() = false + val sent: Boolean get() = when(this) { + is DirectSnd -> true + is DirectRcv -> false + is GroupSnd -> true + is GroupRcv -> false } } @@ -661,23 +783,12 @@ fun getTimestampText(t: Instant): String { @Serializable sealed class CIStatus { - @Serializable @SerialName("sndNew") - class SndNew: CIStatus() - - @Serializable @SerialName("sndSent") - class SndSent: CIStatus() - - @Serializable @SerialName("sndErrorAuth") - class SndErrorAuth: CIStatus() - - @Serializable @SerialName("sndError") - class SndError(val agentError: AgentErrorType): CIStatus() - - @Serializable @SerialName("rcvNew") - class RcvNew: CIStatus() - - @Serializable @SerialName("rcvRead") - class RcvRead: CIStatus() + @Serializable @SerialName("sndNew") class SndNew: CIStatus() + @Serializable @SerialName("sndSent") class SndSent: CIStatus() + @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus() + @Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus() + @Serializable @SerialName("rcvNew") class RcvNew: CIStatus() + @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() } @Serializable @@ -692,29 +803,22 @@ interface ItemContent { @Serializable sealed class CIContent: ItemContent { - abstract override val text: String abstract val msgContent: MsgContent? - @Serializable @SerialName("sndMsgContent") - class SndMsgContent(override val msgContent: MsgContent): CIContent() { - override val text get() = msgContent.text - } + @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent() + @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent() + @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null } + @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null } + @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null } + @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null } - @Serializable @SerialName("rcvMsgContent") - class RcvMsgContent(override val msgContent: MsgContent): CIContent() { - override val text get() = msgContent.text - } - - @Serializable @SerialName("sndDeleted") - class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = generalGetString(R.string.deleted_description) - override val msgContent get() = null - } - - @Serializable @SerialName("rcvDeleted") - class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = generalGetString(R.string.deleted_description) - override val msgContent get() = null + override val text: String get() = when(this) { + is SndMsgContent -> msgContent.text + is RcvMsgContent -> msgContent.text + is SndDeleted -> generalGetString(R.string.deleted_description) + is RcvDeleted -> generalGetString(R.string.deleted_description) + is SndCall -> status.text(duration) + is RcvCall -> status.text(duration) } } @@ -751,15 +855,26 @@ class CIFile( val filePath: String? = null, val fileStatus: CIFileStatus ) { - val stored: Boolean = when (fileStatus) { + val loaded: Boolean = when (fileStatus) { CIFileStatus.SndStored -> true + CIFileStatus.SndTransfer -> true + CIFileStatus.SndComplete -> true CIFileStatus.SndCancelled -> true + CIFileStatus.RcvInvitation -> false + CIFileStatus.RcvAccepted -> false + CIFileStatus.RcvTransfer -> false + CIFileStatus.RcvCancelled -> false CIFileStatus.RcvComplete -> true - else -> false } 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) } } @@ -767,8 +882,11 @@ class CIFile( @Serializable enum class CIFileStatus { @SerialName("snd_stored") SndStored, + @SerialName("snd_transfer") SndTransfer, + @SerialName("snd_complete") SndComplete, @SerialName("snd_cancelled") SndCancelled, @SerialName("rcv_invitation") RcvInvitation, + @SerialName("rcv_accepted") RcvAccepted, @SerialName("rcv_transfer") RcvTransfer, @SerialName("rcv_complete") RcvComplete, @SerialName("rcv_cancelled") RcvCancelled; @@ -779,28 +897,22 @@ enum class CIFileStatus { sealed class MsgContent { abstract val text: String - @Serializable(with = MsgContentSerializer::class) - class MCText(override val text: String): MsgContent() - - @Serializable(with = MsgContentSerializer::class) - class MCLink(override val text: String, val preview: LinkPreview): MsgContent() - - @Serializable(with = MsgContentSerializer::class) - class MCImage(override val text: String, val image: String): MsgContent() - - @Serializable(with = MsgContentSerializer::class) - class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): 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() val cmdString: String get() = when (this) { 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" } } object MsgContentSerializer : KSerializer { - @OptIn(InternalSerializationApi::class) override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { element("MCText", buildClassSerialDescriptor("MCText") { element("text") @@ -813,6 +925,9 @@ object MsgContentSerializer : KSerializer { element("text") element("image") }) + element("MCFile", buildClassSerialDescriptor("MCFile") { + element("text") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -833,6 +948,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 { @@ -863,6 +979,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) @@ -930,3 +1051,34 @@ enum class FormatColor(val color: String) { white -> MaterialTheme.colors.onBackground } } + +@Serializable +class SndFileTransfer() {} + +@Serializable +class FileTransferMeta() {} + +@Serializable +enum class CICallStatus { + @SerialName("pending") Pending, + @SerialName("missed") Missed, + @SerialName("rejected") Rejected, + @SerialName("accepted") Accepted, + @SerialName("negotiated") Negotiated, + @SerialName("progress") Progress, + @SerialName("ended") Ended, + @SerialName("error") Error; + + fun text(sec: Int): String = when (this) { + Pending -> generalGetString(R.string.callstatus_calling) + Missed -> generalGetString(R.string.callstatus_missed) + Rejected -> generalGetString(R.string.callstatus_rejected) + Accepted -> generalGetString(R.string.callstatus_accepted) + Negotiated -> generalGetString(R.string.callstatus_connecting) + Progress -> generalGetString(R.string.callstatus_in_progress) + Ended -> String.format(generalGetString(R.string.callstatus_ended), duration(sec)) + Error -> generalGetString(R.string.callstatus_error) + } + + fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index 2d83c79d50..3e1fee40f9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -79,7 +79,11 @@ class NtfManager(val context: Context) { private fun hideSecrets(cItem: ChatItem) : String { val md = cItem.formattedText return if (md == null) { - cItem.content.text + if (cItem.content.text != "") { + cItem.content.text + } else { + cItem.file?.fileName ?: "" + } } else { var res = "" for (ft in md) { 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 1e7afd0875..9f2e2835e8 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 @@ -9,14 +9,15 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier -import androidx.compose.ui.text.* +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* import chat.simplex.app.R +import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.onboarding.OnboardingStage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -46,8 +47,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt val chats = apiGetChats() chatModel.chats.clear() chatModel.chats.addAll(chats) - chatModel.currentUser = mutableStateOf(user) + chatModel.currentUser.value = user chatModel.userCreated.value = true + chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete Log.d(TAG, "started chat") } catch(e: Error) { Log.e(TAG, "failed starting chat $e") @@ -223,6 +225,15 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt ) return false } + r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent + && r.chatError.agentError is AgentErrorType.SMP + && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.connection_error_auth), + generalGetString(R.string.connection_error_auth_desc) + ) + return false + } else -> { apiErrorAlert("apiConnect", "Connection error", r) return false @@ -232,9 +243,10 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean { val r = sendCmd(CC.ApiDeleteChat(type, id)) - when (r) { - is CR.ContactDeleted -> return true // TODO groups - is CR.ChatCmdError -> { + when { + r is CR.ContactDeleted && type == ChatType.Direct -> return true + r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true + r is CR.ChatCmdError -> { val e = r.chatError if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) { AlertManager.shared.showAlertMsg( @@ -243,7 +255,15 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt ) } } - else -> apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r) + else -> { + val titleId = when (type) { + ChatType.Direct -> R.string.error_deleting_contact + ChatType.Group -> R.string.error_deleting_group + ChatType.ContactRequest -> R.string.error_deleting_contact_request + ChatType.ContactConnection -> R.string.error_deleting_pending_contact_connection + } + apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) + } } return false } @@ -302,6 +322,51 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return false } + suspend fun apiSendCallInvitation(contact: Contact, callType: CallType): Boolean { + val r = sendCmd(CC.ApiSendCallInvitation(contact, callType)) + return r is CR.CmdOk + } + + suspend fun apiRejectCall(contact: Contact): Boolean { + val r = sendCmd(CC.ApiRejectCall(contact)) + return r is CR.CmdOk + } + + suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: List, media: CallMediaType, capabilities: CallCapabilities): Boolean { + val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) + val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) + val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer)) + return r is CR.CmdOk + } + + suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: List): Boolean { + val answer = WebRTCSession(rtcSession, rtcIceCandidates) + val r = sendCmd(CC.ApiSendCallAnswer(contact, answer)) + return r is CR.CmdOk + } + + suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: List): Boolean { + val extraInfo = WebRTCExtraInfo(rtcIceCandidates) + val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo)) + return r is CR.CmdOk + } + + suspend fun apiEndCall(contact: Contact): Boolean { + val r = sendCmd(CC.ApiEndCall(contact)) + return r is CR.CmdOk + } + + suspend fun apiCallStatus(contact: Contact, status: String): Boolean { + try { + val callStatus = WebRTCCallStatus.valueOf(status) + val r = sendCmd(CC.ApiCallStatus(contact, callStatus)) + return r is CR.CmdOk + } catch (e: Error) { + Log.d(TAG,"apiCallStatus: call status $status not used") + return false + } + } + suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { val r = sendCmd(CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true @@ -309,11 +374,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) { @@ -325,11 +390,22 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt fun processReceivedMsg(r: CR) { chatModel.terminalItems.add(TerminalItem.resp(r)) when (r) { + is CR.NewContactConnection -> { + chatModel.updateContactConnection(r.connection) + } + is CR.ContactConnectionDeleted -> { + chatModel.removeChat(r.connection.id) + } is CR.ContactConnected -> { chatModel.updateContact(r.contact) - chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected()) + chatModel.removeChat(r.contact.activeConn.id) + chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected()) // NtfManager.shared.notifyContactConnected(contact) } + is CR.ContactConnecting -> { + chatModel.updateContact(r.contact) + chatModel.removeChat(r.contact.activeConn.id) + } is CR.ReceivedContactRequest -> { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) @@ -342,17 +418,18 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.updateChatInfo(cInfo) } } - is CR.ContactSubscribed -> processContactSubscribed(r.contact) - is CR.ContactDisconnected -> { - chatModel.updateContact(r.contact) - chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected()) - } + is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Connected()) + is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Disconnected()) is CR.ContactSubError -> processContactSubError(r.contact, r.chatError) is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { val err = sub.contactError - if (err == null) processContactSubscribed(sub.contact) - else processContactSubError(sub.contact, sub.contactError) + if (err == null) { + chatModel.updateContact(sub.contact) + chatModel.updateNetworkStatus(sub.contact.id, Chat.NetworkStatus.Connected()) + } else { + processContactSubError(sub.contact, sub.contactError) + } } } is CR.NewChatItem -> { @@ -360,8 +437,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 <= 236700) { // 394500 - 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) @@ -378,13 +460,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 @@ -395,11 +472,23 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.upsertChatItem(cInfo, cItem) } } - is CR.RcvFileComplete -> { - val cInfo = r.chatItem.chatInfo + is CR.RcvFileStart -> + chatItemSimpleUpdate(r.chatItem) + is CR.RcvFileComplete -> + chatItemSimpleUpdate(r.chatItem) + is CR.SndFileStart -> + chatItemSimpleUpdate(r.chatItem) + is CR.SndFileComplete -> { + chatItemSimpleUpdate(r.chatItem) val cItem = r.chatItem.chatItem - if (chatModel.upsertChatItem(cInfo, cItem)) { - ntfManager.notifyMessageReceived(cInfo, cItem) + val mc = cItem.content.msgContent + val fileName = cItem.file?.fileName + if ( + r.chatItem.chatInfo.chatType == ChatType.Direct + && mc is MsgContent.MCFile + && fileName != null + ) { + removeFile(appContext, fileName) } } else -> @@ -407,9 +496,18 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } } - fun processContactSubscribed(contact: Contact) { - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected()) + 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) + } } fun processContactSubError(contact: Contact, chatError: ChatError) { @@ -425,7 +523,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } } else e.string - chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err)) + chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err)) } fun showBackgroundServiceNotice() { @@ -437,9 +535,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt Row { Icon( Icons.Outlined.Bolt, - contentDescription = generalGetString(R.string.icon_descr_instant_notifications), + contentDescription = stringResource(R.string.icon_descr_instant_notifications), ) - Text(generalGetString(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) + Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) } }, text = { @@ -452,7 +550,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } }, confirmButton = { - Button(onClick = AlertManager.shared::hideAlert) { Text(generalGetString(R.string.ok)) } + Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } } ) } @@ -511,6 +609,13 @@ sealed class CC { class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() + class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC() + class ApiRejectCall(val contact: Contact): CC() + class ApiSendCallOffer(val contact: Contact, val callOffer: WebRTCCallOffer): CC() + class ApiSendCallAnswer(val contact: Contact, val answer: WebRTCSession): CC() + class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() + class ApiEndCall(val contact: Contact): CC() + class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() class ApiAcceptContact(val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() @@ -522,15 +627,9 @@ sealed class CC { is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" is StartChat -> "/_start" is SetFilesFolder -> "/_files_folder $filesFolder" - is ApiGetChats -> "/_get chats" + is ApiGetChats -> "/_get chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" - is ApiSendMessage -> when { - file == null && quotedItemId == null -> "/_send ${chatRef(type, id)} ${mc.cmdString}" - file != null && quotedItemId == null -> "/_send ${chatRef(type, id)} file $file ${mc.cmdString}" - file == null && quotedItemId != null -> "/_send ${chatRef(type, id)} quoted $quotedItemId ${mc.cmdString}" - file != null && quotedItemId != null -> "/_send ${chatRef(type, id)} file $file quoted $quotedItemId ${mc.cmdString}" - else -> throw Exception() - } + is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is GetUserSMPServers -> "/smp_servers" @@ -545,6 +644,13 @@ sealed class CC { is ShowMyAddress -> "/show_address" is ApiAcceptContact -> "/_accept $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" + is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}" + is ApiRejectCall -> "/_call reject @${contact.apiId}" + is ApiSendCallOffer -> "/_call offer @${contact.apiId} ${json.encodeToString(callOffer)}" + is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}" + is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" + is ApiEndCall -> "/_call end @${contact.apiId}" + is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus}" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ReceiveFile -> "/freceive $fileId" } @@ -572,6 +678,13 @@ sealed class CC { is ShowMyAddress -> "showMyAddress" is ApiAcceptContact -> "apiAcceptContact" is ApiRejectContact -> "apiRejectContact" + is ApiSendCallInvitation -> "apiSendCallInvitation" + is ApiRejectCall -> "apiRejectCall" + is ApiSendCallOffer -> "apiSendCallOffer" + is ApiSendCallAnswer -> "apiSendCallAnswer" + is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" + is ApiEndCall -> "apiEndCall" + is ApiCallStatus -> "apiCallStatus" is ApiChatRead -> "apiChatRead" is ReceiveFile -> "receiveFile" } @@ -585,6 +698,9 @@ sealed class CC { } } +@Serializable +class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent) + val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -598,7 +714,7 @@ class APIResponse(val resp: CR, val corr: String? = null) { json.decodeFromString(str) } catch(e: Exception) { try { - Log.d(TAG, e.localizedMessage) + Log.d(TAG, e.localizedMessage ?: "") val data = json.parseToJsonElement(str).jsonObject APIResponse( resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)), @@ -633,12 +749,13 @@ sealed class CR { @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR() @Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR() + @Serializable @SerialName("contactConnecting") class ContactConnecting(val contact: Contact): CR() @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR() @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR() - @Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR() - @Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR() + @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() + @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR() @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List): CR() @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR() @@ -649,8 +766,21 @@ 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("sndFileStart") class SndFileStart(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileComplete") class SndFileComplete(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() + @Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR() + @Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR() + @Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR() + @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() + @Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR() + @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR() + @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR() @Serializable @SerialName("cmdOk") class CmdOk: CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() @@ -676,12 +806,13 @@ sealed class CR { is UserContactLinkCreated -> "userContactLinkCreated" is UserContactLinkDeleted -> "userContactLinkDeleted" is ContactConnected -> "contactConnected" + is ContactConnecting -> "contactConnecting" is ReceivedContactRequest -> "receivedContactRequest" is AcceptingContactRequest -> "acceptingContactRequest" is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" - is ContactSubscribed -> "contactSubscribed" - is ContactDisconnected -> "contactDisconnected" + is ContactsSubscribed -> "contactsSubscribed" + is ContactsDisconnected -> "contactsDisconnected" is ContactSubError -> "contactSubError" is ContactSubSummary -> "contactSubSummary" is GroupSubscribed -> "groupSubscribed" @@ -693,7 +824,20 @@ sealed class CR { is ChatItemUpdated -> "chatItemUpdated" is ChatItemDeleted -> "chatItemDeleted" is RcvFileAccepted -> "rcvFileAccepted" + is RcvFileStart -> "rcvFileStart" is RcvFileComplete -> "rcvFileComplete" + is SndFileCancelled -> "sndFileCancelled" + is SndFileComplete -> "sndFileComplete" + is SndFileRcvCancelled -> "sndFileRcvCancelled" + is SndFileStart -> "sndFileStart" + is SndGroupFileCancelled -> "sndGroupFileCancelled" + is CallInvitation -> "callInvitation" + is CallOffer -> "callOffer" + is CallAnswer -> "callAnswer" + is CallExtraInfo -> "callExtraInfo" + is CallEnded -> "callEnded" + is NewContactConnection -> "newContactConnection" + is ContactConnectionDeleted -> "contactConnectionDeleted" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" @@ -720,12 +864,13 @@ sealed class CR { is UserContactLinkCreated -> connReqContact is UserContactLinkDeleted -> noDetails() is ContactConnected -> json.encodeToString(contact) + is ContactConnecting -> json.encodeToString(contact) is ReceivedContactRequest -> json.encodeToString(contactRequest) is AcceptingContactRequest -> json.encodeToString(contact) is ContactRequestRejected -> noDetails() is ContactUpdated -> json.encodeToString(toContact) - is ContactSubscribed -> json.encodeToString(contact) - is ContactDisconnected -> json.encodeToString(contact) + is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" + is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}" is ContactSubSummary -> json.encodeToString(contactSubscriptions) is GroupSubscribed -> json.encodeToString(group) @@ -736,8 +881,21 @@ 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 SndFileCancelled -> json.encodeToString(chatItem) + is SndFileComplete -> json.encodeToString(chatItem) + is SndFileRcvCancelled -> json.encodeToString(chatItem) + is SndFileStart -> json.encodeToString(chatItem) + is SndGroupFileCancelled -> json.encodeToString(chatItem) + is CallInvitation -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}" + is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}" + is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}" + is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}" + is CallEnded -> "contact: ${contact.id}" + is NewContactConnection -> json.encodeToString(connection) + is ContactConnectionDeleted -> json.encodeToString(connection) is CmdOk -> noDetails() is ChatCmdError -> chatError.string is ChatRespError -> chatError.string @@ -792,7 +950,9 @@ sealed class ChatErrorType { val string: String get() = when (this) { is InvalidConnReq -> "invalidConnReq" is ContactGroups -> "groupNames $groupNames" + is NoActiveUser -> "noActiveUser" } + @Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType() @Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType() @Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List): ChatErrorType() } 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 7aeecca387..05e66ffb87 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,6 @@ 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) +val FileLight = Color(183, 190, 199, 255) +val FileDark = Color(101, 101, 106, 255) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 116aa4d299..93ed18039c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.chat.SendMsgView import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets @@ -25,32 +26,42 @@ import kotlinx.coroutines.launch @Composable fun TerminalView(chatModel: ChatModel, close: () -> Unit) { + val composeState = remember { mutableStateOf(ComposeState()) } BackHandler(onBack = close) - TerminalLayout(chatModel.terminalItems, close) { cmd -> - withApi { - // show "in progress" - chatModel.controller.sendCmd(CC.Console(cmd)) - // hide "in progress" - } - } + TerminalLayout( + chatModel.terminalItems, + composeState, + sendCommand = { + withApi { + // show "in progress" + chatModel.controller.sendCmd(CC.Console(composeState.value.message)) + // hide "in progress" + } + }, + close + ) } @Composable -fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCommand: (String) -> Unit) { - var msg = remember { mutableStateOf("") } +fun TerminalLayout( + terminalItems: List, + composeState: MutableState, + sendCommand: () -> Unit, + close: () -> Unit +) { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + + fun onMessageChange(s: String) { + composeState.value = composeState.value.copy(message = s) + } + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, bottomBar = { Box(Modifier.padding(horizontal = 8.dp)) { - SendMsgView( - msg = msg, - linkPreview = remember { mutableStateOf(null) }, - cancelledLinks = remember { mutableSetOf() }, - parseMarkdown = { null }, - sendMessage = sendCommand, - sendEnabled = msg.value.isNotEmpty() - ) + SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle) } }, modifier = Modifier.navigationBarsWithImePadding() @@ -108,8 +119,9 @@ fun PreviewTerminalLayout() { SimpleXTheme { TerminalLayout( terminalItems = TerminalItem.sampleData, - close = {}, - sendCommand = {} + composeState = remember { mutableStateOf(ComposeState()) }, + sendCommand = {}, + close = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 25516fb10e..d26f142f35 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -1,15 +1,24 @@ package chat.simplex.app.views -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowBackIosNew +import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -17,68 +26,12 @@ import chat.simplex.app.R import chat.simplex.app.SimplexService import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile -import chat.simplex.app.views.helpers.* -import com.google.accompanist.insets.ProvideWindowInsets +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleButton +import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.onboarding.OnboardingStage +import chat.simplex.app.views.onboarding.ReadableText import com.google.accompanist.insets.navigationBarsWithImePadding -import kotlinx.coroutines.launch - -@Composable -fun WelcomeView(chatModel: ChatModel) { - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val keyboardState by getKeyboardState() - var savedKeyboardState by remember { mutableStateOf(keyboardState) } - - ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - Box( - modifier = Modifier - .fillMaxSize() - .background(color = MaterialTheme.colors.background) - ) { - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .verticalScroll(scrollState) - .fillMaxSize() - .background(color = MaterialTheme.colors.background) - .padding(12.dp) - ) { - Image( - painter = painterResource(R.drawable.logo), - contentDescription = generalGetString(R.string.image_descr_simplex_logo), - modifier = Modifier.padding(vertical = 15.dp) - ) - Text( - generalGetString(R.string.you_control_your_chat), - style = MaterialTheme.typography.h4, - color = MaterialTheme.colors.onBackground - ) - Text( - generalGetString(R.string.the_messaging_and_app_platform_protecting_your_privacy_and_security), - style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.height(8.dp)) - Text( - generalGetString(R.string.we_do_not_store_contacts_or_messages_on_servers), - style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.height(24.dp)) - CreateProfilePanel(chatModel) - } - if (savedKeyboardState != keyboardState) { - LaunchedEffect(keyboardState) { - scope.launch { - savedKeyboardState = keyboardState - scrollState.animateScrollTo(scrollState.maxValue) - } - } - } - } - } -} - fun isValidDisplayName(name: String) : Boolean { return (name.firstOrNull { it.isWhitespace() }) == null @@ -86,91 +39,107 @@ fun isValidDisplayName(name: String) : Boolean { @Composable fun CreateProfilePanel(chatModel: ChatModel) { - var displayName by remember { mutableStateOf("") } - var fullName by remember { mutableStateOf("") } + val displayName = remember { mutableStateOf("") } + val fullName = remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } - Column( - modifier=Modifier.fillMaxSize() - ) { - Text( - generalGetString(R.string.create_profile), - style = MaterialTheme.typography.h4, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(vertical = 5.dp) - ) - Text( - generalGetString(R.string.your_profile_is_stored_on_your_decide_and_shared_only_with_your_contacts), - style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.height(10.dp)) - Text( - generalGetString(R.string.display_name), - style = MaterialTheme.typography.h6, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 3.dp) - ) - BasicTextField( - value = displayName, - onValueChange = { displayName = it }, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colors.secondary) - .height(40.dp) - .clip(RoundedCornerShape(5.dp)) - .padding(8.dp) - .navigationBarsWithImePadding(), - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - singleLine = true - ) - val errorText = if(!isValidDisplayName(displayName)) generalGetString(R.string.display_name_cannot_contain_whitespace) else "" + Surface(Modifier.background(MaterialTheme.colors.onBackground)) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Text( + stringResource(R.string.create_profile), + style = MaterialTheme.typography.h4, + modifier = Modifier.padding(vertical = 5.dp) + ) + ReadableText(R.string.your_profile_is_stored_on_your_device) + ReadableText(R.string.profile_is_only_shared_with_your_contacts) + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.display_name), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 3.dp) + ) + ProfileNameField(displayName, focusRequester) + val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else "" + Text( + errorText, + fontSize = 15.sp, + color = MaterialTheme.colors.error + ) + Spacer(Modifier.height(3.dp)) + Text( + stringResource(R.string.full_name_optional__prompt), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 5.dp) + ) + ProfileNameField(fullName) + Spacer(Modifier.fillMaxHeight().weight(1f)) + Row { + SimpleButton( + text = stringResource(R.string.about_simplex), + icon = Icons.Outlined.ArrowBackIosNew + ) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo } - Text( - errorText, - fontSize = 15.sp, - color = MaterialTheme.colors.error - ) + Spacer(Modifier.fillMaxWidth().weight(1f)) - Spacer(Modifier.height(3.dp)) - Text( - generalGetString(R.string.full_name_optional__prompt), - style = MaterialTheme.typography.h6, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 5.dp) - ) - BasicTextField( - value = fullName, - onValueChange = { fullName = it }, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colors.secondary) - .height(40.dp) - .clip(RoundedCornerShape(3.dp)) - .padding(8.dp) - .navigationBarsWithImePadding(), - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - singleLine = true - ) - Spacer(Modifier.height(20.dp)) - Button(onClick = { - withApi { - val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName, fullName, null) - ) - chatModel.controller.startChat(user) - SimplexService.start(chatModel.controller.appContext) - chatModel.controller.showBackgroundServiceNotice() + val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + val createModifier: Modifier + val createColor: Color + if (enabled) { + createModifier = Modifier.padding(8.dp).clickable { createProfile(chatModel, displayName.value, fullName.value) } + createColor = MaterialTheme.colors.primary + } else { + createModifier = Modifier.padding(8.dp) + createColor = HighOrLowlight + } + Surface(shape = RoundedCornerShape(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) { + Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor) + Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor) + } + } } - }, - enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName)) - ) { Text(generalGetString(R.string.create_profile_button)) } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } } } + +fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) { + withApi { + val user = chatModel.controller.apiCreateActiveUser( + Profile(displayName, fullName, null) + ) + chatModel.controller.startChat(user) + SimplexService.start(chatModel.controller.appContext) + // TODO show it later? + chatModel.controller.showBackgroundServiceNotice() + chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete + } +} + +@Composable +fun ProfileNameField(name: MutableState, focusRequester: FocusRequester? = null) { + val modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.secondary) + .height(40.dp) + .clip(RoundedCornerShape(5.dp)) + .padding(8.dp) + .navigationBarsWithImePadding() + BasicTextField( + value = name.value, + onValueChange = { name.value = it }, + modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester), + textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrect = false + ), + singleLine = true, + cursorBrush = SolidColor(HighOrLowlight) + ) +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt new file mode 100644 index 0000000000..b85c6b59b7 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -0,0 +1,156 @@ +package chat.simplex.app.views.call + +import android.Manifest +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.util.Log +import android.view.ViewGroup +import android.webkit.* +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewClientCompat +import chat.simplex.app.TAG +import chat.simplex.app.views.helpers.TextEditor +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun VideoCallView(close: () -> Unit) { + BackHandler(onBack = close) + lateinit var wv: WebView + val context = LocalContext.current + val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java) + val permissionsState = rememberMultiplePermissionsState( + permissions = listOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MODIFY_AUDIO_SETTINGS, + Manifest.permission.INTERNET + ) + ) + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) { + permissionsState.launchMultiplePermissionRequest() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + wv.evaluateJavascript("endCall()", null) + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + val localContext = LocalContext.current + val commandToShow = remember { mutableStateOf("processCommand({command: {type: 'start', media: 'video'}})") } //, aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") } + val assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext)) + .build() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .background(MaterialTheme.colors.background) + .fillMaxSize() + ) { + if (permissionsState.allPermissionsGranted) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + ) { + AndroidView( + factory = { AndroidViewContext -> + WebView(AndroidViewContext).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + this.webChromeClient = object: WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { + if (request.origin.toString().startsWith("file:/")) { + request.grant(request.resources) + } else { + Log.d(TAG, "Permission request from webview denied.") + request.deny() + } + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + val rtnValue = super.onConsoleMessage(consoleMessage) + val msg = consoleMessage?.message() as String + if (msg.startsWith("{")) { + commandToShow.value = "processCommand($msg)" + } + return rtnValue + } + } + this.webViewClient = LocalContentWebViewClient(assetLoader) + this.clearHistory() + this.clearCache(true) +// this.addJavascriptInterface(JavascriptInterface(), "Android") + val webViewSettings = this.settings + webViewSettings.allowFileAccess = true + webViewSettings.allowContentAccess = true + webViewSettings.javaScriptEnabled = true + webViewSettings.mediaPlaybackRequiresUserGesture = false + webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE + this.loadUrl("file:android_asset/www/call.html") + } + } + ) { + wv = it + } + } + } else { + Text("NEED PERMISSIONS") + } + + TextEditor(Modifier.height(180.dp), text = commandToShow) + + Row( + Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( onClick = { + val clip: ClipData = ClipData.newPlainText("js command", commandToShow.value) + clipboard?.setPrimaryClip(clip) + }) {Text("Copy")} + Button( onClick = { + println("sending: ${commandToShow.value}") + wv.evaluateJavascript(commandToShow.value, null) + commandToShow.value = "" + }) {Text("Send")} + Button( onClick = { + commandToShow.value = "" + }) {Text("Clear")} + } + } +} + +private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + return assetLoader.shouldInterceptRequest(request.url) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt new file mode 100644 index 0000000000..6484c1d42a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -0,0 +1,96 @@ +package chat.simplex.app.views.call + +import chat.simplex.app.R +import chat.simplex.app.model.Contact +import chat.simplex.app.views.helpers.generalGetString +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +class Call( + val contact: Contact, + val callState: CallState, + val localMedia: CallMediaType, + val localCapabilities: CallCapabilities? = null, + val peerMedia: CallMediaType? = null, + val sharedKey: String? = null, + val audioEnabled: Boolean = true, + val videoEnabled: Boolean = localMedia == CallMediaType.Video +) { + val encrypted: Boolean get() = (localCapabilities?.encryption ?: false) && sharedKey != null +} + +enum class CallState { + WaitCapabilities, + InvitationSent, + InvitationReceived, + OfferSent, + OfferReceived, + Negotiated, + Connected; + + val text: String get() = when(this) { + WaitCapabilities -> generalGetString(R.string.callstate_starting) + InvitationSent -> generalGetString(R.string.callstate_waiting_for_answer) + InvitationReceived -> generalGetString(R.string.callstate_starting) + OfferSent -> generalGetString(R.string.callstate_waiting_for_confirmation) + OfferReceived -> generalGetString(R.string.callstate_received_answer) + Negotiated -> generalGetString(R.string.callstate_connecting) + Connected -> generalGetString(R.string.callstate_connected) + } +} + +@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand) +@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand?) + +@Serializable +sealed class WCallCommand { + @Serializable @SerialName("capabilities") class Capabilities(): WCallCommand() + @Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand() + @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallCommand() + @Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: List): WCallCommand() + @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallCommand() + @Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() + @Serializable @SerialName("end") class End(): WCallCommand() +} + +@Serializable +sealed class WCallResponse { + @Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse() + @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: List): WCallResponse() + // TODO remove accept, it is needed for debugging + @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallResponse() + @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: List): WCallResponse() + @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallResponse() + @Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse() + @Serializable @SerialName("ended") class Ended(): WCallResponse() + @Serializable @SerialName("ok") class Ok(): WCallResponse() + @Serializable @SerialName("error") class Error(val message: String): WCallResponse() + @Serializable class Invalid(val str: String): WCallResponse() +} + +@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) +@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List) +@Serializable class WebRTCExtraInfo(val rtcIceCandidates: List) +@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) +@Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?) +@Serializable class CallCapabilities(val encryption: Boolean) + +enum class WebRTCCallStatus(val status: String) { + Connected("connected"), + Disconnected("disconnected"), + Failed("failed") +} + +@Serializable +enum class CallMediaType(val media: String) { + Video("video"), + Audio("audio") +} + +@Serializable +class ConnectionState( + val connectionState: String, + val iceConnectionState: String, + val iceGatheringState: String, + val signalingState: String +) \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index d37c752cbe..5f380545cc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -60,8 +61,8 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) { ) { CloseSheetBar(close) Spacer(Modifier.size(48.dp)) - ChatInfoImage(chat, size = 192.dp) val cInfo = chat.chatInfo + ChatInfoImage(cInfo, size = 192.dp) Text( cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, @@ -97,7 +98,7 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) { Box(Modifier.padding(48.dp)) { SimpleButton( - generalGetString(R.string.button_delete_contact), + stringResource(R.string.button_delete_contact), icon = Icons.Outlined.Delete, color = Color.Red, click = deleteContact @@ -112,13 +113,13 @@ fun ServerImage(chat: Chat) { val status = chat.serverInfo.networkStatus when { status is Chat.NetworkStatus.Connected -> - Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) + Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) status is Chat.NetworkStatus.Disconnected -> - Icon(Icons.Filled.Pending, generalGetString(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) + Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) status is Chat.NetworkStatus.Error -> - Icon(Icons.Filled.Error, generalGetString(R.string.icon_descr_server_status_error), tint = HighOrLowlight) + Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight) else -> - Icon(Icons.Outlined.Circle, generalGetString(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) + Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) } } 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 37716c8979..9c091ebdfa 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 @@ -1,8 +1,8 @@ package chat.simplex.app.views.chat -import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap +import android.net.Uri import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* @@ -22,11 +22,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R +import chat.simplex.app.SimplexApp.Companion.context import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -37,24 +39,20 @@ import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* import kotlinx.datetime.Clock -import java.io.File -import java.io.FileOutputStream @Composable fun ChatView(chatModel: ChatModel) { val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } val user = chatModel.currentUser.value + val composeState = remember { mutableStateOf(ComposeState()) } + val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val chosenImage = remember { mutableStateOf(null) } + val chosenFile = remember { mutableStateOf(null) } + if (chat == null || user == null) { chatModel.chatId.value = null } else { - val context = LocalContext.current - val quotedItem = remember { mutableStateOf(null) } - val editingItem = remember { mutableStateOf(null) } - val linkPreview = remember { mutableStateOf(null) } - val chosenImage = remember { mutableStateOf(null) } - val imagePreview = remember { mutableStateOf(null) } - var msg = remember { mutableStateOf("") } - BackHandler { chatModel.chatId.value = null } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -72,7 +70,24 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, + ChatLayout( + user, + chat, + composeState, + composeView = { + ComposeView( + chatModel, + chat, + composeState, + chosenImage, + chosenFile, + showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } }) + }, + chosenImage, + chosenFile, + scope, + attachmentBottomSheetState, + chatModel.chatItems, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -81,54 +96,6 @@ fun ChatView(chatModel: ChatModel) { } if (c != null) withApi { openChat(chatModel, c.chatInfo) } }, - sendMessage = { msg -> - withApi { - // show "in progress" - val cInfo = chat.chatInfo - val ei = editingItem.value - if (ei != null) { - val updatedItem = chatModel.controller.apiUpdateChatItem( - type = cInfo.chatType, - id = cInfo.apiId, - itemId = ei.meta.itemId, - mc = MsgContent.MCText(msg) - ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) - } else { - var file: String? = null - val imagePreviewData = imagePreview.value - val chosenImageData = chosenImage.value - val linkPreviewData = linkPreview.value - val mc = when { - imagePreviewData != null && chosenImageData != null -> { - file = saveImage(context, chosenImageData) - MsgContent.MCImage(msg, imagePreviewData) - } - linkPreviewData != null -> { - MsgContent.MCLink(msg, linkPreviewData) - } - else -> { - MsgContent.MCText(msg) - } - } - val newItem = chatModel.controller.apiSendMessage( - type = cInfo.chatType, - id = cInfo.apiId, - file = file, - quotedItemId = quotedItem.value?.meta?.itemId, - mc = mc - ) - if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) - } - // hide "in progress" - editingItem.value = null - quotedItem.value = null - linkPreview.value = null - chosenImage.value = null - imagePreview.value = null - } - }, - resetMessage = { msg.value = "" }, deleteMessage = { itemId, mode -> withApi { val cInfo = chat.chatInfo @@ -141,45 +108,47 @@ fun ChatView(chatModel: ChatModel) { if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } }, - parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } }, - onImageChange = { bitmap -> imagePreview.value = resizeImageToDataSize(bitmap, maxDataSize = 12500) } + receiveFile = { fileId -> + withApi { + val chatItem = chatModel.controller.apiReceiveFile(fileId) + if (chatItem != null) { + val cInfo = chatItem.chatInfo + val cItem = chatItem.chatItem + chatModel.upsertChatItem(cInfo, cItem) + } + } + } ) } } -fun saveImage(context: Context, image: Bitmap): String { - val imageResized = base64ToBitmap(resizeImageToDataSize(image, 160000)) - val fileToSave = "image_${System.currentTimeMillis()}.jpg" - val file = File(getAppFilesDirectory(context) + "/" + fileToSave) - val output = FileOutputStream(file) - imageResized.compress(Bitmap.CompressFormat.JPEG, 100, output) - output.flush() - output.close() - return fileToSave -} - @Composable fun ChatLayout( user: User, chat: Chat, - chatItems: List, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, - linkPreview: MutableState, + composeState: MutableState, + composeView: (@Composable () -> Unit), chosenImage: MutableState, - imagePreview: MutableState, + chosenFile: MutableState, + scope: CoroutineScope, + attachmentBottomSheetState: ModalBottomSheetState, + chatItems: List, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, - sendMessage: (String) -> Unit, - resetMessage: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, - parseMarkdown: (String) -> List?, - onImageChange: (Bitmap) -> Unit + receiveFile: (Long) -> Unit ) { - val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) - val scope = rememberCoroutineScope() + fun onImageChange(bitmap: Bitmap) { + val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) + } + fun onFileChange(uri: Uri) { + val fileName = getFileName(context, uri) + if (fileName != null) { + composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName)) + } + } Surface( Modifier @@ -193,26 +162,23 @@ fun ChatLayout( sheetContent = { GetImageBottomSheet( chosenImage, - onImageChange = onImageChange, + ::onImageChange, + chosenFile, + ::onFileChange, hideBottomSheet = { - scope.launch { bottomSheetModalState.hide() } + scope.launch { attachmentBottomSheetState.hide() } }) }, - sheetState = bottomSheetModalState, + sheetState = attachmentBottomSheetState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { - ComposeView( - msg, quotedItem, editingItem, linkPreview, imagePreview, sendMessage, resetMessage, parseMarkdown, - showBottomSheet = { scope.launch { bottomSheetModalState.show() } } - ) - }, + bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) + ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile) } } } @@ -234,7 +200,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { IconButton(onClick = back) { Icon( Icons.Outlined.ArrowBackIos, - generalGetString(R.string.back), + stringResource(R.string.back), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) @@ -248,7 +214,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { val cInfo = chat.chatInfo - ChatInfoImage(chat, size = 40.dp) + ChatInfoImage(cInfo, size = 40.dp) Column( Modifier.padding(start = 8.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -286,12 +252,11 @@ val CIListStateSaver = run { fun ChatItemsList( user: User, chat: Chat, + composeState: MutableState, chatItems: List, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, 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() @@ -329,11 +294,11 @@ fun ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, cItem, msg, quotedItem, editingItem, 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, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile) } } } else { // direct message @@ -344,7 +309,7 @@ fun ChatItemsList( end = if (sent) 12.dp else 76.dp, ) ) { - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile) } } } @@ -402,21 +367,18 @@ fun PreviewChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - chatItems = chatItems, - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, - linkPreview = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, + composeView = {}, + chosenFile = remember { mutableStateOf(null) }, chosenImage = remember { mutableStateOf(null) }, - imagePreview = remember { mutableStateOf(null) }, + scope = rememberCoroutineScope(), + attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), + chatItems = chatItems, back = {}, info = {}, openDirectChat = {}, - sendMessage = {}, - resetMessage = {}, deleteMessage = { _, _ -> }, - parseMarkdown = { null }, - onImageChange = {} + receiveFile = {} ) } } @@ -450,21 +412,18 @@ fun PreviewGroupChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - chatItems = chatItems, - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, - linkPreview = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, + composeView = {}, chosenImage = remember { mutableStateOf(null) }, - imagePreview = remember { mutableStateOf(null) }, + chosenFile = remember { mutableStateOf(null) }, + scope = rememberCoroutineScope(), + attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), + chatItems = chatItems, back = {}, info = {}, openDirectChat = {}, - sendMessage = {}, - resetMessage = {}, deleteMessage = { _, _ -> }, - parseMarkdown = { null }, - onImageChange = {} + receiveFile = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt new file mode 100644 index 0000000000..48917ec05e --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt @@ -0,0 +1,61 @@ +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +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.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.item.SentColorLight + +@Composable +fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) { + Row( + Modifier + .height(60.dp) + .fillMaxWidth() + .padding(top = 8.dp) + .background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.InsertDriveFile, + stringResource(R.string.icon_descr_file), + Modifier + .padding(start = 4.dp, end = 2.dp) + .size(36.dp), + tint = if (isSystemInDarkTheme()) FileDark else FileLight + ) + Text(fileName) + Spacer(Modifier.weight(1f)) + if (cancelEnabled) { + IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.icon_descr_cancel_file_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } + } +} + +@Preview +@Composable +fun PreviewComposeFileView() { + SimpleXTheme { + ComposeFileView( + "test.txt", + cancelFile = {}, + cancelEnabled = true + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt index 0e2b438de8..d139ba90bd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -1,16 +1,21 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.views.chat.item.SentColorLight import chat.simplex.app.views.helpers.base64ToBitmap @Composable -fun ComposeImageView(image: String) { +fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) { Row( Modifier .fillMaxWidth() @@ -27,5 +32,16 @@ fun ComposeImageView(image: String) { .height(60.dp) .padding(end = 8.dp) ) + Spacer(Modifier.weight(1f)) + if (cancelEnabled) { + IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.icon_descr_cancel_image_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } } } 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 acc57003de..d187cf0344 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 @@ -1,89 +1,361 @@ package chat.simplex.app.views.chat +import ComposeFileView import ComposeImageView +import android.graphics.Bitmap +import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.outlined.Reply import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.views.helpers.ComposeLinkView -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking -// TODO ComposeState +sealed class ComposePreview { + object NoPreview: ComposePreview() + class CLinkPreview(val linkPreview: LinkPreview): ComposePreview() + class ImagePreview(val image: String): ComposePreview() + class FilePreview(val fileName: String): ComposePreview() +} + +sealed class ComposeContextItem { + object NoContextItem: ComposeContextItem() + class QuotedItem(val chatItem: ChatItem): ComposeContextItem() + class EditingItem(val chatItem: ChatItem): ComposeContextItem() +} + +data class ComposeState( + val message: String = "", + val preview: ComposePreview = ComposePreview.NoPreview, + val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, + val inProgress: Boolean = false +) { + constructor(editingItem: ChatItem): this( + editingItem.content.text, + chatItemPreview(editingItem), + ComposeContextItem.EditingItem(editingItem) + ) + + val editing: Boolean + get() = + when (contextItem) { + is ComposeContextItem.EditingItem -> true + else -> false + } + val sendEnabled: () -> Boolean + get() = { + val hasContent = when (preview) { + is ComposePreview.ImagePreview -> true + is ComposePreview.FilePreview -> true + else -> message.isNotEmpty() + } + hasContent && !inProgress + } + val linkPreviewAllowed: Boolean + get() = + when (preview) { + is ComposePreview.ImagePreview -> false + is ComposePreview.FilePreview -> false + else -> true + } + val linkPreview: LinkPreview? + get() = + when (preview) { + is ComposePreview.CLinkPreview -> preview.linkPreview + else -> null + } +} + +fun chatItemPreview(chatItem: ChatItem): ComposePreview { + return when (val mc = chatItem.content.msgContent) { + is MsgContent.MCText -> ComposePreview.NoPreview + is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) + is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image) + is MsgContent.MCFile -> { + val fileName = chatItem.file?.fileName ?: "" + ComposePreview.FilePreview(fileName) + } + else -> ComposePreview.NoPreview + } +} @Composable fun ComposeView( - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, - linkPreview: MutableState, - imagePreview: MutableState, - sendMessage: (String) -> Unit, - resetMessage: () -> Unit, - parseMarkdown: (String) -> List?, - showBottomSheet: () -> Unit + chatModel: ChatModel, + chat: Chat, + composeState: MutableState, + chosenImage: MutableState, + chosenFile: MutableState, + showAttachmentBottomSheet: () -> Unit ) { + val context = LocalContext.current + val linkUrl = remember { mutableStateOf(null) } + val prevLinkUrl = remember { mutableStateOf(null) } + val pendingLinkUrl = remember { mutableStateOf(null) } val cancelledLinks = remember { mutableSetOf() } + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } - fun cancelPreview() { - val uri = linkPreview.value?.uri + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): String? { + val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) } + val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + return link?.text + } + + fun loadLinkPreview(url: String, wait: Long? = null) { + if (pendingLinkUrl.value == url) { + withApi { + if (wait != null) delay(wait) + val lp = getLinkPreview(url) + if (lp != null && pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) + pendingLinkUrl.value = null + } + } + } + } + + fun showLinkPreview(s: String) { + prevLinkUrl.value = linkUrl.value + linkUrl.value = parseMessage(s) + val url = linkUrl.value + if (url != null) { + if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { + pendingLinkUrl.value = url + loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) + } + } else { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + } + + fun resetLinkPreview() { + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + + fun checkLinkPreview(): MsgContent { + val cs = composeState.value + return when (val composePreview = cs.preview) { + is ComposePreview.CLinkPreview -> { + val url = parseMessage(cs.message) + val lp = composePreview.linkPreview + if (url == lp.uri) { + MsgContent.MCLink(cs.message, preview = lp) + } else { + MsgContent.MCText(cs.message) + } + } + else -> MsgContent.MCText(cs.message) + } + } + + fun updateMsgContent(msgContent: MsgContent): MsgContent { + val cs = composeState.value + return when (msgContent) { + 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) + } + } + + fun clearState() { + composeState.value = ComposeState() + chosenImage.value = null + chosenFile.value = null + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + + fun sendMessage() { + withApi { + composeState.value = composeState.value.copy(inProgress = true) + val cInfo = chat.chatInfo + val cs = composeState.value + when (val contextItem = cs.contextItem) { + is ComposeContextItem.EditingItem -> { + val ei = contextItem.chatItem + val oldMsgContent = ei.content.msgContent + if (oldMsgContent != null) { + val updatedItem = chatModel.controller.apiUpdateChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = ei.meta.itemId, + mc = updateMsgContent(oldMsgContent) + ) + if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + } + } + else -> { + var mc: MsgContent? = null + var file: String? = null + when (val preview = cs.preview) { + ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message) + is ComposePreview.CLinkPreview -> mc = checkLinkPreview() + is ComposePreview.ImagePreview -> { + val chosenImageVal = chosenImage.value + if (chosenImageVal != null) { + file = saveImage(context, chosenImageVal) + if (file != null) { + mc = MsgContent.MCImage(cs.message, preview.image) + } + } + } + is ComposePreview.FilePreview -> { + val chosenFileVal = chosenFile.value + if (chosenFileVal != null) { + file = saveFileFromUri(context, chosenFileVal) + if (file != null) { + mc = MsgContent.MCFile(cs.message) + } + } + } + } + val quotedItemId: Long? = when (contextItem) { + is ComposeContextItem.QuotedItem -> contextItem.chatItem.id + else -> null + } + + if (mc != null) { + val aChatItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + file = file, + quotedItemId = quotedItemId, + mc = mc + ) + if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) + } + } + } + clearState() + } + } + + fun onMessageChange(s: String) { + composeState.value = composeState.value.copy(message = s) + if (isShortEmoji(s)) { + textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + } else { + textStyle.value = smallFont + if (composeState.value.linkPreviewAllowed) { + if (s.isNotEmpty()) showLinkPreview(s) + else resetLinkPreview() + } + } + } + + fun cancelLinkPreview() { + val uri = composeState.value.linkPreview?.uri if (uri != null) { cancelledLinks.add(uri) } - linkPreview.value = null + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + + fun cancelImage() { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + chosenImage.value = null + } + + fun cancelFile() { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + chosenFile.value = null + } + + @Composable + fun previewView() { + when (val preview = composeState.value.preview) { + ComposePreview.NoPreview -> {} + is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview) + is ComposePreview.ImagePreview -> ComposeImageView( + preview.image, + ::cancelImage, + cancelEnabled = !composeState.value.editing + ) + is ComposePreview.FilePreview -> ComposeFileView( + preview.fileName, + ::cancelFile, + cancelEnabled = !composeState.value.editing + ) + } + } + + @Composable + fun contextItemView() { + when (val contextItem = composeState.value.contextItem) { + ComposeContextItem.NoContextItem -> {} + is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } + is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) { + clearState() + } + } } Column { - val ip = imagePreview.value - if (ip != null) { - ComposeImageView(ip) - } else { - val lp = linkPreview.value - if (lp != null) ComposeLinkView(lp, ::cancelPreview) - } + contextItemView() when { - quotedItem.value != null -> { - ContextItemView(quotedItem) - } - editingItem.value != null -> { - ContextItemView(editingItem, editing = editingItem.value != null, resetMessage) - } - else -> {} + composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} + else -> previewView() } Row( - modifier = Modifier.padding(horizontal = 8.dp), -// // use this padding when attach button is uncommented -// modifier = Modifier.padding(start = 2.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 8.dp), + verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { -// Icon( -// Icons.Outlined.AddCircleOutline, -// contentDescription = generalGetString(R.string.attach), -// tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, -// modifier = Modifier -// .size(40.dp) -// .padding(vertical = 4.dp) -// .clip(CircleShape) -// .clickable { -// if (editingItem.value == null) { -// showBottomSheet() -// } -// } -// ) - SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, - editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null) + val attachEnabled = !composeState.value.editing + Box(Modifier.padding(bottom = 12.dp)) { + Icon( + Icons.Filled.AttachFile, + contentDescription = stringResource(R.string.attach), + tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .clickable { + if (attachEnabled) { + showAttachmentBottomSheet() + } + } + ) + } + SendMsgView( + composeState, + sendMessage = { + sendMessage() + resetLinkPreview() + }, + ::onMessageChange, + textStyle + ) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 8a16d670ec..2b969b1ee5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -4,10 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Close -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview @@ -15,48 +18,49 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.CIDirection import chat.simplex.app.model.ChatItem +import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* -import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable fun ContextItemView( - contextItem: MutableState, - editing: Boolean = false, - resetMessage: () -> Unit = {} + contextItem: ChatItem, + contextIcon: ImageVector, + cancelContextItem: () -> Unit ) { - val cxtItem = contextItem.value - if (cxtItem != null) { - val sent = cxtItem.chatDir.sent + val sent = contextItem.chatDir.sent + Row( + Modifier + .padding(top = 8.dp) + .background(if (sent) SentColorLight else ReceivedColorLight), + verticalAlignment = Alignment.CenterVertically + ) { Row( Modifier - .padding(top = 8.dp) - .background(if (sent) SentColorLight else ReceivedColorLight), + .padding(vertical = 12.dp) + .fillMaxWidth() + .weight(1F), verticalAlignment = Alignment.CenterVertically ) { - Box( - Modifier - .padding(start = 16.dp) - .padding(vertical = 12.dp) - .fillMaxWidth() - .weight(1F) - ) { - ContextItemText(cxtItem) - } - IconButton(onClick = { - contextItem.value = null - if (editing) { - resetMessage() - } - }) { - Icon( - Icons.Outlined.Close, - contentDescription = generalGetString(R.string.cancel_verb), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) - } + Icon( + contextIcon, + modifier = Modifier + .padding(horizontal = 8.dp) + .height(20.dp) + .width(20.dp), + contentDescription = stringResource(R.string.icon_descr_context), + tint = HighOrLowlight, + ) + ContextItemText(contextItem) + } + IconButton(onClick = cancelContextItem) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.cancel_verb), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) } } } @@ -65,11 +69,11 @@ fun ContextItemView( private fun ContextItemText(cxtItem: ChatItem) { val member = cxtItem.memberDisplayName if (member == null) { - Text(cxtItem.content.text, maxLines = 3) + Text(cxtItem.text, maxLines = 3) } else { val annotatedText = buildAnnotatedString { withStyle(boldFont) { append(member) } - append(": ${cxtItem.content.text}") + append(": ${cxtItem.text}") } Text(annotatedText, maxLines = 3) } @@ -80,13 +84,9 @@ private fun ContextItemText(cxtItem: ChatItem) { fun PreviewContextItemView() { SimpleXTheme { ContextItemView( - contextItem = remember { - mutableStateOf( - ChatItem.getSampleData( - 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" - ) - ) - } + contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"), + contextIcon = Icons.Filled.Edit, + cancelContextItem = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index c81b99e295..b4d5aa24f4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -17,89 +17,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.* +import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.chat.item.* -import chat.simplex.app.views.helpers.* -import kotlinx.coroutines.delay @Composable fun SendMsgView( - msg: MutableState, - linkPreview: MutableState, - cancelledLinks: MutableSet, - parseMarkdown: (String) -> List?, - sendMessage: (String) -> Unit, - editing: Boolean = false, - sendEnabled: Boolean = false + composeState: MutableState, + sendMessage: () -> Unit, + onMessageChange: (String) -> Unit, + textStyle: MutableState ) { - val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - var textStyle by remember { mutableStateOf(smallFont) } - val linkUrl = remember { mutableStateOf(null) } - val prevLinkUrl = remember { mutableStateOf(null) } - val pendingLinkUrl = remember { mutableStateOf(null) } - - fun isSimplexLink(link: String): Boolean = - link.startsWith("https://simplex.chat",true) || link.startsWith("http://simplex.chat", true) - - fun parseMessage(msg: String): String? { - val parsedMsg = parseMarkdown(msg) - val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } - return link?.text - } - - fun loadLinkPreview(url: String, wait: Long? = null) { - if (pendingLinkUrl.value == url) { - withApi { - if (wait != null) delay(wait) - val lp = getLinkPreview(url) - if (pendingLinkUrl.value == url) { - linkPreview.value = lp - pendingLinkUrl.value = null - } - } - } - } - - fun showLinkPreview(s: String) { - prevLinkUrl.value = linkUrl.value - linkUrl.value = parseMessage(s) - val url = linkUrl.value - if (url != null) { - if (url != linkPreview.value?.uri && url != pendingLinkUrl.value) { - pendingLinkUrl.value = url - loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) - } - } else { - linkPreview.value = null - } - } - - fun resetLinkPreview() { - linkUrl.value = null - prevLinkUrl.value = null - pendingLinkUrl.value = null - cancelledLinks.clear() - } - + val cs = composeState.value BasicTextField( - value = msg.value, - onValueChange = { s -> - msg.value = s - if (isShortEmoji(s)) { - textStyle = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont - } else { - textStyle = smallFont - if (s.isNotEmpty()) showLinkPreview(s) - else resetLinkPreview() - } - }, - textStyle = textStyle, + value = cs.message, + onValueChange = onMessageChange, + textStyle = textStyle.value, maxLines = 16, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -125,25 +64,35 @@ fun SendMsgView( ) { innerTextField() } - val color = if (sendEnabled) MaterialTheme.colors.primary else Color.Gray - Icon( - if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, - generalGetString(R.string.icon_descr_send_message), - tint = Color.White, - modifier = Modifier - .size(36.dp) - .padding(4.dp) - .clip(CircleShape) - .background(color) - .clickable { - if (sendEnabled) { - sendMessage(msg.value) - msg.value = "" - textStyle = smallFont - cancelledLinks.clear() + val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward + val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight + if (cs.inProgress + && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) + ) { + CircularProgressIndicator( + Modifier + .size(36.dp) + .padding(4.dp), + color = HighOrLowlight, + strokeWidth = 3.dp + ) + } else { + Icon( + icon, + stringResource(R.string.icon_descr_send_message), + tint = Color.White, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + .clip(CircleShape) + .background(color) + .clickable { + if (cs.sendEnabled()) { + sendMessage() + } } - } - ) + ) + } } } } @@ -158,14 +107,14 @@ fun SendMsgView( ) @Composable fun PreviewSendMsgView() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } SimpleXTheme { SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember {mutableStateOf(null) }, - cancelledLinks = mutableSetOf(), - parseMarkdown = { null }, - sendMessage = { msg -> println(msg) }, - sendEnabled = true + composeState = remember { mutableStateOf(ComposeState()) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle ) } } @@ -178,15 +127,36 @@ fun PreviewSendMsgView() { ) @Composable fun PreviewSendMsgViewEditing() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData()) SimpleXTheme { SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember {mutableStateOf(null) }, - cancelledLinks = mutableSetOf(), - sendMessage = { msg -> println(msg) }, - parseMarkdown = { null }, - editing = true, - sendEnabled = true + composeState = remember { mutableStateOf(composeStateEditing) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle + ) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSendMsgViewInProgress() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true) + SimpleXTheme { + SendMsgView( + composeState = remember { mutableStateOf(composeStateInProgress) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle ) } } 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 0000000000..718d48f1f4 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -0,0 +1,211 @@ +package chat.simplex.app.views.chat.item + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +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.draw.clip +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.helpers.* +import kotlinx.datetime.Clock + +@Composable +fun CIFileView( + file: CIFile?, + edited: Boolean, + receiveFile: (Long) -> Unit +) { + val context = LocalContext.current + val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file) + + @Composable + fun fileIcon( + innerIcon: ImageVector? = null, + color: Color = if (isSystemInDarkTheme()) FileDark else FileLight + ) { + 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), formatBytes(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 = getLoadedFilePath(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 progressIndicator() { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isSystemInDarkTheme()) FileDark else FileLight, + strokeWidth = 4.dp + ) + } + + @Composable + fun fileIndicator() { + Box( + Modifier + .size(42.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = { fileAction() }), + contentAlignment = Alignment.Center + ) { + if (file != null) { + when (file.fileStatus) { + CIFileStatus.SndStored -> fileIcon() + CIFileStatus.SndTransfer -> progressIndicator() + CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check) + 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 -> progressIndicator() + CIFileStatus.RcvComplete -> fileIcon() + CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close) + } + } else { + fileIcon() + } + } + } + + Row( + Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(2.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.SndComplete) + ) + 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 = 1_000_000_000, 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 d0196336c7..e98f6b2b90 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,26 @@ import android.graphics.Bitmap -import androidx.compose.foundation.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.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.model.CIFile -import chat.simplex.app.views.helpers.* import chat.simplex.app.R +import chat.simplex.app.model.CIFile +import chat.simplex.app.model.CIFileStatus +import chat.simplex.app.views.helpers.* @Composable fun CIImageView( @@ -17,28 +28,83 @@ 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.SndTransfer -> + CircularProgressIndicator( + Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) + CIFileStatus.SndComplete -> + Icon( + Icons.Filled.Check, + stringResource(R.string.icon_descr_image_snd_complete), + Modifier.fillMaxSize(), + tint = Color.White + ) + 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 = generalGetString(R.string.image_descr), + contentDescription = stringResource(R.string.image_descr), // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView // if text is short and take all available width if text is long modifier = Modifier .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? = getLoadedImage(context, file) + if (imageBitmap != null) { + imageView(imageBitmap, onClick = { + if (getLoadedFilePath(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/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index 7e67d33664..126b477e80 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -16,7 +17,6 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimplexBlue -import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable @@ -27,7 +27,7 @@ fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) { Icon( Icons.Filled.Edit, modifier = Modifier.height(12.dp).padding(end = 1.dp), - contentDescription = generalGetString(R.string.icon_descr_edited), + contentDescription = stringResource(R.string.icon_descr_edited), tint = metaColor, ) } @@ -47,16 +47,16 @@ fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) { fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) { when (status) { is CIStatus.SndSent -> { - Icon(Icons.Filled.Check, generalGetString(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor) + Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor) } is CIStatus.SndErrorAuth -> { - Icon(Icons.Filled.Close, generalGetString(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) + Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) } is CIStatus.SndError -> { - Icon(Icons.Filled.WarningAmber, generalGetString(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) + Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) } is CIStatus.RcvNew -> { - Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue) + Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue) } else -> {} } 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 c52b99e1f7..21fc8280d7 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 @@ -3,6 +3,7 @@ package chat.simplex.app.views.chat.item import android.content.Context import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -10,15 +11,19 @@ import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.ComposeContextItem +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @@ -26,32 +31,39 @@ import kotlinx.datetime.Clock fun ChatItemView( user: User, cItem: ChatItem, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, + composeState: MutableState, 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 = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file) Box( modifier = Modifier .padding(bottom = 4.dp) .fillMaxWidth(), contentAlignment = alignment, ) { - Column(Modifier.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})) { + Column( + Modifier + .clip(RoundedCornerShape(18.dp)) + .combinedClickable(onLongClick = { showMenu.value = true }, onClick = {}) + ) { if (cItem.isMsgContent) { 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) + } else if (cItem.isCall) { + FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile) } if (cItem.isMsgContent) { DropdownMenu( @@ -59,29 +71,43 @@ fun ChatItemView( onDismissRequest = { showMenu.value = false }, Modifier.width(220.dp) ) { - ItemAction(generalGetString(R.string.reply_verb), Icons.Outlined.Reply, onClick = { - editingItem.value = null - quotedItem.value = cItem + ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem)) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } showMenu.value = false }) - ItemAction(generalGetString(R.string.share_verb), Icons.Outlined.Share, onClick = { + ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { shareText(cxt, cItem.content.text) showMenu.value = false }) - ItemAction(generalGetString(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { + ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { copyText(cxt, cItem.content.text) showMenu.value = false }) + if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) { + val filePath = getLoadedFilePath(context, cItem.file) + if (filePath != null) { + ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = { + when (cItem.content.msgContent) { + is MsgContent.MCImage -> saveImage(context, cItem.file) + is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName) + else -> {} + } + showMenu.value = false + }) + } + } if (cItem.chatDir.sent && cItem.meta.editable) { - ItemAction(generalGetString(R.string.edit_verb), Icons.Filled.Edit, onClick = { - quotedItem.value = null - editingItem.value = cItem - msg.value = cItem.content.text + ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { + composeState.value = ComposeState(editingItem = cItem) showMenu.value = false }) } ItemAction( - generalGetString(R.string.delete_verb), + stringResource(R.string.delete_verb), Icons.Outlined.Delete, onClick = { showMenu.value = false @@ -96,7 +122,7 @@ fun ChatItemView( } @Composable -private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) { +fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) { DropdownMenuItem(onClick) { Row { Text( @@ -125,13 +151,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM Button(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() - }) { Text(generalGetString(R.string.for_me_only)) } + }) { Text(stringResource(R.string.for_me_only)) } if (chatItem.meta.editable) { Spacer(Modifier.padding(horizontal = 4.dp)) Button(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) AlertManager.shared.hideAlert() - }) { Text(generalGetString(R.string.for_everybody)) } + }) { Text(stringResource(R.string.for_everybody)) } } } } @@ -147,11 +173,10 @@ fun PreviewChatItemView() { ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, cxt = LocalContext.current, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + receiveFile = {} ) } } @@ -163,11 +188,10 @@ fun PreviewChatItemViewDeletedContent() { ChatItemView( User.sampleData, ChatItem.getDeletedContentSampleData(), - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, + 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 a75194f97d..bf59756968 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,11 +1,12 @@ package chat.simplex.app.views.chat.item import CIImageView -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -14,15 +15,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle 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.HighOrLowlight -import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.ChatItemLinkView +import chat.simplex.app.views.helpers.base64ToBitmap import kotlinx.datetime.Clock val SentColorLight = Color(0x1E45B8FF) @@ -36,9 +38,62 @@ fun FramedItemView( ci: ChatItem, uriHandler: UriHandler? = null, showMember: Boolean = false, - showMenu: MutableState + showMenu: MutableState, + receiveFile: (Long) -> Unit ) { val sent = ci.chatDir.sent + + @Composable + fun ciQuotedMsgView(qi: CIQuote) { + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.TopStart + ) { + MarkdownText( + qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3, + style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) + ) + } + } + + @Composable + fun ciQuoteView(qi: CIQuote) { + Row( + Modifier + .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) + .fillMaxWidth() + ) { + when (qi.content) { + is MsgContent.MCImage -> { + Box(Modifier.fillMaxWidth().weight(1f)) { + ciQuotedMsgView(qi) + } + val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() + Image( + imageBitmap, + contentDescription = stringResource(R.string.image_descr), + contentScale = ContentScale.Crop, + modifier = Modifier.size(68.dp).clipToBounds() + ) + } + is MsgContent.MCFile -> { + Box(Modifier.fillMaxWidth().weight(1f)) { + ciQuotedMsgView(qi) + } + Icon( + Icons.Filled.InsertDriveFile, + stringResource(R.string.icon_descr_file), + Modifier + .padding(top = 6.dp, end = 4.dp) + .size(22.dp), + tint = if (isSystemInDarkTheme()) FileDark else FileLight + ) + } + else -> ciQuotedMsgView(qi) + } + } + } + Surface( shape = RoundedCornerShape(18.dp), color = if (sent) SentColorLight else ReceivedColorLight @@ -48,33 +103,7 @@ fun FramedItemView( Column(Modifier.width(IntrinsicSize.Max)) { val qi = ci.quotedItem if (qi != null) { - Row( - Modifier - .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) - .fillMaxWidth() - ) { - Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), - contentAlignment = Alignment.TopStart - ) { - MarkdownText( - qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3, - style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) - ) - } - Spacer(Modifier.weight(1f)) - if (qi.content is MsgContent.MCImage) { - val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() - Image( - imageBitmap, - contentDescription = generalGetString(R.string.image_descr), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(60.dp) - .clipToBounds() - ) - } - } + ciQuoteView(qi) } if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { @@ -99,6 +128,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) @@ -139,7 +174,8 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited, ), - showMenu = showMenu + showMenu = showMenu, + receiveFile = {} ) } } @@ -154,7 +190,8 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool ChatItem.getSampleData( 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited ), - showMenu = showMenu + showMenu = showMenu, + receiveFile = {} ) } } @@ -173,7 +210,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 = {} ) } } @@ -193,7 +231,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 = {} ) } } @@ -213,7 +252,91 @@ 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 = {} + ) + } +} + +@Preview +@Composable +fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCImage( + text = "Hello there", + image = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z" + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} + ) + } +} + +@Preview +@Composable +fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCImage( + 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.", + image = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z" + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} + ) + } +} + +@Preview +@Composable +fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCFile( + 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." + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt index bf74ab05ce..e19473d364 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -1,14 +1,15 @@ import android.graphics.Bitmap import androidx.activity.compose.BackHandler import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable 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.res.stringResource import chat.simplex.app.R -import chat.simplex.app.views.helpers.generalGetString @Composable fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) { @@ -21,7 +22,7 @@ fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) { ) { Image( imageBitmap.asImageBitmap(), - contentDescription = generalGetString(R.string.image_descr), + contentDescription = stringResource(R.string.image_descr), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt index f2a96f2418..ff10bbbfeb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt @@ -1,7 +1,7 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration -import androidx.compose.foundation.* +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -18,7 +19,6 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.annotatedStringResource -import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.usersettings.simplexTeamUri val bold = SpanStyle(fontWeight = FontWeight.Bold) @@ -31,7 +31,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { ) { val uriHandler = LocalUriHandler.current - Text(generalGetString(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp) + Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp) Text( annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder), modifier = Modifier.clickable(onClick = { @@ -46,7 +46,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - generalGetString(R.string.to_start_a_new_chat_help_header), + stringResource(R.string.to_start_a_new_chat_help_header), style = MaterialTheme.typography.h2, lineHeight = 22.sp ) @@ -54,13 +54,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(generalGetString(R.string.chat_help_tap_button)) + Text(stringResource(R.string.chat_help_tap_button)) Icon( Icons.Outlined.PersonAdd, - generalGetString(R.string.add_contact), + stringResource(R.string.add_contact), modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier, ) - Text(generalGetString(R.string.above_then_preposition_continuation)) + Text(stringResource(R.string.above_then_preposition_continuation)) } Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp) Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp) @@ -71,8 +71,8 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Text(generalGetString(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2) - Text(generalGetString(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp) + Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2) + Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp) Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp) Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index f707e62c8b..888447965b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -1,19 +1,22 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* -import androidx.compose.material.Divider -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.toMutableStateList +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @@ -22,19 +25,42 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ChatListNavLinkLayout( chat = chat, click = { - if (chat.chatInfo is ChatInfo.ContactRequest) { - contactRequestAlertDialog(chat.chatInfo, chatModel) - } else { - withApi { openChat(chatModel, chat.chatInfo) } + when (chat.chatInfo) { + is ChatInfo.ContactRequest -> contactRequestAlertDialog(chat.chatInfo, chatModel) + is ChatInfo.ContactConnection -> contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) + else -> + if (chat.chatInfo.ready) { + withApi { openChat(chatModel, chat.chatInfo) } + } else { + pendingContactAlertDialog(chat.chatInfo, chatModel) + } } - } + }, + deleteContact = { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.delete_contact__question), + text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + val cInfo = chat.chatInfo + withApi { + val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId) + if (r) { + chatModel.removeChat(cInfo.id) + chatModel.chatId.value = null + } + } + } + ) + }, ) } suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) { val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId) if (chat != null) { - chatModel.chatItems = chat.chatItems.toMutableStateList() + chatModel.chatItems.clear() + chatModel.chatItems.addAll(chat.chatItems) chatModel.chatId.value = cInfo.id } } @@ -63,12 +89,85 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel ) } +fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString( + if (connection.initiated) R.string.you_invited_your_contact + else R.string.you_accepted_connection + ), + text = generalGetString( + if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted + else R.string.you_will_be_connected_when_your_contacts_device_is_online + ), + buttons = { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = { + AlertManager.shared.hideAlert() + deleteContactConnectionAlert(connection, chatModel) + }) { + Text(stringResource(R.string.delete_verb)) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + Button(onClick = { AlertManager.shared.hideAlert() }) { + Text(stringResource(R.string.ok)) + } + } + } + ) +} + +fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.delete_pending_connection__question), + text = generalGetString( + if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect + else R.string.connection_you_accepted_will_be_cancelled + ), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + withApi { + AlertManager.shared.hideAlert() + if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) { + chatModel.removeChat(connection.id) + } + } + } + ) +} + +fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.alert_title_contact_connection_pending), + text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry), + confirmText = generalGetString(R.string.button_delete_contact), + onConfirm = { + withApi { + val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + if (r) { + chatModel.removeChat(chatInfo.id) + chatModel.chatId.value = null + } + } + }, + dismissText = generalGetString(R.string.cancel_verb), + ) +} + @Composable -fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) { +fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Unit) { + val showMenu = remember { mutableStateOf(false) } Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = click) + .combinedClickable( + onClick = click, + onLongClick = { if (chat.chatInfo is ChatInfo.Direct) showMenu.value = true } + ) .height(88.dp) ) { Row( @@ -79,10 +178,28 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) { .padding(end = 12.dp), verticalAlignment = Alignment.Top ) { - if (chat.chatInfo is ChatInfo.ContactRequest) { - ContactRequestView(chat) - } else { - ChatPreviewView(chat) + when (chat.chatInfo) { + is ChatInfo.Direct -> ChatPreviewView(chat) + is ChatInfo.Group -> ChatPreviewView(chat) + is ChatInfo.ContactRequest -> ContactRequestView(chat.chatInfo) + is ChatInfo.ContactConnection -> ContactConnectionView(chat.chatInfo.contactConnection) + } + } + if (chat.chatInfo is ChatInfo.Direct) { + DropdownMenu( + expanded = showMenu.value, + onDismissRequest = { showMenu.value = false }, + Modifier.width(220.dp) + ) { + ItemAction( + stringResource(R.string.delete_verb), + Icons.Outlined.Delete, + onClick = { + deleteContact() + showMenu.value = false + }, + color = Color.Red + ) } } } @@ -111,7 +228,8 @@ fun PreviewChatListNavLinkDirect() { ), chatStats = Chat.ChatStats() ), - click = {} + click = {}, + deleteContact = {} ) } } @@ -138,7 +256,8 @@ fun PreviewChatListNavLinkGroup() { ), chatStats = Chat.ChatStats() ), - click = {} + click = {}, + deleteContact = {} ) } } @@ -158,7 +277,8 @@ fun PreviewChatListNavLinkContactRequest() { chatItems = listOf(), chatStats = Chat.ChatStats() ), - click = {} + click = {}, + deleteContact = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index c6fffb927e..c207f3de46 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -19,8 +20,8 @@ import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.ToolbarDark import chat.simplex.app.ui.theme.ToolbarLight import chat.simplex.app.views.helpers.ModalManager -import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.newchat.NewChatSheet +import chat.simplex.app.views.onboarding.MakeConnection import chat.simplex.app.views.usersettings.SettingsView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -88,8 +89,7 @@ fun ChatListView(chatModel: ChatModel) { if (chatModel.chats.isNotEmpty()) { ChatList(chatModel) } else { - val user = chatModel.currentUser.value - Help(scaffoldCtrl, displayName = user?.profile?.displayName) + MakeConnection(chatModel) } } if (scaffoldCtrl.expanded.value) { @@ -113,8 +113,8 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) { .padding(16.dp) ) { val welcomeMsg = if (displayName != null) { - String.format(generalGetString(R.string.personal_welcome), displayName) - } else generalGetString(R.string.welcome) + String.format(stringResource(R.string.personal_welcome), displayName) + } else stringResource(R.string.welcome) Text( text = welcomeMsg, Modifier.padding(bottom = 24.dp), @@ -128,12 +128,12 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - generalGetString(R.string.this_text_is_available_in_settings), + stringResource(R.string.this_text_is_available_in_settings), color = MaterialTheme.colors.onBackground ) Icon( Icons.Outlined.Settings, - generalGetString(R.string.icon_descr_settings), + stringResource(R.string.icon_descr_settings), tint = MaterialTheme.colors.onBackground, modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() }) ) @@ -155,13 +155,13 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) { Icon( Icons.Outlined.Menu, - generalGetString(R.string.icon_descr_settings), + stringResource(R.string.icon_descr_settings), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) } Text( - generalGetString(R.string.your_chats), + stringResource(R.string.your_chats), color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(5.dp) @@ -169,7 +169,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { Icon( Icons.Outlined.PersonAdd, - generalGetString(R.string.add_contact), + stringResource(R.string.add_contact), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 3c9a4158b2..d99a51f05e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -4,49 +4,63 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R -import chat.simplex.app.model.Chat -import chat.simplex.app.model.getTimestampText +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.ChatInfoImage +import chat.simplex.app.views.helpers.badgeLayout @Composable fun ChatPreviewView(chat: Chat) { Row { - ChatInfoImage(chat, size = 72.dp) + val cInfo = chat.chatInfo + Box(contentAlignment = Alignment.BottomStart) { + ChatInfoImage(cInfo, size = 72.dp) + if (cInfo is ChatInfo.Direct) { + ChatStatusImage(chat) + } + } Column( modifier = Modifier .padding(horizontal = 8.dp) .weight(1F) ) { Text( - chat.chatInfo.chatViewName, + cInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + color = if (cInfo.ready) Color.Unspecified else HighOrLowlight ) - - val ci = chat.chatItems.lastOrNull() - if (ci != null) { - MarkdownText( - ci.text, ci.formattedText, ci.memberDisplayName, - metaText = ci.timestampText, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + if (cInfo.ready) { + val ci = chat.chatItems.lastOrNull() + if (ci != null) { + MarkdownText( + ci.text, ci.formattedText, ci.memberDisplayName, + metaText = ci.timestampText, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } else { + Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight) } } val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt) @@ -63,7 +77,7 @@ fun ChatPreviewView(chat: Chat) { val n = chat.chatStats.unreadCount if (n > 0) { Text( - if (n < 1000) "$n" else "${n / 1000}" + generalGetString(R.string.thousand_abbreviation), + if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation), color = MaterialTheme.colors.onPrimary, fontSize = 14.sp, modifier = Modifier @@ -78,6 +92,27 @@ fun ChatPreviewView(chat: Chat) { } } +@Composable +fun ChatStatusImage(chat: Chat) { + val s = chat.serverInfo.networkStatus + val descr = s.statusString + if (s is Chat.NetworkStatus.Error) { + Icon( + Icons.Filled.Circle, + contentDescription = descr, + tint = HighOrLowlight, + modifier = Modifier.padding(start = 6.dp).padding(bottom = 4.dp).size(6.dp) + ) + } else if (!(s is Chat.NetworkStatus.Connected)) { + Icon( + Icons.Filled.MoreHoriz, + contentDescription = descr, + tint = HighOrLowlight, + modifier = Modifier.width(19.dp).padding(start = 6.dp) + ) + } +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt new file mode 100644 index 0000000000..8b709bd6af --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt @@ -0,0 +1,54 @@ +package chat.simplex.app.views.chatlist + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddLink +import androidx.compose.material.icons.outlined.Link +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.PendingContactConnection +import chat.simplex.app.model.getTimestampText +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.views.helpers.ProfileImage + +@Composable +fun ContactConnectionView(contactConnection: PendingContactConnection) { + Row { + Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) { + ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link) + } + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1F) + ) { + Text( + contactConnection.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = HighOrLowlight + ) + Text(contactConnection.description, maxLines = 2, color = HighOrLowlight) + } + val ts = getTimestampText(contactConnection.updatedAt) + Column( + Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.Top + ) { + Text( + ts, + color = HighOrLowlight, + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(bottom = 5.dp) + ) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 08a7b46795..e6757f8351 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -5,40 +5,36 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.Chat +import chat.simplex.app.model.ChatInfo import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.ChatInfoImage -import chat.simplex.app.views.helpers.generalGetString @Composable -fun ContactRequestView(chat: Chat) { +fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { Row { - ChatInfoImage(chat, size = 72.dp) + ChatInfoImage(contactRequest, size = 72.dp) Column( modifier = Modifier .padding(horizontal = 8.dp) .weight(1F) ) { Text( - chat.chatInfo.chatViewName, + contactRequest.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary ) - Text( - generalGetString(R.string.contact_wants_to_connect_with_you), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2) } - val ts = getTimestampText(chat.chatInfo.createdAt) + val ts = getTimestampText(contactRequest.contactRequest.updatedAt) Column( Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Top diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 3b92c1bf13..b19f17a2be 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -14,20 +14,20 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.Chat import chat.simplex.app.model.ChatInfo import chat.simplex.app.ui.theme.SimpleXTheme @Composable -fun ChatInfoImage(chat: Chat, size: Dp) { +fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) { val icon = - if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle + if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle else Icons.Filled.AccountCircle - ProfileImage(size, chat.chatInfo.image, icon) + ProfileImage(size, chatInfo.image, icon) } @Composable @@ -40,7 +40,7 @@ fun ProfileImage( if (image == null) { Icon( icon, - contentDescription = generalGetString(R.string.icon_descr_profile_image_placeholder), + contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder), tint = MaterialTheme.colors.secondary, modifier = Modifier.fillMaxSize() ) @@ -48,7 +48,7 @@ fun ProfileImage( val imageBitmap = base64ToBitmap(image).asImageBitmap() Image( imageBitmap, - generalGetString(R.string.image_descr_profile_image), + stringResource(R.string.image_descr_profile_image), contentScale = ContentScale.Crop, modifier = Modifier.size(size).padding(size / 12).clip(CircleShape) ) @@ -61,7 +61,7 @@ fun ProfileImage( fun PreviewChatInfoImage() { SimpleXTheme { ChatInfoImage( - chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()), + chatInfo = ChatInfo.Direct.sampleData, size = 55.dp ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt index 2577ae9251..c0de98ad65 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -25,7 +26,7 @@ fun CloseSheetBar(close: () -> Unit) { IconButton(onClick = close) { Icon( Icons.Outlined.Close, - generalGetString(R.string.icon_descr_close_button), + stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index a5a0ef27e3..476300fa1b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -18,12 +18,13 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.CallSuper import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Collections -import androidx.compose.material.icons.outlined.PhotoCamera -import androidx.compose.runtime.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -36,7 +37,6 @@ import kotlin.math.min import kotlin.math.sqrt // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery - fun cropToSquare(image: Bitmap): Bitmap { var xOffset = 0 var yOffset = 0 @@ -49,27 +49,45 @@ fun cropToSquare(image: Bitmap): Bitmap { return Bitmap.createBitmap(image, xOffset, yOffset, side, side) } -fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): String { +fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String { var img = image - var str = compressImage(img) + var str = compressImageStr(img) while (str.length > maxDataSize) { val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) val clippedRatio = min(ratio, 2.0) val width = (img.width.toDouble() / clippedRatio).toInt() val height = img.height * width / img.width img = Bitmap.createScaledBitmap(img, width, height, true) - str = compressImage(img) + str = compressImageStr(img) } return str } -private fun compressImage(bitmap: Bitmap): String { - val stream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream) - return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) +private fun compressImageStr(bitmap: Bitmap): String { + return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP) } -fun base64ToBitmap(base64ImageString: String) : Bitmap { +fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream { + var img = image + var stream = compressImageData(img) + while (stream.size() > maxDataSize) { + val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble()) + val clippedRatio = min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = Bitmap.createScaledBitmap(img, width, height, true) + stream = compressImageData(img) + } + return stream +} + +private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream { + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream) + return stream +} + +fun base64ToBitmap(base64ImageString: String): Bitmap { val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") @@ -77,7 +95,7 @@ fun base64ToBitmap(base64ImageString: String) : Bitmap { return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) } -class CustomTakePicturePreview : ActivityResultContract() { +class CustomTakePicturePreview: ActivityResultContract() { private var uri: Uri? = null private var tmpFile: File? = null lateinit var externalContext: Context @@ -103,16 +121,23 @@ class CustomTakePicturePreview : ActivityResultContract() { tmpFile?.delete() bitmap } else { - Log.e( TAG, "Getting image from camera cancelled or failed.") + Log.e(TAG, "Getting image from camera cancelled or failed.") tmpFile?.delete() null } } } -@Composable -fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) +//class GetGalleryContent: ActivityResultContracts.GetContent() { +// override fun createIntent(context: Context, input: String): Intent { +// super.createIntent(context, input) +// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) +// } +//} + +//@Composable +//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = +// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) @Composable fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher = @@ -122,16 +147,20 @@ fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb) +@Composable +fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) + @Composable fun GetImageBottomSheet( imageBitmap: MutableState, onImageChange: (Bitmap) -> Unit, + fileUri: MutableState? = null, + onFileChange: ((Uri) -> Unit)? = null, hideBottomSheet: () -> Unit ) { val context = LocalContext.current - val isCameraSelected = remember { mutableStateOf (false) } - - val galleryLauncher = rememberGalleryLauncher { uri: Uri? -> + val galleryLauncher = rememberGetContentLauncher { uri: Uri? -> if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) @@ -139,21 +168,32 @@ fun GetImageBottomSheet( onImageChange(bitmap) } } - val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> if (bitmap != null) { imageBitmap.value = bitmap onImageChange(bitmap) } } - val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> if (isGranted) { - if (isCameraSelected.value) cameraLauncher.launch(null) - else galleryLauncher.launch("image/*") + cameraLauncher.launch(null) hideBottomSheet() } else { - Toast.makeText(context, generalGetString(R.string.toast_camera_permission_denied), Toast.LENGTH_SHORT).show() + Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() + } + } + val filesLauncher = rememberGetContentLauncher { uri: Uri? -> + if (uri != null && fileUri != null && onFileChange != null) { + val fileSize = getFileSize(context, uri) + if (fileSize != null && fileSize <= MAX_FILE_SIZE) { + fileUri.value = uri + onFileChange(uri) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.large_file), + String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE)) + ) + } } } @@ -171,28 +211,25 @@ fun GetImageBottomSheet( .padding(horizontal = 8.dp, vertical = 30.dp), horizontalArrangement = Arrangement.SpaceEvenly ) { - ActionButton(null, generalGetString(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) { + ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { cameraLauncher.launch(null) hideBottomSheet() } else -> { - isCameraSelected.value = true permissionLauncher.launch(Manifest.permission.CAMERA) } } } - ActionButton(null, generalGetString(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { - when (PackageManager.PERMISSION_GRANTED) { - ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { - galleryLauncher.launch("image/*") - hideBottomSheet() - } - else -> { - isCameraSelected.value = false - permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } + ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { + galleryLauncher.launch("image/*") + hideBottomSheet() + } + if (fileUri != null && onFileChange != null) { + ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) { + filesLauncher.launch("*/*") + hideBottomSheet() } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt index ec13b5dd27..e4afa68202 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -42,7 +43,7 @@ suspend fun getLinkPreview(url: String): LinkPreview? { if (imageUri != null) { try { val stream = java.net.URL(imageUri).openStream() - val image = resizeImageToDataSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) + val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) // TODO add once supported in iOS // val description = ogTags.firstOrNull { // it.attr("property") == "og:description" @@ -73,7 +74,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() Image( imageBitmap, - generalGetString(R.string.image_descr_link_preview), + stringResource(R.string.image_descr_link_preview), modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) ) Column(Modifier.fillMaxWidth().weight(1F)) { @@ -86,7 +87,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) { Icon( Icons.Outlined.Close, - contentDescription = generalGetString(R.string.icon_descr_cancel_link_preview), + contentDescription = stringResource(R.string.icon_descr_cancel_link_preview), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) @@ -99,7 +100,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview) { Column { Image( base64ToBitmap(linkPreview.image).asImageBitmap(), - generalGetString(R.string.image_descr_link_preview), + stringResource(R.string.image_descr_link_preview), modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth, ) 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 5cac836f14..1045605cd1 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,18 @@ package chat.simplex.app.views.helpers import android.content.* +import android.net.Uri +import android.provider.MediaStore +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat +import chat.simplex.app.R +import chat.simplex.app.model.CIFile +import java.io.BufferedOutputStream +import java.io.File fun shareText(cxt: Context, text: String) { val sendIntent: Intent = Intent().apply { @@ -17,3 +28,50 @@ fun copyText(cxt: Context, text: String) { val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java) clipboard?.setPrimaryClip(ClipData.newPlainText("text", text)) } + +@Composable +fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + destination?.let { + val filePath = getLoadedFilePath(cxt, ciFile) + if (filePath != null) { + val contentResolver = cxt.contentResolver + contentResolver.openOutputStream(destination)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(filePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() + } + } + } + ) + +fun saveImage(cxt: Context, ciFile: CIFile?) { + val filePath = getLoadedFilePath(cxt, ciFile) + val fileName = ciFile?.fileName + if (filePath != null && fileName != null) { + val values = ContentValues() + values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) + values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + values.put(MediaStore.MediaColumns.TITLE, fileName) + val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + uri?.let { + cxt.contentResolver.openOutputStream(uri)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(filePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.image_saved), 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/SimpleButton.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt index c507d8ae2e..a4bc591552 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt @@ -3,6 +3,7 @@ package chat.simplex.app.ui.theme import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Share @@ -18,14 +19,24 @@ import androidx.compose.ui.unit.dp fun SimpleButton(text: String, icon: ImageVector, color: Color = MaterialTheme.colors.primary, click: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { click() } - ) { - Icon(icon, text, tint = color, - modifier = Modifier.padding(horizontal = 10.dp) + SimpleButtonFrame(click) { + Icon( + icon, text, tint = color, + modifier = Modifier.padding(end = 8.dp) ) - Text(text, style = MaterialTheme.typography.caption, color = color) + Text(text, style = MaterialTheme.typography.caption, color = color) + } +} + +@Composable +fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) { + Surface(shape = RoundedCornerShape(20.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { click() } + .padding(8.dp) + ) { content() } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt new file mode 100644 index 0000000000..c02bdfb6a4 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt @@ -0,0 +1,58 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.ui.theme.HighOrLowlight + +@Composable +fun TextEditor(modifier: Modifier, text: MutableState) { + BasicTextField( + value = text.value, + onValueChange = { text.value = it }, + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, fontSize = 14.sp, + color = MaterialTheme.colors.onBackground + ), + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + autoCorrect = false + ), + modifier = modifier, + cursorBrush = SolidColor(HighOrLowlight), + decorationBox = { innerTextField -> + Surface( + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.secondary) + ) { + Row( + Modifier.background(MaterialTheme.colors.background), + verticalAlignment = Alignment.Top + ) { + Box( + Modifier + .weight(1f) + .padding(vertical = 5.dp, horizontal = 7.dp) + ) { + innerTextField() + } + } + } + } + ) +} 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 be4e5d2d7c..478bc7b5fb 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 @@ -4,9 +4,13 @@ import android.content.Context import android.content.res.Resources import android.graphics.* import android.graphics.Typeface +import android.net.Uri +import android.os.FileUtils +import android.provider.OpenableColumns import android.text.Spanned import android.text.SpannedString import android.text.style.* +import android.util.Log import android.view.ViewTreeObserver import androidx.annotation.StringRes import androidx.compose.runtime.* @@ -24,6 +28,11 @@ import chat.simplex.app.SimplexApp import chat.simplex.app.model.CIFile import kotlinx.coroutines.* import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.log2 +import kotlin.math.pow fun withApi(action: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch { withContext(Dispatchers.Main, action) } @@ -57,9 +66,11 @@ fun getKeyboardState(): State { return keyboardState } + // Resource to annotated string from // https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources fun generalGetString(id: Int): String { + // prefer stringResource in Composable items to retain preview abilities return SimplexApp.context.getString(id) } @@ -199,17 +210,25 @@ private fun spannableStringToAnnotatedString( } } +// maximum image file size to be auto-accepted +const val MAX_IMAGE_SIZE: Long = 236700 +const val MAX_FILE_SIZE: Long = 8000000 + fun getFilesDirectory(context: Context): String { return context.filesDir.toString() } fun getAppFilesDirectory(context: Context): String { - return getFilesDirectory(context) + "/app_files" + return "${getFilesDirectory(context)}/app_files" } -fun getStoredFilePath(context: Context, file: CIFile?): String? { - return if (file?.filePath != null && file.stored) { - val filePath = getAppFilesDirectory(context) + "/" + file.filePath +fun getAppFilePath(context: Context, fileName: String): String { + return "${getAppFilesDirectory(context)}/$fileName" +} + +fun getLoadedFilePath(context: Context, file: CIFile?): String? { + return if (file?.filePath != null && file.loaded) { + val filePath = getAppFilePath(context, file.filePath) if (File(filePath).exists()) filePath else null } else { null @@ -217,8 +236,8 @@ fun getStoredFilePath(context: Context, file: CIFile?): String? { } // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -fun getStoredImage(context: Context, file: CIFile?): Bitmap? { - val filePath = getStoredFilePath(context, file) +fun getLoadedImage(context: Context, file: CIFile?): Bitmap? { + val filePath = getLoadedFilePath(context, file) return if (filePath != null) { try { val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) @@ -234,3 +253,93 @@ fun getStoredImage(context: Context, file: CIFile?): Bitmap? { null } } + +fun getFileName(context: Context, uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(nameIndex) + } +} + +fun getFileSize(context: Context, uri: Uri): Long? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + cursor.getLong(sizeIndex) + } +} + +fun saveImage(context: Context, image: Bitmap): String? { + return try { + val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg") + val file = File(getAppFilePath(context, fileToSave)) + val output = FileOutputStream(file) + dataResized.writeTo(output) + output.flush() + output.close() + fileToSave + } catch (e: Exception) { + Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}") + null + } +} + +fun saveFileFromUri(context: Context, uri: Uri): String? { + return try { + val inputStream = context.contentResolver.openInputStream(uri) + val fileToSave = getFileName(context, uri) + if (inputStream != null && fileToSave != null) { + val destFileName = uniqueCombine(context, fileToSave) + val destFile = File(getAppFilePath(context, destFileName)) + FileUtils.copy(inputStream, FileOutputStream(destFile)) + destFileName + } else { + Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream") + null + } + } catch (e: Exception) { + Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}") + null + } +} + +fun uniqueCombine(context: Context, fileName: String): String { + fun tryCombine(fileName: String, n: Int): String { + val name = File(fileName).nameWithoutExtension + val ext = File(fileName).extension + val suffix = if (n == 0) "" else "_$n" + val f = "$name$suffix.$ext" + return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f + } + return tryCombine(fileName, 0) +} + +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) + } +} + +fun removeFile(context: Context, fileName: String): Boolean { + val file = File(getAppFilePath(context, fileName)) + val fileDeleted = file.delete() + if (!fileDeleted) { + Log.e(chat.simplex.app.TAG, "Util.kt removeFile error") + } + return fileDeleted +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index 4e668e3580..0340693610 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -19,7 +20,6 @@ import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.shareText @Composable @@ -43,11 +43,11 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { verticalArrangement = Arrangement.SpaceBetween, ) { Text( - generalGetString(R.string.add_contact), + stringResource(R.string.add_contact), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), ) Text( - generalGetString(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), + stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), style = MaterialTheme.typography.h3, textAlign = TextAlign.Center, ) @@ -58,14 +58,14 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { .padding(vertical = 3.dp) ) Text( - generalGetString(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), + stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), textAlign = TextAlign.Center, lineHeight = 22.sp, modifier = Modifier .padding(horizontal = 16.dp) - .padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp) + .padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp) ) - SimpleButton(generalGetString(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) + SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) Spacer(Modifier.height(10.dp)) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index 2d3ff4924a..d7687a3a57 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat import android.Manifest import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* @@ -10,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -19,7 +21,8 @@ import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chatlist.ScaffoldController -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.app.views.helpers.withApi import com.google.accompanist.permissions.rememberPermissionState @Composable @@ -39,52 +42,65 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) { }, scanCode = { newChatCtrl.collapse() - ModalManager.shared.showCustomModal { close -> ConnectContactView(chatModel, close) } + ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) } cameraPermissionState.launchPermissionRequest() + }, + pasteLink = { + newChatCtrl.collapse() + ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) } } ) } @Composable -fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 48.dp), - horizontalArrangement = Arrangement.SpaceEvenly +fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally ) { - Box( + Text( + stringResource(R.string.add_contact_to_start_new_chat), + modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp) + ) + Row( Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - generalGetString(R.string.add_contact), - generalGetString(R.string.create_QR_code_or_link__bracketed__multiline), - Icons.Outlined.PersonAdd, - click = addContact - ) - } - Box( - Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - generalGetString(R.string.scan_QR_code), - generalGetString(R.string.in_person_or_in_video_call__bracketed), - Icons.Outlined.QrCode, - click = scanCode - ) - } - Box( - Modifier - .weight(1F) - .fillMaxWidth()) { - ActionButton( - generalGetString(R.string.create_group), - generalGetString(R.string.coming_soon__bracketed), - Icons.Outlined.GroupAdd, - disabled = true - ) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 24.dp, bottom = 40.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Box( + Modifier + .weight(1F) + .fillMaxWidth()) { + ActionButton( + stringResource(R.string.create_one_time_link), + stringResource(R.string.to_share_with_your_contact), + Icons.Outlined.AddLink, + click = addContact + ) + } + Box( + Modifier + .weight(1F) + .fillMaxWidth()) { + ActionButton( + stringResource(R.string.paste_received_link), + stringResource(R.string.paste_received_link_from_clipboard), + Icons.Outlined.Article, + click = pasteLink + ) + } + Box( + Modifier + .weight(1F) + .fillMaxWidth()) { + ActionButton( + stringResource(R.string.scan_QR_code), + stringResource(R.string.in_person_or_in_video_call__bracketed), + Icons.Outlined.QrCode, + click = scanCode + ) + } } } } @@ -92,33 +108,35 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { @Composable fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false, click: () -> Unit = {}) { - Column( - Modifier - .clickable(onClick = click) - .padding(horizontal = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary - Icon(icon, text, - tint = tint, - modifier = Modifier - .size(40.dp) - .padding(bottom = 8.dp)) - if (text != null) { - Text( - text, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - color = tint, - modifier = Modifier.padding(bottom = 4.dp) - ) - } - if (comment != null) { - Text( - comment, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.body2 - ) + Surface(shape = RoundedCornerShape(18.dp)) { + Column( + Modifier + .clickable(onClick = click) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary + Icon(icon, text, + tint = tint, + modifier = Modifier + .size(40.dp) + .padding(bottom = 8.dp)) + if (text != null) { + Text( + text, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = tint, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + if (comment != null) { + Text( + comment, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2 + ) + } } } } @@ -129,7 +147,8 @@ fun PreviewNewChatSheet() { SimpleXTheme { NewChatSheetLayout( addContact = {}, - scanCode = {} + scanCode = {}, + pasteLink = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt new file mode 100644 index 0000000000..6e9372dcc2 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt @@ -0,0 +1,127 @@ +package chat.simplex.app.views.newchat + +import android.content.ClipboardManager +import android.content.res.Configuration +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat.getSystemService +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.SimpleButton +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* + +@Composable +fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { + val connectionLink = remember { mutableStateOf("")} + val context = LocalContext.current + val clipboard = getSystemService(context, ClipboardManager::class.java) + BackHandler(onBack = close) + PasteToConnectLayout( + connectionLink = connectionLink, + pasteFromClipboard = { + connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String + }, + connectViaLink = { connReqUri -> + try { + val uri = Uri.parse(connReqUri) + withUriAction(uri) { action -> + connectViaUri(chatModel, action, uri) + } + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.invalid_connection_link), + text = generalGetString(R.string.this_string_is_not_a_connection_link) + ) + } + close() + }, + close = close + ) +} + +@Composable +fun PasteToConnectLayout( + connectionLink: MutableState, + pasteFromClipboard: () -> Unit, + connectViaLink: (String) -> Unit, + close: () -> Unit +) { + ModalView(close) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.connect_via_link), + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + modifier = Modifier.padding(bottom = 16.dp) + ) + Text(stringResource(R.string.paste_connection_link_below_to_connect)) + Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link)) + + Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) { + TextEditor(Modifier.height(180.dp), text = connectionLink) + } + + Row( + Modifier.fillMaxWidth().padding(bottom = 6.dp), + horizontalArrangement = Arrangement.Start, + ) { + if (connectionLink.value == "") { + SimpleButton(text = "Paste", icon = Icons.Outlined.ContentPaste) { + pasteFromClipboard() + } + } else { + SimpleButton(text = "Clear", icon = Icons.Outlined.Clear) { + connectionLink.value = "" + } + } + Spacer(Modifier.weight(1f).fillMaxWidth()) + SimpleButton(text = "Connect", icon = Icons.Outlined.Link) { + connectViaLink(connectionLink.value) + } + } + + Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link)) + } + } +} + + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "Dark Mode" +) +@Composable +fun PreviewPasteToConnectTextbox() { + SimpleXTheme { + PasteToConnectLayout( + connectionLink = remember { mutableStateOf("") }, + pasteFromClipboard = {}, + connectViaLink = { link -> + try { + println(link) + // withApi { chatModel.controller.apiConnect(link) } + } catch (e: Exception) { + e.printStackTrace() + } + }, + close = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt index f55489c7d7..b0f5a0c718 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt @@ -1,38 +1,32 @@ package chat.simplex.app.views.newchat import android.graphics.Bitmap -import android.graphics.Color import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import boofcv.alg.fiducial.qrcode.QrCodeEncoder +import boofcv.alg.fiducial.qrcode.QrCodeGeneratorImage +import boofcv.android.ConvertBitmap import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.generalGetString -import com.google.zxing.BarcodeFormat -import com.google.zxing.EncodeHintType -import com.google.zxing.qrcode.QRCodeWriter @Composable fun QRCode(connReq: String, modifier: Modifier = Modifier) { Image( bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(), - contentDescription = generalGetString(R.string.image_descr_qr_code), + contentDescription = stringResource(R.string.image_descr_qr_code), modifier = modifier ) } fun qrCodeBitmap(content: String, size: Int): Bitmap { - val hints = hashMapOf().also { it[EncodeHintType.MARGIN] = 1 } - val bits = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) - return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also { - for (x in 0 until size) { - for (y in 0 until size) { - it.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE) - } - } - } + val qrCode = QrCodeEncoder().addAutomatic(content).fixate() + val renderer = QrCodeGeneratorImage(5) + renderer.render(qrCode) + return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565) } @Preview diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt index f6bee70490..5d594b98b3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.newchat +import android.annotation.SuppressLint import android.util.Log import android.view.ViewGroup import androidx.camera.core.* @@ -10,21 +11,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import boofcv.abst.fiducial.QrCodeDetector +import boofcv.alg.color.ColorFormat +import boofcv.android.ConvertCameraImage +import boofcv.factory.fiducial.FactoryFiducial +import boofcv.struct.image.GrayU8 import chat.simplex.app.TAG import com.google.common.util.concurrent.ListenableFuture -import com.google.mlkit.vision.barcode.BarcodeScannerOptions -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage import java.util.concurrent.* -// Bar code scanner adapted from https://github.com/MakeItEasyDev/Jetpack-Compose-BarCode-Scanner +// Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5 @Composable fun QRCodeScanner(onBarcode: (String) -> Unit) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } + var lastAnalyzedTimeStamp = 0L + var contactLink = "" AndroidView( factory = { AndroidViewContext -> @@ -36,74 +40,60 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) { ) implementationMode = PreviewView.ImplementationMode.COMPATIBLE } - }, -// modifier = Modifier.fillMaxSize(), - update = { previewView -> - val cameraSelector: CameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() - val cameraProviderFuture: ListenableFuture = - ProcessCameraProvider.getInstance(context) - - cameraProviderFuture.addListener({ - preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() - val barcodeAnalyser = BarCodeAnalyser { barcodes -> - barcodes.firstOrNull()?.rawValue?.let(onBarcode) - } - val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { it.setAnalyzer(cameraExecutor, barcodeAnalyser) } - - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) - } catch (e: Exception) { - Log.d(TAG, "CameraPreview: ${e.localizedMessage}") - } - }, ContextCompat.getMainExecutor(context)) } - ) -} + ) { previewView -> + val cameraSelector: CameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() + val cameraProviderFuture: ListenableFuture = + ProcessCameraProvider.getInstance(context) -class BarCodeAnalyser( - private val onBarcodeDetected: (barcodes: List) -> Unit, -): ImageAnalysis.Analyzer { - private var lastAnalyzedTimeStamp = 0L - - @ExperimentalGetImage - override fun analyze(image: ImageProxy) { - val currentTimestamp = System.currentTimeMillis() - if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) { - image.image?.let { imageToAnalyze -> - val options = BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) - .build() - val barcodeScanner = BarcodeScanning.getClient(options) - val imageToProcess = InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees) - - barcodeScanner.process(imageToProcess) - .addOnSuccessListener { barcodes -> - if (barcodes.isNotEmpty()) { - onBarcodeDetected(barcodes) - } else { - Log.d(TAG, "BarcodeAnalyser: No barcode Scanned") + cameraProviderFuture.addListener({ + preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + val detector: QrCodeDetector = FactoryFiducial.qrcode(null, GrayU8::class.java) + fun getQR(imageProxy: ImageProxy) { + val currentTimeStamp = System.currentTimeMillis() + if (currentTimeStamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) { + detector.process(imageProxyToGrayU8(imageProxy)) + val found = detector.detections + val qr = found.firstOrNull() + if (qr != null) { + if (qr.message != contactLink) { + // Make sure link is new and not a repeat + contactLink = qr.message + onBarcode(contactLink) } } - .addOnFailureListener { exception -> - Log.e(TAG, "BarcodeAnalyser: Something went wrong $exception") - } - .addOnCompleteListener { - image.close() - } + } + imageProxy.close() } - lastAnalyzedTimeStamp = currentTimestamp - } else { - image.close() - } + val imageAnalyzer = ImageAnalysis.Analyzer { proxy -> getQR(proxy) } + val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setImageQueueDepth(1) + .build() + .also { it.setAnalyzer(cameraExecutor, imageAnalyzer) } + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + } catch (e: Exception) { + Log.d(TAG, "CameraPreview: ${e.localizedMessage}") + } + }, ContextCompat.getMainExecutor(context)) } } + +@SuppressLint("UnsafeOptInUsageError") +private fun imageProxyToGrayU8(img: ImageProxy) : GrayU8? { + val image = img.image + if (image != null) { + val outImg = GrayU8() + ConvertCameraImage.imageToBoof(image, ColorFormat.GRAY, outImg, null) + return outImg + } + return null +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt similarity index 98% rename from apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt rename to apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt index f65b000987..11c67a409a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt @@ -19,7 +19,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* @Composable -fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) { +fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { BackHandler(onBack = close) ConnectContactLayout( qrCodeScanner = { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt new file mode 100644 index 0000000000..07ee6d892b --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt @@ -0,0 +1,70 @@ +package chat.simplex.app.views.onboarding + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.User +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.app.views.helpers.annotatedStringResource + +@Composable +fun HowItWorks(user: User?, onboardingStage: MutableState? = null) { + Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) { + Text(stringResource(R.string.how_simplex_works), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = 8.dp)) + ReadableText(R.string.many_people_asked_how_can_it_deliver) + ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues) + ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send) + ReadableText(R.string.only_client_devices_store_contacts_groups_e2e_encrypted_messages) + if (onboardingStage == null) { + val uriHandler = LocalUriHandler.current + Text( + annotatedStringResource(R.string.read_more_in_github_with_link), + modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#readme") }, + lineHeight = 22.sp + ) + } else { + ReadableText(R.string.read_more_in_github) + } + + Spacer(Modifier.fillMaxHeight().weight(1f)) + + if (onboardingStage != null) { + Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) { + OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() }) + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + } + } +} + +@Composable +fun ReadableText(@StringRes stringResId: Int) { + Text(annotatedStringResource(stringResId), modifier = Modifier.padding(bottom = 12.dp), lineHeight = 22.sp) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewHowItWorks() { + SimpleXTheme { + HowItWorks(user = null) + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt new file mode 100644 index 0000000000..d440775cad --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt @@ -0,0 +1,172 @@ +package chat.simplex.app.views.onboarding + +import android.Manifest +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.background +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.outlined.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.User +import chat.simplex.app.ui.theme.SimpleButton +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.* +import chat.simplex.app.views.usersettings.simplexTeamUri +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +fun MakeConnection(chatModel: ChatModel) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + MakeConnectionLayout( + chatModel.currentUser.value, + createLink = { + withApi { + // show spinner + chatModel.connReqInvitation = chatModel.controller.apiAddContact() + // hide spinner + if (chatModel.connReqInvitation != null) { + ModalManager.shared.showModal { AddContactView(chatModel) } + } + } + }, + pasteLink = { + ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) } + }, + scanCode = { + ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) } + cameraPermissionState.launchPermissionRequest() + }, + about = { + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } + ) +} + +@Composable +fun MakeConnectionLayout( + user: User?, + createLink: () -> Unit, + pasteLink: () -> Unit, + scanCode: () -> Unit, + about: () -> Unit +) { + Surface { + Column( + Modifier + .fillMaxSize() + .background(color = MaterialTheme.colors.background) + .padding(20.dp) + ) { + Text( + if (user == null) stringResource(R.string.make_private_connection) + else String.format(stringResource(R.string.personal_welcome), user.profile.displayName), + style = MaterialTheme.typography.h1, + modifier = Modifier.padding(bottom = 8.dp) + ) + Text( + annotatedStringResource(R.string.to_make_your_first_private_connection_choose), + modifier = Modifier.padding(bottom = 16.dp) + ) + ActionRow( + Icons.Outlined.AddLink, + R.string.create_1_time_link_qr_code, + R.string.it_s_secure_to_share__only_one_contact_can_use_it, + createLink + ) + ActionRow( + Icons.Outlined.Article, + R.string.paste_the_link_you_received, + R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile, + pasteLink + ) + ActionRow( + Icons.Outlined.QrCode, + R.string.scan_contact_s_qr_code, + R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect, + scanCode + ) + Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.or)) + } + val uriHandler = LocalUriHandler.current + ActionRow( + Icons.Outlined.Tag, + R.string.connect_with_the_developers, + R.string.to_ask_any_questions_and_to_receive_simplex_chat_updates, + { uriHandler.openUri(simplexTeamUri) } + ) + Spacer(Modifier.fillMaxHeight().weight(1f)) + SimpleButton( + text = stringResource(R.string.about_simplex), + icon = Icons.Outlined.ArrowBackIosNew, + click = about + ) + } + } +} + +@Composable +private fun ActionRow(icon: ImageVector, @StringRes titleId: Int, @StringRes textId: Int, action: () -> Unit) { + Row( + Modifier + .clickable { action() } + .padding(bottom = 16.dp) + ) { + Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(end = 10.dp).size(40.dp)) + Column { + Text(stringResource(titleId), color = MaterialTheme.colors.primary) + Text(annotatedStringResource(textId)) + } + } +// Button(action: action, label: { +// HStack(alignment: .top, spacing: 20) { +// Image(systemName: icon) +// .resizable() +// .scaledToFit() +// .frame(width: 30, height: 30) +// .padding(.leading, 4) +// .padding(.top, 6) +// VStack(alignment: .leading) { +// Group { +// Text(title).font(.headline) +// Text(text).foregroundColor(.primary) +// } +// .multilineTextAlignment(.leading) +// } +// } +// }) +// .padding(.bottom) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewMakeConnection() { + SimpleXTheme { + MakeConnectionLayout( + user = User.sampleData, + createLink = {}, + pasteLink = {}, + scanCode = {}, + about = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt new file mode 100644 index 0000000000..08435f75e7 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt @@ -0,0 +1,47 @@ +package chat.simplex.app.views.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.ChatModel +import chat.simplex.app.views.CreateProfilePanel +import chat.simplex.app.views.helpers.getKeyboardState +import com.google.accompanist.insets.ProvideWindowInsets +import kotlinx.coroutines.launch + +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + OnboardingComplete +} + +@Composable +fun CreateProfile(chatModel: ChatModel) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val keyboardState by getKeyboardState() + var savedKeyboardState by remember { mutableStateOf(keyboardState) } + + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colors.background) + .padding(20.dp) + ) { + CreateProfilePanel(chatModel) + if (savedKeyboardState != keyboardState) { + LaunchedEffect(keyboardState) { + scope.launch { + savedKeyboardState = keyboardState + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt new file mode 100644 index 0000000000..3825954bbc --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt @@ -0,0 +1,139 @@ +package chat.simplex.app.views.onboarding + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.Info +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.User +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.ModalManager + +@Composable +fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { + SimpleXInfoLayout( + user = chatModel.currentUser.value, + onboardingStage = if (onboarding) chatModel.onboardingStage else null, + showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, + ) +} + +@Composable +fun SimpleXInfoLayout( + user: User?, + onboardingStage: MutableState?, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), +) { + Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) { + Image( + painter = painterResource(R.drawable.logo), + contentDescription = stringResource(R.string.image_descr_simplex_logo), + modifier = Modifier + .padding(vertical = 20.dp) + .fillMaxWidth(0.80f) + ) + + Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp)) + + InfoRow("🎭", R.string.privacy_redefined, R.string.first_platform_without_user_ids) + InfoRow("📭", R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share) + InfoRow("🤝", R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers) + + Spacer( + Modifier + .fillMaxHeight() + .weight(1f)) + + if (onboardingStage != null) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + OnboardingActionButton(user, onboardingStage) + } + Spacer( + Modifier + .fillMaxHeight() + .weight(1f)) + } + + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), contentAlignment = Alignment.Center) { + SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info, + click = showModal { HowItWorks(user, onboardingStage) }) + } + } +} + +@Composable +private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) { + Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) { + Text(emoji, fontSize = 36.sp, modifier = Modifier + .width(60.dp) + .padding(end = 16.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp) + Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption) + } + } +} + + +@Composable +fun OnboardingActionButton(user: User?, onboardingStage: MutableState, onclick: (() -> Unit)? = null) { + if (user == null) { + ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick) + } else { + ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick) + } +} + +@Composable +private fun ActionButton( + @StringRes labelId: Int, + onboarding: OnboardingStage?, + onboardingStage: MutableState, + onclick: (() -> Unit)? +) { + SimpleButtonFrame(click = { + onclick?.invoke() + onboardingStage.value = onboarding + }) { + Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary) + Icon( + Icons.Outlined.ArrowForwardIos, "next stage", tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(end = 8.dp) + ) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSimpleXInfo() { + SimpleXTheme { + SimpleXInfoLayout( + user = null, + onboardingStage = null, + showModal = {{}} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt index eca076a6d4..5400aa0aa9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt @@ -10,13 +10,13 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chatlist.ChatHelpView -import chat.simplex.app.views.helpers.generalGetString @Composable fun HelpView(chatModel: ChatModel) { @@ -35,7 +35,7 @@ fun HelpLayout(displayName: String) { horizontalAlignment = Alignment.Start ){ Text( - String.format(generalGetString(R.string.personal_welcome), displayName), + String.format(stringResource(R.string.personal_welcome), displayName), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt index b1cb3e68f2..6493b90bcc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt @@ -7,6 +7,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -14,25 +15,24 @@ import chat.simplex.app.R import chat.simplex.app.model.Format import chat.simplex.app.model.FormatColor import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.generalGetString @Composable fun MarkdownHelpView() { Column { Text( - generalGetString(R.string.how_to_use_markdown), + stringResource(R.string.how_to_use_markdown), style = MaterialTheme.typography.h1, ) Text( - generalGetString(R.string.you_can_use_markdown_to_format_messages__prompt), + stringResource(R.string.you_can_use_markdown_to_format_messages__prompt), Modifier.padding(vertical = 16.dp) ) - val bold = generalGetString(R.string.bold) - val italic = generalGetString(R.string.italic) - val strikethrough = generalGetString(R.string.strikethrough) - val equation = generalGetString(R.string.a_plus_b) - val colored = generalGetString(R.string.colored) - val secret = generalGetString(R.string.secret) + val bold = stringResource(R.string.bold) + val italic = stringResource(R.string.italic) + val strikethrough = stringResource(R.string.strikethrough) + val equation = stringResource(R.string.a_plus_b) + val colored = stringResource(R.string.colored) + val secret = stringResource(R.string.secret) MdFormat("*$bold*", bold, Format.Bold()) MdFormat("_${italic}_", italic, Format.Italic()) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index 6668018792..84b51dc91b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -3,8 +3,6 @@ package chat.simplex.app.views.usersettings import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -12,11 +10,10 @@ import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -32,7 +29,7 @@ fun SMPServersView(chatModel: ChatModel) { if (userSMPServers != null) { var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) } var editSMPServers by remember { mutableStateOf(!isUserSMPServers) } - var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } + var userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } fun saveSMPServers(smpServers: List) { withApi { val r = chatModel.controller.setUserSMPServers(smpServers = smpServers) @@ -66,23 +63,22 @@ fun SMPServersView(chatModel: ChatModel) { onConfirm = { saveSMPServers(listOf()) isUserSMPServers = false - userSMPServersStr = "" + userSMPServersStr.value = "" } ) } else { isUserSMPServers = false - userSMPServersStr = "" + userSMPServersStr.value = "" } } } }, - editUserSMPServersStr = { userSMPServersStr = it }, cancelEdit = { val userSMPServers = chatModel.userSMPServers.value if (userSMPServers != null) { isUserSMPServers = userSMPServers.isNotEmpty() editSMPServers = !isUserSMPServers - userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "" + userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "" } }, saveSMPServers = { saveSMPServers(it) }, @@ -95,9 +91,8 @@ fun SMPServersView(chatModel: ChatModel) { fun SMPServersLayout( isUserSMPServers: Boolean, editSMPServers: Boolean, - userSMPServersStr: String, + userSMPServersStr: MutableState, isUserSMPServersOnOff: (Boolean) -> Unit, - editUserSMPServersStr: (String) -> Unit, cancelEdit: () -> Unit, saveSMPServers: (List) -> Unit, editOn: () -> Unit, @@ -108,14 +103,14 @@ fun SMPServersLayout( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - generalGetString(R.string.your_SMP_servers), + stringResource(R.string.your_SMP_servers), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1 ) Row( verticalAlignment = Alignment.CenterVertically ) { - Text(generalGetString(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp)) + Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp)) Switch( checked = isUserSMPServers, onCheckedChange = isUserSMPServersOnOff, @@ -127,43 +122,11 @@ fun SMPServersLayout( } if (!isUserSMPServers) { - Text(generalGetString(R.string.using_simplex_chat_servers), lineHeight = 22.sp) + Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp) } else { - Text(generalGetString(R.string.enter_one_SMP_server_per_line)) + Text(stringResource(R.string.enter_one_SMP_server_per_line)) if (editSMPServers) { - BasicTextField( - value = userSMPServersStr, - onValueChange = editUserSMPServersStr, - textStyle = TextStyle( - fontFamily = FontFamily.Monospace, fontSize = 14.sp, - color = MaterialTheme.colors.onBackground - ), - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - modifier = Modifier.height(160.dp), - cursorBrush = SolidColor(HighOrLowlight), - decorationBox = { innerTextField -> - Surface( - shape = RoundedCornerShape(10.dp), - border = BorderStroke(1.dp, MaterialTheme.colors.secondary) - ) { - Row( - Modifier.background(MaterialTheme.colors.background), - verticalAlignment = Alignment.Top - ) { - Box( - Modifier - .weight(1f) - .padding(vertical = 5.dp, horizontal = 7.dp) - ) { - innerTextField() - } - } - } - } - ) + TextEditor(Modifier.height(160.dp), text = userSMPServersStr) Row( Modifier.fillMaxWidth(), @@ -173,17 +136,17 @@ fun SMPServersLayout( Column(horizontalAlignment = Alignment.Start) { Row { Text( - generalGetString(R.string.cancel_verb), + stringResource(R.string.cancel_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = cancelEdit) ) Spacer(Modifier.padding(horizontal = 8.dp)) Text( - generalGetString(R.string.save_servers_button), + stringResource(R.string.save_servers_button), color = MaterialTheme.colors.primary, modifier = Modifier.clickable(onClick = { - val servers = userSMPServersStr.split("\n") + val servers = userSMPServersStr.value.split("\n") saveSMPServers(servers) }) ) @@ -205,7 +168,7 @@ fun SMPServersLayout( Modifier.verticalScroll(rememberScrollState()) ) { Text( - userSMPServersStr, + userSMPServersStr.value, Modifier .padding(vertical = 5.dp, horizontal = 7.dp), style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp), @@ -219,7 +182,7 @@ fun SMPServersLayout( ) { Column(horizontalAlignment = Alignment.Start) { Text( - generalGetString(R.string.edit_verb), + stringResource(R.string.edit_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = editOn) @@ -241,9 +204,9 @@ fun howToButton() { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") } ) { - Text(generalGetString(R.string.how_to), color = MaterialTheme.colors.primary) + Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary) Icon( - Icons.Outlined.OpenInNew, generalGetString(R.string.how_to), tint = MaterialTheme.colors.primary, + Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(horizontal = 5.dp) ) } @@ -256,9 +219,8 @@ fun PreviewSMPServersLayoutDefaultServers() { SMPServersLayout( isUserSMPServers = false, editSMPServers = true, - userSMPServersStr = "", + userSMPServersStr = remember { mutableStateOf("") }, isUserSMPServersOnOff = {}, - editUserSMPServersStr = {}, cancelEdit = {}, saveSMPServers = {}, editOn = {}, @@ -273,9 +235,8 @@ fun PreviewSMPServersLayoutUserServersEditOn() { SMPServersLayout( isUserSMPServers = true, editSMPServers = true, - userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") }, isUserSMPServersOnOff = {}, - editUserSMPServersStr = {}, cancelEdit = {}, saveSMPServers = {}, editOn = {}, @@ -290,9 +251,8 @@ fun PreviewSMPServersLayoutUserServersEditOff() { SMPServersLayout( isUserSMPServers = true, editSMPServers = false, - userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") }, isUserSMPServersOnOff = {}, - editUserSMPServersStr = {}, cancelEdit = {}, saveSMPServers = {}, editOn = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index a99a49c99b..64f7610bc9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -23,7 +24,9 @@ import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.TerminalView +import chat.simplex.app.views.call.VideoCallView import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.onboarding.SimpleXInfo @Composable fun SettingsView(chatModel: ChatModel) { @@ -38,7 +41,8 @@ fun SettingsView(chatModel: ChatModel) { }, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, - showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } } + showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }, + showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } }, ) } } @@ -53,7 +57,8 @@ fun SettingsLayout( setRunServiceInBackground: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), - showTerminal: () -> Unit + showTerminal: () -> Unit, + showVideoChatPrototype: () -> Unit ) { val uriHandler = LocalUriHandler.current Surface( @@ -69,7 +74,7 @@ fun SettingsLayout( .padding(top = 16.dp) ) { Text( - generalGetString(R.string.your_settings), + stringResource(R.string.your_settings), style = MaterialTheme.typography.h1, modifier = Modifier.padding(start = 8.dp) ) @@ -91,39 +96,48 @@ fun SettingsLayout( SettingsSectionView(showModal { UserAddressView(it) }) { Icon( Icons.Outlined.QrCode, - contentDescription = generalGetString(R.string.icon_descr_address), + contentDescription = stringResource(R.string.icon_descr_address), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.your_simplex_contact_address)) + Text(stringResource(R.string.your_simplex_contact_address)) } Spacer(Modifier.height(24.dp)) SettingsSectionView(showModal { HelpView(it) }) { Icon( Icons.Outlined.HelpOutline, - contentDescription = generalGetString(R.string.icon_descr_help), + contentDescription = stringResource(R.string.icon_descr_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.how_to_use_simplex_chat)) + Text(stringResource(R.string.how_to_use_simplex_chat)) + } + Divider(Modifier.padding(horizontal = 8.dp)) + SettingsSectionView(showModal { SimpleXInfo(it, onboarding = false) }) { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource(R.string.icon_descr_help), + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(R.string.about_simplex_chat)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showModal { MarkdownHelpView() }) { Icon( Icons.Outlined.TextFormat, - contentDescription = generalGetString(R.string.markdown_help), + contentDescription = stringResource(R.string.markdown_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.markdown_in_messages)) + Text(stringResource(R.string.markdown_in_messages)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) { Icon( Icons.Outlined.Tag, - contentDescription = generalGetString(R.string.icon_descr_simplex_team), + contentDescription = stringResource(R.string.icon_descr_simplex_team), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - generalGetString(R.string.chat_with_the_founder), + stringResource(R.string.chat_with_the_founder), color = MaterialTheme.colors.primary ) } @@ -131,11 +145,11 @@ fun SettingsLayout( SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) { Icon( Icons.Outlined.Email, - contentDescription = generalGetString(R.string.icon_descr_email), + contentDescription = stringResource(R.string.icon_descr_email), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - generalGetString(R.string.send_us_an_email), + stringResource(R.string.send_us_an_email), color = MaterialTheme.colors.primary ) } @@ -144,23 +158,23 @@ fun SettingsLayout( SettingsSectionView(showModal { SMPServersView(it) }) { Icon( Icons.Outlined.Dns, - contentDescription = generalGetString(R.string.smp_servers), + contentDescription = stringResource(R.string.smp_servers), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.smp_servers)) + Text(stringResource(R.string.smp_servers)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView() { Icon( Icons.Outlined.Bolt, - contentDescription = generalGetString(R.string.private_notifications), + contentDescription = stringResource(R.string.private_notifications), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - generalGetString(R.string.private_notifications), Modifier - .padding(end = 24.dp) - .fillMaxWidth() - .weight(1F)) + stringResource(R.string.private_notifications), Modifier + .padding(end = 24.dp) + .fillMaxWidth() + .weight(1F)) Switch( checked = runServiceInBackground.value, onCheckedChange = { setRunServiceInBackground(it) }, @@ -175,10 +189,10 @@ fun SettingsLayout( SettingsSectionView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), - contentDescription = generalGetString(R.string.chat_console), + contentDescription = stringResource(R.string.chat_console), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(generalGetString(R.string.chat_console)) + Text(stringResource(R.string.chat_console)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { @@ -190,6 +204,7 @@ fun SettingsLayout( Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) } Divider(Modifier.padding(horizontal = 8.dp)) +// SettingsSectionView(showVideoChatPrototype) { SettingsSectionView() { Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") } @@ -226,7 +241,8 @@ fun PreviewSettingsLayout() { setRunServiceInBackground = {}, showModal = {{}}, showCustomModal = {{}}, - showTerminal = {} + showTerminal = {}, + showVideoChatPrototype = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt index 734fa19a05..71e5b37033 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -60,12 +61,12 @@ fun UserAddressLayout( verticalArrangement = Arrangement.Top ) { Text( - generalGetString(R.string.your_chat_address), + stringResource(R.string.your_chat_address), Modifier.padding(bottom = 16.dp), style = MaterialTheme.typography.h1, ) Text( - generalGetString(R.string.you_can_share_your_address_anybody_will_be_able_to_connect), + stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect), Modifier.padding(bottom = 12.dp), lineHeight = 22.sp ) @@ -76,11 +77,11 @@ fun UserAddressLayout( ) { if (userAddress == null) { Text( - generalGetString(R.string.if_you_delete_address_you_wont_lose_contacts), + stringResource(R.string.if_you_delete_address_you_wont_lose_contacts), Modifier.padding(bottom = 12.dp), lineHeight = 22.sp ) - SimpleButton(generalGetString(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress) + SimpleButton(stringResource(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress) } else { QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f)) Row( @@ -89,11 +90,11 @@ fun UserAddressLayout( modifier = Modifier.padding(vertical = 10.dp) ) { SimpleButton( - generalGetString(R.string.share_link), + stringResource(R.string.share_link), icon = Icons.Outlined.Share, click = { share(userAddress) }) SimpleButton( - generalGetString(R.string.delete_address), + stringResource(R.string.delete_address), icon = Icons.Outlined.Delete, color = Color.Red, click = deleteAddress diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 0a401c1460..60b99c620f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -79,7 +80,7 @@ fun UserProfileLayout( sheetContent = { GetImageBottomSheet( chosenImage, - onImageChange = { bitmap -> profileImage.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) }, + onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) }, hideBottomSheet = { scope.launch { bottomSheetModalState.hide() } }) @@ -95,13 +96,13 @@ fun UserProfileLayout( horizontalAlignment = Alignment.Start ) { Text( - generalGetString(R.string.your_chat_profile), + stringResource(R.string.your_chat_profile), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, color = MaterialTheme.colors.onBackground ) Text( - generalGetString(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it), + stringResource(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it), Modifier.padding(bottom = 24.dp), color = MaterialTheme.colors.onBackground, lineHeight = 22.sp @@ -130,14 +131,14 @@ fun UserProfileLayout( ProfileNameTextField(displayName) ProfileNameTextField(fullName) Row { - TextButton(generalGetString(R.string.cancel_verb)) { + TextButton(stringResource(R.string.cancel_verb)) { displayName.value = profile.displayName fullName.value = profile.fullName profileImage.value = profile.image editProfile.value = false } Spacer(Modifier.padding(horizontal = 8.dp)) - TextButton(generalGetString(R.string.save_and_notify_contacts)) { + TextButton(stringResource(R.string.save_and_notify_contacts)) { saveProfile(displayName.value, fullName.value, profileImage.value) } } @@ -160,9 +161,9 @@ fun UserProfileLayout( } } } - ProfileNameRow(generalGetString(R.string.display_name__field), profile.displayName) - ProfileNameRow(generalGetString(R.string.full_name__field), profile.fullName) - TextButton(generalGetString(R.string.edit_verb)) { editProfile.value = true } + ProfileNameRow(stringResource(R.string.display_name__field), profile.displayName) + ProfileNameRow(stringResource(R.string.full_name__field), profile.fullName) + TextButton(stringResource(R.string.edit_verb)) { editProfile.value = true } } } if (savedKeyboardState != keyboardState) { @@ -229,7 +230,7 @@ fun EditImageButton(click: () -> Unit) { ) { Icon( Icons.Outlined.PhotoCamera, - contentDescription = generalGetString(R.string.edit_image), + contentDescription = stringResource(R.string.edit_image), tint = MaterialTheme.colors.primary, modifier = Modifier.size(36.dp) ) @@ -241,7 +242,7 @@ fun DeleteImageButton(click: () -> Unit) { IconButton(onClick = click) { Icon( Icons.Outlined.Close, - contentDescription = generalGetString(R.string.delete_image), + contentDescription = stringResource(R.string.delete_image), tint = MaterialTheme.colors.primary, ) } 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 00e966e7ce..9496535853 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -24,6 +24,15 @@ неизвестный формат сообщения неверный формат сообщения + + connection %1$d + соединение установлено + приглашение соединиться + соединяется… + вы создали одноразовую ссылку + через ссылку-контакт + через одноразовую ссылку + Ошибка при сохранении SMP серверов Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. @@ -33,9 +42,15 @@ Вы уже соединены с %1$s! через эту ссылку. Ошибка в ссылке контакта Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую. + Ошибка соединения (AUTH) + Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью. Невозможно удалить контакт! Контакт %1$s! не может быть удален, так как является членом групп(ы) %2$s. Мгновенные уведомления + Ошибка удаления контакта + Ошибка удаления группы + Ошибка удаления запроса + Ошибка удаления ожидаемого соединения Приватные мгновенные уведомления! @@ -50,6 +65,7 @@ Ответить Поделиться Копировать + Сохранить Редактировать Удалить Удалить сообщение? @@ -69,12 +85,32 @@ Здравствуйте! Этот текст можно найти в Настройках Ваши чаты + соединяется… - + Прикрепить + Значок контекста + Удалить превью изображения + Удалить превью файла - + Изображение + Ожидается прием изображения + Изображение отправлено + Ожидается прием изображения + Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже! + Изображение сохранено в Галерею + + + Файл + Большой файл! + Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s). + В настоящее время максимальный поддерживаемый размер файла составляет %1$s. + Ожидается прием файла + Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже! + Файл сохранен + Файл не найден + Ошибка сохранения файла Удалить контакт? @@ -95,18 +131,20 @@ нет описания Добавить контакт - Сканировать QR код - (создать QR код или ссылку) + Создать одноразовую ссылку + Вставить полученную ссылку + Сканировать QR код + (чтобы отправить вашему контакту) (при встрече или через видеозвонок) - Создать группу - (скоро!) + (вставить ссылку из буфера обмена) - Разрешение не получено! - Использовать камеру - Открыть галерею + Разрешение не получено! + Камера + Галерея + Файлы\n(v2.0) Спасибо, что установили SimpleX Chat! @@ -127,6 +165,17 @@ Принять Отклонить + + Вы пригласили ваш контакт + Вы приняли приглашение соединиться + Удалить ожидаемое соединение? + Контакт, которому вы отправили эту ссылку, не сможет соединиться! + Подтвержденное соединение будет отменено! + + + Соединение еще не установлено! + Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой). + хочет соединиться с вами! @@ -159,11 +208,14 @@ Ваш профиль будет отправлен\nвашему контакту Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку. Поделиться ссылкой + Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. + Настройки Ваш SimpleX адрес - Как использовать SimpleX Chat + Информация о SimpleX Chat + Как использовать Форматирование сообщений Форматирование сообщений Соединиться с разработчиками @@ -205,11 +257,13 @@ Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. Мы не храним ваши контакты и сообщения (после доставки) на серверах. Создать профиль - Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам. + Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве. + Профиль отправляется только вашим контактам. Имя профиля не может содержать пробелы. Имя профиля Полное имя (не обязательно) Создать + О SimpleX Как форматировать @@ -220,5 +274,59 @@ a + b цвет секрет + Соединиться через ссылку + Эта строка не является ссылкой-приглашением! + Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку Open in mobile app. + Добавьте контакт, чтобы начать разговор: + + входящий звонок… + пропущенный звонок + отклоненный звонок + принятый звонок + соединяется… + активный звонок + звонок завершён %1$s! + ошибка соединения + + + инициализация… + ожидается ответ… + ожидается подтверждение… + получен ответ… + соединяется… + соединено + + + Новое поколение приватных сообщений + Более конфиденциальный + Первая в мире платформа без идентификаторов пользователей. + Защищен от спама + С вами можно соединиться только через созданные вами ссылки. + Децентрализованный + Открытый протокол и код - кто угодно может запустить сервер. + Создать профиль + Добавьте контакт + Как это работает + + + Как SimpleX работает + Много пользователей спросили: как SimpleX доставляет сообщения без идентификаторов пользователей? + Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта. + Вы определяете через какие серверы вы получаете сообщения, ваши контакты - серверы, которые вы используете для отправки. + Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются с двухуровневым end-to-end шифрованием. + Узнайте больше из нашего GitHub репозитория. + Узнайте больше из нашего GitHub репозитория. + + + Чтобы добавить ваш первый контакт, выберите одно из: + Создать ссылку / QR код + Ей безопасно поделиться - только один контакт может использовать её. + Вставьте полученную ссылку + Или откройте ссылку в браузере и нажмите Open in mobile. + Сосканировать QR код контакта + При встрече или в видеозвонке – самый безопасный способ установить соединение + или + Соединиться с разработчиками + Чтобы задать вопросы и получать уведомления о SimpleX Chat. diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 29b95f2921..5dce4ad6ea 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -24,6 +24,15 @@ unknown message format invalid message format + + connection %1$d + connection established + invited to connect + connecting… + you shared one-time link + via contact address link + via one-time link + Error saving SMP servers Make sure SMP server addresses are in correct format, line separated and are not duplicated. @@ -33,9 +42,15 @@ You are already connected to %1$s! via this link. Invalid connection link Please check that you used the correct link or ask your contact to send you another one. + Connection error (AUTH) + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. Can\'t delete contact! Contact %1$s! cannot be deleted, they are a member of the group(s) %2$s. Instant notifications + Error deleting contact + Error deleting group + Error deleting contact request + Error deleting pending contact connection Private instant notifications! @@ -50,6 +65,7 @@ Reply Share Copy + Save Edit Delete Delete message? @@ -69,12 +85,32 @@ Welcome! This text is available in settings Your chats + connecting… - + Attach + Context icon + Cancel image preview + Cancel file preview - + Image + Waiting for image + Image sent + Waiting for image + Image will be received when your contact is online, please wait or check later! + Image saved to Gallery + + + File + Large file! + Your contact sent a file that is larger than currently supported maximum size (%1$s). + Currently maximum supported file size is %1$s. + 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? @@ -95,18 +131,21 @@ Ok no details Add contact - Scan QR code - (create QR code\nor link) + Add contact to start a new chat: + Create link / QR code + Connect via received link + Scan QR code + (to share with your contact) (in person or in video call) - Create Group - (coming soon!) + (paste link from clipboard) - Permission Denied! + Permission Denied! Use Camera From Gallery + Choose file\n(new in v2.0) Thank you for installing SimpleX Chat! @@ -121,12 +160,23 @@ 💻 desktop: scan displayed QR code from the app, via Scan QR code. 📱 mobile: tap Open in mobile app, then tap Connect in the app. - + Accept connection request? If you choose to reject sender will NOT be notified. Accept Reject + + You invited your contact + You accepted connection + Delete pending connection? + The contact you shared this link with will NOT be able to connect! + The connection you accepted will be cancelled! + + + Contact is not connected yet! + Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link). + wants to connect to you! @@ -159,11 +209,18 @@ Your chat profile will be sent\nto your contact If you cannot meet in person, you can scan QR code in the video call, or your contact can share an invitation link. Share invitation link + Paste the link you received into the box below to connect with your contact. + + + Connect via link + This string is not a connection link! + You can also connect by clicking the link. If it opens in the browser, click Open in mobile app button. Your settings Your SimpleX contact address - How to use SimpleX Chat + About SimpleX Chat + How to use it Markdown help Markdown in messages Connect to the developers @@ -205,11 +262,13 @@ The messaging and application platform protecting your privacy and security. We don\'t store any of your contacts or messages (once delivered) on the servers. Create profile - Your profile is stored on your device and shared only with your contacts. + Your profile, contacts and delivered messages are stored on your device. + The profile is only shared with your contacts. Display name cannot contain whitespace. Display Name - Full Name (Optional) + Full Name (optional) Create + About SimpleX How to use markdown @@ -221,4 +280,54 @@ colored secret + + calling… + missed + rejected + accepted + connecting… + in progress + ended %1$s! + error + + + starting… + waiting for answer… + waiting for confirmation… + received answer… + connecting… + connected + + + The next generation of private messaging + Privacy redefined + The 1st platform without any user identifiers – private by design. + Immune to spam and abuse + People can connect to you only via the links you share. + Decentralized + Open-source protocol and code – anybody can run the servers. + Create your profile + Make a private connection + How it works + + + How SimpleX works + Many people asked: if SimpleX has no user identifiers, how can it deliver messages? + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + You control through which server(s) to receive the messages, your contacts – the servers you use to message them. + Only client devices store user profiles, contacts, groups, and messages sent with 2-layer end-to-end encryption. + Read more in our GitHub repository. + Read more in our GitHub repository. + + + To make your first private connection, choose one of the following: + Create 1-time link / QR code + It\'s secure to share - only one contact can use it. + Paste the link you received + Or open the link in the browser and tap Open in mobile. + Scan contact\'s QR code + In person or via a video call – the most secure way to connect. + or + Connect with the developers + To ask any questions and to receive SimpleX Chat updates. diff --git a/apps/android/readme.md b/apps/android/readme.md new file mode 100644 index 0000000000..7822847e56 --- /dev/null +++ b/apps/android/readme.md @@ -0,0 +1,25 @@ +# Android App Development + +This readme is currently a stub and as such is in development. + +Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app. + + +## Gotchas + +#### SHA Signature for verification for app links/deep links + +In order for the SimpleX app to be automatically adopted for opening links from https://simplex.chat the SHA certificate fingerprint for the App installed on the phone must be in the hosted [assetlinks.json](https://simplex.chat/.well-known/assetlinks.json) file on simplex.chat. + +The accepted fingerprints are in the `sha256_cert_fingerprints` list. + +To find your SHA certificate fingerprint perform the following steps. + +1. Build and install your development version of the app as usual +2. From the terminal in Android studio run `adb shell pm get-app-links chat.simplex.app` +3. Copy the signature listed in `signatures` in the result +4. Add your signature to [assetlinks.json](https://github.com/simplex-chat/website/blob/master/.well-known/assetlinks.json) in the [website repo](https://github.com/simplex-chat/website) and make a PR. On approval, wait a few minutes for the changes to propagate to the public website and then you should be able to verify SimpleX. + +More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`. + +Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing. diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore index 99ef9f7530..3d152a0610 100644 --- a/apps/ios/.gitignore +++ b/apps/ios/.gitignore @@ -67,3 +67,5 @@ iOSInjectionProject/ Libraries/ Shared/MyPlayground.playground/* + +testpush.sh diff --git a/apps/ios/LOCALIZATION.md b/apps/ios/LOCALIZATION.md index f58b8918b4..b0c6c7088b 100644 --- a/apps/ios/LOCALIZATION.md +++ b/apps/ios/LOCALIZATION.md @@ -11,7 +11,7 @@ There are three ways XCode generates localization keys from strings: 3. All strings wrapped in `NSLocalizedString`. Please note that such strings do not support swift interpolation, instead formatted strings should be used: ```swift -String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body") +String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), value) ``` ## Adding strings to the existing localizations diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift new file mode 100644 index 0000000000..415f6348f0 --- /dev/null +++ b/apps/ios/Shared/AppDelegate.swift @@ -0,0 +1,95 @@ +// +// AppDelegate.swift +// SimpleX +// +// Created by Evgeny on 30/03/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UIKit + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + logger.debug("AppDelegate: didFinishLaunchingWithOptions") + application.registerForRemoteNotifications() + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() + logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") + let m = ChatModel.shared + m.deviceToken = token + UserDefaults.standard.set(false, forKey: DEFAULT_USE_NOTIFICATIONS) +// let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications") +// if useNotifications { +// Task { +// do { +// m.tokenStatus = try await apiRegisterToken(token: token) +// } catch { +// logger.error("apiRegisterToken error: \(responseError(error))") +// } +// } +// } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + logger.error("AppDelegate: didFailToRegisterForRemoteNotificationsWithError \(error.localizedDescription)") + } + + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable : Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + logger.debug("AppDelegate: didReceiveRemoteNotification") + if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], + UserDefaults.standard.bool(forKey: "useNotifications") { + if let verification = ntfData["verification"] as? String, + let nonce = ntfData["nonce"] as? String { + if let token = ChatModel.shared.deviceToken { + logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)") + Task { + let m = ChatModel.shared + do { + if case .active = m.tokenStatus {} else { m.tokenStatus = .confirmed } + try await apiVerifyToken(token: token, code: verification, nonce: nonce) + m.tokenStatus = .active + try await apiIntervalNofication(token: token, interval: 20) + } catch { + if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr { + m.tokenStatus = .expired + } + logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") + } + completionHandler(.newData) + } + } else { + completionHandler(.noData) + } + } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { + // TODO check if app in background + logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") + // TODO remove + NtfManager.shared.notifyCheckingMessages() + receiveMessages(completionHandler) + } else if let smpQueue = ntfData["checkMessage"] as? String { + // TODO check if app in background + logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessage \(smpQueue)") + receiveMessages(completionHandler) + } else { + completionHandler(.noData) + } + } else { + completionHandler(.noData) + } + } + + private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + let complete = BGManager.shared.completionHandler { + logger.debug("AppDelegate: completed BGManager.receiveMessages") + completionHandler(.newData) + } + + BGManager.shared.receiveMessages(complete) + } +} diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b5c5b8392a..64eeb2fc1f 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -13,27 +13,22 @@ struct ContentView: View { @State private var showNotificationAlert = false var body: some View { - if let user = chatModel.currentUser { - ChatListView(user: user) - .onAppear { - do { - try apiStartChat() - try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - chatModel.userAddress = try apiGetUserAddress() - chatModel.userSMPServers = try getUserSMPServers() - chatModel.chats = try apiGetChats() - } catch { - fatalError("Failed to start or load chats: \(error)") + ZStack { + if let step = chatModel.onboardingStage { + if case .onboardingComplete = step, + let user = chatModel.currentUser { + ChatListView(user: user) + .onAppear { + NtfManager.shared.requestAuthorization(onDeny: { + alertManager.showAlert(notificationAlert()) + }) } - ChatReceiver.shared.start() - NtfManager.shared.requestAuthorization(onDeny: { - alertManager.showAlert(notificationAlert()) - }) + } else { + OnboardingView(onboarding: step) } - .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } - } else { - WelcomeView() + } } + .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } func notificationAlert() -> Alert { @@ -50,6 +45,37 @@ struct ContentView: View { } } +func connectViaUrl() { + let m = ChatModel.shared + if let url = m.appOpenUrl { + m.appOpenUrl = nil + AlertManager.shared.showAlert(connectViaUrlAlert(url)) + } +} + +func connectViaUrlAlert(_ url: URL) -> Alert { + var path = url.path + logger.debug("ChatListView.connectViaUrlAlert path: \(path)") + if (path == "/contact" || path == "/invitation") { + path.removeFirst() + let action: ConnReqType = path == "contact" ? .contact : .invitation + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + let title: LocalizedStringKey + if case .contact = action { title = "Connect via contact link?" } + else { title = "Connect via one-time link?" } + return Alert( + title: Text(title), + message: Text("Your profile will be sent to the contact that you received this link from"), + primaryButton: .default(Text("Connect")) { + connectViaLink(link) + }, + secondaryButton: .cancel() + ) + } else { + return Alert(title: Text("Error: URL is invalid")) + } +} + final class AlertManager: ObservableObject { static let shared = AlertManager() @Published var presentAlert = false diff --git a/apps/ios/Shared/DebugJSON.playground/Contents.swift b/apps/ios/Shared/DebugJSON.playground/Contents.swift new file mode 100644 index 0000000000..e62ca1ab53 --- /dev/null +++ b/apps/ios/Shared/DebugJSON.playground/Contents.swift @@ -0,0 +1,20 @@ +import UIKit + +let s = """ + { + "contactConnection" : { + "contactConnection" : { + "viaContactUri" : false, + "pccConnId" : 456, + "pccAgentConnId" : "cTdjbmR4ZzVzSmhEZHdzMQ==", + "pccConnStatus" : "new", + "updatedAt" : "2022-04-24T11:59:23.703162Z", + "createdAt" : "2022-04-24T11:59:23.703162Z" + } + } + } +""" +//let s = "\"2022-04-24T11:59:23.703162Z\"" +let json = getJSONDecoder() +let d = s.data(using: .utf8)! +print (try! json.decode(ChatInfo.self, from: d)) diff --git a/apps/ios/Shared/DebugJSON.playground/contents.xcplayground b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground new file mode 100644 index 0000000000..cf026f2286 --- /dev/null +++ b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift index 2c36e33a2f..abb1509653 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/Shared/FileUtils.swift @@ -9,26 +9,189 @@ import Foundation import SwiftUI +// maximum image file size to be auto-accepted +let maxImageSize: Int64 = 236700 + +let maxFileSize: Int64 = 8000000 + func getDocumentsDirectory() -> URL { - return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } func getAppFilesDirectory() -> URL { - return getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) + getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) } -func getStoredFilePath(_ file: CIFile?) -> String? { +func getAppFilePath(_ fileName: String) -> URL { + getAppFilesDirectory().appendingPathComponent(fileName) +} + +func getLoadedFilePath(_ file: CIFile?) -> String? { if let file = file, - file.stored, + file.loaded, let savedFile = file.filePath { - return getAppFilesDirectory().path + "/" + savedFile + return getAppFilePath(savedFile).path } return nil } -func getStoredImage(_ file: CIFile?) -> UIImage? { - if let filePath = getStoredFilePath(file) { +func getLoadedImage(_ file: CIFile?) -> UIImage? { + if let filePath = getLoadedFilePath(file) { return UIImage(contentsOfFile: filePath) } return nil } + +func saveFileFromURL(_ url: URL) -> String? { + let savedFile: String? + if url.startAccessingSecurityScopedResource() { + do { + let fileData = try Data(contentsOf: url) + let fileName = uniqueCombine(url.lastPathComponent) + savedFile = saveFile(fileData, fileName) + } catch { + logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)") + savedFile = nil + } + } else { + logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false") + savedFile = nil + } + url.stopAccessingSecurityScopedResource() + return savedFile +} + +func saveImage(_ uiImage: UIImage) -> String? { + if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) { + let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss") + let fileName = uniqueCombine("IMG_\(timestamp).jpg") + return saveFile(imageDataResized, fileName) + } + return nil +} + +extension Date { + func getFormattedDate(_ format: String) -> String { + let df = DateFormatter() + df.dateFormat = format + df.locale = Locale(identifier: "US") + return df.string(from: self) + } +} + +private func saveFile(_ data: Data, _ fileName: String) -> String? { + let filePath = getAppFilePath(fileName) + do { + try data.write(to: filePath) + return fileName + } catch { + logger.error("FileUtils.saveFile error: \(error.localizedDescription)") + return nil + } +} + +private func uniqueCombine(_ fileName: String) -> String { + func tryCombine(_ fileName: String, _ n: Int) -> String { + let name = fileName.deletingPathExtension + let ext = fileName.pathExtension + let suffix = (n == 0) ? "" : "_\(n)" + let f = "\(name)\(suffix).\(ext)" + return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f + } + return tryCombine(fileName, 0) +} + +private extension String { + var ns: NSString { + return self as NSString + } + var pathExtension: String { + return ns.pathExtension + } + var deletingPathExtension: String { + return ns.deletingPathExtension + } +} + +func removeFile(_ fileName: String) { + do { + try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path) + } catch { + logger.error("FileUtils.removeFile error: \(error.localizedDescription)") + } +} + +// 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: Int64) -> 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: Int64) -> 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/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 8b66fb6e67..b76097bc79 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -20,7 +20,7 @@ class BGManager { static let shared = BGManager() var chatReceiver: ChatReceiver? var bgTimer: Timer? - var completed = false + var completed = true func register() { logger.debug("BGManager.register") @@ -43,36 +43,48 @@ class BGManager { private func handleRefresh(_ task: BGAppRefreshTask) { logger.debug("BGManager.handleRefresh") schedule() - self.completed = false + let completeRefresh = completionHandler { + task.setTaskCompleted(success: true) + } + task.expirationHandler = { completeRefresh("expirationHandler") } + receiveMessages(completeRefresh) + } - let completeTask: (String) -> Void = { reason in - logger.debug("BGManager.handleRefresh completeTask: \(reason)") + func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) { + { reason in + logger.debug("BGManager.completionHandler: \(reason)") if !self.completed { self.completed = true self.chatReceiver?.stop() self.chatReceiver = nil self.bgTimer?.invalidate() self.bgTimer = nil - task.setTaskCompleted(success: true) + complete() } } + } - task.expirationHandler = { completeTask("expirationHandler") } + func receiveMessages(_ completeReceiving: @escaping (String) -> Void) { + if (!self.completed) { + logger.debug("BGManager.receiveMessages: in progress, exiting") + return + } + self.completed = false DispatchQueue.main.async { initializeChat() if ChatModel.shared.currentUser == nil { - completeTask("no current user") + completeReceiving("no current user") return } - logger.debug("BGManager.handleRefresh: starting chat") + logger.debug("BGManager.receiveMessages: starting chat") let cr = ChatReceiver() self.chatReceiver = cr cr.start() RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in - logger.debug("BGManager.handleRefresh: timer") + logger.debug("BGManager.receiveMessages: timer") self.bgTimer = timer if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages { - completeTask("timer (no messages after \(waitForMessages) seconds)") + completeReceiving("timer (no messages after \(waitForMessages) seconds)") } }, forMode: .default) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4d63b485a6..578552a992 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -11,6 +11,7 @@ import Combine import SwiftUI final class ChatModel: ObservableObject { + @Published var onboardingStage: OnboardingStage? @Published var currentUser: User? // list of chat "previews" @Published var chats: [Chat] = [] @@ -23,6 +24,13 @@ final class ChatModel: ObservableObject { @Published var userAddress: String? @Published var userSMPServers: [String]? @Published var appOpenUrl: URL? + @Published var deviceToken: String? + @Published var tokenStatus = NtfTknStatus.new + // current WebRTC call + @Published var callInvitations: Dictionary = [:] + @Published var activeCallInvitation: ContactRef? + @Published var activeCall: Call? + @Published var callCommand: WCallCommand? var messageDelivery: Dictionary Void> = [:] @@ -52,18 +60,25 @@ final class ChatModel: ObservableObject { } } + func updateContactConnection(_ contactConnection: PendingContactConnection) { + updateChat(.contactConnection(contactConnection: contactConnection)) + } + func updateContact(_ contact: Contact) { - let cInfo = ChatInfo.direct(contact: contact) - if hasChat(contact.id) { + updateChat(.direct(contact: contact)) + } + + private func updateChat(_ cInfo: ChatInfo) { + if hasChat(cInfo.id) { updateChatInfo(cInfo) } else { addChat(Chat(chatInfo: cInfo, chatItems: [])) } } - func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) { - if let ix = getChatIndex(contact.id) { - chats[ix].serverInfo.networkStatus = status + func updateNetworkStatus(_ id: ChatId, _ status: Chat.NetworkStatus) { + if let i = getChatIndex(id) { + chats[i].serverInfo.networkStatus = status } } @@ -101,7 +116,7 @@ final class ChatModel: ObservableObject { if case .rcvNew = cItem.meta.itemStatus { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { if self.chatId == cInfo.id { - Task { await SimpleX.markChatItemRead(cInfo, cItem) } + Task { await apiMarkChatItemRead(cInfo, cItem) } } } } @@ -207,167 +222,6 @@ final class ChatModel: ObservableObject { } } -struct User: Decodable, NamedChat { - var userId: Int64 - var userContactId: Int64 - var localDisplayName: ContactName - var profile: Profile - var activeUser: Bool - - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = User( - userId: 1, - userContactId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - activeUser: true - ) -} - -typealias ContactName = String - -typealias GroupName = String - -struct Profile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? - - static let sampleData = Profile( - displayName: "alice", - fullName: "Alice" - ) -} - -enum ChatType: String { - case direct = "@" - case group = "#" - case contactRequest = "<@" -} - -protocol NamedChat { - var displayName: String { get } - var fullName: String { get } - var image: String? { get } -} - -extension NamedChat { - var chatViewName: String { - get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } - } -} - -typealias ChatId = String - -enum ChatInfo: Identifiable, Decodable, NamedChat { - case direct(contact: Contact) - case group(groupInfo: GroupInfo) - case contactRequest(contactRequest: UserContactRequest) - - var localDisplayName: String { - get { - switch self { - case let .direct(contact): return contact.localDisplayName - case let .group(groupInfo): return groupInfo.localDisplayName - case let .contactRequest(contactRequest): return contactRequest.localDisplayName - } - } - } - - var displayName: String { - get { - switch self { - case let .direct(contact): return contact.displayName - case let .group(groupInfo): return groupInfo.displayName - case let .contactRequest(contactRequest): return contactRequest.displayName - } - } - } - - var fullName: String { - get { - switch self { - case let .direct(contact): return contact.fullName - case let .group(groupInfo): return groupInfo.fullName - case let .contactRequest(contactRequest): return contactRequest.fullName - } - } - } - - var image: String? { - get { - switch self { - case let .direct(contact): return contact.image - case let .group(groupInfo): return groupInfo.image - case let .contactRequest(contactRequest): return contactRequest.image - } - } - } - - var id: ChatId { - get { - switch self { - case let .direct(contact): return contact.id - case let .group(groupInfo): return groupInfo.id - case let .contactRequest(contactRequest): return contactRequest.id - } - } - } - - var chatType: ChatType { - get { - switch self { - case .direct: return .direct - case .group: return .group - case .contactRequest: return .contactRequest - } - } - } - - var apiId: Int64 { - get { - switch self { - case let .direct(contact): return contact.apiId - case let .group(groupInfo): return groupInfo.apiId - case let .contactRequest(contactRequest): return contactRequest.apiId - } - } - } - - var ready: Bool { - get { - switch self { - case let .direct(contact): return contact.ready - case let .group(groupInfo): return groupInfo.ready - case let .contactRequest(contactRequest): return contactRequest.ready - } - } - } - - var createdAt: Date { - switch self { - case let .direct(contact): return contact.createdAt - case let .group(groupInfo): return groupInfo.createdAt - case let .contactRequest(contactRequest): return contactRequest.createdAt - } - } - - struct SampleData { - var direct: ChatInfo - var group: ChatInfo - var contactRequest: ChatInfo - } - - static var sampleData: ChatInfo.SampleData = SampleData( - direct: ChatInfo.direct(contact: Contact.sampleData), - group: ChatInfo.group(groupInfo: GroupInfo.sampleData), - contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) - ) -} - final class Chat: ObservableObject, Identifiable { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] @@ -422,510 +276,12 @@ final class Chat: ObservableObject, Identifiable { self.chatStats = cData.chatStats } - init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) { + init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats(), serverInfo: ServerInfo = ServerInfo(networkStatus: .unknown)) { self.chatInfo = chatInfo self.chatItems = chatItems self.chatStats = chatStats + self.serverInfo = serverInfo } var id: ChatId { get { chatInfo.id } } } - -struct ChatData: Decodable, Identifiable { - var chatInfo: ChatInfo - var chatItems: [ChatItem] - var chatStats: ChatStats - - var id: ChatId { get { chatInfo.id } } -} - -struct ChatStats: Decodable { - var unreadCount: Int = 0 - var minUnreadItemId: Int64 = 0 -} - -struct Contact: Identifiable, Decodable, NamedChat { - var contactId: Int64 - var localDisplayName: ContactName - var profile: Profile - var activeConn: Connection - var viaGroup: Int64? - var createdAt: Date - - var id: ChatId { get { "@\(contactId)" } } - var apiId: Int64 { get { contactId } } - var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = Contact( - contactId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - activeConn: Connection.sampleData, - createdAt: .now - ) -} - -struct ContactSubStatus: Decodable { - var contact: Contact - var contactError: ChatError? -} - -struct Connection: Decodable { - var connStatus: String - - static let sampleData = Connection(connStatus: "ready") -} - -struct UserContactRequest: Decodable, NamedChat { - var contactRequestId: Int64 - var localDisplayName: ContactName - var profile: Profile - var createdAt: Date - - var id: ChatId { get { "<@\(contactRequestId)" } } - var apiId: Int64 { get { contactRequestId } } - var ready: Bool { get { true } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = UserContactRequest( - contactRequestId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - createdAt: .now - ) -} - -struct GroupInfo: Identifiable, Decodable, NamedChat { - var groupId: Int64 - var localDisplayName: GroupName - var groupProfile: GroupProfile - var createdAt: Date - - var id: ChatId { get { "#\(groupId)" } } - var apiId: Int64 { get { groupId } } - var ready: Bool { get { true } } - var displayName: String { get { groupProfile.displayName } } - var fullName: String { get { groupProfile.fullName } } - var image: String? { get { groupProfile.image } } - - static let sampleData = GroupInfo( - groupId: 1, - localDisplayName: "team", - groupProfile: GroupProfile.sampleData, - createdAt: .now - ) -} - -struct GroupProfile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? - - static let sampleData = GroupProfile( - displayName: "team", - fullName: "My Team" - ) -} - -struct GroupMember: Decodable { - var groupMemberId: Int64 - var memberId: String -// var memberRole: GroupMemberRole -// var memberCategory: GroupMemberCategory -// var memberStatus: GroupMemberStatus -// var invitedBy: InvitedBy - var localDisplayName: ContactName - var memberProfile: Profile - var memberContactId: Int64? -// var activeConn: Connection? - - var directChatId: ChatId? { - get { - if let chatId = memberContactId { - return "@\(chatId)" - } else { - return nil - } - } - } - - static let sampleData = GroupMember( - groupMemberId: 1, - memberId: "abcd", - localDisplayName: "alice", - memberProfile: Profile.sampleData, - memberContactId: 1 - ) -} - -struct MemberSubError: Decodable { - var member: GroupMember - var memberError: ChatError -} - -struct AChatItem: Decodable { - var chatInfo: ChatInfo - var chatItem: ChatItem -} - -struct ChatItem: Identifiable, Decodable { - var chatDir: CIDirection - var meta: CIMeta - var content: CIContent - var formattedText: [FormattedText]? - var quotedItem: CIQuote? - var file: CIFile? - - var id: Int64 { get { meta.itemId } } - - var timestampText: Text { get { meta.timestampText } } - - var text: String { - get { - switch (content.text, file) { - case let ("", .some(file)): return file.fileName - default: return content.text - } - } - } - - func isRcvNew() -> Bool { - if case .rcvNew = meta.itemStatus { return true } - return false - } - - func isMsgContent() -> Bool { - switch content { - case .sndMsgContent: return true - case .rcvMsgContent: return true - default: return false - } - } - - func isDeletedContent() -> Bool { - switch content { - case .sndDeleted: return true - case .rcvDeleted: return true - default: return false - } - } - - var memberDisplayName: String? { - get { - if case let .groupRcv(groupMember) = chatDir { - return groupMember.memberProfile.displayName - } else { - return nil - } - } - } - - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { - ChatItem( - chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), - content: .sndMsgContent(msgContent: .text(text)), - quotedItem: quotedItem, - file: file - ) - } - - 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, - meta: CIMeta.getSample(id, ts, text, status, false, false, false), - content: .rcvDeleted(deleteMode: .cidmBroadcast), - quotedItem: nil, - file: nil - ) - } -} - -enum CIDirection: Decodable { - case directSnd - case directRcv - case groupSnd - case groupRcv(groupMember: GroupMember) - - var sent: Bool { - get { - switch self { - case .directSnd: return true - case .directRcv: return false - case .groupSnd: return true - case .groupRcv: return false - } - } - } -} - -struct CIMeta: Decodable { - var itemId: Int64 - var itemTs: Date - var itemText: String - var itemStatus: CIStatus - var createdAt: Date - var itemDeleted: Bool - var itemEdited: Bool - var editable: Bool - - var timestampText: Text { get { SimpleX.timestampText(itemTs) } } - - static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { - CIMeta( - itemId: id, - itemTs: ts, - itemText: text, - itemStatus: status, - createdAt: ts, - itemDeleted: itemDeleted, - itemEdited: itemEdited, - editable: editable - ) - } -} - -let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() -let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) - -func timestampText(_ date: Date) -> Text { - let now = Calendar.current.dateComponents([.day, .hour], from: .now) - let dc = Calendar.current.dateComponents([.day, .hour], from: date) - let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) - return Text(date, format: recent ? msgTimeFormat : msgDateFormat) -} - -enum CIStatus: Decodable { - case sndNew - case sndSent - case sndErrorAuth - case sndError(agentError: AgentErrorType) - case rcvNew - case rcvRead -} - -enum CIDeleteMode: String, Decodable { - case cidmBroadcast = "broadcast" - case cidmInternal = "internal" -} - -protocol ItemContent { - var text: String { get } -} - -enum CIContent: Decodable, ItemContent { - case sndMsgContent(msgContent: MsgContent) - case rcvMsgContent(msgContent: MsgContent) - case sndDeleted(deleteMode: CIDeleteMode) - case rcvDeleted(deleteMode: CIDeleteMode) - - var text: String { - get { - switch self { - case let .sndMsgContent(mc): return mc.text - case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - } - } - } - - var msgContent: MsgContent? { - get { - switch self { - case let .sndMsgContent(mc): return mc - case let .rcvMsgContent(mc): return mc - default: return nil - } - } - } -} - -struct CIQuote: Decodable, ItemContent { - var chatDir: CIDirection? - var itemId: Int64? - var sharedMsgId: String? = nil - var sentAt: Date - var content: MsgContent - var formattedText: [FormattedText]? - - var text: String { get { content.text } } - - var sender: String? { - get { - switch (chatDir) { - case .directSnd: return "you" - case .directRcv: return nil - case .groupSnd: return ChatModel.shared.currentUser?.displayName - case let .groupRcv(member): return member.memberProfile.displayName - case nil: return nil - } - } - } - - static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { - let mc: MsgContent - if let image = image { - mc = .image(text: text, image: image) - } else { - mc = .text(text) - } - return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) - } -} - -struct CIFile: Decodable { - var fileId: Int64 - var fileName: String - var fileSize: Int64 - var filePath: String? - var fileStatus: CIFileStatus - - static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { - CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) - } - - var stored: Bool { - get { - switch self.fileStatus { - case .sndStored: return true - case .sndCancelled: return true - case .rcvComplete: return true - default: return false - } - } - } -} - -enum CIFileStatus: String, Decodable { - case sndStored = "snd_stored" - case sndCancelled = "snd_cancelled" - case rcvInvitation = "rcv_invitation" - case rcvTransfer = "rcv_transfer" - case rcvComplete = "rcv_complete" - case rcvCancelled = "rcv_cancelled" -} - -enum MsgContent { - case text(String) - case link(text: String, preview: LinkPreview) - case image(text: String, image: String) - // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift - case unknown(type: String, text: String) - - var text: String { - get { - switch self { - case let .text(text): return text - case let .link(text, _): return text - case let .image(text, _): return text - case let .unknown(_, text): return text - } - } - } - - var cmdString: String { - get { - switch self { - case let .text(text): return "text \(text)" - case let .link(text: text, preview: preview): - 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))}" - default: return "" - } - } - } - - enum CodingKeys: String, CodingKey { - case type - case text - case preview - case image - } -} - -// TODO define Encodable -extension MsgContent: Decodable { - init(from decoder: Decoder) throws { - do { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "text": - let text = try container.decode(String.self, forKey: CodingKeys.text) - self = .text(text) - case "link": - let text = try container.decode(String.self, forKey: CodingKeys.text) - let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) - self = .link(text: text, preview: preview) - case "image": - 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) - default: - let text = try? container.decode(String.self, forKey: CodingKeys.text) - self = .unknown(type: type, text: text ?? "unknown message format") - } - } catch { - self = .unknown(type: "unknown", text: "invalid message format") - } - } -} - -struct FormattedText: Decodable { - var text: String - var format: Format? -} - -enum Format: Decodable, Equatable { - case bold - case italic - case strikeThrough - case snippet - case secret - case colored(color: FormatColor) - case uri - case email - case phone -} - -enum FormatColor: String, Decodable { - case red = "red" - case green = "green" - case blue = "blue" - case yellow = "yellow" - case cyan = "cyan" - case magenta = "magenta" - case black = "black" - case white = "white" - - var uiColor: Color { - get { - switch (self) { - case .red: return .red - case .green: return .green - case .blue: return .blue - case .yellow: return .yellow - case .cyan: return .cyan - case .magenta: return .purple - case .black: return .primary - case .white: return .primary - } - } - } -} - -// Struct to use with simplex API -struct LinkPreview: Codable { - var uri: URL - var title: String - // TODO remove once optional in haskell - var description: String = "" - var image: String -} diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 8861968b2c..a4c53c52ea 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -10,13 +10,9 @@ import Foundation import UserNotifications import UIKit -let ntfActionAccept = "NTF_ACT_ACCEPT" - -let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" -let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" -let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" - -let appNotificationId = "chat.simplex.app.notification" +let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT" +let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL" +let ntfActionRejectCall = "NTF_ACT_REJECT_CALL" private let ntfTimeInterval: TimeInterval = 1 @@ -34,10 +30,33 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.userNotificationCenter: didReceive") let content = response.notification.request.content let chatModel = ChatModel.shared - if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept, + let action = response.actionIdentifier + if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String, case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { Task { await acceptContactRequest(contactRequest) } + } else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall), + let chatId = content.userInfo["chatId"] as? String, + case let .direct(contact) = chatModel.getChat(chatId)?.chatInfo, + let invitation = chatModel.callInvitations[chatId] { + if action == ntfActionAcceptCall { + chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia) + chatModel.chatId = nil + chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey) + } else { + Task { + do { + try await apiRejectCall(contact) + if chatModel.activeCall?.contact.id == chatId { + DispatchQueue.main.async { + chatModel.callCommand = .end + chatModel.activeCall = nil + } + } + } + } + } + chatModel.callInvitations.removeValue(forKey: chatId) } else { chatModel.chatId = content.targetContentIdentifier } @@ -67,10 +86,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { // in another chat return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list] } + // this notification is deliverd from the notifications server + // when the app is in foreground it does not need to be shown + case ntfCategoryCheckMessage: return [] default: return [.sound, .banner, .list] } } else { - return [.sound, .banner, .list] + return content.categoryIdentifier == ntfCategoryCheckMessage ? [] : [.sound, .banner, .list] } } @@ -91,7 +113,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { UNNotificationCategory( identifier: ntfCategoryContactRequest, actions: [UNNotificationAction( - identifier: ntfActionAccept, + identifier: ntfActionAcceptContact, title: NSLocalizedString("Accept", comment: "accept contact request via notification") )], intentIdentifiers: [], @@ -107,7 +129,29 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { identifier: ntfCategoryMessageReceived, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notifications") + hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notification") + ), + UNNotificationCategory( + identifier: ntfCategoryCallInvitation, + actions: [ + UNNotificationAction( + identifier: ntfActionAcceptCall, + title: NSLocalizedString("Answer", comment: "accept incoming call via notification") + ), + UNNotificationAction( + identifier: ntfActionRejectCall, + title: NSLocalizedString("Ignore", comment: "ignore incoming call via notification") + ) + ], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification") + ), + // TODO remove + UNNotificationCategory( + identifier: ntfCategoryCheckingMessages, + actions: [], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notification") ) ]) } @@ -137,67 +181,36 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { func notifyContactRequest(_ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") - addNotification( - categoryIdentifier: ntfCategoryContactRequest, - title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), - targetContentIdentifier: nil, - userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] - ) + addNotification(createContactRequestNtf(contactRequest)) } func notifyContactConnected(_ contact: Contact) { logger.debug("NtfManager.notifyContactConnected") - addNotification( - categoryIdentifier: ntfCategoryContactConnected, - title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), - targetContentIdentifier: contact.id -// userInfo: ["chatId": contact.id, "contactId": contact.apiId] - ) + addNotification(createContactConnectedNtf(contact)) } func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") - addNotification( - categoryIdentifier: ntfCategoryMessageReceived, - title: "\(cInfo.chatViewName):", - body: hideSecrets(cItem), - targetContentIdentifier: cInfo.id -// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] - ) - } - - func hideSecrets(_ cItem: ChatItem) -> String { - if let md = cItem.formattedText { - var res = "" - for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text - } - } - return res - } else { - return cItem.content.text - } + addNotification(createMessageReceivedNtf(cInfo, cItem)) } - private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, - targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) { + func notifyCallInvitation(_ contact: Contact, _ invitation: CallInvitation) { + logger.debug("NtfManager.notifyCallInvitation") + addNotification(createCallInvitationNtf(contact, invitation)) + } + + // TODO remove + func notifyCheckingMessages() { + logger.debug("NtfManager.notifyCheckingMessages") + let content = createNotification( + categoryIdentifier: ntfCategoryCheckingMessages, + title: NSLocalizedString("Checking new messages...", comment: "notification") + ) + addNotification(content) + } + + private func addNotification(_ content: UNMutableNotificationContent) { if !granted { return } - let content = UNMutableNotificationContent() - content.categoryIdentifier = categoryIdentifier - content.title = title - if let s = subtitle { content.subtitle = s } - if let s = body { content.body = s } - content.targetContentIdentifier = targetContentIdentifier - content.userInfo = userInfo - // TODO move logic of adding sound here, so it applies to background notifications too - content.sound = .default -// content.interruptionLevel = .active -// content.relevanceScore = 0.5 // 0-1 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false) let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in diff --git a/apps/ios/Shared/Model/Shared/API.swift b/apps/ios/Shared/Model/Shared/API.swift new file mode 100644 index 0000000000..7208c45d99 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/API.swift @@ -0,0 +1,66 @@ +// +// API.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +private var chatController: chat_ctrl? + +func getChatCtrl() -> chat_ctrl { + if let controller = chatController { return controller } + let dataDir = getDocumentsDirectory().path + "/mobile_v1" + logger.debug("documents directory \(dataDir)") + var cstr = dataDir.cString(using: .utf8)! + chatController = chat_init(&cstr) + logger.debug("getChatCtrl: chat_init") + return chatController! +} + +func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { + var c = cmd.cmdString.cString(using: .utf8)! + return chatResponse(chat_send_cmd(getChatCtrl(), &c)) +} + +func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! +// TODO is there a way to do it without copying the data? e.g: +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let j1 = j["resp"] as? NSDictionary, j1.count == 1 { + type = j1.allKeys[0] as? String + } + json = prettyJSON(j) + } + free(cjson) + return ChatResponse.response(type: type ?? "invalid", json: json ?? s) +} + +func prettyJSON(_ obj: NSDictionary) -> String? { + if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) { + return String(decoding: d, as: UTF8.self) + } + return nil +} + +func responseError(_ err: Error) -> String { + if let r = err as? ChatResponse { + return String(describing: r) + } else { + return err.localizedDescription + } +} diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift new file mode 100644 index 0000000000..6873c8bb42 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -0,0 +1,516 @@ +// +// SimpleXAPI.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +let jsonDecoder = getJSONDecoder() +let jsonEncoder = getJSONEncoder() + +enum ChatCommand { + case showActiveUser + case createActiveUser(profile: Profile) + case startChat + case setFilesFolder(filesFolder: String) + case apiGetChats + case apiGetChat(type: ChatType, id: Int64) + case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) + case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) + case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) + case apiRegisterToken(token: String) + case apiVerifyToken(token: String, code: String, nonce: String) + case apiIntervalNofication(token: String, interval: Int) + case apiDeleteToken(token: String) + case getUserSMPServers + case setUserSMPServers(smpServers: [String]) + case addContact + case connect(connReq: String) + case apiDeleteChat(type: ChatType, id: Int64) + case apiUpdateProfile(profile: Profile) + case apiParseMarkdown(text: String) + case createMyAddress + case deleteMyAddress + case showMyAddress + case apiAcceptContact(contactReqId: Int64) + case apiRejectContact(contactReqId: Int64) + // WebRTC calls + case apiSendCallInvitation(contact: Contact, callType: CallType) + case apiRejectCall(contact: Contact) + case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer) + case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) + case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) + case apiEndCall(contact: Contact) + case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) + case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) + case receiveFile(fileId: Int64) + case string(String) + + var cmdString: String { + get { + switch self { + case .showActiveUser: return "/u" + case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" + case .startChat: return "/_start" + case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" + case .apiGetChats: return "/_get chats pcc=on" + case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" + case let .apiSendMessage(type, id, file, quotedItemId, mc): + let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) + return "/_send \(ref(type, id)) json \(msg)" + case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" + case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" + case let .apiRegisterToken(token): return "/_ntf register apns \(token)" + case let .apiVerifyToken(token, code, nonce): return "/_ntf verify apns \(token) \(code) \(nonce)" + case let .apiIntervalNofication(token, interval): return "/_ntf interval apns \(token) \(interval)" + case let .apiDeleteToken(token): return "/_ntf delete apns \(token)" + case .getUserSMPServers: return "/smp_servers" + case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" + case .addContact: return "/connect" + case let .connect(connReq): return "/connect \(connReq)" + case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" + case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiParseMarkdown(text): return "/_parse \(text)" + case .createMyAddress: return "/address" + case .deleteMyAddress: return "/delete_address" + case .showMyAddress: return "/show_address" + case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" + case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" + case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" + case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))" + case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" + case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" + case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" + case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" + case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" + case let .receiveFile(fileId): return "/freceive \(fileId)" + case let .string(str): return str + } + } + } + + var cmdType: String { + get { + switch self { + case .showActiveUser: return "showActiveUser" + case .createActiveUser: return "createActiveUser" + case .startChat: return "startChat" + case .setFilesFolder: return "setFilesFolder" + case .apiGetChats: return "apiGetChats" + case .apiGetChat: return "apiGetChat" + case .apiSendMessage: return "apiSendMessage" + case .apiUpdateChatItem: return "apiUpdateChatItem" + case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiRegisterToken: return "apiRegisterToken" + case .apiVerifyToken: return "apiVerifyToken" + case .apiIntervalNofication: return "apiIntervalNofication" + case .apiDeleteToken: return "apiDeleteToken" + case .getUserSMPServers: return "getUserSMPServers" + case .setUserSMPServers: return "setUserSMPServers" + case .addContact: return "addContact" + case .connect: return "connect" + case .apiDeleteChat: return "apiDeleteChat" + case .apiUpdateProfile: return "apiUpdateProfile" + case .apiParseMarkdown: return "apiParseMarkdown" + case .createMyAddress: return "createMyAddress" + case .deleteMyAddress: return "deleteMyAddress" + case .showMyAddress: return "showMyAddress" + case .apiAcceptContact: return "apiAcceptContact" + case .apiRejectContact: return "apiRejectContact" + case .apiSendCallInvitation: return "apiSendCallInvitation" + case .apiRejectCall: return "apiRejectCall" + case .apiSendCallOffer: return "apiSendCallOffer" + case .apiSendCallAnswer: return "apiSendCallAnswer" + case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" + case .apiEndCall: return "apiEndCall" + case .apiCallStatus: return "apiCallStatus" + case .apiChatRead: return "apiChatRead" + case .receiveFile: return "receiveFile" + case .string: return "console command" + } + } + } + + func ref(_ type: ChatType, _ id: Int64) -> String { + "\(type.rawValue)\(id)" + } + + func smpServersStr(smpServers: [String]) -> String { + smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") + } +} + +struct APIResponse: Decodable { + var resp: ChatResponse +} + +enum ChatResponse: Decodable, Error { + case response(type: String, json: String) + case activeUser(user: User) + case chatStarted + case chatRunning + case apiChats(chats: [ChatData]) + case apiChat(chat: ChatData) + case userSMPServers(smpServers: [String]) + case invitation(connReqInvitation: String) + case sentConfirmation + case sentInvitation + case contactAlreadyExists(contact: Contact) + case contactDeleted(contact: Contact) + case userProfileNoChange + case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case apiParsedMarkdown(formattedText: [FormattedText]?) + case userContactLink(connReqContact: String) + case userContactLinkCreated(connReqContact: String) + case userContactLinkDeleted + case contactConnected(contact: Contact) + case contactConnecting(contact: Contact) + case receivedContactRequest(contactRequest: UserContactRequest) + case acceptingContactRequest(contact: Contact) + case contactRequestRejected + case contactUpdated(toContact: Contact) + case contactsSubscribed(server: String, contactRefs: [ContactRef]) + case contactsDisconnected(server: String, contactRefs: [ContactRef]) + case contactSubError(contact: Contact, chatError: ChatError) + case contactSubSummary(contactSubscriptions: [ContactSubStatus]) + case groupSubscribed(groupInfo: GroupInfo) + case memberSubErrors(memberSubErrors: [MemberSubError]) + case groupEmpty(groupInfo: GroupInfo) + case userContactLinkSubscribed + case newChatItem(chatItem: AChatItem) + case chatItemStatusUpdated(chatItem: AChatItem) + case chatItemUpdated(chatItem: AChatItem) + case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) + // receiving file events + case rcvFileAccepted(chatItem: AChatItem) + case rcvFileStart(chatItem: AChatItem) + case rcvFileComplete(chatItem: AChatItem) + // sending file events + case sndFileStart(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case callInvitation(contact: Contact, callType: CallType, sharedKey: String?) + case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(contact: Contact, answer: WebRTCSession) + case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(contact: Contact) + case ntfTokenStatus(status: NtfTknStatus) + case newContactConnection(connection: PendingContactConnection) + case contactConnectionDeleted(connection: PendingContactConnection) + case cmdOk + case chatCmdError(chatError: ChatError) + case chatError(chatError: ChatError) + + var responseType: String { + get { + switch self { + case let .response(type, _): return "* \(type)" + case .activeUser: return "activeUser" + case .chatStarted: return "chatStarted" + case .chatRunning: return "chatRunning" + case .apiChats: return "apiChats" + case .apiChat: return "apiChat" + case .userSMPServers: return "userSMPServers" + case .invitation: return "invitation" + case .sentConfirmation: return "sentConfirmation" + case .sentInvitation: return "sentInvitation" + case .contactAlreadyExists: return "contactAlreadyExists" + case .contactDeleted: return "contactDeleted" + case .userProfileNoChange: return "userProfileNoChange" + case .userProfileUpdated: return "userProfileUpdated" + case .apiParsedMarkdown: return "apiParsedMarkdown" + case .userContactLink: return "userContactLink" + case .userContactLinkCreated: return "userContactLinkCreated" + case .userContactLinkDeleted: return "userContactLinkDeleted" + case .contactConnected: return "contactConnected" + case .contactConnecting: return "contactConnecting" + case .receivedContactRequest: return "receivedContactRequest" + case .acceptingContactRequest: return "acceptingContactRequest" + case .contactRequestRejected: return "contactRequestRejected" + case .contactUpdated: return "contactUpdated" + case .contactsSubscribed: return "contactsSubscribed" + case .contactsDisconnected: return "contactsDisconnected" + case .contactSubError: return "contactSubError" + case .contactSubSummary: return "contactSubSummary" + case .groupSubscribed: return "groupSubscribed" + case .memberSubErrors: return "memberSubErrors" + case .groupEmpty: return "groupEmpty" + case .userContactLinkSubscribed: return "userContactLinkSubscribed" + case .newChatItem: return "newChatItem" + case .chatItemStatusUpdated: return "chatItemStatusUpdated" + case .chatItemUpdated: return "chatItemUpdated" + case .chatItemDeleted: return "chatItemDeleted" + case .rcvFileAccepted: return "rcvFileAccepted" + case .rcvFileStart: return "rcvFileStart" + case .rcvFileComplete: return "rcvFileComplete" + case .sndFileStart: return "sndFileStart" + case .sndFileComplete: return "sndFileComplete" + case .sndFileCancelled: return "sndFileCancelled" + case .sndFileRcvCancelled: return "sndFileRcvCancelled" + case .sndGroupFileCancelled: return "sndGroupFileCancelled" + case .callInvitation: return "callInvitation" + case .callOffer: return "callOffer" + case .callAnswer: return "callAnswer" + case .callExtraInfo: return "callExtraInfo" + case .callEnded: return "callEnded" + case .ntfTokenStatus: return "ntfTokenStatus" + case .newContactConnection: return "newContactConnection" + case .contactConnectionDeleted: return "contactConnectionDeleted" + case .cmdOk: return "cmdOk" + case .chatCmdError: return "chatCmdError" + case .chatError: return "chatError" + } + } + } + + var details: String { + get { + switch self { + case let .response(_, json): return json + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case .chatRunning: return noDetails + case let .apiChats(chats): return String(describing: chats) + case let .apiChat(chat): return String(describing: chat) + case let .userSMPServers(smpServers): return String(describing: smpServers) + case let .invitation(connReqInvitation): return connReqInvitation + case .sentConfirmation: return noDetails + case .sentInvitation: return noDetails + case let .contactAlreadyExists(contact): return String(describing: contact) + case let .contactDeleted(contact): return String(describing: contact) + case .userProfileNoChange: return noDetails + case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) + case let .userContactLink(connReq): return connReq + case let .userContactLinkCreated(connReq): return connReq + case .userContactLinkDeleted: return noDetails + case let .contactConnected(contact): return String(describing: contact) + case let .contactConnecting(contact): return String(describing: contact) + case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) + case let .acceptingContactRequest(contact): return String(describing: contact) + case .contactRequestRejected: return noDetails + case let .contactUpdated(toContact): return String(describing: toContact) + case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" + case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) + case let .groupSubscribed(groupInfo): return String(describing: groupInfo) + case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) + case let .groupEmpty(groupInfo): return String(describing: groupInfo) + case .userContactLinkSubscribed: return noDetails + case let .newChatItem(chatItem): return String(describing: chatItem) + 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 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 .sndFileStart(chatItem, _): return String(describing: chatItem) + case let .sndFileComplete(chatItem, _): return String(describing: chatItem) + case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) + case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) + case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) + case let .callInvitation(contact, callType, sharedKey): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")" + case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))" + case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))" + case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" + case let .callEnded(contact): return "contact: \(contact.id)" + case let .ntfTokenStatus(status): return String(describing: status) + case let .newContactConnection(connection): return String(describing: connection) + case let .contactConnectionDeleted(connection): return String(describing: connection) + case .cmdOk: return noDetails + case let .chatCmdError(chatError): return String(describing: chatError) + case let .chatError(chatError): return String(describing: chatError) + } + } + } + + private var noDetails: String { get { "\(responseType): no details" } } +} + +struct ComposedMessage: Encodable { + var filePath: String? + var quotedItemId: Int64? + var msgContent: MsgContent +} + +func decodeJSON(_ json: String) -> T? { + if let data = json.data(using: .utf8) { + return try? jsonDecoder.decode(T.self, from: data) + } + return nil +} + +func decodeCJSON(_ cjson: UnsafePointer) -> T? { + // TODO is there a way to do it without copying the data? e.g: + // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) + // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + decodeJSON(String.init(cString: cjson)) +} + +private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! + return try? JSONSerialization.jsonObject(with: d) as? NSDictionary +} + +func encodeJSON(_ value: T) -> String { + let data = try! jsonEncoder.encode(value) + return String(decoding: data, as: UTF8.self) +} + +private func encodeCJSON(_ value: T) -> [CChar] { + encodeJSON(value).cString(using: .utf8)! +} + +enum ChatError: Decodable { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) +} + +enum ChatErrorType: Decodable { + case noActiveUser + case activeUserExists + case chatNotStarted + case invalidConnReq + case invalidChatMessage(message: String) + case contactNotReady(contact: Contact) + case contactGroups(contact: Contact, groupNames: [GroupName]) + case groupUserRole + case groupContactRole(contactName: ContactName) + case groupDuplicateMember(contactName: ContactName) + case groupDuplicateMemberId + case groupNotJoined(groupInfo: GroupInfo) + case groupMemberNotActive + case groupMemberUserRemoved + case groupMemberNotFound(contactName: ContactName) + case groupMemberIntroNotFound(contactName: ContactName) + case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName) + case groupInternal(message: String) + case fileNotFound(message: String) + case fileAlreadyReceiving(message: String) + case fileAlreadyExists(filePath: String) + case fileRead(filePath: String, message: String) + case fileWrite(filePath: String, message: String) + case fileSend(fileId: Int64, agentError: String) + case fileRcvChunk(message: String) + case fileInternal(message: String) + case invalidQuote + case invalidChatItemUpdate + case invalidChatItemDelete + case agentVersion + case commandError(message: String) +} + +enum StoreError: Decodable { + case duplicateName + case contactNotFound(contactId: Int64) + case contactNotFoundByName(contactName: ContactName) + case contactNotReady(contactName: ContactName) + case duplicateContactLink + case userContactLinkNotFound + case contactRequestNotFound(contactRequestId: Int64) + case contactRequestNotFoundByName(contactName: ContactName) + case groupNotFound(groupId: Int64) + case groupNotFoundByName(groupName: GroupName) + case groupWithoutUser + case duplicateGroupMember + case groupAlreadyJoined + case groupInvitationNotFound + case sndFileNotFound(fileId: Int64) + case sndFileInvalid(fileId: Int64) + case rcvFileNotFound(fileId: Int64) + case fileNotFound(fileId: Int64) + case rcvFileInvalid(fileId: Int64) + case connectionNotFound(agentConnId: String) + case pendingConnectionNotFound(connId: Int64) + case introNotFound + case uniqueID + case internalError(message: String) + case noMsgDelivery(connId: Int64, agentMsgId: String) + case badChatItem(itemId: Int64) + case chatItemNotFound(itemId: Int64) + case quotedChatItemNotFound + case chatItemSharedMsgIdNotFound(sharedMsgId: String) + case chatItemNotFoundByFileId(fileId: Int64) +} + +enum AgentErrorType: Decodable { + case CMD(cmdErr: CommandErrorType) + case CONN(connErr: ConnectionErrorType) + case SMP(smpErr: ProtocolErrorType) + case NTF(ntfErr: ProtocolErrorType) + case BROKER(brokerErr: BrokerErrorType) + case AGENT(agentErr: SMPAgentError) + case INTERNAL(internalErr: String) +} + +enum CommandErrorType: Decodable { + case PROHIBITED + case SYNTAX + case NO_CONN + case SIZE + case LARGE +} + +enum ConnectionErrorType: Decodable { + case NOT_FOUND + case DUPLICATE + case SIMPLEX + case NOT_ACCEPTED + case NOT_AVAILABLE +} + +enum BrokerErrorType: Decodable { + case RESPONSE(smpErr: ProtocolErrorType) + case UNEXPECTED + case NETWORK + case TRANSPORT(transportErr: ProtocolTransportError) + case TIMEOUT +} + +enum ProtocolErrorType: Decodable { + case BLOCK + case SESSION + case CMD(cmdErr: ProtocolCommandError) + case AUTH + case QUOTA + case NO_MSG + case LARGE_MSG + case INTERNAL +} + +enum ProtocolCommandError: Decodable { + case UNKNOWN + case SYNTAX + case NO_AUTH + case HAS_AUTH + case NO_ENTITY +} + +enum ProtocolTransportError: Decodable { + case badBlock + case largeMsg + case badSession + case handshake(handshakeErr: SMPHandshakeError) +} + +enum SMPHandshakeError: Decodable { + case PARSE + case VERSION + case IDENTITY +} + +enum SMPAgentError: Decodable { + case A_MESSAGE + case A_PROHIBITED + case A_VERSION + case A_ENCRYPTION +} diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift new file mode 100644 index 0000000000..355cb15983 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/CallTypes.swift @@ -0,0 +1,48 @@ +// +// CallTypes.swift +// SimpleX (iOS) +// +// Created by Evgeny on 05/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +struct WebRTCCallOffer: Encodable { + var callType: CallType + var rtcSession: WebRTCSession +} + +struct WebRTCSession: Codable { + var rtcSession: String + var rtcIceCandidates: [String] +} + +struct WebRTCExtraInfo: Codable { + var rtcIceCandidates: [String] +} + +struct CallInvitation { + var peerMedia: CallMediaType + var sharedKey: String? +} + +struct CallType: Codable { + var media: CallMediaType + var capabilities: CallCapabilities +} + +enum CallMediaType: String, Codable, Equatable { + case video = "video" + case audio = "audio" +} + +struct CallCapabilities: Codable, Equatable { + var encryption: Bool +} + +enum WebRTCCallStatus: String, Encodable { + case connected = "connected" + case disconnected = "disconnected" + case failed = "failed" +} diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift new file mode 100644 index 0000000000..9a3b36d05b --- /dev/null +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -0,0 +1,878 @@ +// +// ChatModel.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +struct User: Decodable, NamedChat { + var userId: Int64 + var userContactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeUser: Bool + + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = User( + userId: 1, + userContactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeUser: true + ) +} + +typealias ContactName = String + +typealias GroupName = String + +struct Profile: Codable, NamedChat { + var displayName: String + var fullName: String + var image: String? + + static let sampleData = Profile( + displayName: "alice", + fullName: "Alice" + ) +} + +enum ChatType: String { + case direct = "@" + case group = "#" + case contactRequest = "<@" + case contactConnection = ":" +} + +protocol NamedChat { + var displayName: String { get } + var fullName: String { get } + var image: String? { get } +} + +extension NamedChat { + var chatViewName: String { + get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } + } +} + +typealias ChatId = String + +enum ChatInfo: Identifiable, Decodable, NamedChat { + case direct(contact: Contact) + case group(groupInfo: GroupInfo) + case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) + + var localDisplayName: String { + get { + switch self { + case let .direct(contact): return contact.localDisplayName + case let .group(groupInfo): return groupInfo.localDisplayName + case let .contactRequest(contactRequest): return contactRequest.localDisplayName + case let .contactConnection(contactConnection): return contactConnection.localDisplayName + } + } + } + + var displayName: String { + get { + switch self { + case let .direct(contact): return contact.displayName + case let .group(groupInfo): return groupInfo.displayName + case let .contactRequest(contactRequest): return contactRequest.displayName + case let .contactConnection(contactConnection): return contactConnection.displayName + } + } + } + + var fullName: String { + get { + switch self { + case let .direct(contact): return contact.fullName + case let .group(groupInfo): return groupInfo.fullName + case let .contactRequest(contactRequest): return contactRequest.fullName + case let .contactConnection(contactConnection): return contactConnection.fullName + } + } + } + + var image: String? { + get { + switch self { + case let .direct(contact): return contact.image + case let .group(groupInfo): return groupInfo.image + case let .contactRequest(contactRequest): return contactRequest.image + case let .contactConnection(contactConnection): return contactConnection.image + } + } + } + + var id: ChatId { + get { + switch self { + case let .direct(contact): return contact.id + case let .group(groupInfo): return groupInfo.id + case let .contactRequest(contactRequest): return contactRequest.id + case let .contactConnection(contactConnection): return contactConnection.id + } + } + } + + var chatType: ChatType { + get { + switch self { + case .direct: return .direct + case .group: return .group + case .contactRequest: return .contactRequest + case .contactConnection: return .contactConnection + } + } + } + + var apiId: Int64 { + get { + switch self { + case let .direct(contact): return contact.apiId + case let .group(groupInfo): return groupInfo.apiId + case let .contactRequest(contactRequest): return contactRequest.apiId + case let .contactConnection(contactConnection): return contactConnection.apiId + } + } + } + + var ready: Bool { + get { + switch self { + case let .direct(contact): return contact.ready + case let .group(groupInfo): return groupInfo.ready + case let .contactRequest(contactRequest): return contactRequest.ready + case let .contactConnection(contactConnection): return contactConnection.ready + } + } + } + + var createdAt: Date { + switch self { + case let .direct(contact): return contact.createdAt + case let .group(groupInfo): return groupInfo.createdAt + case let .contactRequest(contactRequest): return contactRequest.createdAt + case let .contactConnection(contactConnection): return contactConnection.createdAt + } + } + + struct SampleData { + var direct: ChatInfo + var group: ChatInfo + var contactRequest: ChatInfo + } + + static var sampleData: ChatInfo.SampleData = SampleData( + direct: ChatInfo.direct(contact: Contact.sampleData), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData), + contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) + ) +} + +struct ChatData: Decodable, Identifiable { + var chatInfo: ChatInfo + var chatItems: [ChatItem] + var chatStats: ChatStats + + var id: ChatId { get { chatInfo.id } } +} + +struct ChatStats: Decodable { + var unreadCount: Int = 0 + var minUnreadItemId: Int64 = 0 +} + +struct Contact: Identifiable, Decodable, NamedChat { + var contactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeConn: Connection + var viaGroup: Int64? + var createdAt: Date + + var id: ChatId { get { "@\(contactId)" } } + var apiId: Int64 { get { contactId } } + var ready: Bool { get { activeConn.connStatus == .ready } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = Contact( + contactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeConn: Connection.sampleData, + createdAt: .now + ) +} + +struct ContactRef: Decodable, Equatable { + var contactId: Int64 + var localDisplayName: ContactName + + var id: ChatId { get { "@\(contactId)" } } +} + +struct ContactSubStatus: Decodable { + var contact: Contact + var contactError: ChatError? +} + +struct Connection: Decodable { + var connId: Int64 + var connStatus: ConnStatus + + var id: ChatId { get { ":\(connId)" } } + + static let sampleData = Connection( + connId: 1, + connStatus: .ready + ) +} + +struct UserContactRequest: Decodable, NamedChat { + var contactRequestId: Int64 + var localDisplayName: ContactName + var profile: Profile + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { "<@\(contactRequestId)" } } + var apiId: Int64 { get { contactRequestId } } + var ready: Bool { get { true } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = UserContactRequest( + contactRequestId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + createdAt: .now, + updatedAt: .now + ) +} + +struct PendingContactConnection: Decodable, NamedChat { + var pccConnId: Int64 + var pccAgentConnId: String + var pccConnStatus: ConnStatus + var viaContactUri: Bool + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { ":\(pccConnId)" } } + var apiId: Int64 { get { pccConnId } } + var ready: Bool { get { false } } + var localDisplayName: String { + get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } + } + var displayName: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("invited to connect", comment: "chat list item title") + : NSLocalizedString("connecting…", comment: "chat list item title") + } else { + // this should not be in the list + return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown") + } + } + } + var fullName: String { get { "" } } + var image: String? { get { nil } } + var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } + + var description: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("you shared one-time link", comment: "chat list item description") + : viaContactUri + ? NSLocalizedString("via contact address link", comment: "chat list item description") + : NSLocalizedString("via one-time link", comment: "chat list item description") + } else { + return "" + } + } + } + + static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { + PendingContactConnection( + pccConnId: 1, + pccAgentConnId: "abcd", + pccConnStatus: status, + viaContactUri: viaContactUri, + createdAt: .now, + updatedAt: .now + ) + } +} + +enum ConnStatus: String, Decodable { + case new = "new" + case joined = "joined" + case requested = "requested" + case accepted = "accepted" + case sndReady = "snd-ready" + case ready = "ready" + case deleted = "deleted" + + var initiated: Bool? { + get { + switch self { + case .new: return true + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return false + case .ready: return nil + case .deleted: return nil + } + } + } +} + +struct GroupInfo: Identifiable, Decodable, NamedChat { + var groupId: Int64 + var localDisplayName: GroupName + var groupProfile: GroupProfile + var createdAt: Date + + var id: ChatId { get { "#\(groupId)" } } + var apiId: Int64 { get { groupId } } + var ready: Bool { get { true } } + var displayName: String { get { groupProfile.displayName } } + var fullName: String { get { groupProfile.fullName } } + var image: String? { get { groupProfile.image } } + + static let sampleData = GroupInfo( + groupId: 1, + localDisplayName: "team", + groupProfile: GroupProfile.sampleData, + createdAt: .now + ) +} + +struct GroupProfile: Codable, NamedChat { + var displayName: String + var fullName: String + var image: String? + + static let sampleData = GroupProfile( + displayName: "team", + fullName: "My Team" + ) +} + +struct GroupMember: Decodable { + var groupMemberId: Int64 + var memberId: String +// var memberRole: GroupMemberRole +// var memberCategory: GroupMemberCategory +// var memberStatus: GroupMemberStatus +// var invitedBy: InvitedBy + var localDisplayName: ContactName + var memberProfile: Profile + var memberContactId: Int64? +// var activeConn: Connection? + + var directChatId: ChatId? { + get { + if let chatId = memberContactId { + return "@\(chatId)" + } else { + return nil + } + } + } + + static let sampleData = GroupMember( + groupMemberId: 1, + memberId: "abcd", + localDisplayName: "alice", + memberProfile: Profile.sampleData, + memberContactId: 1 + ) +} + +struct MemberSubError: Decodable { + var member: GroupMember + var memberError: ChatError +} + +struct AChatItem: Decodable { + var chatInfo: ChatInfo + var chatItem: ChatItem +} + +struct ChatItem: Identifiable, Decodable { + var chatDir: CIDirection + var meta: CIMeta + var content: CIContent + var formattedText: [FormattedText]? + var quotedItem: CIQuote? + var file: CIFile? + + var id: Int64 { get { meta.itemId } } + + var timestampText: Text { get { meta.timestampText } } + + var text: String { + get { + switch (content.text, file) { + case let ("", .some(file)): return file.fileName + default: return content.text + } + } + } + + func isRcvNew() -> Bool { + if case .rcvNew = meta.itemStatus { return true } + return false + } + + func isMsgContent() -> Bool { + switch content { + case .sndMsgContent: return true + case .rcvMsgContent: return true + default: return false + } + } + + func isDeletedContent() -> Bool { + switch content { + case .sndDeleted: return true + case .rcvDeleted: return true + default: return false + } + } + + func isCall() -> Bool { + switch content { + case .sndCall: return true + case .rcvCall: return true + default: return false + } + } + + var memberDisplayName: String? { + get { + if case let .groupRcv(groupMember) = chatDir { + return groupMember.memberProfile.displayName + } else { + return nil + } + } + } + + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { + ChatItem( + chatDir: dir, + meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), + content: .sndMsgContent(msgContent: .text(text)), + quotedItem: quotedItem, + file: file + ) + } + + 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, + meta: CIMeta.getSample(id, ts, text, status, false, false, false), + content: .rcvDeleted(deleteMode: .cidmBroadcast), + quotedItem: nil, + file: nil + ) + } +} + +enum CIDirection: Decodable { + case directSnd + case directRcv + case groupSnd + case groupRcv(groupMember: GroupMember) + + var sent: Bool { + get { + switch self { + case .directSnd: return true + case .directRcv: return false + case .groupSnd: return true + case .groupRcv: return false + } + } + } +} + +struct CIMeta: Decodable { + var itemId: Int64 + var itemTs: Date + var itemText: String + var itemStatus: CIStatus + var createdAt: Date + var itemDeleted: Bool + var itemEdited: Bool + var editable: Bool + + var timestampText: Text { get { formatTimestampText(itemTs) } } + + static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { + CIMeta( + itemId: id, + itemTs: ts, + itemText: text, + itemStatus: status, + createdAt: ts, + itemDeleted: itemDeleted, + itemEdited: itemEdited, + editable: editable + ) + } +} + +let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() +let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) + +func formatTimestampText(_ date: Date) -> Text { + let now = Calendar.current.dateComponents([.day, .hour], from: .now) + let dc = Calendar.current.dateComponents([.day, .hour], from: date) + let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) + return Text(date, format: recent ? msgTimeFormat : msgDateFormat) +} + +enum CIStatus: Decodable { + case sndNew + case sndSent + case sndErrorAuth + case sndError(agentError: AgentErrorType) + case rcvNew + case rcvRead +} + +enum CIDeleteMode: String, Decodable { + case cidmBroadcast = "broadcast" + case cidmInternal = "internal" +} + +protocol ItemContent { + var text: String { get } +} + +enum CIContent: Decodable, ItemContent { + case sndMsgContent(msgContent: MsgContent) + case rcvMsgContent(msgContent: MsgContent) + case sndDeleted(deleteMode: CIDeleteMode) + case rcvDeleted(deleteMode: CIDeleteMode) + case sndCall(status: CICallStatus, duration: Int) + case rcvCall(status: CICallStatus, duration: Int) + + var text: String { + get { + switch self { + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case let .sndCall(status, duration): return status.text(duration) + case let .rcvCall(status, duration): return status.text(duration) + } + } + } + + var msgContent: MsgContent? { + get { + switch self { + case let .sndMsgContent(mc): return mc + case let .rcvMsgContent(mc): return mc + default: return nil + } + } + } +} + +struct CIQuote: Decodable, ItemContent { + var chatDir: CIDirection? + var itemId: Int64? + var sharedMsgId: String? = nil + var sentAt: Date + var content: MsgContent + var formattedText: [FormattedText]? + + var text: String { get { content.text } } + + func getSender(_ currentUser: User?) -> String? { + switch (chatDir) { + case .directSnd: return "you" + case .directRcv: return nil + case .groupSnd: return currentUser?.displayName + case let .groupRcv(member): return member.memberProfile.displayName + case nil: return nil + } + } + + static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { + let mc: MsgContent + if let image = image { + mc = .image(text: text, image: image) + } else { + mc = .text(text) + } + return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) + } +} + +struct CIFile: Decodable { + var fileId: Int64 + var fileName: String + var fileSize: Int64 + var filePath: String? + var fileStatus: CIFileStatus + + 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) + } + + var loaded: Bool { + get { + switch self.fileStatus { + case .sndStored: return true + case .sndTransfer: return true + case .sndComplete: return true + case .sndCancelled: return true + case .rcvInvitation: return false + case .rcvAccepted: return false + case .rcvTransfer: return false + case .rcvCancelled: return false + case .rcvComplete: return true + } + } + } +} + +enum CIFileStatus: String, Decodable { + case sndStored = "snd_stored" + case sndTransfer = "snd_transfer" + case sndComplete = "snd_complete" + case sndCancelled = "snd_cancelled" + case rcvInvitation = "rcv_invitation" + case rcvAccepted = "rcv_accepted" + case rcvTransfer = "rcv_transfer" + case rcvComplete = "rcv_complete" + case rcvCancelled = "rcv_cancelled" +} + +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) + + var text: String { + get { + switch self { + 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 + } + } + } + + var cmdString: String { + get { + switch self { + case let .text(text): return "text \(text)" + default: return "json \(encodeJSON(self))" + } + } + } + + func isFile() -> Bool { + switch self { + case .file: return true + default: return false + } + } + + enum CodingKeys: String, CodingKey { + case type + case text + case preview + case image + } +} + +extension MsgContent: Decodable { + init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "text": + let text = try container.decode(String.self, forKey: CodingKeys.text) + self = .text(text) + case "link": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) + self = .link(text: text, preview: preview) + case "image": + 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") + } + } catch { + self = .unknown(type: "unknown", text: "invalid message format") + } + } +} + +extension MsgContent: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .text(text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case let .link(text, preview): + try container.encode("link", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(preview, forKey: .preview) + case let .image(text, image): + try container.encode("image", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(image, forKey: .image) + case let .file(text): + try container.encode("file", forKey: .type) + try container.encode(text, forKey: .text) + // TODO use original JSON and type + case let .unknown(_, text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + } + } +} + +struct FormattedText: Decodable { + var text: String + var format: Format? +} + +enum Format: Decodable, Equatable { + case bold + case italic + case strikeThrough + case snippet + case secret + case colored(color: FormatColor) + case uri + case email + case phone +} + +enum FormatColor: String, Decodable { + case red = "red" + case green = "green" + case blue = "blue" + case yellow = "yellow" + case cyan = "cyan" + case magenta = "magenta" + case black = "black" + case white = "white" + + var uiColor: Color { + get { + switch (self) { + case .red: return .red + case .green: return .green + case .blue: return .blue + case .yellow: return .yellow + case .cyan: return .cyan + case .magenta: return .purple + case .black: return .primary + case .white: return .primary + } + } + } +} + +// Struct to use with simplex API +struct LinkPreview: Codable { + var uri: URL + var title: String + // TODO remove once optional in haskell + var description: String = "" + var image: String +} + +enum NtfTknStatus: String, Decodable { + case new = "NEW" + case registered = "REGISTERED" + case invalid = "INVALID" + case confirmed = "CONFIRMED" + case active = "ACTIVE" + case expired = "EXPIRED" +} + +struct SndFileTransfer: Decodable { + +} + +struct FileTransferMeta: Decodable { + +} + +enum CICallStatus: String, Decodable { + case pending + case missed + case rejected + case accepted + case negotiated + case progress + case ended + case error + + func text(_ sec: Int) -> String { + switch self { + case .pending: return NSLocalizedString("calling…", comment: "call status") + case .missed: return NSLocalizedString("missed", comment: "call status") + case .rejected: return NSLocalizedString("rejected", comment: "call status") + case .accepted: return NSLocalizedString("accepted", comment: "call status") + case .negotiated: return NSLocalizedString("connecting…", comment: "call status") + case .progress: return NSLocalizedString("in progress", comment: "call status") + case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended %02d:%02d", comment: "call status"), sec / 60, sec % 60) + case .error: return NSLocalizedString("error", comment: "call status") + } + } +} diff --git a/apps/ios/Shared/Model/Shared/GroupDefaults.swift b/apps/ios/Shared/Model/Shared/GroupDefaults.swift new file mode 100644 index 0000000000..ca5aaef410 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/GroupDefaults.swift @@ -0,0 +1,30 @@ +// +// GroupDefaults.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +func getGroupDefaults() -> UserDefaults? { + UserDefaults(suiteName: "5NN7GUYB6T.group.chat.simplex.app") +} + +func setAppState(_ phase: ScenePhase) { + if let defaults = getGroupDefaults() { + defaults.set(phase == .background, forKey: "appInBackground") + defaults.synchronize() + } +} + +func getAppState() -> ScenePhase { + if let defaults = getGroupDefaults() { + if defaults.bool(forKey: "appInBackground") { + return .background + } + } + return .active +} diff --git a/apps/ios/Shared/Model/JSON.swift b/apps/ios/Shared/Model/Shared/JSON.swift similarity index 100% rename from apps/ios/Shared/Model/JSON.swift rename to apps/ios/Shared/Model/Shared/JSON.swift diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift new file mode 100644 index 0000000000..725dbb8553 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -0,0 +1,96 @@ +// +// Notifications.swift +// SimpleX +// +// Created by Evgeny on 28/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UserNotifications + +let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" +let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" +let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" +let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION" +let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE" +// TODO remove +let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" + +let appNotificationId = "chat.simplex.app.notification" + +func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryContactRequest, + title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), + targetContentIdentifier: nil, + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + ) +} + +func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryContactConnected, + title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), + targetContentIdentifier: contact.id +// userInfo: ["chatId": contact.id, "contactId": contact.apiId] + ) +} + +func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryMessageReceived, + title: "\(cInfo.chatViewName):", + body: hideSecrets(cItem), + targetContentIdentifier: cInfo.id +// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] + ) +} + +func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryCallInvitation, + title: "\(contact.chatViewName):", + body: String.localizedStringWithFormat(NSLocalizedString("Incoming %@ call", comment: "notification body"), invitation.peerMedia.rawValue), + targetContentIdentifier: nil, + userInfo: ["chatId": contact.id] + ) +} + +func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, + targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.categoryIdentifier = categoryIdentifier + content.title = title + if let s = subtitle { content.subtitle = s } + if let s = body { content.body = s } + content.targetContentIdentifier = targetContentIdentifier + content.userInfo = userInfo + // TODO move logic of adding sound here, so it applies to background notifications too + content.sound = .default +// content.interruptionLevel = .active +// content.relevanceScore = 0.5 // 0-1 + return content +} + +func hideSecrets(_ cItem: ChatItem) -> String { + if cItem.content.text != "" { + if let md = cItem.formattedText { + var res = "" + for ft in md { + if case .secret = ft.format { + res = res + "..." + } else { + res = res + ft.text + } + } + return res + } else { + return cItem.content.text + } + } else { + return cItem.file?.fileName ?? "" + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e173f865cd..b6573f1bbb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -10,256 +10,9 @@ import Foundation import UIKit import Dispatch import BackgroundTasks +import SwiftUI private var chatController: chat_ctrl? -private let jsonDecoder = getJSONDecoder() -let jsonEncoder = getJSONEncoder() - -enum ChatCommand { - case showActiveUser - case createActiveUser(profile: Profile) - case startChat - case setFilesFolder(filesFolder: String) - case apiGetChats - case apiGetChat(type: ChatType, id: Int64) - case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) - case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) - case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) - case getUserSMPServers - case setUserSMPServers(smpServers: [String]) - case addContact - case connect(connReq: String) - case apiDeleteChat(type: ChatType, id: Int64) - case apiUpdateProfile(profile: Profile) - case apiParseMarkdown(text: String) - case createMyAddress - case deleteMyAddress - case showMyAddress - case apiAcceptContact(contactReqId: Int64) - case apiRejectContact(contactReqId: Int64) - case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) - case receiveFile(fileId: Int64) - case string(String) - - var cmdString: String { - get { - switch self { - case .showActiveUser: return "/u" - case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" - case .startChat: return "/_start" - case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" - case .apiGetChats: return "/_get chats" - case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" - case let .apiSendMessage(type, id, file, quotedItemId, mc): - switch (file, quotedItemId) { - case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" - case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)" - case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" - case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" - } - case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" - case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" - case .getUserSMPServers: return "/smp_servers" - case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" - case .addContact: return "/connect" - case let .connect(connReq): return "/connect \(connReq)" - case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" - case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" - case let .apiParseMarkdown(text): return "/_parse \(text)" - case .createMyAddress: return "/address" - case .deleteMyAddress: return "/delete_address" - case .showMyAddress: return "/show_address" - case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" - case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" - case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" - case let .receiveFile(fileId): return "/freceive \(fileId)" - case let .string(str): return str - } - } - } - - var cmdType: String { - get { - switch self { - case .showActiveUser: return "showActiveUser" - case .createActiveUser: return "createActiveUser" - case .startChat: return "startChat" - case .setFilesFolder: return "setFilesFolder" - case .apiGetChats: return "apiGetChats" - case .apiGetChat: return "apiGetChat" - case .apiSendMessage: return "apiSendMessage" - case .apiUpdateChatItem: return "apiUpdateChatItem" - case .apiDeleteChatItem: return "apiDeleteChatItem" - case .getUserSMPServers: return "getUserSMPServers" - case .setUserSMPServers: return "setUserSMPServers" - case .addContact: return "addContact" - case .connect: return "connect" - case .apiDeleteChat: return "apiDeleteChat" - case .apiUpdateProfile: return "apiUpdateProfile" - case .apiParseMarkdown: return "apiParseMarkdown" - case .createMyAddress: return "createMyAddress" - case .deleteMyAddress: return "deleteMyAddress" - case .showMyAddress: return "showMyAddress" - case .apiAcceptContact: return "apiAcceptContact" - case .apiRejectContact: return "apiRejectContact" - case .apiChatRead: return "apiChatRead" - case .receiveFile: return "receiveFile" - case .string: return "console command" - } - } - } - - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" - } - - func smpServersStr(smpServers: [String]) -> String { - smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") - } -} - -struct APIResponse: Decodable { - var resp: ChatResponse -} - -enum ChatResponse: Decodable, Error { - case response(type: String, json: String) - case activeUser(user: User) - case chatStarted - case chatRunning - case apiChats(chats: [ChatData]) - case apiChat(chat: ChatData) - case userSMPServers(smpServers: [String]) - case invitation(connReqInvitation: String) - case sentConfirmation - case sentInvitation - case contactAlreadyExists(contact: Contact) - case contactDeleted(contact: Contact) - case userProfileNoChange - case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case apiParsedMarkdown(formattedText: [FormattedText]?) - case userContactLink(connReqContact: String) - case userContactLinkCreated(connReqContact: String) - case userContactLinkDeleted - case contactConnected(contact: Contact) - case receivedContactRequest(contactRequest: UserContactRequest) - case acceptingContactRequest(contact: Contact) - case contactRequestRejected - case contactUpdated(toContact: Contact) - case contactSubscribed(contact: Contact) - case contactDisconnected(contact: Contact) - case contactSubError(contact: Contact, chatError: ChatError) - case contactSubSummary(contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(groupInfo: GroupInfo) - case memberSubErrors(memberSubErrors: [MemberSubError]) - case groupEmpty(groupInfo: GroupInfo) - case userContactLinkSubscribed - case newChatItem(chatItem: AChatItem) - case chatItemStatusUpdated(chatItem: AChatItem) - case chatItemUpdated(chatItem: AChatItem) - case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) - case rcvFileAccepted - case rcvFileComplete(chatItem: AChatItem) - case cmdOk - case chatCmdError(chatError: ChatError) - case chatError(chatError: ChatError) - - var responseType: String { - get { - switch self { - case let .response(type, _): return "* \(type)" - case .activeUser: return "activeUser" - case .chatStarted: return "chatStarted" - case .chatRunning: return "chatRunning" - case .apiChats: return "apiChats" - case .apiChat: return "apiChat" - case .userSMPServers: return "userSMPServers" - case .invitation: return "invitation" - case .sentConfirmation: return "sentConfirmation" - case .sentInvitation: return "sentInvitation" - case .contactAlreadyExists: return "contactAlreadyExists" - case .contactDeleted: return "contactDeleted" - case .userProfileNoChange: return "userProfileNoChange" - case .userProfileUpdated: return "userProfileUpdated" - case .apiParsedMarkdown: return "apiParsedMarkdown" - case .userContactLink: return "userContactLink" - case .userContactLinkCreated: return "userContactLinkCreated" - case .userContactLinkDeleted: return "userContactLinkDeleted" - case .contactConnected: return "contactConnected" - case .receivedContactRequest: return "receivedContactRequest" - case .acceptingContactRequest: return "acceptingContactRequest" - case .contactRequestRejected: return "contactRequestRejected" - case .contactUpdated: return "contactUpdated" - case .contactSubscribed: return "contactSubscribed" - case .contactDisconnected: return "contactDisconnected" - case .contactSubError: return "contactSubError" - case .contactSubSummary: return "contactSubSummary" - case .groupSubscribed: return "groupSubscribed" - case .memberSubErrors: return "memberSubErrors" - case .groupEmpty: return "groupEmpty" - case .userContactLinkSubscribed: return "userContactLinkSubscribed" - case .newChatItem: return "newChatItem" - case .chatItemStatusUpdated: return "chatItemStatusUpdated" - case .chatItemUpdated: return "chatItemUpdated" - case .chatItemDeleted: return "chatItemDeleted" - case .rcvFileAccepted: return "rcvFileAccepted" - case .rcvFileComplete: return "rcvFileComplete" - case .cmdOk: return "cmdOk" - case .chatCmdError: return "chatCmdError" - case .chatError: return "chatError" - } - } - } - - var details: String { - get { - switch self { - case let .response(_, json): return json - case let .activeUser(user): return String(describing: user) - case .chatStarted: return noDetails - case .chatRunning: return noDetails - case let .apiChats(chats): return String(describing: chats) - case let .apiChat(chat): return String(describing: chat) - case let .userSMPServers(smpServers): return String(describing: smpServers) - case let .invitation(connReqInvitation): return connReqInvitation - case .sentConfirmation: return noDetails - case .sentInvitation: return noDetails - case let .contactAlreadyExists(contact): return String(describing: contact) - case let .contactDeleted(contact): return String(describing: contact) - case .userProfileNoChange: return noDetails - case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) - case let .userContactLink(connReq): return connReq - case let .userContactLinkCreated(connReq): return connReq - case .userContactLinkDeleted: return noDetails - case let .contactConnected(contact): return String(describing: contact) - case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) - case let .acceptingContactRequest(contact): return String(describing: contact) - case .contactRequestRejected: return noDetails - case let .contactUpdated(toContact): return String(describing: toContact) - case let .contactSubscribed(contact): return String(describing: contact) - case let .contactDisconnected(contact): return String(describing: contact) - case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" - case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) - case let .groupSubscribed(groupInfo): return String(describing: groupInfo) - case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) - case let .groupEmpty(groupInfo): return String(describing: groupInfo) - case .userContactLinkSubscribed: return noDetails - case let .newChatItem(chatItem): return String(describing: chatItem) - 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 .rcvFileComplete(chatItem): return String(describing: chatItem) - case .cmdOk: return noDetails - case let .chatCmdError(chatError): return String(describing: chatError) - case let .chatError(chatError): return String(describing: chatError) - } - } - } - - private var noDetails: String { get { "\(responseType): no details" } } -} enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) @@ -293,11 +46,6 @@ enum TerminalItem: Identifiable { } } -private func _sendCmd(_ cmd: ChatCommand) -> ChatResponse { - var c = cmd.cmdString.cString(using: .utf8)! - return chatResponse(chat_send_cmd(getChatCtrl(), &c)) -} - private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true @@ -338,8 +86,8 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse { logger.debug("chatSendCmd \(cmd.cmdType)") let resp = bgTask - ? withBGTask(bgDelay: bgDelay) { _sendCmd(cmd) } - : _sendCmd(cmd) + ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) } + : sendSimpleXCmd(cmd) logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") @@ -385,10 +133,13 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { throw r } -func apiStartChat() throws { +func apiStartChat() throws -> Bool { let r = chatSendCmdSync(.startChat) - if case .chatStarted = r { return } - throw r + switch r { + case .chatStarted: return true + case .chatRunning: return false + default: throw r + } } func apiSetFilesFolder(filesFolder: String) throws { @@ -444,6 +195,24 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM throw r } +func apiRegisterToken(token: String) async throws -> NtfTknStatus { + let r = await chatSendCmd(.apiRegisterToken(token: token)) + if case let .ntfTokenStatus(status) = r { return status } + throw r +} + +func apiVerifyToken(token: String, code: String, nonce: String) async throws { + try await sendCommandOkResp(.apiVerifyToken(token: token, code: code, nonce: nonce)) +} + +func apiIntervalNofication(token: String, interval: Int) async throws { + try await sendCommandOkResp(.apiIntervalNofication(token: token, interval: interval)) +} + +func apiDeleteToken(token: String) async throws { + try await sendCommandOkResp(.apiDeleteToken(token: token)) +} + func getUserSMPServers() throws -> [String] { let r = chatSendCmdSync(.getUserSMPServers) if case let .userSMPServers(smpServers) = r { return smpServers } @@ -451,9 +220,7 @@ func getUserSMPServers() throws -> [String] { } func setUserSMPServers(smpServers: [String]) async throws { - let r = await chatSendCmd(.setUserSMPServers(smpServers: smpServers)) - if case .cmdOk = r { return } - throw r + try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers)) } func apiAddContact() throws -> String { @@ -462,43 +229,60 @@ func apiAddContact() throws -> String { throw r } -func apiConnect(connReq: String) async throws -> Bool { +func apiConnect(connReq: String) async throws -> ConnReqType? { let r = await chatSendCmd(.connect(connReq: connReq)) let am = AlertManager.shared switch r { - case .sentConfirmation: return true - case .sentInvitation: return true + case .sentConfirmation: return .invitation + case .sentInvitation: return .contact case let .contactAlreadyExists(contact): am.showAlertMsg( title: "Contact already exists", message: "You are already connected to \(contact.displayName) via this link." ) - return false + return nil case .chatCmdError(.error(.invalidConnReq)): am.showAlertMsg( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) - return false + return nil case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))): am.showAlertMsg( title: "Connection timeout", message: "Please check your network connection and try again." ) - return false + return nil case .chatCmdError(.errorAgent(.BROKER(.NETWORK))): am.showAlertMsg( title: "Connection error", message: "Please check your network connection and try again." ) - return false + return nil + case .chatCmdError(.errorAgent(.SMP(.AUTH))): + am.showAlertMsg( + title: "Connection error (AUTH)", + message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." + ) + return nil + case let .chatCmdError(.errorAgent(.INTERNAL(internalErr))): + if internalErr == "SEUniqueID" { + am.showAlertMsg( + title: "Already connected?", + message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))." + ) + return nil + } else { + throw r + } default: throw r } } func apiDeleteChat(type: ChatType, id: Int64) async throws { let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) - if case .contactDeleted = r { return } + if case .direct = type, case .contactDeleted = r { return } + if case .contactConnection = type, case .contactConnectionDeleted = r { return } throw r } @@ -553,14 +337,21 @@ func apiRejectContactRequest(contactReqId: Int64) async throws { } func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws { - let r = await chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange)) - if case .cmdOk = r { return } - throw r + 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 } @@ -570,7 +361,7 @@ func acceptContactRequest(_ contactRequest: UserContactRequest) async { let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) } } catch let error { - logger.error("acceptContactRequest error: \(error.localizedDescription)") + logger.error("acceptContactRequest error: \(responseError(error))") } } @@ -579,7 +370,43 @@ func rejectContactRequest(_ contactRequest: UserContactRequest) async { try await apiRejectContactRequest(contactReqId: contactRequest.apiId) DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) } } catch let error { - logger.error("rejectContactRequest: \(error.localizedDescription)") + logger.error("rejectContactRequest: \(responseError(error))") + } +} + +func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws { + try await sendCommandOkResp(.apiSendCallInvitation(contact: contact, callType: callType)) +} + +func apiRejectCall(_ contact: Contact) async throws { + try await sendCommandOkResp(.apiRejectCall(contact: contact)) +} + +func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String], media: CallMediaType, capabilities: CallCapabilities) async throws { + let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) + let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession) + try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer)) +} + +func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String]) async throws { + let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) + try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer)) +} + +func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: [String]) async throws { + let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates) + try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo)) +} + +func apiEndCall(_ contact: Contact) async throws { + try await sendCommandOkResp(.apiEndCall(contact: contact)) +} + +func apiCallStatus(_ contact: Contact, _ status: String) async throws { + if let callStatus = WebRTCCallStatus.init(rawValue: status) { + try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus)) + } else { + logger.debug("apiCallStatus: call status \(status) not used") } } @@ -591,27 +418,63 @@ func markChatRead(_ chat: Chat) async { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange) DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) } } catch { - logger.error("markChatRead apiChatRead error: \(error.localizedDescription)") + logger.error("markChatRead apiChatRead error: \(responseError(error))") } } -func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { +func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { do { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) } } catch { - logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)") + logger.error("markChatItemRead apiChatRead error: \(responseError(error))") } } +private func sendCommandOkResp(_ cmd: ChatCommand) async throws { + let r = await chatSendCmd(cmd) + if case .cmdOk = r { return } + throw r +} + func initializeChat() { + logger.debug("initializeChat") do { - ChatModel.shared.currentUser = try apiGetActiveUser() + let m = ChatModel.shared + m.currentUser = try apiGetActiveUser() + if m.currentUser == nil { + m.onboardingStage = .step1_SimpleXInfo + } else { + startChat() + } } catch { fatalError("Failed to initialize chat controller or database: \(error)") } } +func startChat() { + logger.debug("startChat") + do { + let m = ChatModel.shared + // TODO set file folder once, before chat is started + let justStarted = try apiStartChat() + if justStarted { + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + m.userAddress = try apiGetUserAddress() + m.userSMPServers = try getUserSMPServers() + m.chats = try apiGetChats() + withAnimation { + m.onboardingStage = m.chats.isEmpty + ? .step3_MakeConnection + : .onboardingComplete + } + } + ChatReceiver.shared.start() + } catch { + fatalError("Failed to start or load chats: \(error)") + } +} + class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true @@ -649,31 +512,38 @@ class ChatReceiver { } func processReceivedMsg(_ res: ChatResponse) { - let chatModel = ChatModel.shared + let m = ChatModel.shared DispatchQueue.main.async { - chatModel.terminalItems.append(.resp(.now, res)) + m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { + case let .newContactConnection(connection): + m.updateContactConnection(connection) + case let .contactConnectionDeleted(connection): + m.removeChat(connection.id) case let .contactConnected(contact): - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, .connected) + m.updateContact(contact) + m.removeChat(contact.activeConn.id) + m.updateNetworkStatus(contact.id, .connected) NtfManager.shared.notifyContactConnected(contact) + case let .contactConnecting(contact): + m.updateContact(contact) + m.removeChat(contact.activeConn.id) case let .receivedContactRequest(contactRequest): - chatModel.addChat(Chat( + m.addChat(Chat( chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), chatItems: [] )) NtfManager.shared.notifyContactRequest(contactRequest) case let .contactUpdated(toContact): let cInfo = ChatInfo.direct(contact: toContact) - if chatModel.hasChat(toContact.id) { - chatModel.updateChatInfo(cInfo) + if m.hasChat(toContact.id) { + m.updateChatInfo(cInfo) } - case let .contactSubscribed(contact): - processContactSubscribed(contact) - case let .contactDisconnected(contact): - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, .disconnected) + case let .contactsSubscribed(_, contactRefs): + updateContactsStatus(contactRefs, status: .connected) + case let .contactsDisconnected(_, contactRefs): + updateContactsStatus(contactRefs, status: .disconnected) case let .contactSubError(contact, chatError): processContactSubError(contact, chatError) case let .contactSubSummary(contactSubscriptions): @@ -681,35 +551,34 @@ func processReceivedMsg(_ res: ChatResponse) { if let err = sub.contactError { processContactSubError(sub.contact, err) } else { - processContactSubscribed(sub.contact) + m.updateContact(sub.contact) + m.updateNetworkStatus(sub.contact.id, .connected) } } case let .newChatItem(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - chatModel.addChatItem(cInfo, cItem) - if let file = cItem.file, - file.fileSize <= 236700 { - // file.fileSize <= 394500 { + m.addChatItem(cInfo, cItem) + 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) + if !cItem.isCall() { + NtfManager.shared.notifyMessageReceived(cInfo, cItem) + } case let .chatItemStatusUpdated(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem var res = false if !cItem.isDeletedContent() { - res = chatModel.upsertChatItem(cInfo, cItem) + res = m.upsertChatItem(cInfo, cItem) } if res { NtfManager.shared.notifyMessageReceived(cInfo, cItem) - } else if let endTask = chatModel.messageDelivery[cItem.id] { + } else if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { case .sndSent: endTask() case .sndErrorAuth: endTask() @@ -718,36 +587,87 @@ func processReceivedMsg(_ res: ChatResponse) { } } case let .chatItemUpdated(aChatItem): - let cInfo = aChatItem.chatInfo - let cItem = aChatItem.chatItem - if chatModel.upsertChatItem(cInfo, cItem) { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) - } + chatItemSimpleUpdate(aChatItem) case let .chatItemDeleted(_, toChatItem): let cInfo = toChatItem.chatInfo let cItem = toChatItem.chatItem if cItem.meta.itemDeleted { - chatModel.removeChatItem(cInfo, cItem) + m.removeChatItem(cInfo, cItem) } else { // currently only broadcast deletion of rcv message can be received, and only this case should happen - _ = chatModel.upsertChatItem(cInfo, cItem) + _ = m.upsertChatItem(cInfo, cItem) } + case let .rcvFileStart(aChatItem): + chatItemSimpleUpdate(aChatItem) case let .rcvFileComplete(aChatItem): - let cInfo = aChatItem.chatInfo + chatItemSimpleUpdate(aChatItem) + case let .sndFileStart(aChatItem, _): + chatItemSimpleUpdate(aChatItem) + case let .sndFileComplete(aChatItem, _): + chatItemSimpleUpdate(aChatItem) let cItem = aChatItem.chatItem - if chatModel.upsertChatItem(cInfo, cItem) { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + if aChatItem.chatInfo.chatType == .direct, + let mc = cItem.content.msgContent, + mc.isFile(), + let fileName = cItem.file?.filePath { + removeFile(fileName) + } + case let .callInvitation(contact, callType, sharedKey): + let invitation = CallInvitation(peerMedia: callType.media, sharedKey: sharedKey) + m.callInvitations[contact.id] = invitation + if (m.activeCallInvitation == nil) { + m.activeCallInvitation = ContactRef(contactId: contact.apiId, localDisplayName: contact.localDisplayName) + } + NtfManager.shared.notifyCallInvitation(contact, invitation) + case let .callOffer(contact, callType, offer, sharedKey, _): + // TODO askConfirmation? + // TODO check encryption is compatible + withCall(contact) { call in + m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey) + m.callCommand = .accept(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey) + } + case let .callAnswer(contact, answer): + withCall(contact) { call in + m.activeCall = call.copy(callState: .negotiated) + m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) + } + case let .callExtraInfo(contact, extraInfo): + withCall(contact) { _ in + m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) + } + case let .callEnded(contact): + m.activeCallInvitation = nil + withCall(contact) { _ in + m.callCommand = .end } default: logger.debug("unsupported event: \(res.responseType)") } + + func withCall(_ contact: Contact, _ perform: (Call) -> Void) { + if let call = m.activeCall, call.contact.apiId == contact.apiId { + perform(call) + } else { + logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") + } + } } } -func processContactSubscribed(_ contact: Contact) { +func chatItemSimpleUpdate(_ aChatItem: AChatItem) { let m = ChatModel.shared - m.updateContact(contact) - m.updateNetworkStatus(contact, .connected) + 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 { + m.updateNetworkStatus(c.id, status) + } } func processContactSubError(_ contact: Contact, _ chatError: ChatError) { @@ -759,222 +679,10 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.updateNetworkStatus(contact, .error(err)) + m.updateNetworkStatus(contact.id, .error(err)) } private struct UserResponse: Decodable { var user: User? var error: String? } - -private func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! -// TODO is there a way to do it without copying the data? e.g: -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - -// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? - - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let j1 = j["resp"] as? NSDictionary, j1.count == 1 { - type = j1.allKeys[0] as? String - } - json = prettyJSON(j) - } - free(cjson) - return ChatResponse.response(type: type ?? "invalid", json: json ?? s) -} - -func prettyJSON(_ obj: NSDictionary) -> String? { - if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) { - return String(decoding: d, as: UTF8.self) - } - return nil -} - -private func getChatCtrl() -> chat_ctrl { - if let controller = chatController { return controller } - let dataDir = getDocumentsDirectory().path + "/mobile_v1" - var cstr = dataDir.cString(using: .utf8)! - logger.debug("getChatCtrl: chat_init") - ChatModel.shared.terminalItems.append(.cmd(.now, .string("chat_init"))) - chatController = chat_init(&cstr) - ChatModel.shared.terminalItems.append(.resp(.now, .response(type: "chat_controller", json: "chat_controller: no details"))) - return chatController! -} - -private func decodeCJSON(_ cjson: UnsafePointer) -> T? { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - return try? jsonDecoder.decode(T.self, from: d) -} - -private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! - return try? JSONSerialization.jsonObject(with: d) as? NSDictionary -} - -func encodeJSON(_ value: T) -> String { - let data = try! jsonEncoder.encode(value) - return String(decoding: data, as: UTF8.self) -} - -private func encodeCJSON(_ value: T) -> [CChar] { - encodeJSON(value).cString(using: .utf8)! -} - -enum ChatError: Decodable { - case error(errorType: ChatErrorType) - case errorAgent(agentError: AgentErrorType) - case errorStore(storeError: StoreError) -} - -enum ChatErrorType: Decodable { - case noActiveUser - case activeUserExists - case chatNotStarted - case invalidConnReq - case invalidChatMessage(message: String) - case contactNotReady(contact: Contact) - case contactGroups(contact: Contact, groupNames: [GroupName]) - case groupUserRole - case groupContactRole(contactName: ContactName) - case groupDuplicateMember(contactName: ContactName) - case groupDuplicateMemberId - case groupNotJoined(groupInfo: GroupInfo) - case groupMemberNotActive - case groupMemberUserRemoved - case groupMemberNotFound(contactName: ContactName) - case groupMemberIntroNotFound(contactName: ContactName) - case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName) - case groupInternal(message: String) - case fileNotFound(message: String) - case fileAlreadyReceiving(message: String) - case fileAlreadyExists(filePath: String) - case fileRead(filePath: String, message: String) - case fileWrite(filePath: String, message: String) - case fileSend(fileId: Int64, agentError: String) - case fileRcvChunk(message: String) - case fileInternal(message: String) - case invalidQuote - case invalidChatItemUpdate - case invalidChatItemDelete - case agentVersion - case commandError(message: String) -} - -enum StoreError: Decodable { - case duplicateName - case contactNotFound(contactId: Int64) - case contactNotFoundByName(contactName: ContactName) - case contactNotReady(contactName: ContactName) - case duplicateContactLink - case userContactLinkNotFound - case contactRequestNotFound(contactRequestId: Int64) - case contactRequestNotFoundByName(contactName: ContactName) - case groupNotFound(groupId: Int64) - case groupNotFoundByName(groupName: GroupName) - case groupWithoutUser - case duplicateGroupMember - case groupAlreadyJoined - case groupInvitationNotFound - case sndFileNotFound(fileId: Int64) - case sndFileInvalid(fileId: Int64) - case rcvFileNotFound(fileId: Int64) - case fileNotFound(fileId: Int64) - case rcvFileInvalid(fileId: Int64) - case connectionNotFound(agentConnId: String) - case introNotFound - case uniqueID - case internalError(message: String) - case noMsgDelivery(connId: Int64, agentMsgId: String) - case badChatItem(itemId: Int64) - case chatItemNotFound(itemId: Int64) - case quotedChatItemNotFound - case chatItemSharedMsgIdNotFound(sharedMsgId: String) -} - -enum AgentErrorType: Decodable { - case CMD(cmdErr: CommandErrorType) - case CONN(connErr: ConnectionErrorType) - case SMP(smpErr: SMPErrorType) - case BROKER(brokerErr: BrokerErrorType) - case AGENT(agentErr: SMPAgentError) - case INTERNAL(internalErr: String) -} - -enum CommandErrorType: Decodable { - case PROHIBITED - case SYNTAX - case NO_CONN - case SIZE - case LARGE -} - -enum ConnectionErrorType: Decodable { - case NOT_FOUND - case DUPLICATE - case SIMPLEX - case NOT_ACCEPTED - case NOT_AVAILABLE -} - -enum BrokerErrorType: Decodable { - case RESPONSE(smpErr: SMPErrorType) - case UNEXPECTED - case NETWORK - case TRANSPORT(transportErr: SMPTransportError) - case TIMEOUT -} - -enum SMPErrorType: Decodable { - case BLOCK - case SESSION - case CMD(cmdErr: SMPCommandError) - case AUTH - case QUOTA - case NO_MSG - case LARGE_MSG - case INTERNAL -} - -enum SMPCommandError: Decodable { - case UNKNOWN - case SYNTAX - case NO_AUTH - case HAS_AUTH - case NO_QUEUE -} - -enum SMPTransportError: Decodable { - case badBlock - case largeMsg - case badSession - case handshake(handshakeErr: SMPHandshakeError) -} - -enum SMPHandshakeError: Decodable { - case PARSE - case VERSION - case IDENTITY -} - -enum SMPAgentError: Decodable { - case A_MESSAGE - case A_PROHIBITED - case A_VERSION - case A_ENCRYPTION -} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 392857d7be..8dac273ab8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -12,6 +12,7 @@ let logger = Logger() @main struct SimpleXApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @Environment(\.scenePhase) var scenePhase @@ -33,6 +34,8 @@ struct SimpleXApp: App { initializeChat() } .onChange(of: scenePhase) { phase in + logger.debug("scenePhase \(String(describing: scenePhase))") + setAppState(phase) if phase == .background { BGManager.shared.schedule() } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift new file mode 100644 index 0000000000..33628e685f --- /dev/null +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -0,0 +1,188 @@ +// +// ActiveCallView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 05/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ActiveCallView: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.dismiss) private var dismiss + @Binding var showCallView: Bool + @State private var coordinator: WebRTCCoordinator? = nil + @State private var webViewReady: Bool = false + @State private var webViewMsg: WVAPIMessage? = nil + + var body: some View { + ZStack(alignment: .bottom) { + WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg) + .onAppear() { sendCommandToWebView() } + .onChange(of: chatModel.callCommand) { _ in sendCommandToWebView() } + .onChange(of: webViewReady) { _ in sendCommandToWebView() } + .onChange(of: webViewMsg) { _ in processWebViewMessage() } + .background(.black) + ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() }) + } + } + + private func sendCommandToWebView() { + if chatModel.activeCall != nil && webViewReady, + let cmd = chatModel.callCommand, + let c = coordinator { + chatModel.callCommand = nil + logger.debug("ActiveCallView: command \(cmd.cmdType)") + c.sendCommand(command: cmd) + } + } + + private func processWebViewMessage() { + let m = chatModel + if let msg = webViewMsg, + let call = chatModel.activeCall { + logger.debug("ActiveCallView: response \(msg.resp.respType)") + Task { + switch msg.resp { + case let .capabilities(capabilities): + let callType = CallType(media: call.localMedia, capabilities: capabilities) + try await apiSendCallInvitation(call.contact, callType) + m.activeCall = call.copy(callState: .invitationSent, localCapabilities: capabilities) + case let .offer(offer, iceCandidates, capabilities): + try await apiSendCallOffer(call.contact, offer, iceCandidates, + media: call.localMedia, capabilities: capabilities) + m.activeCall = call.copy(callState: .offerSent, localCapabilities: capabilities) + case let .answer(answer, iceCandidates): + try await apiSendCallAnswer(call.contact, answer, iceCandidates) + m.activeCall = call.copy(callState: .negotiated) + case let .ice(iceCandidates): + try await apiSendCallExtraInfo(call.contact, iceCandidates) + case let .connection(state): + if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), + case .connected = callStatus { + m.activeCall = call.copy(callState: .connected) + } + try await apiCallStatus(call.contact, state.connectionState) + case .ended: + m.activeCall = nil + m.activeCallInvitation = nil + m.callCommand = nil + showCallView = false + case .ok: + switch msg.command { + case let .media(media, enable): + switch media { + case .video: m.activeCall = call.copy(videoEnabled: enable) + case .audio: m.activeCall = call.copy(audioEnabled: enable) + } + case .end: + m.activeCall = nil + m.activeCallInvitation = nil + m.callCommand = nil + showCallView = false + default: () + } + case let .error(message): + logger.debug("ActiveCallView: command error: \(message)") + case let .invalid(type): + logger.debug("ActiveCallView: invalid response: \(type)") + } + } + } + } +} + +struct ActiveCallOverlay: View { + @EnvironmentObject var chatModel: ChatModel + var call: Call? + var dismiss: () -> Void + + var body: some View { + VStack { + if let call = call { + switch call.localMedia { + case .video: + callInfoView(call, .leading) + .foregroundColor(.white) + .opacity(0.8) + .padding() + case .audio: + VStack { + ProfileImage(imageStr: call.contact.profile.image) + .scaledToFit() + .frame(width: 192, height: 192) + callInfoView(call, .center) + } + .foregroundColor(.white) + .opacity(0.8) + .padding() + .frame(maxHeight: .infinity) + } + Spacer() + ZStack(alignment: .bottom) { + VStack(alignment: .leading) { + if call.localMedia == .video { + callButton(call.videoEnabled ? "video.fill" : "video.slash", size: 48) { + chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled) + } + .foregroundColor(.white) + .opacity(0.85) + } + callButton(call.audioEnabled ? "mic.fill" : "mic.slash", size: 48) { + chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled) + } + .foregroundColor(.white) + .opacity(0.85) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + } + callButton("phone.down.fill", size: 60) { dismiss() } + .foregroundColor(.red) + } + .padding(.bottom, 60) + .padding(.horizontal, 48) + } + } + .frame(maxWidth: .infinity) + } + + private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View { + VStack { + Text(call.contact.chatViewName) + .lineLimit(1) + .font(.title) + .frame(maxWidth: .infinity, alignment: alignment) + let status = call.callState == .connected + ? call.encrypted + ? "end-to-end encrypted" + : "no end-to-end encryption" + : call.callState.text + Text(status) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: alignment) + } + } + + private func callButton(_ imageName: String, size: CGFloat, perform: @escaping () -> Void) -> some View { + Button { + perform() + } label: { + Image(systemName: imageName) + .resizable() + .scaledToFit() + .frame(maxWidth: size, maxHeight: size) + } + } +} + +struct ActiveCallOverlay_Previews: PreviewProvider { + static var previews: some View { + Group{ + ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .video), dismiss: {}) + .background(.black) + ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .audio), dismiss: {}) + .background(.black) + } + } +} diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift new file mode 100644 index 0000000000..ec3842bb07 --- /dev/null +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -0,0 +1,328 @@ +// +// WebRTC.swift +// SimpleX (iOS) +// +// Created by Evgeny on 03/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +class Call: Equatable { + static func == (lhs: Call, rhs: Call) -> Bool { + lhs.contact.apiId == rhs.contact.apiId + } + + var contact: Contact + var callState: CallState + var localMedia: CallMediaType + var localCapabilities: CallCapabilities? + var peerMedia: CallMediaType? + var sharedKey: String? + var audioEnabled: Bool + var videoEnabled: Bool + + init( + contact: Contact, + callState: CallState, + localMedia: CallMediaType, + localCapabilities: CallCapabilities? = nil, + peerMedia: CallMediaType? = nil, + sharedKey: String? = nil, + audioEnabled: Bool? = nil, + videoEnabled: Bool? = nil + ) { + self.contact = contact + self.callState = callState + self.localMedia = localMedia + self.localCapabilities = localCapabilities + self.peerMedia = peerMedia + self.sharedKey = sharedKey + self.audioEnabled = audioEnabled ?? true + self.videoEnabled = videoEnabled ?? (localMedia == .video) + } + + func copy( + contact: Contact? = nil, + callState: CallState? = nil, + localMedia: CallMediaType? = nil, + localCapabilities: CallCapabilities? = nil, + peerMedia: CallMediaType? = nil, + sharedKey: String? = nil, + audioEnabled: Bool? = nil, + videoEnabled: Bool? = nil + ) -> Call { + Call ( + contact: contact ?? self.contact, + callState: callState ?? self.callState, + localMedia: localMedia ?? self.localMedia, + localCapabilities: localCapabilities ?? self.localCapabilities, + peerMedia: peerMedia ?? self.peerMedia, + sharedKey: sharedKey ?? self.sharedKey, + audioEnabled: audioEnabled ?? self.audioEnabled, + videoEnabled: videoEnabled ?? self.videoEnabled + ) + } + + var encrypted: Bool { + (localCapabilities?.encryption ?? false) && sharedKey != nil + } +} + +enum CallState { + case waitCapabilities + case invitationSent + case invitationReceived + case offerSent + case offerReceived + case negotiated + case connected + + var text: LocalizedStringKey { + switch self { + case .waitCapabilities: return "starting…" + case .invitationSent: return "waiting for answer…" + case .invitationReceived: return "starting…" + case .offerSent: return "waiting for confirmation…" + case .offerReceived: return "received answer…" + case .negotiated: return "connecting…" + case .connected: return "connected" + } + } +} + +struct WVAPICall: Encodable { + var corrId: Int? = nil + var command: WCallCommand +} + +struct WVAPIMessage: Equatable, Decodable { + var corrId: Int? + var resp: WCallResponse + var command: WCallCommand? +} + +enum WCallCommand: Equatable, Encodable, Decodable { + case capabilities + case start(media: CallMediaType, aesKey: String? = nil) + case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) + case answer(answer: String, iceCandidates: [String]) + case ice(iceCandidates: [String]) + case media(media: CallMediaType, enable: Bool) + case end + + enum CodingKeys: String, CodingKey { + case type + case media + case aesKey + case offer + case answer + case iceCandidates + case enable + } + + var cmdType: String { + get { + switch self { + case .capabilities: return "capabilities" + case .start: return "start" + case .accept: return "accept" + case .answer: return "answer" + case .ice: return "ice" + case .media: return "media" + case .end: return "end" + } + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .capabilities: + try container.encode("capabilities", forKey: .type) + case let .start(media, aesKey): + try container.encode("start", forKey: .type) + try container.encode(media, forKey: .media) + try container.encode(aesKey, forKey: .aesKey) + case let .accept(offer, iceCandidates, media, aesKey): + try container.encode("accept", forKey: .type) + try container.encode(offer, forKey: .offer) + try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encode(media, forKey: .media) + try container.encode(aesKey, forKey: .aesKey) + case let .answer(answer, iceCandidates): + try container.encode("answer", forKey: .type) + try container.encode(answer, forKey: .answer) + try container.encode(iceCandidates, forKey: .iceCandidates) + case let .ice(iceCandidates): + try container.encode("ice", forKey: .type) + try container.encode(iceCandidates, forKey: .iceCandidates) + case let .media(media, enable): + try container.encode("media", forKey: .type) + try container.encode(media, forKey: .media) + try container.encode(enable, forKey: .enable) + case .end: + try container.encode("end", forKey: .type) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "capabilities": + self = .capabilities + case "start": + let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) + let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey) + self = .start(media: media, aesKey: aesKey) + case "accept": + let offer = try container.decode(String.self, forKey: CodingKeys.offer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) + let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey) + self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey) + case "answer": + let answer = try container.decode(String.self, forKey: CodingKeys.answer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .answer(answer: answer, iceCandidates: iceCandidates) + case "ice": + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .ice(iceCandidates: iceCandidates) + case "media": + let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) + let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) + self = .media(media: media, enable: enable) + case "end": + self = .end + default: + throw DecodingError.typeMismatch(WCallCommand.self, DecodingError.Context(codingPath: [CodingKeys.type], debugDescription: "cannot decode WCallCommand, unknown type \(type)")) + } + } +} + + +enum WCallResponse: Equatable, Decodable { + case capabilities(capabilities: CallCapabilities) + case offer(offer: String, iceCandidates: [String], capabilities: CallCapabilities) + // TODO remove accept, it is needed for debugging +// case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) + case answer(answer: String, iceCandidates: [String]) + case ice(iceCandidates: [String]) + case connection(state: ConnectionState) + case ended + case ok + case error(message: String) + case invalid(type: String) + + enum CodingKeys: String, CodingKey { + case type + case capabilities + case offer + case answer + case iceCandidates + case state + case message + // TODO remove media, aesKey + case media + case aesKey + } + + var respType: String { + get { + switch self { + case .capabilities: return("capabilities") + case .offer: return("offer") +// case .accept: return("accept") + case .answer: return("answer (TODO remove)") + case .ice: return("ice") + case .connection: return("connection") + case .ended: return("ended") + case .ok: return("ok") + case .error: return("error") + case .invalid: return("invalid") + } + } + } + + init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "capabilities": + let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities) + self = .capabilities(capabilities: capabilities) + case "offer": + let offer = try container.decode(String.self, forKey: CodingKeys.offer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities) + self = .offer(offer: offer, iceCandidates: iceCandidates, capabilities: capabilities) + case "answer": + let answer = try container.decode(String.self, forKey: CodingKeys.answer) + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .answer(answer: answer, iceCandidates: iceCandidates) + case "ice": + let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + self = .ice(iceCandidates: iceCandidates) + case "connection": + let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state) + self = .connection(state: state) + case "ended": + self = .ended + case "ok": + self = .ok + case "error": + let message = try container.decode(String.self, forKey: CodingKeys.message) + self = .error(message: message) + default: + self = .invalid(type: type) + } + } catch { + self = .invalid(type: "unknown") + } + } +} + +// This protocol is for debugging +//extension WCallResponse: Encodable { +// func encode(to encoder: Encoder) throws { +// var container = encoder.container(keyedBy: CodingKeys.self) +// switch self { +// case .capabilities: +// try container.encode("capabilities", forKey: .type) +// case let .offer(offer, iceCandidates, capabilities): +// try container.encode("offer", forKey: .type) +// try container.encode(offer, forKey: .offer) +// try container.encode(iceCandidates, forKey: .iceCandidates) +// try container.encode(capabilities, forKey: .capabilities) +// case let .answer(answer, iceCandidates): +// try container.encode("answer", forKey: .type) +// try container.encode(answer, forKey: .answer) +// try container.encode(iceCandidates, forKey: .iceCandidates) +// case let .ice(iceCandidates): +// try container.encode("ice", forKey: .type) +// try container.encode(iceCandidates, forKey: .iceCandidates) +// case let .connection(state): +// try container.encode("connection", forKey: .type) +// try container.encode(state, forKey: .state) +// case .ended: +// try container.encode("ended", forKey: .type) +// case .ok: +// try container.encode("ok", forKey: .type) +// case let .error(message): +// try container.encode("error", forKey: .type) +// try container.encode(message, forKey: .message) +// case let .invalid(type): +// try container.encode(type, forKey: .type) +// } +// } +//} + +struct ConnectionState: Codable, Equatable { + var connectionState: String + var iceConnectionState: String + var iceGatheringState: String + var signalingState: String +} diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift new file mode 100644 index 0000000000..1dad35e67c --- /dev/null +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -0,0 +1,166 @@ +// +// WebRTCView.swift +// SimpleX (iOS) +// +// Created by Ian Davies on 29/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import WebKit + +class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var webViewReady: Binding + var webViewMsg: Binding + private var webView: WKWebView? + + internal init(webViewReady: Binding, webViewMsg: Binding) { + self.webViewReady = webViewReady + self.webViewMsg = webViewMsg + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.allowsBackForwardNavigationGestures = false + self.webView = webView + webViewReady.wrappedValue = true + } + + // receive message from WKWebView + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + logger.debug("WebRTCCoordinator.userContentController") + if let msgStr = message.body as? String, + let msg: WVAPIMessage = decodeJSON(msgStr) { + webViewMsg.wrappedValue = msg + } else { + logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") + } + } + + func sendCommand(command: WCallCommand) { + if let webView = webView { + logger.debug("WebRTCCoordinator.sendCommand") + let apiCmd = encodeJSON(WVAPICall(command: command)) + let js = "processCommand(\(apiCmd))" + webView.evaluateJavaScript(js) + } + } +} + +struct WebRTCView: UIViewRepresentable { + @Binding var coordinator: WebRTCCoordinator? + @Binding var webViewReady: Bool + @Binding var webViewMsg: WVAPIMessage? + + func makeCoordinator() -> WebRTCCoordinator { + WebRTCCoordinator(webViewReady: $webViewReady, webViewMsg: $webViewMsg) + } + + func makeUIView(context: Context) -> WKWebView { + let wkCoordinator = makeCoordinator() + DispatchQueue.main.async { coordinator = wkCoordinator } + + let wkController = WKUserContentController() + + let cfg = WKWebViewConfiguration() + cfg.userContentController = wkController + cfg.mediaTypesRequiringUserActionForPlayback = [] + cfg.allowsInlineMediaPlayback = true + + let source = "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))" + let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false) + wkController.addUserScript(script) + wkController.add(wkCoordinator, name: "webrtc") + + let wkWebView = WKWebView(frame: .zero, configuration: cfg) + wkWebView.navigationDelegate = wkCoordinator + guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else { + logger.error("WebRTCView.makeUIView call.html not found") + return wkWebView + } + let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false) + wkWebView.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl) + return wkWebView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + logger.debug("WebRTCView.updateUIView") + } +} + +//struct CallViewDebug: View { +// @State private var coordinator: WebRTCCoordinator? = nil +// @State private var commandStr = "" +// @State private var webViewReady: Bool = false +// @State private var webViewMsg: WVAPIMessage? = nil +// @FocusState private var keyboardVisible: Bool +// +// var body: some View { +// VStack(spacing: 30) { +// WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260) +// .onChange(of: webViewMsg) { _ in +// if let resp = webViewMsg { +// commandStr = encodeJSON(resp) +// } +// } +// TextEditor(text: $commandStr) +// .focused($keyboardVisible) +// .disableAutocorrection(true) +// .textInputAutocapitalization(.never) +// .padding(.horizontal, 5) +// .padding(.top, 2) +// .frame(height: 112) +// .overlay( +// RoundedRectangle(cornerRadius: 10) +// .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) +// ) +// HStack(spacing: 20) { +// Button("Copy") { +// UIPasteboard.general.string = commandStr +// } +// Button("Paste") { +// commandStr = UIPasteboard.general.string ?? "" +// } +// Button("Clear") { +// commandStr = "" +// } +// Button("Send") { +// if let c = coordinator, +// let command: WCallCommand = decodeJSON(commandStr) { +// c.sendCommand(command: command) +// } +// } +// } +// HStack(spacing: 20) { +// Button("Capabilities") { +// +// } +// Button("Start") { +// if let c = coordinator { +// c.sendCommand(command: .start(media: .video)) +// } +// } +// Button("Accept") { +// +// } +// Button("Answer") { +// +// } +// Button("ICE") { +// +// } +// Button("End") { +// +// } +// } +// } +// } +//} +// +//struct CallViewDebug_Previews: PreviewProvider { +// static var previews: some View { +// CallViewDebug() +// } +//} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 21670879dc..80aa7dd830 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -27,12 +27,7 @@ struct FramedItemView: View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { if let qi = chatItem.quotedItem { - if let imgWidth = imgWidth, imgWidth < maxWidth { - ciQuoteView(qi) - .frame(maxWidth: imgWidth, alignment: .leading) - } else { - ciQuoteView(qi) - } + ciQuoteView(qi) } if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) { @@ -58,12 +53,13 @@ struct FramedItemView: View { value: .white ) } else { - let v = ciMsgContentView (chatItem, showMember) - if let imgWidth = imgWidth, imgWidth < maxWidth { - v.frame(maxWidth: imgWidth, alignment: .leading) - } else { - v - } + ciMsgContentView (chatItem, showMember) + } + case let .file(text): + CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) + .overlay(DetermineWidth()) + if text != "" { + ciMsgContentView (chatItem, showMember) } case let .link(_, preview): CILinkView(linkPreview: preview) @@ -100,8 +96,8 @@ struct FramedItemView: View { ) } - private func ciQuoteView(_ qi: CIQuote) -> some View { - ZStack(alignment: .topTrailing) { + @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View { + let v = ZStack(alignment: .topTrailing) { if case let .image(_, image) = qi.content, let data = Data(base64Encoded: dropImagePrefix(image)), let uiImage = UIImage(data: data) { @@ -112,6 +108,16 @@ struct FramedItemView: View { .aspectRatio(contentMode: .fill) .frame(width: 68, height: 68) .clipped() + } else if case .file = qi.content { + ciQuotedMsgView(qi) + .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) + Image(systemName: "doc.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .padding(.top, 6) + .padding(.trailing, 4) } else { ciQuotedMsgView(qi) } @@ -123,6 +129,33 @@ struct FramedItemView: View { ? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark) : Color(uiColor: .quaternarySystemFill) ) + + if let imgWidth = imgWidth, imgWidth < maxWidth { + v.frame(maxWidth: imgWidth, alignment: .leading) + } else { + v + } + } + + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View { + let v = MsgContentView( + content: ci.content, + formattedText: ci.formattedText, + sender: showMember ? ci.memberDisplayName : nil, + metaText: ci.timestampText, + edited: ci.meta.itemEdited + ) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .overlay(DetermineWidth()) + .frame(minWidth: 0, alignment: .leading) + .textSelection(.enabled) + + if let imgWidth = imgWidth, imgWidth < maxWidth { + v.frame(maxWidth: imgWidth, alignment: .leading) + } else { + v + } } } @@ -136,7 +169,7 @@ private struct MetaColorPreferenceKey: PreferenceKey { private func ciQuotedMsgView(_ qi: CIQuote) -> some View { MsgContentView( content: qi, - sender: qi.sender + sender: qi.getSender(ChatModel.shared.currentUser) ) .lineLimit(3) .font(.subheadline) @@ -144,21 +177,6 @@ private func ciQuotedMsgView(_ qi: CIQuote) -> some View { .padding(.horizontal, 12) } -private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View { - MsgContentView( - content: ci.content, - formattedText: ci.formattedText, - sender: showMember ? ci.memberDisplayName : nil, - metaText: ci.timestampText, - edited: ci.meta.itemEdited - ) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .overlay(DetermineWidth()) - .frame(minWidth: 0, alignment: .leading) - .textSelection(.enabled) -} - func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color { ci.chatDir.sent ? (colorScheme == .light ? sentColorLight : sentColorDark) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5cb4aad03e..6d549a92b8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -22,6 +22,8 @@ struct ChatItemView: View { } } else if chatItem.isDeletedContent() { DeletedItemView(chatItem: chatItem, showMember: showMember) + } else if chatItem.isCall() { + FramedItemView(chatItem: chatItem, showMember: showMember, maxWidth: maxWidth) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 00068617f6..f9c313863a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -14,19 +14,13 @@ struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme @ObservedObject var chat: Chat - @State var message: String = "" - @State var quotedItem: ChatItem? = nil - @State var editingItem: ChatItem? = nil - @State var linkPreview: LinkPreview? = nil - @State var deletingItem: ChatItem? = nil - @State private var inProgress: Bool = false + @Binding var showCallView: Bool + @State private var composeState = ComposeState() + @State private var deletingItem: ChatItem? = nil @FocusState private var keyboardVisible: Bool @State private var showChatInfo = false @State private var showDeleteMessage = false - @State private var chosenImage: UIImage? = nil - @State private var imagePreview: String? = nil - var body: some View { let cInfo = chat.chatInfo @@ -86,16 +80,9 @@ struct ChatView: View { Spacer(minLength: 0) ComposeView( - message: $message, - quotedItem: $quotedItem, - editingItem: $editingItem, - linkPreview: $linkPreview, - sendMessage: sendMessage, - resetMessage: { message = "" }, - inProgress: inProgress, - keyboardVisible: $keyboardVisible, - chosenImage: $chosenImage, - imagePreview: $imagePreview + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible ) } .navigationTitle(cInfo.chatViewName) @@ -119,10 +106,32 @@ struct ChatView: View { ChatInfoView(chat: chat, showChatInfo: $showChatInfo) } } +// ToolbarItem(placement: .navigationBarTrailing) { +// if case let .direct(contact) = cInfo { +// HStack { +// callButton(contact, .audio, imageName: "phone") +// callButton(contact, .video, imageName: "video") +// } +// } +// } } .navigationBarBackButtonHidden(true) } + private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { + Button { + chatModel.activeCall = Call( + contact: contact, + callState: .waitCapabilities, + localMedia: media + ) + showCallView = true + chatModel.callCommand = .capabilities + } label: { + Image(systemName: imageName) + } + } + private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading return ChatItemView(chatItem: ci, showMember: showMember, maxWidth: maxWidth) @@ -130,30 +139,39 @@ struct ChatView: View { if ci.isMsgContent() { Button { withAnimation { - editingItem = nil - quotedItem = ci + if composeState.editing() { + composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + } else { + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + } } } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } Button { var shareItems: [Any] = [ci.content.text] - if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) { + if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { shareItems.append(image) } showShareSheet(items: shareItems) } label: { Label("Share", systemImage: "square.and.arrow.up") } Button { - if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) { + if case let .image(text, _) = ci.content.msgContent, + text == "", + let image = getLoadedImage(ci.file) { UIPasteboard.general.image = image } else { UIPasteboard.general.string = ci.content.text } } label: { Label("Copy", systemImage: "doc.on.doc") } + if case .image = ci.content.msgContent, + let image = getLoadedImage(ci.file) { + Button { + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } label: { Label("Save", systemImage: "square.and.arrow.down") } + } if ci.meta.editable { Button { withAnimation { - quotedItem = nil - editingItem = ci - message = ci.content.text + composeState = ComposeState(editingItem: ci) } } label: { Label("Edit", systemImage: "square.and.pencil") } } @@ -171,7 +189,8 @@ struct ChatView: View { } if let di = deletingItem { if di.meta.editable { - Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast) + Button("Delete for everyone",role: .destructive) { + deleteMessage(.cidmBroadcast) } } } @@ -214,75 +233,6 @@ struct ChatView: View { } } } - - func sendMessage(_ text: String) { - logger.debug("ChatView sendMessage") - Task { - logger.debug("ChatView sendMessage: in Task") - do { - if let ei = editingItem { - let chatItem = try await apiUpdateChatItem( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - itemId: ei.id, - msg: .text(text) - ) - DispatchQueue.main.async { - editingItem = nil - linkPreview = nil - let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem) - } - } else { - let mc: MsgContent - var file: String? = nil - if let preview = imagePreview, - let uiImage = chosenImage, - let savedFile = saveImage(uiImage) { - mc = .image(text: text, image: preview) - file = savedFile - } else if let preview = linkPreview { - mc = .link(text: text, preview: preview) - } else { - mc = .text(text) - } - let chatItem = try await apiSendMessage( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - file: file, - quotedItemId: quotedItem?.meta.itemId, - msg: mc - ) - DispatchQueue.main.async { - quotedItem = nil - linkPreview = nil - chosenImage = nil - imagePreview = nil - chatModel.addChatItem(chat.chatInfo, chatItem) - } - } - } catch { - logger.error("ChatView.sendMessage error: \(error.localizedDescription)") - } - } - } - - func saveImage(_ uiImage: UIImage) -> String? { - if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000), - let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)), - let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) { - let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) - let fileToSave = "image_\(millisecondsSince1970).jpg" - let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave) - do { - try jpegData.write(to: filePath) - return fileToSave - } catch { - logger.error("ChatView.saveImage error: \(error.localizedDescription)") - return nil - } - } - return nil - } func deleteMessage(_ mode: CIDeleteMode) { logger.debug("ChatView deleteMessage") @@ -310,6 +260,7 @@ struct ChatView: View { struct ChatView_Previews: PreviewProvider { static var previews: some View { + @State var showCallView = false let chatModel = ChatModel() chatModel.chatId = "@1" chatModel.chatItems = [ @@ -323,7 +274,7 @@ struct ChatView_Previews: PreviewProvider { ChatItem.getSample(8, .directSnd, .now, "👍👍👍👍"), ChatItem.getSample(9, .directSnd, .now, "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.") ] - return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) + return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showCallView: $showCallView) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 62e9eec0dd..1561426845 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -8,43 +8,115 @@ import SwiftUI -// TODO -//enum ComposeState { -// case plain -// case quoted(quotedItem: ChatItem) -// case editing(editingItem: ChatItem) -//} +enum ComposePreview { + case noPreview + case linkPreview(linkPreview: LinkPreview) + case imagePreview(imagePreview: String) + case filePreview(fileName: String) +} -//enum ReferencedItem { -// case none -// case quoted(quotedItem: ChatItem) -// case editing(editingItem: ChatItem) -//} -// -//enum Preview { -// case none -// case link(linkPreview: LinkPreview) -// case image(image: UIImage) -//} -// -//struct ComposeState { -// var quotedItem: ChatItem? = nil -// var editingItem: ChatItem? = nil -// var linkPreview: LinkPreview? = nil -//} +enum ComposeContextItem { + case noContextItem + case quotedItem(chatItem: ChatItem) + case editingItem(chatItem: ChatItem) +} + +struct ComposeState { + var message: String + var preview: ComposePreview + var contextItem: ComposeContextItem + var inProgress: Bool = false + + init( + message: String = "", + preview: ComposePreview = .noPreview, + contextItem: ComposeContextItem = .noContextItem + ) { + self.message = message + self.preview = preview + self.contextItem = contextItem + } + + init(editingItem: ChatItem) { + self.message = editingItem.content.text + self.preview = chatItemPreview(chatItem: editingItem) + self.contextItem = .editingItem(chatItem: editingItem) + } + + func copy( + message: String? = nil, + preview: ComposePreview? = nil, + contextItem: ComposeContextItem? = nil + ) -> ComposeState { + ComposeState( + message: message ?? self.message, + preview: preview ?? self.preview, + contextItem: contextItem ?? self.contextItem + ) + } + + func editing() -> Bool { + switch contextItem { + case .editingItem: return true + default: return false + } + } + + func sendEnabled() -> Bool { + switch preview { + case .imagePreview: + return true + case .filePreview: + return true + default: + return !message.isEmpty + } + } + + func linkPreviewAllowed() -> Bool { + switch preview { + case .imagePreview: + return false + case .filePreview: + return false + default: + return true + } + } + + func linkPreview() -> LinkPreview? { + switch preview { + case let .linkPreview(linkPreview): + return linkPreview + default: + return nil + } + } +} + +func chatItemPreview(chatItem: ChatItem) -> ComposePreview { + let chatItemPreview: ComposePreview + switch chatItem.content.msgContent { + case .text: + chatItemPreview = .noPreview + case let .link(_, preview: preview): + chatItemPreview = .linkPreview(linkPreview: preview) + case let .image(_, image: image): + chatItemPreview = .imagePreview(imagePreview: image) + case .file: + chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "") + default: + chatItemPreview = .noPreview + } + return chatItemPreview +} struct ComposeView: View { - @Binding var message: String - @Binding var quotedItem: ChatItem? - @Binding var editingItem: ChatItem? - @Binding var linkPreview: LinkPreview? - - var sendMessage: (String) -> Void - var resetMessage: () -> Void - var inProgress: Bool = false + @EnvironmentObject var chatModel: ChatModel + let chat: Chat + @Binding var composeState: ComposeState @FocusState.Binding var keyboardVisible: Bool - @State var editing: Bool = false - @State var sendEnabled: Bool = false + @State var linkUrl: URL? = nil @State var prevLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil @@ -53,59 +125,49 @@ struct ComposeView: View { @State private var showChooseSource = false @State private var showImagePicker = false @State private var imageSource: ImageSource = .imageLibrary - @Binding var chosenImage: UIImage? - @Binding var imagePreview: String? + @State var chosenImage: UIImage? = nil + + @State private var showFileImporter = false + @State var chosenFile: URL? = nil var body: some View { VStack(spacing: 0) { - if let metadata = imagePreview { - ComposeImageView(image: metadata, cancelImage: nil) - } else if let metadata = linkPreview { - ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview) + contextItemView() + switch (composeState.editing(), composeState.preview) { + case (true, .filePreview): EmptyView() + default: previewView() } - if (quotedItem != nil) { - ContextItemView(contextItem: $quotedItem, editing: $editing) - } else if (editingItem != nil) { - ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage) - } - HStack{ -// Button { -// showChooseSource = true -// } label: { -// Image(systemName: "paperclip") -// .resizable() -// } -// .disabled(editingItem != nil) -// .frame(width: 25, height: 25) -// .padding(.vertical, 4) -// .padding(.leading, 12) + HStack (alignment: .bottom) { + Button { + showChooseSource = true + } label: { + Image(systemName: "paperclip") + .resizable() + } + .disabled(composeState.editing()) + .frame(width: 25, height: 25) + .padding(.bottom, 12) + .padding(.leading, 12) SendMessageView( - sendMessage: { text in - sendMessage(text) + composeState: $composeState, + sendMessage: { + sendMessage() resetLinkPreview() }, - inProgress: inProgress, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editing, - sendEnabled: $sendEnabled + keyboardVisible: $keyboardVisible ) - .padding(.horizontal, 12) -// // use this padding when attach button is uncommented -// .padding(.trailing, 12) + .padding(.trailing, 12) .background(.background) } } - .onChange(of: message) { _ in - if message.count > 0 { - showLinkPreview(message) - } else { - resetLinkPreview() + .onChange(of: composeState.message) { _ in + if composeState.linkPreviewAllowed() { + if composeState.message.count > 0 { + showLinkPreview(composeState.message) + } else { + resetLinkPreview() + } } - sendEnabled = (imagePreview != nil || !message.isEmpty) - } - .onChange(of: editingItem == nil) { _ in - editing = (editingItem != nil) } .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { @@ -116,6 +178,9 @@ struct ComposeView: View { imageSource = .imageLibrary showImagePicker = true } + Button("Choose file") { + showFileImporter = true + } } .sheet(isPresented: $showImagePicker) { switch imageSource { @@ -128,14 +193,177 @@ struct ComposeView: View { } } .onChange(of: chosenImage) { image in - if let image = image { - imagePreview = resizeImageToDataSize(image, maxDataSize: 12500) + if let image = image, + let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) { + composeState = composeState.copy(preview: .imagePreview(imagePreview: imagePreview)) } else { - imagePreview = nil + composeState = composeState.copy(preview: .noPreview) } } - .onChange(of: imagePreview) { _ in - sendEnabled = (imagePreview != nil || !message.isEmpty) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.data], + allowsMultipleSelection: false + ) { result in + if case .success = result { + do { + let fileURL: URL = try result.get().first! + var fileSize: Int? = nil + if fileURL.startAccessingSecurityScopedResource() { + let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) + fileSize = resourceValues.fileSize + } + fileURL.stopAccessingSecurityScopedResource() + if let fileSize = fileSize, + fileSize <= maxFileSize { + chosenFile = fileURL + composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent)) + } else { + let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Currently maximum supported file size is \(prettyMaxFileSize)." + ) + } + } catch { + logger.error("ComposeView fileImporter error \(error.localizedDescription)") + } + } + } + } + + @ViewBuilder func previewView() -> some View { + switch composeState.preview { + case .noPreview: + EmptyView() + case let .linkPreview(linkPreview: preview): + ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview) + case let .imagePreview(imagePreview: img): + ComposeImageView( + image: img, + cancelImage: { + composeState = composeState.copy(preview: .noPreview) + chosenImage = nil + }, + cancelEnabled: !composeState.editing()) + case let .filePreview(fileName: fileName): + ComposeFileView( + fileName: fileName, + cancelFile: { + composeState = composeState.copy(preview: .noPreview) + chosenFile = nil + }, + cancelEnabled: !composeState.editing()) + } + } + + @ViewBuilder private func contextItemView() -> some View { + switch composeState.contextItem { + case .noContextItem: + EmptyView() + case let .quotedItem(chatItem: quotedItem): + ContextItemView( + contextItem: quotedItem, + contextIcon: "arrowshape.turn.up.left", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } + ) + case let .editingItem(chatItem: editingItem): + ContextItemView( + contextItem: editingItem, + contextIcon: "pencil", + cancelContextItem: { clearState() } + ) + } + } + + private func sendMessage() { + logger.debug("ChatView sendMessage") + Task { + logger.debug("ChatView sendMessage: in Task") + do { + switch composeState.contextItem { + case let .editingItem(chatItem: ei): + if let oldMsgContent = ei.content.msgContent { + let chatItem = try await apiUpdateChatItem( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: ei.id, + msg: updateMsgContent(oldMsgContent) + ) + DispatchQueue.main.async { + let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem) + } + } + default: + var mc: MsgContent? = nil + var file: String? = nil + switch (composeState.preview) { + case .noPreview: + mc = .text(composeState.message) + case .linkPreview: + mc = checkLinkPreview() + case let .imagePreview(imagePreview: image): + if let uiImage = chosenImage, + let savedFile = saveImage(uiImage) { + mc = .image(text: composeState.message, image: image) + file = savedFile + } + case .filePreview: + if let fileURL = chosenFile, + let savedFile = saveFileFromURL(fileURL) { + mc = .file(composeState.message) + file = savedFile + } + } + + var quotedItemId: Int64? = nil + switch (composeState.contextItem) { + case let .quotedItem(chatItem: quotedItem): + quotedItemId = quotedItem.id + default: + quotedItemId = nil + } + if let mc = mc { + let chatItem = try await apiSendMessage( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + file: file, + quotedItemId: quotedItemId, + msg: mc + ) + chatModel.addChatItem(chat.chatInfo, chatItem) + } + } + clearState() + } catch { + clearState() + logger.error("ChatView.sendMessage error: \(error.localizedDescription)") + } + } + } + + private func clearState() { + composeState = ComposeState() + linkUrl = nil + prevLinkUrl = nil + pendingLinkUrl = nil + cancelledLinks = [] + chosenImage = nil + chosenFile = nil + } + + private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent { + switch msgContent { + case .text: + return checkLinkPreview() + case .link: + 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) } } @@ -143,7 +371,7 @@ struct ComposeView: View { prevLinkUrl = linkUrl linkUrl = parseMessage(s) if let url = linkUrl { - if url != linkPreview?.uri && url != pendingLinkUrl { + if url != composeState.linkPreview()?.uri && url != pendingLinkUrl { pendingLinkUrl = url if prevLinkUrl == url { loadLinkPreview(url) @@ -154,7 +382,7 @@ struct ComposeView: View { } } } else { - linkPreview = nil + composeState = composeState.copy(preview: .noPreview) } } @@ -162,7 +390,7 @@ struct ComposeView: View { do { let parsedMsg = try apiParseMarkdown(text: msg) let uri = parsedMsg?.first(where: { ft in - ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) + ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }) if let uri = uri { return URL(string: uri.text) } else { return nil } @@ -176,18 +404,19 @@ struct ComposeView: View { link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") } - private func cancelPreview() { - if let uri = linkPreview?.uri.absoluteString { + private func cancelLinkPreview() { + if let uri = composeState.linkPreview()?.uri.absoluteString { cancelledLinks.insert(uri) } - linkPreview = nil + composeState = composeState.copy(preview: .noPreview) } private func loadLinkPreview(_ url: URL) { if pendingLinkUrl == url { - getLinkPreview(url: url) { lp in - if pendingLinkUrl == url { - linkPreview = lp + getLinkPreview(url: url) { linkPreview in + if let linkPreview = linkPreview, + pendingLinkUrl == url { + composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) pendingLinkUrl = nil } } @@ -200,40 +429,38 @@ struct ComposeView: View { pendingLinkUrl = nil cancelledLinks = [] } + + private func checkLinkPreview() -> MsgContent { + switch (composeState.preview) { + case let .linkPreview(linkPreview: linkPreview): + if let url = parseMessage(composeState.message), + url == linkPreview.uri { + return .link(text: composeState.message, preview: linkPreview) + } else { + return .text(composeState.message) + } + default: + return .text(composeState.message) + } + } } struct ComposeView_Previews: PreviewProvider { static var previews: some View { - @State var message: String = "" + let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) + @State var composeState = ComposeState(message: "hello") @FocusState var keyboardVisible: Bool - @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") - @State var nilItem: ChatItem? = nil - @State var linkPreview: LinkPreview? = nil - @State var chosenImage: UIImage? = nil - @State var imagePreview: String? = nil return Group { ComposeView( - message: $message, - quotedItem: $item, - editingItem: $nilItem, - linkPreview: $linkPreview, - sendMessage: { print ($0) }, - resetMessage: {}, - keyboardVisible: $keyboardVisible, - chosenImage: $chosenImage, - imagePreview: $imagePreview + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible ) ComposeView( - message: $message, - quotedItem: $nilItem, - editingItem: $item, - linkPreview: $linkPreview, - sendMessage: { print ($0) }, - resetMessage: {}, - keyboardVisible: $keyboardVisible, - chosenImage: $chosenImage, - imagePreview: $imagePreview + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible ) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index d853e17026..e59d7bfeef 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -10,46 +10,46 @@ import SwiftUI struct ContextItemView: View { @Environment(\.colorScheme) var colorScheme - @Binding var contextItem: ChatItem? - @Binding var editing: Bool - var resetMessage: () -> Void = {} + let contextItem: ChatItem + let contextIcon: String + let cancelContextItem: () -> Void var body: some View { - if let cxtItem = contextItem { - HStack { - contextText(cxtItem).lineLimit(3) - Spacer() - Button { - withAnimation { - contextItem = nil - if editing { resetMessage() } - } - } label: { - Image(systemName: "multiply") + HStack { + Image(systemName: contextIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .foregroundColor(.secondary) + contextText(contextItem).lineLimit(3) + Spacer() + Button { + withAnimation { + cancelContextItem() } + } label: { + Image(systemName: "multiply") } - .padding(12) - .frame(maxWidth: .infinity) - .background(chatItemFrameColor(cxtItem, colorScheme)) - .padding(.top, 8) - } else { - EmptyView() } + .padding(12) + .frame(minHeight: 50) + .frame(maxWidth: .infinity) + .background(chatItemFrameColor(contextItem, colorScheme)) + .padding(.top, 8) } func contextText(_ cxtItem: ChatItem) -> some View { if let s = cxtItem.memberDisplayName { - return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.content.text)")) + return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.text)")) } else { - return Text(cxtItem.content.text) + return Text(cxtItem.text) } } } struct ContextItemView_Previews: PreviewProvider { static var previews: some View { - @State var contextItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") - @State var editing: Bool = false - return ContextItemView(contextItem: $contextItem, editing: $editing) + let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") + return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index bede2e2c03..22510808ca 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -9,13 +9,10 @@ import SwiftUI struct SendMessageView: View { - var sendMessage: (String) -> Void - var inProgress: Bool = false - @Binding var message: String + @Binding var composeState: ComposeState + var sendMessage: () -> Void @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool - @Binding var editing: Bool - @Binding var sendEnabled: Bool @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body var maxHeight: CGFloat = 360 @@ -25,15 +22,15 @@ struct SendMessageView: View { ZStack { HStack(alignment: .bottom) { ZStack(alignment: .leading) { - Text(message) + Text(composeState.message) + .lineLimit(10) .font(teFont) .foregroundColor(.clear) .padding(.horizontal, 10) .padding(.vertical, 8) .matchedGeometryEffect(id: "te", in: namespace) .background(GeometryReader(content: updateHeight)) - TextEditor(text: $message) - .onSubmit(submit) + TextEditor(text: $composeState.message) .focused($keyboardVisible) .font(teFont) .textInputAutocapitalization(.sentences) @@ -42,18 +39,18 @@ struct SendMessageView: View { .frame(height: teHeight) } - if (inProgress) { + if (composeState.inProgress) { ProgressView() .scaleEffect(1.4) .frame(width: 31, height: 31, alignment: .center) .padding([.bottom, .trailing], 3) } else { - Button(action: submit) { - Image(systemName: editing ? "checkmark.circle.fill" : "arrow.up.circle.fill") + Button(action: { sendMessage() }) { + Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill") .resizable() .foregroundColor(.accentColor) } - .disabled(!sendEnabled) + .disabled(!composeState.sendEnabled()) .frame(width: 29, height: 29) .padding([.bottom, .trailing], 4) } @@ -66,16 +63,11 @@ struct SendMessageView: View { .padding(.vertical, 8) } - func submit() { - sendMessage(message) - message = "" - } - func updateHeight(_ g: GeometryProxy) -> Color { DispatchQueue.main.async { teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) - teFont = isShortEmoji(message) - ? message.count < 4 + teFont = isShortEmoji(composeState.message) + ? composeState.message.count < 4 ? largeEmojiFont : mediumEmojiFont : .body @@ -86,34 +78,29 @@ struct SendMessageView: View { struct SendMessageView_Previews: PreviewProvider { static var previews: some View { - @State var message: String = "" + @State var composeStateNew = ComposeState() + let ci = ChatItem.getSample(1, .directSnd, .now, "hello") + @State var composeStateEditing = ComposeState(editingItem: ci) @FocusState var keyboardVisible: Bool - @State var editingOff: Bool = false - @State var editingOn: Bool = true @State var sendEnabled: Bool = true - @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") return Group { VStack { Text("") Spacer(minLength: 0) SendMessageView( - sendMessage: { print ($0) }, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editingOff, - sendEnabled: $sendEnabled + composeState: $composeStateNew, + sendMessage: {}, + keyboardVisible: $keyboardVisible ) } VStack { Text("") Spacer(minLength: 0) SendMessageView( - sendMessage: { print ($0) }, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editingOn, - sendEnabled: $sendEnabled + composeState: $composeStateEditing, + sendMessage: {}, + keyboardVisible: $keyboardVisible ) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 5f83f22d95..7c02bd64e0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -28,30 +28,19 @@ struct ChatHelp: View { } VStack(alignment: .leading, spacing: 10) { - Text("To start a new chat") + Text("To make a new connection") .font(.title2) .fontWeight(.bold) HStack(spacing: 8) { Text("Tap button ") NewChatButton() - Text("above, then:") + Text("above, then choose:") } - Text("**Add new contact**: to create your one-time QR Code for your contact.") - Text("**Scan QR code**: to connect to your contact who shows QR code to you.") - } - .padding(.top, 24) - - VStack(alignment: .leading, spacing: 10) { - Text("To connect via link") - .font(.title2) - .fontWeight(.bold) - - Text("If you received SimpleX Chat invitation link you can open it in your browser:") - - Text("💻 desktop: scan displayed QR code from the app, via **Scan QR code**.") - Text("📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.") + Text("**Create link / QR code** for your contact to use.") + Text("**Paste received link** or open it in the browser and tap **Open in mobile app**.") + Text("**Scan QR code**: to connect to your contact in person or via video call.") } .padding(.top, 24) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index d25ffafe53..dd2a16cf91 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -11,6 +11,7 @@ import SwiftUI struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @State var chat: Chat + @Binding var showCallView: Bool @State private var showContactRequestDialog = false var body: some View { @@ -21,11 +22,13 @@ struct ChatListNavLink: View { groupNavLink(groupInfo) case let .contactRequest(cReq): contactRequestNavLink(cReq) + case let .contactConnection(cConn): + contactConnectionNavLink(cConn) } } private func chatView() -> some View { - ChatView(chat: chat) + ChatView(chat: chat, showCallView: $showCallView) .onAppear { do { let cInfo = chat.chatInfo @@ -38,8 +41,8 @@ struct ChatListNavLink: View { } } - private func contactNavLink(_ contact: Contact) -> some View { - NavLinkPlain( + @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { + let v = NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, destination: { chatView() }, @@ -53,12 +56,24 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing) { Button(role: .destructive) { - AlertManager.shared.showAlert(deleteContactAlert(contact)) + AlertManager.shared.showAlert( + contact.ready + ? deleteContactAlert(contact) + : deletePendingContactAlert(chat, contact) + ) } label: { Label("Delete", systemImage: "trash") } } .frame(height: 80) + + if contact.ready { + v + } else { + v.onTapGesture { + AlertManager.shared.showAlert(pendingContactAlert(chat, contact)) + } + } } private func groupNavLink(_ groupInfo: GroupInfo) -> some View { @@ -94,7 +109,7 @@ struct ChatListNavLink: View { } private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { - ContactRequestView(contactRequest: contactRequest) + ContactRequestView(contactRequest: contactRequest, chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { Task { await acceptContactRequest(contactRequest) } } label: { Label("Accept", systemImage: "checkmark") } @@ -113,6 +128,31 @@ struct ChatListNavLink: View { } } + private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { + ContactConnectionView(contactConnection: contactConnection) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection)) + } label: { + Label("Delete", systemImage: "trash") + } + } + .frame(height: 80) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: + contactConnection.initiated + ? "You invited your contact" + : "You accepted connection", + // below are the same messages that are shown in alert + message: + contactConnection.viaContactUri + ? "You will be connected when your connection request is accepted, please wait or check later!" + : "You will be connected when your contact's device is online, please wait or check later!" + ) + } + } + private func deleteContactAlert(_ contact: Contact) -> Alert { Alert( title: Text("Delete contact?"), @@ -125,7 +165,7 @@ struct ChatListNavLink: View { chatModel.removeChat(contact.id) } } catch let error { - logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") + logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(responseError(error))") } } }, @@ -150,24 +190,83 @@ struct ChatListNavLink: View { secondaryButton: .cancel() ) } + + private func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection) -> Alert { + Alert( + title: Text("Delete pending connection?"), + message: + contactConnection.initiated + ? Text("The contact you shared this link with will NOT be able to connect!") + : Text("The connection you accepted will be cancelled!"), + primaryButton: .destructive(Text("Delete")) { + Task { + do { + try await apiDeleteChat(type: .contactConnection, id: contactConnection.apiId) + DispatchQueue.main.async { + chatModel.removeChat(contactConnection.id) + } + } catch let error { + logger.error("ChatListNavLink.deleteContactConnectionAlert apiDeleteChat error: \(responseError(error))") + } + } + }, + secondaryButton: .cancel() + ) + } + + private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { + Alert( + title: Text("Contact is not connected yet!"), + message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete Contact")) { + removePendingContact(chat, contact) + } + ) + } + + private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { + Alert( + title: Text("Delete pending connection"), + message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."), + primaryButton: .destructive(Text("Delete")) { + removePendingContact(chat, contact) + }, + secondaryButton: .cancel() + ) + } + + private func removePendingContact(_ chat: Chat, _ contact: Contact) { + Task { + do { + try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) + DispatchQueue.main.async { + chatModel.removeChat(contact.id) + } + } catch let error { + logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))") + } + } + } } struct ChatListNavLink_Previews: PreviewProvider { static var previews: some View { @State var chatId: String? = "@1" + @State var showCallView = false return Group { ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] - )) + ), showCallView: $showCallView) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] - )) + ), showCallView: $showCallView) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.contactRequest, chatItems: [] - )) + ), showCallView: $showCallView) } .previewLayout(.fixed(width: 360, height: 80)) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index e220882494..6b0af5da76 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -13,19 +13,17 @@ struct ChatListView: View { // not really used in this view @State private var showSettings = false @State private var searchText = "" + @State private var showCallView = false + @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true var user: User var body: some View { let v = NavigationView { List { - if chatModel.chats.isEmpty { - ChatHelp(showSettings: $showSettings) - } else { - ForEach(filteredChats()) { chat in - ChatListNavLink(chat: chat) - .padding(.trailing, -16) - } + ForEach(filteredChats()) { chat in + ChatListNavLink(chat: chat, showCallView: $showCallView) + .padding(.trailing, -16) } } .onChange(of: chatModel.chatId) { _ in @@ -34,15 +32,15 @@ struct ChatListView: View { chatModel.popChat(chatId) } } - .onChange(of: chatModel.appOpenUrl) { _ in - if let url = chatModel.appOpenUrl { - chatModel.appOpenUrl = nil - AlertManager.shared.showAlert(connectViaUrlAlert(url)) - } + .onChange(of: chatModel.chats.isEmpty) { empty in + if !empty { return } + withAnimation { chatModel.onboardingStage = .step3_MakeConnection } } + .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } + .onAppear() { connectViaUrl() } .offset(x: -8) .listStyle(.plain) - .navigationTitle(chatModel.chats.isEmpty ? "Welcome \(user.displayName)!" : "Your chats") + .navigationTitle("Your chats") .navigationBarTitleDisplayMode(chatModel.chats.count > 8 ? .inline : .large) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -52,6 +50,29 @@ struct ChatListView: View { NewChatButton() } } + .fullScreenCover(isPresented: $showCallView) { + ActiveCallView(showCallView: $showCallView) + } + .onChange(of: showCallView) { _ in + if (showCallView) { return } + if let call = chatModel.activeCall { + Task { + do { + try await apiEndCall(call.contact) + } catch { + logger.error("ChatListView apiEndCall error: \(error.localizedDescription)") + } + } + } + chatModel.callCommand = .end + } + .onChange(of: chatModel.activeCallInvitation) { _ in + if let contactRef = chatModel.activeCallInvitation, + case let .direct(contact) = chatModel.getChat(contactRef.id)?.chatInfo, + let invitation = chatModel.callInvitations.removeValue(forKey: contactRef.id) { + answerCallAlert(contact, invitation) + } + } } .navigationViewStyle(.stack) @@ -64,43 +85,39 @@ struct ChatListView: View { private func filteredChats() -> [Chat] { let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - return s == "" + return s == "" && pendingConnections ? chatModel.chats - : chatModel.chats.filter { $0.chatInfo.chatViewName.localizedLowercase.contains(s) } + : s == "" + ? chatModel.chats.filter { + pendingConnections || $0.chatInfo.chatType != .contactConnection + } + : chatModel.chats.filter { + (pendingConnections || $0.chatInfo.chatType != .contactConnection) && + $0.chatInfo.chatViewName.localizedLowercase.contains(s) + } } - private func connectViaUrlAlert(_ url: URL) -> Alert { - var path = url.path - logger.debug("ChatListView.connectViaUrlAlert path: \(path)") - if (path == "/contact" || path == "/invitation") { - path.removeFirst() - let action: ConnReqType = path == "contact" ? .contact : .invitation - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - let title: LocalizedStringKey - if case .contact = action { title = "Connect via contact link?" } - else { title = "Connect via invitation link?" } - return Alert( - title: Text(title), - message: Text("Your profile will be sent to the contact that you received this link from"), - primaryButton: .default(Text("Connect")) { + private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) { + AlertManager.shared.showAlert(Alert( + title: Text("Incoming call"), + primaryButton: .default(Text("Answer")) { + if chatModel.activeCallInvitation == nil { DispatchQueue.main.async { - Task { - do { - let ok = try await apiConnect(connReq: link) - if ok { connectionReqSentAlert(action) } - } catch { - let err = error.localizedDescription - AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(err)") - logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)") - } - } + AlertManager.shared.showAlertMsg(title: "Call already ended!") } - }, - secondaryButton: .cancel() - ) - } else { - return Alert(title: Text("Error: URL is invalid")) - } + } else { + chatModel.activeCallInvitation = nil + chatModel.activeCall = Call( + contact: contact, + callState: .invitationReceived, + localMedia: invitation.peerMedia + ) + showCallView = true + chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey) + } + }, + secondaryButton: .cancel() + )) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5088d63557..fc61520166 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -17,19 +17,9 @@ struct ChatPreviewView: View { let cItem = chat.chatItems.last let unread = chat.chatStats.unreadCount return HStack(spacing: 8) { - ZStack(alignment: .bottomLeading) { - ChatInfoImage(chat: chat) - .frame(width: 63, height: 63) - if case .direct = chat.chatInfo, - chat.serverInfo.networkStatus == .connected { - Image(systemName: "circle.fill") - .resizable() - .foregroundColor(colorScheme == .dark ? darkGreen : .green) - .frame(width: 5, height: 5) - .padding([.bottom, .leading], 1) - } - } - .padding(.leading, 4) + ChatInfoImage(chat: chat) + .frame(width: 63, height: 63) + .padding(.leading, 4) VStack(spacing: 0) { HStack(alignment: .top) { @@ -39,7 +29,7 @@ struct ChatPreviewView: View { .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - (cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt)) + (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.createdAt)) .font(.subheadline) .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) @@ -49,10 +39,10 @@ struct ChatPreviewView: View { .padding(.top, 4) .padding(.horizontal, 8) - if let cItem = cItem { - ZStack(alignment: .topTrailing) { + ZStack(alignment: .topTrailing) { + if let cItem = cItem { (itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true)) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding(.leading, 8) .padding(.trailing, 36) .padding(.bottom, 4) @@ -65,15 +55,21 @@ struct ChatPreviewView: View { .background(Color.accentColor) .cornerRadius(10) } + } else if case let .direct(contact) = chat.chatInfo, !contact.ready { + Text("Connecting...") + .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + } + if case .direct = chat.chatInfo { + chatStatusImage() + .padding(.top, 24) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .trailing) } - .padding(.trailing, 8) - } - else if case let .direct(contact) = chat.chatInfo, !contact.ready { - Text("Connecting...") - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) - .padding([.leading, .trailing], 8) - .padding(.bottom, 4) } + + .padding(.trailing, 8) } } } @@ -91,6 +87,20 @@ struct ChatPreviewView: View { default: return Text("") } } + + @ViewBuilder private func chatStatusImage() -> some View { + switch chat.serverInfo.networkStatus { + case .connected: EmptyView() + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + .foregroundColor(.secondary) + default: + ProgressView() + } + } } struct ChatPreviewView_Previews: PreviewProvider { @@ -104,6 +114,17 @@ struct ChatPreviewView_Previews: PreviewProvider { chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)] )) + ChatPreviewView(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)], + chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) + )) + ChatPreviewView(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)], + chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0), + serverInfo: Chat.ServerInfo(networkStatus: .error("status")) + )) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. 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.")], diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift new file mode 100644 index 0000000000..5243188c6b --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -0,0 +1,55 @@ +// +// ContactConnectionView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ContactConnectionView: View { + var contactConnection: PendingContactConnection + + var body: some View { + HStack(spacing: 8) { + Image(systemName: contactConnection.initiated ? "link.badge.plus" : "link") + .resizable() + .foregroundColor(Color(uiColor: .secondarySystemBackground)) + .scaledToFill() + .frame(width: 48, height: 48) + .frame(width: 63, height: 63) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text(contactConnection.chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.secondary) + .padding(.leading, 8) + .padding(.top, 4) + .frame(maxHeight: .infinity, alignment: .topLeading) + Spacer() + formatTimestampText(contactConnection.updatedAt) + .font(.subheadline) + .padding(.trailing, 8) + .padding(.top, 4) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(.secondary) + } + Text(contactConnection.description) + .frame(minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + .padding(.top, 1) + } + } + } +} + +struct ContactConnectionView_Previews: PreviewProvider { + static var previews: some View { + ContactConnectionView(contactConnection: PendingContactConnection.getSampleData()) + .previewLayout(.fixed(width: 360, height: 80)) + } +} diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 2eedbdeb49..ac873fc8f9 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -10,17 +10,15 @@ import SwiftUI struct ContactRequestView: View { var contactRequest: UserContactRequest + @ObservedObject var chat: Chat var body: some View { return HStack(spacing: 8) { - Image(systemName: "person.crop.circle.fill") - .resizable() - .foregroundColor(Color(uiColor: .secondarySystemBackground)) + ChatInfoImage(chat: chat) .frame(width: 63, height: 63) - .padding(.leading, 4) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top) { - Text(ChatInfo.contactRequest(contactRequest: contactRequest).chatViewName) + Text(contactRequest.chatViewName) .font(.title3) .fontWeight(.bold) .foregroundColor(.blue) @@ -28,7 +26,7 @@ struct ContactRequestView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - timestampText(contactRequest.createdAt) + formatTimestampText(contactRequest.updatedAt) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) @@ -47,7 +45,7 @@ struct ContactRequestView: View { struct ContactRequestView_Previews: PreviewProvider { static var previews: some View { - ContactRequestView(contactRequest: UserContactRequest.sampleData) + ContactRequestView(contactRequest: UserContactRequest.sampleData, chat: Chat(chatInfo: ChatInfo.sampleData.contactRequest , chatItems: [])) .previewLayout(.fixed(width: 360, height: 80)) } } diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift new file mode 100644 index 0000000000..c192b0644f --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift @@ -0,0 +1,164 @@ +// +// 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 { + let prettyFileSize = ByteCountFormatter().string(fromByteCount: file.fileSize) + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(.primary) + Text(prettyFileSize + 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 { + let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) + } + 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 = getLoadedFilePath(file){ + let url = URL(fileURLWithPath: filePath) + showShareSheet(items: [url]) + } + default: break + } + } + } + + @ViewBuilder func fileIndicator() -> some View { + if let file = file { + switch file.fileStatus { + case .sndStored: fileIcon("doc.fill") + case .sndTransfer: ProgressView().frame(width: 30, height: 30) + case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10) + 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 .rcvComplete: fileIcon("doc.fill") + case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + } + } else { + fileIcon("doc.fill") + } + } + + func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), 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) + } + } + } +} + +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: .sndComplete) + ) + 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: 1_000_000_000, 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 13d3f6e1a9..c4af213e6e 100644 --- a/apps/ios/Shared/Views/Helpers/CIImageView.swift +++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift @@ -18,7 +18,7 @@ struct CIImageView: View { var body: some View { VStack(alignment: .center, spacing: 6) { - if let uiImage = getStoredImage(file) { + if let uiImage = getLoadedImage(file) { imageView(uiImage) .fullScreenCover(isPresented: $showFullScreenImage) { ZStack { @@ -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,46 @@ 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() + } + } + + @ViewBuilder private func loadingIndicator() -> some View { + if let file = file { + switch file.fileStatus { + case .sndTransfer: + ProgressView() + .progressViewStyle(.circular) + .frame(width: 20, height: 20) + .tint(.white) + .padding(8) + case .sndComplete: + Image(systemName: "checkmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 10, height: 10) + .foregroundColor(.white) + .padding(13) + case .rcvAccepted: + Image(systemName: "ellipsis") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .foregroundColor(.white) + .padding(11) + case .rcvTransfer: + ProgressView() + .progressViewStyle(.circular) + .frame(width: 20, height: 20) + .tint(.white) + .padding(8) + default: EmptyView() + } + } } } diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index c1b9abc2f4..c9a2bdf166 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -17,6 +17,7 @@ struct ChatInfoImage: View { switch chat.chatInfo { case .direct: iconName = "person.crop.circle.fill" case .group: iconName = "person.2.circle.fill" + case .contactRequest: iconName = "person.crop.circle.fill" default: iconName = "circle.fill" } return ProfileImage( diff --git a/apps/ios/Shared/Views/Helpers/ComposeFileView.swift b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift new file mode 100644 index 0000000000..bc6a96aa86 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift @@ -0,0 +1,40 @@ +// +// ComposeFileView.swift +// SimpleX (iOS) +// +// Created by JRoberts on 04.05.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ComposeFileView: View { + @Environment(\.colorScheme) var colorScheme + let fileName: String + let cancelFile: (() -> Void) + let cancelEnabled: Bool + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "doc.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .padding(.leading, 4) + Text(fileName) + Spacer() + if cancelEnabled { + Button { cancelFile() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 1) + .padding(.trailing, 12) + .frame(height: 50) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift index 2d083c9721..c23de72229 100644 --- a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift +++ b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift @@ -11,7 +11,8 @@ import SwiftUI struct ComposeImageView: View { @Environment(\.colorScheme) var colorScheme let image: String - var cancelImage: (() -> Void)? = nil + let cancelImage: (() -> Void) + let cancelEnabled: Bool var body: some View { HStack(alignment: .center, spacing: 8) { @@ -20,9 +21,10 @@ struct ComposeImageView: View { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: 80, maxHeight: 60) + .frame(maxWidth: 80, minHeight: 40, maxHeight: 60) } - if let cancelImage = cancelImage { + Spacer() + if cancelEnabled { Button { cancelImage() } label: { Image(systemName: "multiply") } @@ -35,3 +37,30 @@ struct ComposeImageView: View { .padding(.top, 8) } } + +//struct ComposeImageView: View { +// @Environment(\.colorScheme) var colorScheme +// let image: String +// let cancelImage: (() -> Void) +// +// var body: some View { +// if let data = Data(base64Encoded: dropImagePrefix(image)), +// let uiImage = UIImage(data: data) { +// HStack(alignment: .center) { +// ZStack(alignment: .topTrailing) { +// Image(uiImage: uiImage) +// .resizable() +// .scaledToFit() +// .cornerRadius(20) +// .frame(maxHeight: 150) +// Button { cancelImage() } label: { +// Image(systemName: "multiply") +// .foregroundColor(.white) +// } +// .padding(8) +// } +// } +// .padding(.top, 8) +// } +// } +//} diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift index 1a9c446499..6ab5e49980 100644 --- a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift @@ -25,7 +25,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)") } else { if let image = object as? UIImage, - let resized = resizeImageToDataSize(image, maxDataSize: 14000), + let resized = resizeImageToStrSize(image, maxDataSize: 14000), let title = metadata.title, let uri = metadata.originalURL { linkPreview = LinkPreview(uri: uri, title: title, image: resized) diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 7fa0bc722b..8c2b68b8bc 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -9,65 +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 resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> String? { - var img = image - var str = compressImage(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 = compressImage(img) - dataSize = str?.count ?? 0 - } - logger.debug("resizeImageToDataSize final \(dataSize)") - return str -} - -func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { - if let data = image.jpegData(compressionQuality: compressionQuality) { - return "data:image/jpg;base64,\(data.base64EncodedString())" - } - return nil -} - enum ImageSource { case imageLibrary case camera diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 9101e893f8..99f62e65c8 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -11,28 +11,26 @@ import CoreImage.CIFilterBuiltins struct AddContactView: View { var connReqInvitation: String - var body: some View { - VStack { - Text("Add contact") + VStack(alignment: .leading) { + Text("One-time invitation link") .font(.title) + .padding(.vertical) + Text("Your contact can scan it from the app") .padding(.bottom) - Text("Show QR code to your contact\nto scan from the app") - .font(.title2) - .multilineTextAlignment(.center) QRCode(uri: connReqInvitation) - .padding() - Text("If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.") - .font(.subheadline) - .multilineTextAlignment(.center) - .padding(.horizontal) + .padding(.bottom) + Text("If you can't meet in person, **show QR code in the video call**, or share the link.") + .padding(.bottom) Button { showShareSheet(items: [connReqInvitation]) } label: { Label("Share invitation link", systemImage: "square.and.arrow.up") } - .padding() + .frame(maxWidth: .infinity, alignment: .center) } + .padding() + .frame(maxHeight: .infinity, alignment: .top) } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 83f7fd85fe..ddd693c8f6 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -8,36 +8,41 @@ import SwiftUI +enum NewChatAction: Identifiable { + case createLink + case pasteLink + case scanQRCode + + var id: NewChatAction { get { self } } +} + struct NewChatButton: View { @State private var showAddChat = false - @State private var addContact = false - @State private var connReqInvitation: String = "" - @State private var connectContact = false - @State private var createGroup = false + @State private var connReq: String = "" + @State private var actionSheet: NewChatAction? var body: some View { Button { showAddChat = true } label: { Image(systemName: "person.crop.circle.badge.plus") } - .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) { - Button("Add contact") { addContactAction() } - Button("Scan QR code") { connectContact = true } - Button("Create group") { createGroup = true } - .disabled(true) + .confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) { + Button("Create link / QR code") { addContactAction() } + Button("Paste received link") { actionSheet = .pasteLink } + Button("Scan QR code") { actionSheet = .scanQRCode } + } + .sheet(item: $actionSheet) { sheet in + switch sheet { + case .createLink: AddContactView(connReqInvitation: connReq) + case .pasteLink: PasteToConnectView(openedSheet: $actionSheet) + case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet) + } } - .sheet(isPresented: $addContact, content: { - AddContactView(connReqInvitation: connReqInvitation) - }) - .sheet(isPresented: $connectContact, content: { - connectContactSheet() - }) - .sheet(isPresented: $createGroup, content: { CreateGroupView() }) } func addContactAction() { do { - connReqInvitation = try apiAddContact() - addContact = true + connReq = try apiAddContact() + actionSheet = .createLink } catch { DispatchQueue.global().async { connectionErrorAlert(error) @@ -45,28 +50,6 @@ struct NewChatButton: View { logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") } } - - func addContactSheet() -> some View { - AddContactView(connReqInvitation: connReqInvitation) - } - - func connectContactSheet() -> some View { - ConnectContactView(completed: { err in - connectContact = false - DispatchQueue.global().async { - switch (err) { - case let .success(ok): - if ok { connectionReqSentAlert(.invitation) } - case let .failure(error): - connectionErrorAlert(error) - } - } - }) - } - - func connectionErrorAlert(_ error: Error) { - AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)") - } } enum ConnReqType: Equatable { @@ -74,6 +57,30 @@ enum ConnReqType: Equatable { case invitation } +func connectViaLink(_ connectionLink: String, _ openedSheet: Binding? = nil) { + Task { + do { + let res = try await apiConnect(connReq: connectionLink) + DispatchQueue.main.async { + openedSheet?.wrappedValue = nil + if let connReqType = res { + connectionReqSentAlert(connReqType) + } + } + } catch { + logger.error("connectViaLink apiConnect error: \(responseError(error))") + DispatchQueue.main.async { + openedSheet?.wrappedValue = nil + connectionErrorAlert(error) + } + } + } +} + +func connectionErrorAlert(_ error: Error) { + AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(responseError(error))") +} + func connectionReqSentAlert(_ type: ConnReqType) { AlertManager.shared.showAlertMsg( title: "Connection request sent!", diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift new file mode 100644 index 0000000000..eadf249b45 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -0,0 +1,74 @@ +// +// PasteToConnectView.swift +// SimpleX (iOS) +// +// Created by Ian Davies on 22/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct PasteToConnectView: View { + @Binding var openedSheet: NewChatAction? + @State private var connectionLink: String = "" + + var body: some View { + VStack(alignment: .leading) { + Text("Connect via link") + .font(.title) + .padding(.vertical) + Text("Paste the link you received into the box below to connect with your contact.") + Text("Your profile will be sent to the contact that you received this link from") + .padding(.bottom) + TextEditor(text: $connectionLink) + .onSubmit(connect) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .allowsTightening(false) + .frame(height: 180) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) + ) + + HStack(spacing: 20) { + if connectionLink == "" { + Button { + connectionLink = UIPasteboard.general.string ?? "" + } label: { + Label("Paste", systemImage: "doc.plaintext") + } + } else { + Button { + connectionLink = "" + } label: { + Label("Clear", systemImage: "multiply") + } + + } + Spacer() + Button(action: connect, label: { + Label("Connect", systemImage: "link") + }) + .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil) + } + .frame(height: 48) + .padding(.bottom) + + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button") + } + .padding() + .frame(maxHeight: .infinity, alignment: .top) + } + + private func connect() { + connectViaLink(connectionLink.trimmingCharacters(in: .whitespaces), $openedSheet) + } +} + +struct PasteToConnectView_Previews: PreviewProvider { + static var previews: some View { + @State var openedSheet: NewChatAction? = nil + return PasteToConnectView(openedSheet: $openedSheet) + } +} diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift similarity index 57% rename from apps/ios/Shared/Views/NewChat/ConnectContactView.swift rename to apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 9944dc21fb..80db91d528 100644 --- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -9,52 +9,43 @@ import SwiftUI import CodeScanner -struct ConnectContactView: View { - var completed: ((Result) -> Void) +struct ScanToConnectView: View { + @Binding var openedSheet: NewChatAction? var body: some View { - VStack { + VStack(alignment: .leading) { Text("Scan QR code") .font(.title) - .padding(.bottom) + .padding(.vertical) Text("Your chat profile will be sent to your contact") - .font(.title2) - .multilineTextAlignment(.center) - .padding() + .padding(.bottom) ZStack { CodeScannerView(codeTypes: [.qr], completion: processQRCode) .aspectRatio(1, contentMode: .fit) .border(.gray) } - .padding(12) + .padding(.bottom) Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") - .font(.subheadline) - .multilineTextAlignment(.center) - .padding(.horizontal) + .padding(.bottom) } + .padding() + .frame(maxHeight: .infinity, alignment: .top) } func processQRCode(_ resp: Result) { switch resp { case let .success(r): - Task { - do { - let ok = try await apiConnect(connReq: r.string) - completed(.success(ok)) - } catch { - logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)") - completed(.failure(error)) - } - } + Task { connectViaLink(r.string, $openedSheet) } case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") - completed(.failure(e)) + openedSheet = nil } } } struct ConnectContactView_Previews: PreviewProvider { static var previews: some View { - return ConnectContactView(completed: {_ in }) + @State var openedSheet: NewChatAction? = nil + return ScanToConnectView(openedSheet: $openedSheet) } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift new file mode 100644 index 0000000000..cae263e938 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -0,0 +1,122 @@ +// +// CreateProfile.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct CreateProfile: View { + @EnvironmentObject var m: ChatModel + @State private var displayName: String = "" + @State private var fullName: String = "" + @FocusState private var focusDisplayName + @FocusState private var focusFullName + + var body: some View { + VStack(alignment: .leading) { + Text("Create your profile") + .font(.largeTitle) + .padding(.bottom, 4) + Text("Your profile, contacts and delivered messages are stored on your device.") + .padding(.bottom, 4) + Text("The profile is only shared with your contacts.") + .padding(.bottom) + ZStack(alignment: .topLeading) { + if !validDisplayName(displayName) { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + .padding(.top, 4) + } + textField("Display name", text: $displayName) + .focused($focusDisplayName) + .submitLabel(.next) + .onSubmit { + if canCreateProfile() { focusFullName = true } + else { focusDisplayName = true } + } + } + textField("Full name (optional)", text: $fullName) + .focused($focusFullName) + .submitLabel(.go) + .onSubmit { + if canCreateProfile() { createProfile() } + else { focusFullName = true } + } + + Spacer() + + HStack { + Button { + hideKeyboard() + withAnimation { m.onboardingStage = .step1_SimpleXInfo } + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") + } + } + + Spacer() + + HStack { + Button { + createProfile() + } label: { + Text("Create") + Image(systemName: "greaterthan") + } + .disabled(!canCreateProfile()) + } + } + } + .onAppear() { + focusDisplayName = true + } + .padding() + } + + func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.leading, 28) + .padding(.bottom) + } + + func createProfile() { + hideKeyboard() + let profile = Profile( + displayName: displayName, + fullName: fullName + ) + do { + m.currentUser = try apiCreateActiveUser(profile) + startChat() + withAnimation { m.onboardingStage = .step3_MakeConnection } + + } catch { + fatalError("Failed to create user: \(error)") + } + } + + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + func validDisplayName(_ name: String) -> Bool { + name.firstIndex(of: " ") == nil + } + + func canCreateProfile() -> Bool { + displayName != "" && validDisplayName(displayName) + } +} + +struct CreateProfile_Previews: PreviewProvider { + static var previews: some View { + CreateProfile() + } +} diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift new file mode 100644 index 0000000000..fdd73d2632 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -0,0 +1,54 @@ +// +// HowItWorks.swift +// SimpleX (iOS) +// +// Created by Evgeny on 08/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct HowItWorks: View { + @EnvironmentObject var m: ChatModel + var onboarding: Bool + + var body: some View { + VStack(alignment: .leading) { + Text("How SimpleX works") + .font(.largeTitle) + .padding(.vertical) + ScrollView { + VStack(alignment: .leading) { + Group { + Text("Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*") + Text("To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.") + Text("You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.") + Text("Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.") + if onboarding { + Text("Read more in our GitHub repository.") + } else { + Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).") + } + } + .padding(.bottom) + } + } + + Spacer() + + if onboarding { + OnboardingActionButton() + .padding(.bottom, 8) + } + } + .lineLimit(10) + .padding() + .frame(maxHeight: .infinity, alignment: .top) + } +} + +struct HowItWorks_Previews: PreviewProvider { + static var previews: some View { + HowItWorks(onboarding: true) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift new file mode 100644 index 0000000000..3251b16535 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift @@ -0,0 +1,145 @@ +// +// MakeConnection.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct MakeConnection: View { + @EnvironmentObject var m: ChatModel + @State private var connReq: String = "" + @State private var actionSheet: NewChatAction? + + var body: some View { + VStack(alignment: .leading) { + SettingsButton().padding(.bottom, 1) + + if let user = m.currentUser { + Text("Welcome \(user.displayName)!") + .font(.largeTitle) + .multilineTextAlignment(.leading) + .padding(.bottom, 8) + } else { + Text("Make a private connection") + .font(.largeTitle) + .padding(.bottom) + } + + ScrollView { + VStack(alignment: .leading) { + Text("To make your first private connection, choose **one of the following**:") + .padding(.bottom) + + actionRow( + icon: "link.badge.plus", + title: "Create 1-time link / QR code", + text: "It's secure to share - only one contact can use it." + ) { addContactAction() } + + actionRow( + icon: "doc.plaintext", + title: "Paste the link you received", + text: "Or open the link in the browser and tap **Open in mobile**." + ) { actionSheet = .pasteLink } + + actionRow( + icon: "qrcode.viewfinder", + title: "Scan contact's QR code", + text: "In person or via a video call – the most secure way to connect." + ) { actionSheet = .scanQRCode } + + Text("or") + .padding(.bottom) + .frame(maxWidth: .infinity) + + actionRow( + icon: "number", + title: "Connect with the developers", + text: "To ask any questions and to receive SimpleX Chat updates." + ) { + DispatchQueue.main.async { + UIApplication.shared.open(simplexTeamURL) + } + } + } + } + + Spacer() + + Button { + withAnimation { m.onboardingStage = .step1_SimpleXInfo } + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") + } + } + .padding(.bottom, 8) + .padding(.bottom) + } + .sheet(item: $actionSheet) { sheet in + switch sheet { + case .createLink: AddContactView(connReqInvitation: connReq) + case .pasteLink: PasteToConnectView(openedSheet: $actionSheet) + case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet) + } + } + .onChange(of: actionSheet) { _ in checkOnboarding() } + .onChange(of: m.chats.isEmpty) { _ in checkOnboarding() } + .onChange(of: m.appOpenUrl) { _ in connectViaUrl() } + .onAppear() { connectViaUrl() } + .padding(.horizontal) + .frame(maxHeight: .infinity, alignment: .top) + } + + private func checkOnboarding() { + if actionSheet == nil && !m.chats.isEmpty { + withAnimation { m.onboardingStage = .onboardingComplete } + } + } + + private func addContactAction() { + do { + connReq = try apiAddContact() + actionSheet = .createLink + } catch { + DispatchQueue.global().async { + connectionErrorAlert(error) + } + logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") + } + } + + private func actionRow(icon: String, title: LocalizedStringKey, text: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button(action: action, label: { + HStack(alignment: .top, spacing: 20) { + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .padding(.leading, 4) + .padding(.top, 6) + VStack(alignment: .leading) { + Group { + Text(title).font(.headline) + Text(text).foregroundColor(.primary) + } + .multilineTextAlignment(.leading) + } + } + }) + .padding(.bottom) + } +} + +struct MakeConnection_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.currentUser = User.sampleData + return MakeConnection() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000000..04fcbf42e5 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,35 @@ +// +// OnboardingStepsView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct OnboardingView: View { + var onboarding: OnboardingStage + + var body: some View { + switch onboarding { + case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) + case .step2_CreateProfile: CreateProfile() + case .step3_MakeConnection: MakeConnection() + case .onboardingComplete: EmptyView() + } + } +} + +enum OnboardingStage { + case step1_SimpleXInfo + case step2_CreateProfile + case step3_MakeConnection + case onboardingComplete +} + +struct OnboardingStepsView_Previews: PreviewProvider { + static var previews: some View { + OnboardingView(onboarding: .step1_SimpleXInfo) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift new file mode 100644 index 0000000000..2d05852640 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -0,0 +1,104 @@ +// +// SimpleXInfo.swift +// SimpleX (iOS) +// +// Created by Evgeny on 07/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SimpleXInfo: View { + @EnvironmentObject var m: ChatModel + @State private var showHowItWorks = false + var onboarding: Bool + + var body: some View { + GeometryReader { g in + VStack(alignment: .leading) { + Image("logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: g.size.width * 0.7) + .padding(.bottom, 8) + + VStack(alignment: .leading) { + Text("The next generation of private messaging") + .font(.title) + .padding(.bottom) + infoRow("🎭", "Privacy redefined", + "The 1st platform without any user identifiers – private by design.") + infoRow("📭", "Immune to spam and abuse", + "People can connect to you only via the links you share.") + infoRow("🤝", "Decentralized", + "Open-source protocol and code – anybody can run the servers.") + } + + Spacer() + if onboarding { + OnboardingActionButton() + Spacer() + } + + Button { + showHowItWorks = true + } label: { + Label("How it works", systemImage: "info.circle") + .font(.subheadline) + } + .padding(.bottom, 8) + .frame(maxWidth: .infinity) + } + .sheet(isPresented: $showHowItWorks) { + HowItWorks(onboarding: onboarding) + } + } + .padding() + } + + private func infoRow(_ emoji: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey) -> some View { + HStack(alignment: .top) { + Text(emoji) + .font(mediumEmojiFont) + .frame(width: 40) + VStack(alignment: .leading) { + Text(title).font(.headline) + Text(text) + } + } + .padding(.bottom, 8) + } +} + +struct OnboardingActionButton: View { + @EnvironmentObject var m: ChatModel + + var body: some View { + if m.currentUser == nil { + actionButton("Create your profile", onboarding: .step2_CreateProfile) + } else { + actionButton("Make a private connection", onboarding: .step3_MakeConnection) + } + } + + private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View { + Button { + withAnimation { + m.onboardingStage = onboarding + } + } label: { + HStack { + Text(label).font(.title2) + Image(systemName: "greaterthan") + } + } + .frame(maxWidth: .infinity) + .padding(.bottom) + } +} + +struct SimpleXInfo_Previews: PreviewProvider { + static var previews: some View { + SimpleXInfo(onboarding: true) + } +} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 98d7bbd39d..75198c0977 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -14,11 +14,8 @@ private let maxItemSize: Int = 50000 struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel - @State var inProgress: Bool = false - @State var message: String = "" + @State var composeState: ComposeState = ComposeState() @FocusState private var keyboardVisible: Bool - @State var editing: Bool = false - @State var sendEnabled: Bool = false var body: some View { VStack { @@ -64,21 +61,15 @@ struct TerminalView: View { Spacer() SendMessageView( + composeState: $composeState, sendMessage: sendMessage, - inProgress: inProgress, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editing, - sendEnabled: $sendEnabled + keyboardVisible: $keyboardVisible ) .padding(.horizontal, 12) } } .navigationViewStyle(.stack) .navigationTitle("Chat console") - .onChange(of: message) { _ in - sendEnabled = !message.isEmpty - } } func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { @@ -89,15 +80,16 @@ struct TerminalView: View { } } - func sendMessage(_ cmdStr: String) { - let cmd = ChatCommand.string(cmdStr) + func sendMessage() { + let cmd = ChatCommand.string(composeState.message) DispatchQueue.global().async { Task { - inProgress = true + composeState.inProgress = true _ = await chatSendCmd(cmd) - inProgress = false + composeState.inProgress = false } } + composeState = ComposeState() } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 933f5f95bd..05af85086b 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -14,10 +14,19 @@ let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionS let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String +let DEFAULT_USE_NOTIFICATIONS = "useNotifications" +let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" + +private var indent: CGFloat = 36 + struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool + @AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false + @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true + @State var showNotificationsAlert: Bool = false + @State var whichNotificationsAlert = NotificationAlert.enable var body: some View { let user: User = chatModel.currentUser! @@ -47,24 +56,19 @@ struct SettingsView: View { UserAddress() .navigationTitle("Your chat address") } label: { - HStack { - Image(systemName: "qrcode") - .padding(.trailing, 8) - Text("Your SimpleX contact address") - } + settingsRow("qrcode") { Text("Your SimpleX contact address") } } } Section("Settings") { + settingsRow("link") { + Toggle("Show pending connections", isOn: $pendingConnections) + } NavigationLink { SMPServers() .navigationTitle("Your SMP servers") } label: { - HStack { - Image(systemName: "server.rack") - .padding(.trailing, 4) - Text("SMP servers") - } + settingsRow("server.rack") { Text("SMP servers") } } } @@ -74,26 +78,23 @@ struct SettingsView: View { .navigationTitle("Welcome \(user.displayName)!") .frame(maxHeight: .infinity, alignment: .top) } label: { - HStack { - Image(systemName: "questionmark.circle") - .padding(.trailing, 8) - Text("How to use SimpleX Chat") - } + settingsRow("questionmark") { Text("How to use it") } + } + NavigationLink { + SimpleXInfo(onboarding: false) + .navigationBarTitle("", displayMode: .inline) + .frame(maxHeight: .infinity, alignment: .top) + } label: { + settingsRow("info") { Text("About SimpleX Chat") } } NavigationLink { MarkdownHelp() .navigationTitle("How to use markdown") .frame(maxHeight: .infinity, alignment: .top) } label: { - HStack { - Image(systemName: "textformat") - .padding(.trailing, 4) - Text("Markdown in messages") - } + settingsRow("textformat") { Text("Markdown in messages") } } - HStack { - Image(systemName: "number") - .padding(.trailing, 8) + settingsRow("number") { Button { showSettings = false DispatchQueue.main.async { @@ -103,37 +104,142 @@ struct SettingsView: View { Text("Chat with the developers") } } - HStack { - Image(systemName: "envelope") - .padding(.trailing, 4) - Text("[Send us email](mailto:chat@simplex.chat)") - } + settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") } } Section("Develop") { NavigationLink { TerminalView() } label: { - HStack { - Image(systemName: "terminal") - .frame(maxWidth: 24) - .padding(.trailing, 8) - Text("Chat console") - } + settingsRow("terminal") { Text("Chat console") } } - HStack { + ZStack(alignment: .leading) { Image(colorScheme == .dark ? "github_light" : "github") .resizable() .frame(width: 24, height: 24) - .padding(.trailing, 8) Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") + .padding(.leading, indent) } - Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") +// if let token = chatModel.deviceToken { +// HStack { +// notificationsIcon() +// notificationsToggle(token) +// } +// } +// NavigationLink { +// CallViewDebug() +// .frame(maxHeight: .infinity, alignment: .top) +// } label: { + Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") +// } } } .navigationTitle("Your settings") } } + + private func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View { + ZStack(alignment: .leading) { + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) + content().padding(.leading, indent) + } + } + + enum NotificationAlert { + case enable + case error(LocalizedStringKey, String) + } + + private func notificationsIcon() -> some View { + let icon: String + let color: Color + switch (chatModel.tokenStatus) { + case .new: + icon = "bolt" + color = .primary + case .registered: + icon = "bolt.fill" + color = .primary + case .invalid: + icon = "bolt.slash" + color = .primary + case .confirmed: + icon = "bolt.fill" + color = .yellow + case .active: + icon = "bolt.fill" + color = .green + case .expired: + icon = "bolt.slash.fill" + color = .primary + } + return Image(systemName: icon) + .padding(.trailing, 9) + .foregroundColor(color) + } + + private func notificationsToggle(_ token: String) -> some View { + Toggle("Check messages", isOn: $useNotifications) + .onChange(of: useNotifications) { enable in + if enable { + showNotificationsAlert = true + whichNotificationsAlert = .enable + } else { + Task { + do { + try await apiDeleteToken(token: token) + chatModel.tokenStatus = .new + } + catch { + DispatchQueue.main.async { + if let cr = error as? ChatResponse { + let err = String(describing: cr) + logger.error("apiDeleteToken error: \(err)") + showNotificationsAlert = true + whichNotificationsAlert = .error("Error deleting token", err) + } else { + logger.error("apiDeleteToken unknown error: \(error.localizedDescription)") + } + } + } + } + } + } + .alert(isPresented: $showNotificationsAlert) { + switch (whichNotificationsAlert) { + case .enable: return enableNotificationsAlert(token) + case let .error(title, err): return Alert(title: Text(title), message: Text(err)) + } + } + } + + private func enableNotificationsAlert(_ token: String) -> Alert { + Alert( + title: Text("Enable notifications? (BETA)"), + message: Text("The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server."), + primaryButton: .destructive(Text("Confirm")) { + Task { + do { + chatModel.tokenStatus = try await apiRegisterToken(token: token) + } catch { + DispatchQueue.main.async { + useNotifications = false + if let cr = error as? ChatResponse { + let err = String(describing: cr) + logger.error("apiRegisterToken error: \(err)") + showNotificationsAlert = true + whichNotificationsAlert = .error("Error registering token", err) + } else { + logger.error("apiRegisterToken unknown error: \(error.localizedDescription)") + } + } + } + } + }, secondaryButton: .cancel() { + withAnimation() { useNotifications = false } + } + ) + } } struct SettingsView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 8c38681b3c..4de7695136 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -99,7 +99,7 @@ struct UserProfile: View { } .onChange(of: chosenImage) { image in if let image = image { - profile.image = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500) + profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) } else { profile.image = nil } diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift deleted file mode 100644 index 3f2d80000f..0000000000 --- a/apps/ios/Shared/Views/WelcomeView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// WelcomeView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 18/01/2022. -// - -import SwiftUI - -struct WelcomeView: View { - @EnvironmentObject var chatModel: ChatModel - @State var displayName: String = "" - @State var fullName: String = "" - - var body: some View { - GeometryReader { g in - VStack(alignment: .leading) { - Image("logo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: g.size.width * 0.7) - .padding(.vertical) - Text("You control your chat!") - .font(.title) - .padding(.bottom) - Text("The messaging and application platform 100% private by design!") - .padding(.bottom, 8) - Text("Your profile, contacts and messages (once delivered) are only stored locally on your device.") - .padding(.bottom, 8) - Text("Create profile") - .font(.largeTitle) - .padding(.bottom, 4) - Text("(shared only with your contacts)") - .padding(.bottom) - ZStack(alignment: .topLeading) { - if !validDisplayName(displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.top, 4) - } - TextField("Display name", text: $displayName) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom, 2) - } - .padding(.bottom) - TextField("Full name (optional)", text: $fullName) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom) - Button("Create") { - let profile = Profile( - displayName: displayName, - fullName: fullName - ) - do { - let user = try apiCreateActiveUser(profile) - chatModel.currentUser = user - } catch { - fatalError("Failed to create user: \(error)") - } - } - .disabled(!validDisplayName(displayName) || displayName == "") - } - } - .padding() - } - - func validDisplayName(_ name: String) -> Bool { - name.firstIndex(of: " ") == nil - } -} - -struct WelcomeView_Previews: PreviewProvider { - static var previews: some View { - WelcomeView() - } -} diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 3a6f1244b0..6dda31ceba 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -2,11 +2,17 @@ + aps-environment + development com.apple.developer.associated-domains applinks:simplex.chat applinks:www.simplex.chat applinks:simplex.chat?mode=developer + com.apple.security.application-groups + + group.chat.simplex.app + diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index d4c03f1dfa..f4bd247669 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -50,24 +50,29 @@ %lldk No comment provided by engineer. - - (shared only with your contacts) - (shared only with your contacts) - No comment provided by engineer. - ) ) No comment provided by engineer. - **Add new contact**: to create your one-time QR Code for your contact. - **Add new contact**: to create your one-time QR Code for your contact. + **Add new contact**: to create your one-time QR Code or link for your contact. + **Add new contact**: to create your one-time QR Code or link for your contact. No comment provided by engineer. - - **Scan QR code**: to connect to your contact who shows QR code to you. - **Scan QR code**: to connect to your contact who shows QR code to you. + + **Create link / QR code** for your contact to use. + **Create link / QR code** for your contact to use. + No comment provided by engineer. + + + **Paste received link** or open it in the browser and tap **Open in mobile app**. + **Paste received link** or open it in the browser and tap **Open in mobile app**. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact in person or via video call. + **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. @@ -95,6 +100,16 @@ : %@ No comment provided by engineer. + + About SimpleX + About SimpleX + No comment provided by engineer. + + + About SimpleX Chat + About SimpleX Chat + No comment provided by engineer. + Accept Accept @@ -110,9 +125,9 @@ Accept contact request from %@? notification body - - Add contact - Add contact + + Add contact to start a new chat + Add contact to start a new chat No comment provided by engineer. @@ -120,11 +135,26 @@ All your contacts will remain connected No comment provided by engineer. + + Already connected? + Already connected? + No comment provided by engineer. + + + Answer + Answer + accept incoming call via notification + Attach Attach No comment provided by engineer. + + Call already ended! + Call already ended! + No comment provided by engineer. + Cancel Cancel @@ -145,11 +175,31 @@ Chats back button to return to chats list + + Check messages + Check messages + No comment provided by engineer. + + + Checking new messages... + Checking new messages... + notification + + + Choose file (new in v2.0) + Choose file (new in v2.0) + No comment provided by engineer. + Choose from library Choose from library No comment provided by engineer. + + Clear + Clear + No comment provided by engineer. + Configure SMP servers Configure SMP servers @@ -170,9 +220,19 @@ Connect via contact link? No comment provided by engineer. - - Connect via invitation link? - Connect via invitation link? + + Connect via link + Connect via link + No comment provided by engineer. + + + Connect via one-time link? + Connect via one-time link? + No comment provided by engineer. + + + Connect with the developers + Connect with the developers No comment provided by engineer. @@ -195,6 +255,11 @@ Connection error No comment provided by engineer. + + Connection error (AUTH) + Connection error (AUTH) + No comment provided by engineer. + Connection request Connection request @@ -225,6 +290,11 @@ Contact is connected notification + + Contact is not connected yet! + Contact is not connected yet! + No comment provided by engineer. + Copy Copy @@ -235,19 +305,34 @@ Create No comment provided by engineer. + + Create 1-time link / QR code + Create 1-time link / QR code + No comment provided by engineer. + Create address Create address No comment provided by engineer. - - Create group - Create group + + Create link / QR code + Create link / QR code No comment provided by engineer. - - Create profile - Create profile + + Create your profile + Create your profile + No comment provided by engineer. + + + Currently maximum supported file size is %@. + Currently maximum supported file size is %@. + No comment provided by engineer. + + + Decentralized + Decentralized No comment provided by engineer. @@ -255,6 +340,11 @@ Delete No comment provided by engineer. + + Delete Contact + Delete Contact + No comment provided by engineer. + Delete address Delete address @@ -295,6 +385,16 @@ Delete message? No comment provided by engineer. + + Delete pending connection + Delete pending connection + No comment provided by engineer. + + + Delete pending connection? + Delete pending connection? + No comment provided by engineer. + Develop Develop @@ -310,11 +410,26 @@ Edit No comment provided by engineer. + + Enable notifications? (BETA) + Enable notifications? (BETA) + No comment provided by engineer. + Enter one SMP server per line: Enter one SMP server per line: No comment provided by engineer. + + Error deleting token + Error deleting token + No comment provided by engineer. + + + Error registering token + Error registering token + No comment provided by engineer. + Error saving SMP servers Error saving SMP servers @@ -330,6 +445,11 @@ Error: URL is invalid No comment provided by engineer. + + File will be received when your contact is online, please wait or check later! + File will be received when your contact is online, please wait or check later! + No comment provided by engineer. + Full name (optional) Full name (optional) @@ -345,14 +465,24 @@ Help No comment provided by engineer. + + How SimpleX works + How SimpleX works + No comment provided by engineer. + + + How it works + How it works + No comment provided by engineer. + How to How to No comment provided by engineer. - - How to use SimpleX Chat - How to use SimpleX Chat + + How to use it + How to use it No comment provided by engineer. @@ -360,21 +490,46 @@ How to use markdown No comment provided by engineer. + + If you can't meet in person, **show QR code in the video call**, or share the link. + If you can't meet in person, **show QR code in the video call**, or share the link. + No comment provided by engineer. + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. No comment provided by engineer. - - If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. - If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. + + Ignore + Ignore + ignore incoming call via notification + + + Image will be received when your contact is online, please wait or check later! + Image will be received when your contact is online, please wait or check later! No comment provided by engineer. - - If you received SimpleX Chat invitation link you can open it in your browser: - If you received SimpleX Chat invitation link you can open it in your browser: + + Immune to spam and abuse + Immune to spam and abuse No comment provided by engineer. + + In person or via a video call – the most secure way to connect. + In person or via a video call – the most secure way to connect. + No comment provided by engineer. + + + Incoming %@ call + Incoming %@ call + notification body + + + Incoming call + Incoming call + notification + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) @@ -385,11 +540,36 @@ Invalid connection link No comment provided by engineer. + + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + No comment provided by engineer. + + + It's secure to share - only one contact can use it. + It's secure to share - only one contact can use it. + No comment provided by engineer. + + + Large file! + Large file! + No comment provided by engineer. + + + Make a private connection + Make a private connection + No comment provided by engineer. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. Make sure SMP server addresses are in correct format, line separated and are not duplicated. No comment provided by engineer. + + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + No comment provided by engineer. + Markdown in messages Markdown in messages @@ -413,18 +593,63 @@ New message New message - notifications + notification Notifications are disabled! Notifications are disabled! No comment provided by engineer. + + One-time invitation link + One-time invitation link + No comment provided by engineer. + + + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + No comment provided by engineer. + Open Settings Open Settings No comment provided by engineer. + + Open-source protocol – anybody can run the servers. + Open-source protocol – anybody can run the servers. + No comment provided by engineer. + + + Or open the link in the browser and tap **Open in mobile**. + Or open the link in the browser and tap **Open in mobile**. + No comment provided by engineer. + + + Paste + Paste + No comment provided by engineer. + + + Paste received link + Paste received link + No comment provided by engineer. + + + Paste the link you received + Paste the link you received + No comment provided by engineer. + + + Paste the link you received into the box below to connect with your contact. + Paste the link you received into the box below to connect with your contact. + No comment provided by engineer. + + + People can connect to you only via the links you share. + People can connect to you only via the links you share. + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Please check that you used the correct link or ask your contact to send you another one. @@ -435,6 +660,11 @@ Please check your network connection and try again. No comment provided by engineer. + + Privacy redefined + Privacy redefined + No comment provided by engineer. + Profile image Profile image @@ -445,6 +675,16 @@ Read No comment provided by engineer. + + Read more in our GitHub repository. + Read more in our GitHub repository. + No comment provided by engineer. + + + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + No comment provided by engineer. + Reject Reject @@ -490,6 +730,11 @@ Scan QR code No comment provided by engineer. + + Scan contact's QR code + Scan contact's QR code + No comment provided by engineer. + Server connected Server connected @@ -515,16 +760,9 @@ Share link No comment provided by engineer. - - Show QR code to your contact -to scan from the app - Show QR code to your contact -to scan from the app - No comment provided by engineer. - - - Start new chat - Start new chat + + Show pending connections + Show pending connections No comment provided by engineer. @@ -542,14 +780,41 @@ to scan from the app Thank you for installing SimpleX Chat! No comment provided by engineer. + + The 1st platform without any user identifiers – private by design. + The 1st platform without any user identifiers – private by design. + No comment provided by engineer. + The app can notify you when you receive messages or contact requests - please open settings to enable. The app can notify you when you receive messages or contact requests - please open settings to enable. No comment provided by engineer. - - The messaging and application platform 100% private by design! - The messaging and application platform 100% private by design! + + The app can receive background notifications every 20 minutes to check the new messages. +*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. + The app can receive background notifications every 20 minutes to check the new messages. +*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. + No comment provided by engineer. + + + The connection you accepted will be cancelled! + The connection you accepted will be cancelled! + No comment provided by engineer. + + + The contact you shared this link with will NOT be able to connect! + The contact you shared this link with will NOT be able to connect! + No comment provided by engineer. + + + The next generation of private messaging + The next generation of private messaging + No comment provided by engineer. + + + The profile is only shared with your contacts. + The profile is only shared with your contacts. No comment provided by engineer. @@ -557,19 +822,29 @@ to scan from the app The sender will NOT be notified No comment provided by engineer. + + To ask any questions and to receive SimpleX Chat updates. + To ask any questions and to receive SimpleX Chat updates. + No comment provided by engineer. + To ask any questions and to receive updates: To ask any questions and to receive updates: No comment provided by engineer. - - To connect via link - To connect via link + + To make a new connection + To make a new connection No comment provided by engineer. - - To start a new chat - To start a new chat + + To make your first private connection, choose **one of the following**: + To make your first private connection, choose **one of the following**: + No comment provided by engineer. + + + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. No comment provided by engineer. @@ -587,6 +862,13 @@ to scan from the app Unexpected error: %@ No comment provided by engineer. + + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. +To connect, please ask your contact to create another connection link and check that you have a stable network connection. + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. +To connect, please ask your contact to create another connection link and check that you have a stable network connection. + No comment provided by engineer. + Use SimpleX Chat servers? Use SimpleX Chat servers? @@ -597,6 +879,16 @@ to scan from the app Using SimpleX Chat servers. No comment provided by engineer. + + Waiting for file + Waiting for file + No comment provided by engineer. + + + Waiting for image + Waiting for image + No comment provided by engineer. + Welcome %@! Welcome %@! @@ -607,6 +899,11 @@ to scan from the app You No comment provided by engineer. + + You accepted connection + You accepted connection + No comment provided by engineer. + You are already connected to %@ via this link. You are already connected to %@ via this link. @@ -617,6 +914,11 @@ to scan from the app You are connected to the server used to receive messages from this contact. No comment provided by engineer. + + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + No comment provided by engineer. + You can now send messages to %@ You can now send messages to %@ @@ -632,9 +934,14 @@ to scan from the app You can use markdown to format messages: No comment provided by engineer. - - You control your chat! - You control your chat! + + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. + No comment provided by engineer. + + + You invited your contact + You invited your contact No comment provided by engineer. @@ -677,6 +984,23 @@ to scan from the app Your chats No comment provided by engineer. + + Your contact can scan it from the app + Your contact can scan it from the app + No comment provided by engineer. + + + Your contact needs to be online for the connection to complete. +You can cancel this connection and remove the contact (and try later with a new link). + Your contact needs to be online for the connection to complete. +You can cancel this connection and remove the contact (and try later with a new link). + No comment provided by engineer. + + + Your contact sent a file that is larger than currently supported maximum size (%@). + Your contact sent a file that is larger than currently supported maximum size (%@). + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -689,9 +1013,9 @@ SimpleX servers cannot see your profile. Your profile will be sent to the contact that you received this link from No comment provided by engineer. - - Your profile, contacts and messages (once delivered) are only stored locally on your device. - Your profile, contacts and messages (once delivered) are only stored locally on your device. + + Your profile, contacts and delivered messages are stored on your device. + Your profile, contacts and delivered messages are stored on your device. No comment provided by engineer. @@ -714,16 +1038,26 @@ SimpleX servers cannot see your profile. `a + b` No comment provided by engineer. - - above, then: - above, then: + + above, then choose: + above, then choose: No comment provided by engineer. + + accepted + accepted + call status + bold bold No comment provided by engineer. + + calling… + calling… + call status + colored colored @@ -734,21 +1068,97 @@ SimpleX servers cannot see your profile. connect to SimpleX Chat developers. No comment provided by engineer. + + connected + connected + No comment provided by engineer. + + + connecting… + connecting… + call status + chat list item title + + + connection established + connection established + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + deleted deleted deleted chat item + + end-to-end encrypted + end-to-end encrypted + No comment provided by engineer. + + + ended %1$d:%2$d + ended %1$d:%2$d + call status + + + error + error + call status + + + in progress + in progress + call status + + + invited to connect + invited to connect + chat list item title + italic italic No comment provided by engineer. + + missed + missed + call status + + + no end-to-end encryption + no end-to-end encryption + No comment provided by engineer. + + + or + or + No comment provided by engineer. + + + received answer… + received answer… + No comment provided by engineer. + + + rejected + rejected + call status + secret secret No comment provided by engineer. + + starting… + starting… + No comment provided by engineer. + strike strike @@ -759,26 +1169,41 @@ SimpleX servers cannot see your profile. v%@ (%@) No comment provided by engineer. + + via contact address link + via contact address link + chat list item description + + + via one-time link + via one-time link + chat list item description + + + waiting for answer… + waiting for answer… + No comment provided by engineer. + + + waiting for confirmation… + waiting for confirmation… + No comment provided by engineer. + wants to connect to you! wants to connect to you! No comment provided by engineer. + + you shared one-time link + you shared one-time link + chat list item description + ~strike~ ~strike~ No comment provided by engineer. - - 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. - 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. - No comment provided by engineer. - - - 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. - 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. - No comment provided by engineer. - @@ -792,10 +1217,150 @@ SimpleX servers cannot see your profile. Bundle name - SimpleX needs camera access to scan QR codes to connect to other app users - SimpleX needs camera access to scan QR codes to connect to other app users + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. Privacy - Camera Usage Description + + SimpleX needs microphone access for audio and video calls. + SimpleX needs microphone access for audio and video calls. + Privacy - Microphone Usage Description + + + SimpleX needs access to Photo Library for saving captured and received media + SimpleX needs access to Photo Library for saving captured and received media + Privacy - Photo Library Additions Usage Description + + + + +
+ +
+ + + SimpleX NSE + SimpleX NSE + Bundle display name + + + SimpleX NSE + SimpleX NSE + Bundle name + + + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright (human-readable) + + +
+ +
+ +
+ + + %@ is connected! + %@ is connected! + notification title + + + %@ wants to connect! + %@ wants to connect! + notification title + + + Accept contact request from %@? + Accept contact request from %@? + notification body + + + Incoming %@ call + Incoming %@ call + notification body + + + You can now send messages to %@ + You can now send messages to %@ + notification body + + + accepted + accepted + call status + + + calling… + calling… + call status + + + connecting… + connecting… + call status + chat list item title + + + connection established + connection established + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + + + deleted + deleted + deleted chat item + + + ended %1$d:%2$d + ended %1$d:%2$d + call status + + + error + error + call status + + + in progress + in progress + call status + + + invited to connect + invited to connect + chat list item title + + + missed + missed + call status + + + rejected + rejected + call status + + + via contact address link + via contact address link + chat list item description + + + via one-time link + via one-time link + chat list item description + + + you shared one-time link + you shared one-time link + chat list item description +
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 0000000000..124ddbcc33 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings new file mode 100644 index 0000000000..7a81716312 Binary files /dev/null and b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings differ diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings index 97efd9d6d7..7bbeebb05a 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings @@ -1,6 +1,11 @@ +/* No comment provided by engineer. */ +"Choose file" = "Choose file (new in v2.0)"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 39dfcd9dc5..60ab866015 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -1,4 +1,8 @@ /* Bundle name */ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ -"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other app users"; +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 53bebfcc1d..9fd55a97c1 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -50,24 +50,29 @@ %lldk No comment provided by engineer.
- - (shared only with your contacts) - (отправляется только вашим контактам) - No comment provided by engineer. - ) ) No comment provided by engineer. - **Add new contact**: to create your one-time QR Code for your contact. + **Add new contact**: to create your one-time QR Code or link for your contact. **Добавить новый контакт**: чтобы создать одноразовый QR код или ссылку для вашего контакта. No comment provided by engineer. - - **Scan QR code**: to connect to your contact who shows QR code to you. - **Сканировать QR код**: чтобы соединиться с вашим контактом (который показывает вам QR код). + + **Create link / QR code** for your contact to use. + **Создать ссылку / QR код** для вашего контакта. + No comment provided by engineer. + + + **Paste received link** or open it in the browser and tap **Open in mobile app**. + **Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact in person or via video call. + **Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка. No comment provided by engineer. @@ -95,6 +100,16 @@ : %@ No comment provided by engineer. + + About SimpleX + О SimpleX + No comment provided by engineer. + + + About SimpleX Chat + Информация о SimpleX Chat + No comment provided by engineer. + Accept Принять @@ -110,9 +125,9 @@ Принять запрос на соединение от %@? notification body - - Add contact - Добавить контакт + + Add contact to start a new chat + Добавьте контакт, чтобы начать разговор No comment provided by engineer. @@ -120,11 +135,26 @@ Все контакты, которые соединились через этот адрес, сохранятся. No comment provided by engineer. + + Already connected? + Соединение уже установлено? + No comment provided by engineer. + + + Answer + Ответить + accept incoming call via notification + Attach Прикрепить No comment provided by engineer. + + Call already ended! + Звонок уже завершен! + No comment provided by engineer. + Cancel Отменить @@ -145,11 +175,31 @@ Назад back button to return to chats list + + Check messages + Проверять сообщения + No comment provided by engineer. + + + Checking new messages... + Проверяются новые сообщения... + notification + + + Choose file (new in v2.0) + Выбрать файл (v2.0) + No comment provided by engineer. + Choose from library Выбрать из библиотеки No comment provided by engineer. + + Clear + Очистить + No comment provided by engineer. + Configure SMP servers Настройка SMP серверов @@ -170,9 +220,19 @@ Соединиться через ссылку-контакт? No comment provided by engineer. - - Connect via invitation link? - Соединиться через ссылку-приглашение? + + Connect via link + Соединиться через ссылку + No comment provided by engineer. + + + Connect via one-time link? + Соединиться через одноразовую ссылку? + No comment provided by engineer. + + + Connect with the developers + Соединиться с разработчиками No comment provided by engineer. @@ -195,6 +255,11 @@ Ошибка соединения No comment provided by engineer. + + Connection error (AUTH) + Ошибка соединения (AUTH) + No comment provided by engineer. + Connection request Запрос на соединение @@ -225,6 +290,11 @@ Соединение с контактом установлено notification + + Contact is not connected yet! + Соединение еще не установлено! + No comment provided by engineer. + Copy Скопировать @@ -235,26 +305,46 @@ Создать No comment provided by engineer. + + Create 1-time link / QR code + Создать ссылку / QR код + No comment provided by engineer. + Create address Создать адрес No comment provided by engineer. - - Create group - Создать группу + + Create link / QR code + Создать ссылку / QR код No comment provided by engineer. - - Create profile + + Create your profile Создать профиль No comment provided by engineer. + + Currently maximum supported file size is %@. + Максимальный размер файла - %@. + No comment provided by engineer. + + + Decentralized + Децентрализованный + No comment provided by engineer. + Delete Удалить No comment provided by engineer. + + Delete Contact + Удалить контакт + No comment provided by engineer. + Delete address Удалить адрес @@ -295,6 +385,16 @@ Удалить сообщение? No comment provided by engineer. + + Delete pending connection + Удалить соединение + No comment provided by engineer. + + + Delete pending connection? + Удалить ожидаемое соединение? + No comment provided by engineer. + Develop Для разработчиков @@ -310,11 +410,26 @@ Редактировать No comment provided by engineer. + + Enable notifications? (BETA) + Включить уведомления? (БЕТА) + No comment provided by engineer. + Enter one SMP server per line: Введите SMP серверы, каждый на отдельной строке: No comment provided by engineer. + + Error deleting token + Ошибка удаления токена + No comment provided by engineer. + + + Error registering token + Ошибка регистрации токена + No comment provided by engineer. + Error saving SMP servers Ошибка при сохранении SMP серверов @@ -330,6 +445,11 @@ Ошибка: неверная ссылка No comment provided by engineer. + + File will be received when your contact is online, please wait or check later! + Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже! + No comment provided by engineer. + Full name (optional) Полное имя (не обязательно) @@ -345,14 +465,24 @@ Помощь No comment provided by engineer. + + How SimpleX works + Как SimpleX работает + No comment provided by engineer. + + + How it works + Как это работает + No comment provided by engineer. + How to Инфо No comment provided by engineer. - - How to use SimpleX Chat - Как использовать SimpleX Chat + + How to use it + Как использовать No comment provided by engineer. @@ -360,21 +490,46 @@ Как форматировать No comment provided by engineer. + + If you can't meet in person, **show QR code in the video call**, or share the link. + Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой. + No comment provided by engineer. + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку. No comment provided by engineer. - - If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. - Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка** или отправить ссылку через любой другой канал связи. + + Ignore + Не отвечать + ignore incoming call via notification + + + Image will be received when your contact is online, please wait or check later! + Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже! No comment provided by engineer. - - If you received SimpleX Chat invitation link you can open it in your browser: - Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере: + + Immune to spam and abuse + Защищен от спама No comment provided by engineer. + + In person or via a video call – the most secure way to connect. + При встрече или в видеозвонке – самый безопасный способ установить соединение + No comment provided by engineer. + + + Incoming %@ call + Входящий звонок + notification body + + + Incoming call + Входящий звонок + notification + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) [SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat) @@ -385,11 +540,36 @@ Ошибка в ссылке контакта No comment provided by engineer. + + It seems like you are already connected via this link. If it is not the case, there was an error (%@). + Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@). + No comment provided by engineer. + + + It's secure to share - only one contact can use it. + Ей безопасно поделиться - только один контакт может использовать её. + No comment provided by engineer. + + + Large file! + Большой файл! + No comment provided by engineer. + + + Make a private connection + Добавьте контакт + No comment provided by engineer. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. No comment provided by engineer. + + Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* + Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?* + No comment provided by engineer. + Markdown in messages Форматирование сообщений @@ -413,18 +593,63 @@ New message Новое сообщение - notifications + notification Notifications are disabled! Уведомления выключены No comment provided by engineer. + + One-time invitation link + Одноразовая ссылка + No comment provided by engineer. + + + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием** + No comment provided by engineer. + Open Settings Открыть Настройки No comment provided by engineer. + + Open-source protocol and code – anybody can run the servers. + Открытый протокол и код - кто угодно может запустить сервер. + No comment provided by engineer. + + + Or open the link in the browser and tap **Open in mobile**. + Или откройте ссылку в браузере и нажмите **Open in mobile**. + No comment provided by engineer. + + + Paste + Вставить + No comment provided by engineer. + + + Paste received link + Вставить полученную ссылку + No comment provided by engineer. + + + Paste the link you received + Вставьте полученную ссылку + No comment provided by engineer. + + + Paste the link you received into the box below to connect with your contact. + Чтобы соединиться, вставьте ссылку, полученную от вашего контакта. + No comment provided by engineer. + + + People can connect to you only via the links you share. + С вами можно соединиться только через созданные вами ссылки. + No comment provided by engineer. + Please check that you used the correct link or ask your contact to send you another one. Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку. @@ -435,6 +660,11 @@ Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз. No comment provided by engineer. + + Privacy redefined + Более конфиденциальный + No comment provided by engineer. + Profile image Аватар @@ -445,6 +675,16 @@ Прочитано No comment provided by engineer. + + Read more in our GitHub repository. + Узнайте больше из нашего GitHub репозитория. + No comment provided by engineer. + + + Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme). + Узнайте больше из нашего [GitHub репозитория](https://github.com/simplex-chat/simplex-chat#readme). + No comment provided by engineer. + Reject Отклонить @@ -490,6 +730,11 @@ Сканировать QR код No comment provided by engineer. + + Scan contact's QR code + Сосканировать QR код контакта + No comment provided by engineer. + Server connected Установлено соединение с сервером @@ -515,15 +760,9 @@ Поделиться ссылкой No comment provided by engineer. - - Show QR code to your contact -to scan from the app - Покажите QR код вашему контакту для сканирования в приложении - No comment provided by engineer. - - - Start new chat - Начать новый разговор + + Show pending connections + Показать ожидаемые соединения No comment provided by engineer. @@ -541,14 +780,41 @@ to scan from the app Спасибо, что установили SimpleX Chat! No comment provided by engineer. + + The 1st platform without any user identifiers – private by design. + Первая в мире платформа без идентификаторов пользователей. + No comment provided by engineer. + The app can notify you when you receive messages or contact requests - please open settings to enable. Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках. No comment provided by engineer. - - The messaging and application platform 100% private by design! - Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. + + The app can receive background notifications every 20 minutes to check the new messages. +*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server. + Приложение может получать скрытые уведомления каждые 20 минут чтобы проверить новые сообщения. +*Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat. + No comment provided by engineer. + + + The connection you accepted will be cancelled! + Подтвержденное соединение будет отменено! + No comment provided by engineer. + + + The contact you shared this link with will NOT be able to connect! + Контакт, которому вы отправили эту ссылку, не сможет соединиться! + No comment provided by engineer. + + + The next generation of private messaging + Новое поколение приватных сообщений + No comment provided by engineer. + + + The profile is only shared with your contacts. + Профиль отправляется только вашим контактам. No comment provided by engineer. @@ -556,19 +822,29 @@ to scan from the app Отправитель не будет уведомлён No comment provided by engineer. + + To ask any questions and to receive SimpleX Chat updates. + Чтобы задать вопросы и получать уведомления о SimpleX Chat. + No comment provided by engineer. + To ask any questions and to receive updates: Чтобы задать вопросы и получать уведомления о новых версиях, No comment provided by engineer. - - To connect via link - Соединиться через ссылку + + To make a new connection + Чтобы соединиться No comment provided by engineer. - - To start a new chat - Начать новый разговор + + To make your first private connection, choose **one of the following**: + Чтобы добавить ваш первый контакт, выберите **одно из**: + No comment provided by engineer. + + + To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. + Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта. No comment provided by engineer. @@ -586,6 +862,13 @@ to scan from the app Неожиданная ошибка: %@ No comment provided by engineer. + + Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. +To connect, please ask your contact to create another connection link and check that you have a stable network connection. + Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом. +Чтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью. + No comment provided by engineer. + Use SimpleX Chat servers? Использовать серверы предосталенные SimpleX Chat? @@ -596,6 +879,16 @@ to scan from the app Используются серверы, предоставленные SimpleX Chat. No comment provided by engineer. + + Waiting for file + Ожидается прием файла + No comment provided by engineer. + + + Waiting for image + Ожидается прием изображения + No comment provided by engineer. + Welcome %@! Здравствуйте %@! @@ -606,6 +899,11 @@ to scan from the app Вы No comment provided by engineer. + + You accepted connection + Вы приняли приглашение соединиться + No comment provided by engineer. + You are already connected to %@ via this link. Вы уже соединены с %@ через эту ссылку. @@ -616,6 +914,11 @@ to scan from the app Установлено соединение с сервером, через который вы получаете сообщения от этого контакта. No comment provided by engineer. + + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**. + No comment provided by engineer. + You can now send messages to %@ Вы теперь можете отправлять сообщения %@ @@ -631,9 +934,14 @@ to scan from the app Вы можете форматировать сообщения: No comment provided by engineer. - - You control your chat! - Вы котролируете Ваш чат! + + You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them. + Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки. + No comment provided by engineer. + + + You invited your contact + Вы пригласили ваш контакт No comment provided by engineer. @@ -676,6 +984,23 @@ to scan from the app Ваши чаты No comment provided by engineer. + + Your contact can scan it from the app + Ваш контакт может сосканировать QR в приложении + No comment provided by engineer. + + + Your contact needs to be online for the connection to complete. +You can cancel this connection and remove the contact (and try later with a new link). + Ваш контакт должен быть в сети чтобы установить соединение. +Вы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой). + No comment provided by engineer. + + + Your contact sent a file that is larger than currently supported maximum size (%@). + Ваш контакт отправил файл, размер которого превышает максимальный размер (%@). + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -685,12 +1010,12 @@ SimpleX серверы не могут получить доступ к ваше Your profile will be sent to the contact that you received this link from - Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку. + Ваш профиль будет отправлен вашему контакту. No comment provided by engineer. - - Your profile, contacts and messages (once delivered) are only stored locally on your device. - Ваш профиль, контакты и сообщения (после доставки) хранятся только на вашем устройстве. + + Your profile, contacts and delivered messages are stored on your device. + Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве. No comment provided by engineer. @@ -713,16 +1038,26 @@ SimpleX серверы не могут получить доступ к ваше \`a + b` No comment provided by engineer. - - above, then: - наверху, затем: + + above, then choose: + наверху, затем выберите: No comment provided by engineer. + + accepted + принятый звонок + call status + bold жирный No comment provided by engineer. + + calling… + входящий звонок… + call status + colored цвет @@ -733,21 +1068,97 @@ SimpleX серверы не могут получить доступ к ваше соединитесь с разработчиками. No comment provided by engineer. + + connected + соединение установлено + No comment provided by engineer. + + + connecting… + соединяется… + call status + chat list item title + + + connection established + соединение установлено + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + deleted удалено deleted chat item + + end-to-end encrypted + end-to-end зашифрованный звонок + No comment provided by engineer. + + + ended %1$d:%2$d + звонок завершён %1$d:%2$d + call status + + + error + ошибка соединения + call status + + + in progress + активный звонок + call status + + + invited to connect + приглашение + chat list item title + italic курсив No comment provided by engineer. + + missed + пропущенный звонок + call status + + + no end-to-end encryption + end-to-end шифрование не поддерживается + No comment provided by engineer. + + + or + или + No comment provided by engineer. + + + received answer… + получен ответ… + No comment provided by engineer. + + + rejected + отклоненный звонок + call status + secret секрет No comment provided by engineer. + + starting… + инициализация… + No comment provided by engineer. + strike зачеркнуть @@ -758,26 +1169,41 @@ SimpleX серверы не могут получить доступ к ваше v%@ (%@) No comment provided by engineer. + + via contact address link + через ссылку-контакт + chat list item description + + + via one-time link + через одноразовую ссылку + chat list item description + + + waiting for answer… + ожидается ответ… + No comment provided by engineer. + + + waiting for confirmation… + ожидается подтверждение… + No comment provided by engineer. + wants to connect to you! хочет соединиться с вами! No comment provided by engineer. + + you shared one-time link + вы создали ссылку + chat list item description + ~strike~ \~зачеркнуть~ No comment provided by engineer. - - 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. - 💻 на компьютере: сосканируйте QR код из приложения через **Сканировать QR код**. - No comment provided by engineer. - - - 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. - 📱 на мобильном: намжите кнопку **Open in mobile app** на веб странице, затем нажмите **Соединиться** в приложении. - No comment provided by engineer. - @@ -791,10 +1217,150 @@ SimpleX серверы не могут получить доступ к ваше Bundle name - SimpleX needs camera access to scan QR codes to connect to other app users - SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями + SimpleX needs camera access to scan QR codes to connect to other users and for video calls. + SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями и для видео звонков. Privacy - Camera Usage Description + + SimpleX needs microphone access for audio and video calls. + SimpleX использует микрофон для аудио и видео звонков. + Privacy - Microphone Usage Description + + + SimpleX needs access to Photo Library for saving captured and received media + SimpleX использует доступ к Photo Library для сохранения сделанных и полученных фотографий + Privacy - Photo Library Additions Usage Description + + + + +
+ +
+ + + SimpleX NSE + SimpleX NSE + Bundle display name + + + SimpleX NSE + SimpleX NSE + Bundle name + + + Copyright © 2022 SimpleX Chat. All rights reserved. + Copyright © 2022 SimpleX Chat. Все права зарезервированы. + Copyright (human-readable) + + +
+ +
+ +
+ + + %@ is connected! + соединение с %@ установлено! + notification title + + + %@ wants to connect! + %@ хочет соединиться! + notification title + + + Accept contact request from %@? + Принять запрос на соединение от %@? + notification body + + + Incoming %@ call + Входящий звонок + notification body + + + You can now send messages to %@ + Вы можете отправлять сообщения %@ + notification body + + + accepted + принятый звонок + call status + + + calling… + входящий звонок… + call status + + + connecting… + соединяется… + call status + chat list item title + + + connection established + соединение установлено + chat list item title (it should not be shown + + + connection:%@ + connection:%@ + connection information + + + deleted + удалено + deleted chat item + + + ended %1$d:%2$d + звонок завершён %1$d:%2$d + call status + + + error + ошибка соединения + call status + + + in progress + активный звонок + call status + + + invited to connect + приглашение соединиться + chat list item title + + + missed + пропущенный звонок + call status + + + rejected + отклоненный звонок + call status + + + via contact address link + через ссылку-контакт + chat list item description + + + via one-time link + через одноразовую ссылку + chat list item description + + + you shared one-time link + вы создали одноразовую ссылку + chat list item description +
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings new file mode 100644 index 0000000000..124ddbcc33 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings new file mode 100644 index 0000000000..7a81716312 Binary files /dev/null and b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/SimpleX NSE/en.lproj/Localizable.strings differ diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings index 97efd9d6d7..7bbeebb05a 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings @@ -1,6 +1,11 @@ +/* No comment provided by engineer. */ +"Choose file" = "Choose file (new in v2.0)"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings index 39dfcd9dc5..60ab866015 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -1,4 +1,8 @@ /* Bundle name */ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ -"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other app users"; +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls."; +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media"; diff --git a/apps/ios/SimpleX NSE/Info.plist b/apps/ios/SimpleX NSE/Info.plist new file mode 100644 index 0000000000..57421ebf9b --- /dev/null +++ b/apps/ios/SimpleX NSE/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift new file mode 100644 index 0000000000..a615ac5b71 --- /dev/null +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -0,0 +1,132 @@ +// +// NotificationService.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import UserNotifications +import OSLog + +let logger = Logger() + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + logger.debug("NotificationService.didReceive") + if getAppState() != .background { + contentHandler(request.content) + return + } + logger.debug("NotificationService: app is in the background") + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + if let _ = startChat() { + let content = receiveMessages() + contentHandler (content) + return + } + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" + + contentHandler(bestAttemptContent) + } + } + + override func serviceExtensionTimeWillExpire() { + logger.debug("NotificationService.serviceExtensionTimeWillExpire") + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} + +func startChat() -> User? { + hs_init(0, nil) + if let user = apiGetActiveUser() { + logger.debug("active user \(String(describing: user))") + do { + try apiStartChat() + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + return user + } catch { + logger.error("NotificationService startChat error: \(responseError(error))") + } + } else { + logger.debug("no active user") + } + return nil +} + +func receiveMessages() -> UNNotificationContent { + logger.debug("NotificationService receiveMessages started") + while true { + let res = chatResponse(chat_recv_msg(getChatCtrl())!) + logger.debug("NotificationService receiveMessages: \(res.responseType)") + switch res { +// case let .newContactConnection(connection): +// case let .contactConnectionDeleted(connection): + case let .contactConnected(contact): + return createContactConnectedNtf(contact) +// case let .contactConnecting(contact): +// TODO profile update + case let .receivedContactRequest(contactRequest): + return createContactRequestNtf(contactRequest) +// case let .contactUpdated(toContact): +// TODO profile updated + case let .newChatItem(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + return createMessageReceivedNtf(cInfo, cItem) +// case let .chatItemUpdated(aChatItem): +// TODO message updated +// let cInfo = aChatItem.chatInfo +// let cItem = aChatItem.chatItem +// NtfManager.shared.notifyMessageReceived(cInfo, cItem) +// case let .chatItemDeleted(_, toChatItem): +// TODO message updated +// case let .rcvFileComplete(aChatItem): +// TODO file received? +// let cInfo = aChatItem.chatInfo +// let cItem = aChatItem.chatItem +// NtfManager.shared.notifyMessageReceived(cInfo, cItem) + default: + logger.debug("NotificationService ignored event: \(res.responseType)") + } + } +} + +func apiGetActiveUser() -> User? { + let _ = getChatCtrl() + let r = sendSimpleXCmd(.showActiveUser) + logger.debug("apiGetActiveUser sendSimpleXCmd responce: \(String(describing: r))") + switch r { + case let .activeUser(user): return user + case .chatCmdError(.error(.noActiveUser)): return nil + default: + logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))") + return nil + } +} + +func apiStartChat() throws { + let r = sendSimpleXCmd(.startChat) + if case .chatStarted = r { return } + throw r +} + +func apiSetFilesFolder(filesFolder: String) throws { + let r = sendSimpleXCmd(.setFilesFolder(filesFolder: filesFolder)) + if case .cmdOk = r { return } + throw r +} + diff --git a/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h b/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h new file mode 100644 index 0000000000..bc28b42d38 --- /dev/null +++ b/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h @@ -0,0 +1,11 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +extern void hs_init(int argc, char **argv[]); + +typedef void* chat_ctrl; + +extern chat_ctrl chat_init(char *path); +extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); +extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/SimpleX NSE/SimpleX NSE.entitlements b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements new file mode 100644 index 0000000000..82cf32be67 --- /dev/null +++ b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.chat.simplex.app + + + diff --git a/apps/ios/SimpleX NSE/dummy.m b/apps/ios/SimpleX NSE/dummy.m new file mode 100644 index 0000000000..adef540363 --- /dev/null +++ b/apps/ios/SimpleX NSE/dummy.m @@ -0,0 +1,24 @@ +// +// dummy.m +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +#import + +#if defined(__x86_64__) && TARGET_IPHONE_SIMULATOR + +#import + +int readdir_r$INODE64(DIR *restrict dirp, struct dirent *restrict entry, + struct dirent **restrict result) { + return readdir_r(dirp, entry, result); +} + +DIR *opendir$INODE64(const char *name) { + return opendir(name); +} + +#endif diff --git a/apps/ios/SimpleX NSE/ru.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/ru.lproj/InfoPlist.strings new file mode 100644 index 0000000000..d41717da3c --- /dev/null +++ b/apps/ios/SimpleX NSE/ru.lproj/InfoPlist.strings @@ -0,0 +1,9 @@ +/* Bundle display name */ +"CFBundleDisplayName" = "SimpleX NSE"; + +/* Bundle name */ +"CFBundleName" = "SimpleX NSE"; + +/* Copyright (human-readable) */ +"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. Все права зарезервированы."; + diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..44b29f3ae0 --- /dev/null +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -0,0 +1,61 @@ +/* notification title */ +"%@ is connected!" = "соединение с %@ установлено!"; + +/* notification title */ +"%@ wants to connect!" = "%@ хочет соединиться!"; + +/* notification body */ +"Accept contact request from %@?" = "Принять запрос на соединение от %@?"; + +/* call status */ +"accepted" = "принятый звонок"; + +/* call status */ +"calling…" = "входящий звонок…"; + +/* call status + chat list item title */ +"connecting…" = "соединяется…"; + +/* chat list item title (it should not be shown */ +"connection established" = "соединение установлено"; + +/* connection information */ +"connection:%@" = "connection:%@"; + +/* deleted chat item */ +"deleted" = "удалено"; + +/* call status */ +"ended %02d:%02d" = "звонок завершён %1$d:%2$d"; + +/* call status */ +"error" = "ошибка соединения"; + +/* call status */ +"in progress" = "активный звонок"; + +/* notification body */ +"Incoming %@ call" = "Входящий звонок"; + +/* chat list item title */ +"invited to connect" = "приглашение соединиться"; + +/* call status */ +"missed" = "пропущенный звонок"; + +/* call status */ +"rejected" = "отклоненный звонок"; + +/* chat list item description */ +"via contact address link" = "через ссылку-контакт"; + +/* chat list item description */ +"via one-time link" = "через одноразовую ссылку"; + +/* notification body */ +"You can now send messages to %@" = "Вы можете отправлять сообщения %@"; + +/* chat list item description */ +"you shared one-time link" = "вы создали одноразовую ссылку"; + diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index f996e93668..1287772e17 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -24,6 +24,7 @@ UIBackgroundModes fetch + remote-notification diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 073cfe88aa..bccca3627b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,26 +7,39 @@ objects = { /* Begin PBXBuildFile section */ + 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* WebRTCView.swift */; }; + 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; + 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; + 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; + 5C293B582829490C00CB16C0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B532829490C00CB16C0 /* libffi.a */; }; + 5C293B592829490C00CB16C0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B532829490C00CB16C0 /* libffi.a */; }; + 5C293B5A2829490C00CB16C0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B542829490C00CB16C0 /* libgmpxx.a */; }; + 5C293B5B2829490C00CB16C0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B542829490C00CB16C0 /* libgmpxx.a */; }; + 5C293B5C2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */; }; + 5C293B5D2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */; }; + 5C293B5E2829490C00CB16C0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B562829490C00CB16C0 /* libgmp.a */; }; + 5C293B5F2829490C00CB16C0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B562829490C00CB16C0 /* libgmp.a */; }; + 5C293B602829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */; }; + 5C293B612829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; + 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; - 5C545C81280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */; }; - 5C545C82280D7A7E007A6B96 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7D280D7A7E007A6B96 /* libffi.a */; }; - 5C545C83280D7A7E007A6B96 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7E280D7A7E007A6B96 /* libgmp.a */; }; - 5C545C84280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */; }; - 5C545C85280D7A7E007A6B96 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C80280D7A7E007A6B96 /* libgmpxx.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; + 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; + 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; + 5C5E5D3E282447BF00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; @@ -40,6 +53,7 @@ 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; + 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; @@ -47,7 +61,13 @@ 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; - 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; + 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; + 5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; }; + 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8D2827126500B3292C /* OnboardingView.swift */; }; + 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */; }; + 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; + 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA952827143500B3292C /* MakeConnection.swift */; }; + 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; @@ -58,13 +78,31 @@ 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; - 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; + 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; + 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; + 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; + 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; + 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5728187C7500503DA2 /* dummy.m */; }; + 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */; }; + 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD6028187D7900503DA2 /* libz.tbd */; }; + 5CDCAD682818876500503DA2 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; + 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; + 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; + 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; + 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; + 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; }; + 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; + 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; + 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; + 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 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 */; }; + 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.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 */; }; @@ -83,27 +121,35 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3C714776281C081000CB4D4B /* WebRTCView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCView.swift; sourceTree = ""; }; + 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; + 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; + 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; + 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; + 5C293B532829490C00CB16C0 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C293B542829490C00CB16C0 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a"; sourceTree = ""; }; + 5C293B562829490C00CB16C0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a"; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; 5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; 5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = ""; }; 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; + 5C36027227F47AD5009F19D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; - 5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a"; sourceTree = ""; }; - 5C545C7D280D7A7E007A6B96 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C545C7E280D7A7E007A6B96 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a"; sourceTree = ""; }; - 5C545C80280D7A7E007A6B96 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; + 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallView.swift; sourceTree = ""; }; + 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; @@ -117,6 +163,7 @@ 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; + 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; }; 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = ""; }; @@ -126,7 +173,13 @@ 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; - 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; + 5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + 5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 5CB0BA8D2827126500B3292C /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXInfo.swift; sourceTree = ""; }; + 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = ""; }; + 5CB0BA952827143500B3292C /* MakeConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeConnection.swift; sourceTree = ""; }; + 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -137,13 +190,28 @@ 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; - 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = ""; }; + 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; + 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX NSE.entitlements"; sourceTree = ""; }; + 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupDefaults.swift; sourceTree = ""; }; + 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX NSE-Bridging-Header.h"; sourceTree = ""; }; + 5CDCAD5728187C7500503DA2 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; + 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; + 5CDCAD6028187D7900503DA2 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; + 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypes.swift; sourceTree = ""; }; + 5CDCAD7428188D2900503DA2 /* APITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITypes.swift; sourceTree = ""; }; + 5CDCAD7D2818941F00503DA2 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + 5CDCAD80281A7E2700503DA2 /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 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 = ""; }; + 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.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 = ""; }; @@ -158,13 +226,13 @@ buildActionMask = 2147483647; files = ( 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, + 5C293B5E2829490C00CB16C0 /* libgmp.a in Frameworks */, + 5C293B5C2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */, + 5C293B5A2829490C00CB16C0 /* libgmpxx.a in Frameworks */, + 5C293B602829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C545C81280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a in Frameworks */, - 5C545C82280D7A7E007A6B96 /* libffi.a in Frameworks */, - 5C545C85280D7A7E007A6B96 /* libgmpxx.a in Frameworks */, - 5C545C84280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a in Frameworks */, - 5C545C83280D7A7E007A6B96 /* libgmp.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, + 5C293B582829490C00CB16C0 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -175,19 +243,44 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD422818589900503DA2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C293B5B2829490C00CB16C0 /* libgmpxx.a in Frameworks */, + 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */, + 5C293B592829490C00CB16C0 /* libffi.a in Frameworks */, + 5C293B5D2829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a in Frameworks */, + 5C293B5F2829490C00CB16C0 /* libgmp.a in Frameworks */, + 5C293B612829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a in Frameworks */, + 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3C714775281C080100CB4D4B /* Call */ = { + isa = PBXGroup; + children = ( + 3C714776281C081000CB4D4B /* WebRTCView.swift */, + 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */, + 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */, + ); + path = Call; + sourceTree = ""; + }; 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 5CB0BA8C282711BC00B3292C /* Onboarding */, + 3C714775281C080100CB4D4B /* Call */, 5C971E1F27AEBF7000C8A3CE /* Helpers */, 5C5F4AC227A5E9AF00B51EF1 /* Chat */, 5CB9250B27A942F300ACCCDD /* ChatList */, 5CB924DD27A8622200ACCCDD /* NewChat */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, - 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, ); path = Views; sourceTree = ""; @@ -209,11 +302,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C545C7D280D7A7E007A6B96 /* libffi.a */, - 5C545C7E280D7A7E007A6B96 /* libgmp.a */, - 5C545C80280D7A7E007A6B96 /* libgmpxx.a */, - 5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */, - 5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */, + 5C293B532829490C00CB16C0 /* libffi.a */, + 5C293B562829490C00CB16C0 /* libgmp.a */, + 5C293B542829490C00CB16C0 /* libgmpxx.a */, + 5C293B552829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M-ghc8.10.7.a */, + 5C293B572829490C00CB16C0 /* libHSsimplex-chat-1.6.0-H7MppEuDeVu3ntZTT7iY0M.a */, ); path = Libraries; sourceTree = ""; @@ -221,6 +314,8 @@ 5C764E7A279C71D4000C6508 /* Frameworks */ = { isa = PBXGroup; children = ( + 5CDCAD6028187D7900503DA2 /* libz.tbd */, + 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */, 5C764E7C279C71DB000C6508 /* libz.tbd */, 5C764E7B279C71D4000C6508 /* libiconv.tbd */, ); @@ -230,9 +325,9 @@ 5C764E87279CBC8E000C6508 /* Model */ = { isa = PBXGroup; children = ( + 5CDCAD7128188CEB00503DA2 /* Shared */, 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, - 5C9FD96A27A56D4D0075386C /* JSON.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, ); @@ -252,6 +347,8 @@ 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, + 648010AA281ADD15009009B9 /* CIFileView.swift */, + 6454036E2822A9750090DDFF /* ComposeFileView.swift */, ); path = Helpers; sourceTree = ""; @@ -259,11 +356,13 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 3C714779281C0F6800CB4D4B /* www */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, + 5CDCAD462818589900503DA2 /* SimpleX NSE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, @@ -274,6 +373,7 @@ isa = PBXGroup; children = ( 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, + 5C36027227F47AD5009F19D9 /* AppDelegate.swift */, 5CA059C4279559F40002BEB4 /* ContentView.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C764E87279CBC8E000C6508 /* Model */, @@ -281,6 +381,7 @@ 5CA059C5279559F40002BEB4 /* Assets.xcassets */, 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, 5C764E7F279C7276000C6508 /* dummy.m */, + 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); path = Shared; sourceTree = ""; @@ -290,6 +391,7 @@ children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, + 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, ); name = Products; sourceTree = ""; @@ -303,12 +405,25 @@ path = "Tests iOS"; sourceTree = ""; }; + 5CB0BA8C282711BC00B3292C /* Onboarding */ = { + isa = PBXGroup; + children = ( + 5CB0BA8D2827126500B3292C /* OnboardingView.swift */, + 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */, + 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, + 5CB0BA952827143500B3292C /* MakeConnection.swift */, + 5CB0BA992827FD8800B3292C /* HowItWorks.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; 5CB924DD27A8622200ACCCDD /* NewChat */ = { isa = PBXGroup; children = ( 5C6AD81227A834E300348BD7 /* NewChatButton.swift */, 5CCD403327A5F6DF00368C90 /* AddContactView.swift */, - 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */, + 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */, + 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */, 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */, 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, ); @@ -336,10 +451,39 @@ 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, + 5C13730A28156D2700F43030 /* ContactConnectionView.swift */, ); path = ChatList; sourceTree = ""; }; + 5CDCAD462818589900503DA2 /* SimpleX NSE */ = { + isa = PBXGroup; + children = ( + 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CDCAD472818589900503DA2 /* NotificationService.swift */, + 5CDCAD492818589900503DA2 /* Info.plist */, + 5CB0BA892826CB3A00B3292C /* Localizable.strings */, + 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, + 5CDCAD5728187C7500503DA2 /* dummy.m */, + 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */, + ); + path = "SimpleX NSE"; + sourceTree = ""; + }; + 5CDCAD7128188CEB00503DA2 /* Shared */ = { + isa = PBXGroup; + children = ( + 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */, + 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, + 5CDCAD7428188D2900503DA2 /* APITypes.swift */, + 5C5E5D3C282447AB00B0488A /* CallTypes.swift */, + 5C9FD96A27A56D4D0075386C /* JSON.swift */, + 5CDCAD7D2818941F00503DA2 /* API.swift */, + 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, + ); + path = Shared; + sourceTree = ""; + }; 5CE4407427ADB657007B033A /* ChatItem */ = { isa = PBXGroup; children = ( @@ -403,6 +547,23 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 5CDCAD442818589900503DA2 /* SimpleX NSE */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */; + buildPhases = ( + 5CDCAD412818589900503DA2 /* Sources */, + 5CDCAD422818589900503DA2 /* Frameworks */, + 5CDCAD432818589900503DA2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "SimpleX NSE"; + productName = "SimpleX NSE"; + productReference = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -410,7 +571,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1320; + LastSwiftUpdateCheck = 1330; LastUpgradeCheck = 1330; ORGANIZATIONNAME = "SimpleX Chat"; TargetAttributes = { @@ -422,6 +583,10 @@ CreatedOnToolsVersion = 13.2.1; TestTargetID = 5CA059C9279559F40002BEB4; }; + 5CDCAD442818589900503DA2 = { + CreatedOnToolsVersion = 13.3; + LastSwiftMigration = 1330; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -443,6 +608,7 @@ targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, + 5CDCAD442818589900503DA2 /* SimpleX NSE */, ); }; /* End PBXProject section */ @@ -452,6 +618,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3C71477A281C0F6800CB4D4B /* www in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, @@ -465,6 +632,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD432818589900503DA2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */, + 5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -473,11 +649,19 @@ buildActionMask = 2147483647; files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, + 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */, + 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, + 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */, + 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, + 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, + 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 */, @@ -485,9 +669,14 @@ 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, + 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, + 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, + 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */, + 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, + 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, @@ -497,17 +686,18 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, + 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, - 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */, 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, - 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */, + 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, @@ -515,9 +705,13 @@ 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, + 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, + 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */, + 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, + 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, @@ -534,6 +728,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD412818589900503DA2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */, + 5C5E5D3E282447BF00B0488A /* CallTypes.swift in Sources */, + 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */, + 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, + 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */, + 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */, + 5CDCAD682818876500503DA2 /* JSON.swift in Sources */, + 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */, + 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */, + 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -545,6 +756,22 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CB0BA872826CB3A00B3292C /* ru */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 5CB0BA892826CB3A00B3292C /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CB0BA8A2826CB3A00B3292C /* ru */, + ); + name = Localizable.strings; + sourceTree = ""; + }; 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( @@ -686,13 +913,15 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; 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_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -704,13 +933,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -730,13 +955,15 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; 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_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -748,13 +975,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -807,6 +1030,86 @@ }; name = Release; }; + 5CDCAD4E2818589900503DA2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 45; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX NSE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX NSE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/ios", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/sim", + ); + MARKETING_VERSION = 2.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 5CDCAD4F2818589900503DA2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 45; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX NSE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX NSE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/ios", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/sim", + ); + MARKETING_VERSION = 2.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -837,6 +1140,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CDCAD4E2818589900503DA2 /* Debug */, + 5CDCAD4F2818589900503DA2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/apps/ios/en.lproj/Localizable.strings b/apps/ios/en.lproj/Localizable.strings index 97efd9d6d7..7bbeebb05a 100644 --- a/apps/ios/en.lproj/Localizable.strings +++ b/apps/ios/en.lproj/Localizable.strings @@ -1,6 +1,11 @@ +/* No comment provided by engineer. */ +"Choose file" = "Choose file (new in v2.0)"; + /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Connecting to server… (error: %@)"; +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact."; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 443dc446bc..0f86f73a9e 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -22,9 +22,6 @@ /* No comment provided by engineer. */ "!1 colored!" = "!1 цвет!"; -/* No comment provided by engineer. */ -"(shared only with your contacts)" = "(отправляется только вашим контактам)"; - /* No comment provided by engineer. */ ")" = ")"; @@ -35,7 +32,13 @@ "**Add new contact**: to create your one-time QR Code for your contact." = "**Добавить новый контакт**: чтобы создать одноразовый QR код или ссылку для вашего контакта."; /* No comment provided by engineer. */ -"**Scan QR code**: to connect to your contact who shows QR code to you." = "**Сканировать QR код**: чтобы соединиться с вашим контактом (который показывает вам QR код)."; +"**Create link / QR code** for your contact to use." = "**Создать ссылку / QR код** для вашего контакта."; + +/* No comment provided by engineer. */ +"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact in person or via video call." = "**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка."; /* No comment provided by engineer. */ "*bold*" = "\\*жирный*"; @@ -61,17 +64,17 @@ /* No comment provided by engineer. */ "~strike~" = "\\~зачеркнуть~"; -/* No comment provided by engineer. */ -"💻 desktop: scan displayed QR code from the app, via **Scan QR code**." = "💻 на компьютере: сосканируйте QR код из приложения через **Сканировать QR код**."; - -/* No comment provided by engineer. */ -"📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app." = "📱 на мобильном: намжите кнопку **Open in mobile app** на веб странице, затем нажмите **Соединиться** в приложении."; - /* No comment provided by engineer. */ "6" = "6"; /* No comment provided by engineer. */ -"above, then:" = "наверху, затем:"; +"About SimpleX" = "О SimpleX"; + +/* No comment provided by engineer. */ +"About SimpleX Chat" = "Информация о SimpleX Chat"; + +/* No comment provided by engineer. */ +"above, then choose:" = "наверху, затем выберите:"; /* accept contact request via notification */ "Accept" = "Принять"; @@ -82,18 +85,33 @@ /* notification body */ "Accept contact request from %@?" = "Принять запрос на соединение от %@?"; +/* call status */ +"accepted" = "принятый звонок"; + /* No comment provided by engineer. */ -"Add contact" = "Добавить контакт"; +"Add contact to start a new chat" = "Добавьте контакт, чтобы начать разговор"; /* No comment provided by engineer. */ "All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся."; +/* No comment provided by engineer. */ +"Already connected?" = "Соединение уже установлено?"; + +/* accept incoming call via notification */ +"Answer" = "Ответить"; + /* No comment provided by engineer. */ "Attach" = "Прикрепить"; /* No comment provided by engineer. */ "bold" = "жирный"; +/* No comment provided by engineer. */ +"Call already ended!" = "Звонок уже завершен!"; + +/* call status */ +"calling…" = "входящий звонок…"; + /* No comment provided by engineer. */ "Cancel" = "Отменить"; @@ -106,9 +124,21 @@ /* back button to return to chats list */ "Chats" = "Назад"; +/* No comment provided by engineer. */ +"Check messages" = "Проверять сообщения"; + +/* notification */ +"Checking new messages..." = "Проверяются новые сообщения..."; + +/* No comment provided by engineer. */ +"Choose file" = "Выбрать файл (v2.0)"; + /* No comment provided by engineer. */ "Choose from library" = "Выбрать из библиотеки"; +/* No comment provided by engineer. */ +"Clear" = "Очистить"; + /* No comment provided by engineer. */ "colored" = "цвет"; @@ -128,7 +158,16 @@ "Connect via contact link?" = "Соединиться через ссылку-контакт?"; /* No comment provided by engineer. */ -"Connect via invitation link?" = "Соединиться через ссылку-приглашение?"; +"Connect via link" = "Соединиться через ссылку"; + +/* No comment provided by engineer. */ +"Connect via one-time link?" = "Соединиться через одноразовую ссылку?"; + +/* No comment provided by engineer. */ +"Connect with the developers" = "Соединиться с разработчиками"; + +/* No comment provided by engineer. */ +"connected" = "соединение установлено"; /* No comment provided by engineer. */ "Connecting server…" = "Устанавливается соединение с сервером…"; @@ -136,12 +175,22 @@ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Устанавливается соединение с сервером… (ошибка: %@)"; +/* call status + chat list item title */ +"connecting…" = "соединяется…"; + /* No comment provided by engineer. */ "Connecting..." = "Устанавливается соединение…"; /* No comment provided by engineer. */ "Connection error" = "Ошибка соединения"; +/* No comment provided by engineer. */ +"Connection error (AUTH)" = "Ошибка соединения (AUTH)"; + +/* chat list item title (it should not be shown */ +"connection established" = "соединение установлено"; + /* No comment provided by engineer. */ "Connection request" = "Запрос на соединение"; @@ -151,6 +200,9 @@ /* No comment provided by engineer. */ "Connection timeout" = "Превышено время соединения"; +/* connection information */ +"connection:%@" = "connection:%@"; + /* No comment provided by engineer. */ "Contact already exists" = "Существующий контакт"; @@ -160,20 +212,32 @@ /* notification */ "Contact is connected" = "Соединение с контактом установлено"; +/* No comment provided by engineer. */ +"Contact is not connected yet!" = "Соединение еще не установлено!"; + /* No comment provided by engineer. */ "Copy" = "Скопировать"; /* No comment provided by engineer. */ "Create" = "Создать"; +/* No comment provided by engineer. */ +"Create 1-time link / QR code" = "Создать ссылку / QR код"; + /* No comment provided by engineer. */ "Create address" = "Создать адрес"; /* No comment provided by engineer. */ -"Create group" = "Создать группу"; +"Create link / QR code" = "Создать ссылку / QR код"; /* No comment provided by engineer. */ -"Create profile" = "Создать профиль"; +"Create your profile" = "Создать профиль"; + +/* No comment provided by engineer. */ +"Currently maximum supported file size is %@." = "Максимальный размер файла - %@."; + +/* No comment provided by engineer. */ +"Decentralized" = "Децентрализованный"; /* No comment provided by engineer. */ "Delete" = "Удалить"; @@ -187,6 +251,9 @@ /* No comment provided by engineer. */ "Delete contact" = "Удалить контакт"; +/* No comment provided by engineer. */ +"Delete Contact" = "Удалить контакт"; + /* No comment provided by engineer. */ "Delete contact?" = "Удалить контакт?"; @@ -202,6 +269,12 @@ /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; +/* No comment provided by engineer. */ +"Delete pending connection" = "Удалить соединение"; + +/* No comment provided by engineer. */ +"Delete pending connection?" = "Удалить ожидаемое соединение?"; + /* deleted chat item */ "deleted" = "удалено"; @@ -214,9 +287,27 @@ /* No comment provided by engineer. */ "Edit" = "Редактировать"; +/* No comment provided by engineer. */ +"Enable notifications? (BETA)" = "Включить уведомления? (БЕТА)"; + +/* No comment provided by engineer. */ +"end-to-end encrypted" = "end-to-end зашифрованный звонок"; + +/* call status */ +"ended %02d:%02d" = "звонок завершён %1$d:%2$d"; + /* No comment provided by engineer. */ "Enter one SMP server per line:" = "Введите SMP серверы, каждый на отдельной строке:"; +/* call status */ +"error" = "ошибка соединения"; + +/* No comment provided by engineer. */ +"Error deleting token" = "Ошибка удаления токена"; + +/* No comment provided by engineer. */ +"Error registering token" = "Ошибка регистрации токена"; + /* No comment provided by engineer. */ "Error saving SMP servers" = "Ошибка при сохранении SMP серверов"; @@ -226,6 +317,9 @@ /* No comment provided by engineer. */ "Error: URL is invalid" = "Ошибка: неверная ссылка"; +/* No comment provided by engineer. */ +"File will be received when your contact is online, please wait or check later!" = "Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!"; + /* No comment provided by engineer. */ "Full name (optional)" = "Полное имя (не обязательно)"; @@ -235,23 +329,47 @@ /* No comment provided by engineer. */ "Help" = "Помощь"; +/* No comment provided by engineer. */ +"How it works" = "Как это работает"; + +/* No comment provided by engineer. */ +"How SimpleX works" = "Как SimpleX работает"; + /* No comment provided by engineer. */ "How to" = "Инфо"; +/* No comment provided by engineer. */ +"How to use it" = "Как использовать"; + /* No comment provided by engineer. */ "How to use markdown" = "Как форматировать"; /* No comment provided by engineer. */ -"How to use SimpleX Chat" = "Как использовать SimpleX Chat"; +"If you can't meet in person, **show QR code in the video call**, or share the link." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой."; /* No comment provided by engineer. */ "If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку."; -/* No comment provided by engineer. */ -"If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка** или отправить ссылку через любой другой канал связи."; +/* ignore incoming call via notification */ +"Ignore" = "Не отвечать"; /* No comment provided by engineer. */ -"If you received SimpleX Chat invitation link you can open it in your browser:" = "Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере:"; +"Image will be received when your contact is online, please wait or check later!" = "Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!"; + +/* No comment provided by engineer. */ +"Immune to spam and abuse" = "Защищен от спама"; + +/* No comment provided by engineer. */ +"In person or via a video call – the most secure way to connect." = "При встрече или в видеозвонке – самый безопасный способ установить соединение"; + +/* call status */ +"in progress" = "активный звонок"; + +/* notification body */ +"Incoming %@ call" = "Входящий звонок"; + +/* notification */ +"Incoming call" = "Входящий звонок"; /* No comment provided by engineer. */ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)"; @@ -259,45 +377,111 @@ /* No comment provided by engineer. */ "Invalid connection link" = "Ошибка в ссылке контакта"; +/* chat list item title */ +"invited to connect" = "приглашение"; + +/* No comment provided by engineer. */ +"It seems like you are already connected via this link. If it is not the case, there was an error (%@)." = "Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@)."; + +/* No comment provided by engineer. */ +"It's secure to share - only one contact can use it." = "Ей безопасно поделиться - только один контакт может использовать её."; + /* No comment provided by engineer. */ "italic" = "курсив"; +/* No comment provided by engineer. */ +"Large file!" = "Большой файл!"; + +/* No comment provided by engineer. */ +"Make a private connection" = "Добавьте контакт"; + /* No comment provided by engineer. */ "Make sure SMP server addresses are in correct format, line separated and are not duplicated." = "Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется."; +/* No comment provided by engineer. */ +"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?*"; + /* No comment provided by engineer. */ "Markdown in messages" = "Форматирование сообщений"; /* No comment provided by engineer. */ "Message delivery error" = "Ошибка доставки сообщения"; +/* call status */ +"missed" = "пропущенный звонок"; + /* No comment provided by engineer. */ "Most likely this contact has deleted the connection with you." = "Скорее всего, этот контакт удалил соединение с вами."; /* notification */ "New contact request" = "Новый запрос на соединение"; -/* notifications */ +/* notification */ "New message" = "Новое сообщение"; +/* No comment provided by engineer. */ +"no end-to-end encryption" = "end-to-end шифрование не поддерживается"; + /* No comment provided by engineer. */ "Notifications are disabled!" = "Уведомления выключены"; +/* No comment provided by engineer. */ +"One-time invitation link" = "Одноразовая ссылка"; + +/* No comment provided by engineer. */ +"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**"; + /* No comment provided by engineer. */ "Open Settings" = "Открыть Настройки"; +/* No comment provided by engineer. */ +"Open-source protocol and code – anybody can run the servers." = "Открытый протокол и код - кто угодно может запустить сервер."; + +/* No comment provided by engineer. */ +"or" = "или"; + +/* No comment provided by engineer. */ +"Or open the link in the browser and tap **Open in mobile**." = "Или откройте ссылку в браузере и нажмите **Open in mobile**."; + +/* No comment provided by engineer. */ +"Paste" = "Вставить"; + +/* No comment provided by engineer. */ +"Paste received link" = "Вставить полученную ссылку"; + +/* No comment provided by engineer. */ +"Paste the link you received" = "Вставьте полученную ссылку"; + +/* No comment provided by engineer. */ +"Paste the link you received into the box below to connect with your contact." = "Чтобы соединиться, вставьте ссылку, полученную от вашего контакта."; + +/* No comment provided by engineer. */ +"People can connect to you only via the links you share." = "С вами можно соединиться только через созданные вами ссылки."; + /* No comment provided by engineer. */ "Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку."; /* No comment provided by engineer. */ "Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз."; +/* No comment provided by engineer. */ +"Privacy redefined" = "Более конфиденциальный"; + /* No comment provided by engineer. */ "Profile image" = "Аватар"; /* No comment provided by engineer. */ "Read" = "Прочитано"; +/* No comment provided by engineer. */ +"Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme)." = "Узнайте больше из нашего [GitHub репозитория](https://github.com/simplex-chat/simplex-chat#readme)."; + +/* No comment provided by engineer. */ +"Read more in our GitHub repository." = "Узнайте больше из нашего GitHub репозитория."; + +/* No comment provided by engineer. */ +"received answer…" = "получен ответ…"; + /* No comment provided by engineer. */ "Reject" = "Отклонить"; @@ -307,6 +491,9 @@ /* No comment provided by engineer. */ "Reject contact request" = "Отклонить запрос"; +/* call status */ +"rejected" = "отклоненный звонок"; + /* No comment provided by engineer. */ "Reply" = "Ответить"; @@ -319,6 +506,9 @@ /* No comment provided by engineer. */ "Saved SMP servers will be removed" = "Сохраненные SMP серверы будут удалены"; +/* No comment provided by engineer. */ +"Scan contact's QR code" = "Сосканировать QR код контакта"; + /* No comment provided by engineer. */ "Scan QR code" = "Сканировать QR код"; @@ -341,13 +531,13 @@ "Share link" = "Поделиться ссылкой"; /* No comment provided by engineer. */ -"Show QR code to your contact\nto scan from the app" = "Покажите QR код вашему контакту для сканирования в приложении"; +"Show pending connections" = "Показать ожидаемые соединения"; /* No comment provided by engineer. */ "SMP servers" = "SMP серверы"; /* No comment provided by engineer. */ -"Start new chat" = "Начать новый разговор"; +"starting…" = "инициализация…"; /* No comment provided by engineer. */ "strike" = "зачеркнуть"; @@ -361,23 +551,44 @@ /* No comment provided by engineer. */ "Thank you for installing SimpleX Chat!" = "Спасибо, что установили SimpleX Chat!"; +/* No comment provided by engineer. */ +"The 1st platform without any user identifiers – private by design." = "Первая в мире платформа без идентификаторов пользователей."; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках."; /* No comment provided by engineer. */ -"The messaging and application platform 100% private by design!" = "Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность."; +"The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server." = "Приложение может получать скрытые уведомления каждые 20 минут чтобы проверить новые сообщения.\n*Обратите внимание*: если вы подтвердите, токен вашего устройства будет послан на сервер SimpleX Chat."; + +/* No comment provided by engineer. */ +"The connection you accepted will be cancelled!" = "Подтвержденное соединение будет отменено!"; + +/* No comment provided by engineer. */ +"The contact you shared this link with will NOT be able to connect!" = "Контакт, которому вы отправили эту ссылку, не сможет соединиться!"; + +/* No comment provided by engineer. */ +"The next generation of private messaging" = "Новое поколение приватных сообщений"; + +/* No comment provided by engineer. */ +"The profile is only shared with your contacts." = "Профиль отправляется только вашим контактам."; /* No comment provided by engineer. */ "The sender will NOT be notified" = "Отправитель не будет уведомлён"; +/* No comment provided by engineer. */ +"To ask any questions and to receive SimpleX Chat updates." = "Чтобы задать вопросы и получать уведомления о SimpleX Chat."; + /* No comment provided by engineer. */ "To ask any questions and to receive updates:" = "Чтобы задать вопросы и получать уведомления о новых версиях,"; /* No comment provided by engineer. */ -"To connect via link" = "Соединиться через ссылку"; +"To make a new connection" = "Чтобы соединиться"; /* No comment provided by engineer. */ -"To start a new chat" = "Начать новый разговор"; +"To make your first private connection, choose **one of the following**:" = "Чтобы добавить ваш первый контакт, выберите **одно из**:"; + +/* No comment provided by engineer. */ +"To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." = "Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта."; /* No comment provided by engineer. */ "Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@)."; @@ -388,6 +599,9 @@ /* No comment provided by engineer. */ "Unexpected error: %@" = "Неожиданная ошибка: %@"; +/* No comment provided by engineer. */ +"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью."; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; @@ -397,6 +611,24 @@ /* No comment provided by engineer. */ "v%@ (%@)" = "v%@ (%@)"; +/* chat list item description */ +"via contact address link" = "через ссылку-контакт"; + +/* chat list item description */ +"via one-time link" = "через одноразовую ссылку"; + +/* No comment provided by engineer. */ +"waiting for answer…" = "ожидается ответ…"; + +/* No comment provided by engineer. */ +"waiting for confirmation…" = "ожидается подтверждение…"; + +/* No comment provided by engineer. */ +"Waiting for file" = "Ожидается прием файла"; + +/* No comment provided by engineer. */ +"Waiting for image" = "Ожидается прием изображения"; + /* No comment provided by engineer. */ "wants to connect to you!" = "хочет соединиться с вами!"; @@ -406,12 +638,18 @@ /* No comment provided by engineer. */ "You" = "Вы"; +/* No comment provided by engineer. */ +"You accepted connection" = "Вы приняли приглашение соединиться"; + /* No comment provided by engineer. */ "You are already connected to %@ via this link." = "Вы уже соединены с %@ через эту ссылку."; /* No comment provided by engineer. */ "You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который вы получаете сообщения от этого контакта."; +/* No comment provided by engineer. */ +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" = "Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**."; + /* notification body */ "You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@"; @@ -422,7 +660,13 @@ "You can use markdown to format messages:" = "Вы можете форматировать сообщения:"; /* No comment provided by engineer. */ -"You control your chat!" = "Вы котролируете Ваш чат!"; +"You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." = "Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки."; + +/* No comment provided by engineer. */ +"You invited your contact" = "Вы пригласили ваш контакт"; + +/* chat list item description */ +"you shared one-time link" = "вы создали ссылку"; /* No comment provided by engineer. */ "You will be connected when your connection request is accepted, please wait or check later!" = "Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!"; @@ -442,14 +686,23 @@ /* No comment provided by engineer. */ "Your chats" = "Ваши чаты"; +/* No comment provided by engineer. */ +"Your contact can scan it from the app" = "Ваш контакт может сосканировать QR в приложении"; + +/* No comment provided by engineer. */ +"Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой)."; + +/* No comment provided by engineer. */ +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ваш контакт отправил файл, размер которого превышает максимальный размер (%@)."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\nSimpleX серверы не могут получить доступ к вашему профилю."; /* No comment provided by engineer. */ -"Your profile will be sent to the contact that you received this link from" = "Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку."; +"Your profile will be sent to the contact that you received this link from" = "Ваш профиль будет отправлен вашему контакту."; /* No comment provided by engineer. */ -"Your profile, contacts and messages (once delivered) are only stored locally on your device." = "Ваш профиль, контакты и сообщения (после доставки) хранятся только на вашем устройстве."; +"Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве."; /* No comment provided by engineer. */ "Your settings" = "Настройки"; diff --git a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings index a8fa77d9f6..4dc64e9475 100644 --- a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings @@ -2,5 +2,11 @@ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ -"NSCameraUsageDescription" = "SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями"; +"NSCameraUsageDescription" = "SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями и для видео звонков."; + +/* Privacy - Microphone Usage Description */ +"NSMicrophoneUsageDescription" = "SimpleX использует микрофон для аудио и видео звонков."; + +/* Privacy - Photo Library Additions Usage Description */ +"NSPhotoLibraryAddUsageDescription" = "SimpleX использует доступ к Photo Library для сохранения сделанных и полученных фотографий"; diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index bb81911913..6b31a62dce 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -11,12 +11,12 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad.Reader import qualified Data.Text as T -import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Messages import Simplex.Chat.Options +import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Types import System.Directory (getAppUserDataDirectory) import Text.Read @@ -24,7 +24,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore defaultChatConfig opts Nothing mySquaringBot + simplexChatCore terminalChatConfig opts Nothing mySquaringBot welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index 1c322dbc18..2228070039 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -2,18 +2,18 @@ module Main where -import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller (versionNumber) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Terminal (terminalChatConfig) import System.Directory (getAppUserDataDirectory) import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore defaultChatConfig opts Nothing $ + simplexChatCore terminalChatConfig opts Nothing $ chatBotRepl "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" $ \msg -> case readMaybe msg :: Maybe Integer of Just n -> msg <> " * " <> msg <> " = " <> show (n * n) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 0a94c77935..821bc2a270 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -3,7 +3,6 @@ module Main where import Control.Concurrent (threadDelay) -import Simplex.Chat import Simplex.Chat.Controller (versionNumber) import Simplex.Chat.Core import Simplex.Chat.Options @@ -20,8 +19,8 @@ main = do then do welcome opts t <- withTerminal pure - simplexChatTerminal defaultChatConfig opts t - else simplexChatCore defaultChatConfig opts Nothing $ \_ cc -> do + simplexChatTerminal terminalChatConfig opts t + else simplexChatCore terminalChatConfig opts Nothing $ \_ cc -> do r <- sendChatCmd cc chatCmd putStrLn $ serializeChatResponse r threadDelay $ chatCmdDelay opts * 1000000 diff --git a/blog/20220511-simplex-chat-v2-images-files.md b/blog/20220511-simplex-chat-v2-images-files.md new file mode 100644 index 0000000000..0a0b9bfd6e --- /dev/null +++ b/blog/20220511-simplex-chat-v2-images-files.md @@ -0,0 +1,35 @@ +# SimpleX Chat v2.0 - sending images and files in mobile apps + +**Published:** May 11, 2022 + +## New in version 2.0 - sending images and files privately + +To send image and files SimpleX Chat uses privacy-preserving system components, both in iOS and Android apps. We do not ask for permission to access multiple or selected files, as, for example, Signal and Telegram do - it compromises either privacy or convenience. + +How does it work? The gallery and files are accessed from a system provided dialogue that runs in a separate process, and provides a temporary URI to access only one file selected by the user, only until the app is restarted. + +To make file and images work for mobile apps we made a breaking change in SimpleX Chat core. The current version can exchange files with the previous version 1.6 of the terminal app, but not with the version before that. + +In the mobile app, to send and receive files both devices must have version 2.0 installed - so please check it with your contacts. Receiving images works in the previous version, so even if your contacts did not yet upgrade the app, they should be able to receive the images. + +## The first messaging platform without user identifiers. + +To protect identities of users and their connections, SimpleX Chat has no user identifiers visible to the network – unlike any other messaging platform. + +Many people asked: _if SimpleX has no user identifiers, how can it deliver messages?_ + +To deliver mesages, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts. In the current version of the protocol each queue is used until the contact is deleted. Later this year we plan to add queue rotation to the client protocol, so that even conversations don't have long term identifiers visible to the network. This design prevents leaking any users metadata on the application level. + +You define which server(s) to use **to receive** the messages, your contacts – the servers you use **to send** the messages to them. It means that every conversation is likely to use two different servers - one for each message direction. + +Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. + +Read more in [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md). + +## How to connect with your contacts in SimpleX Chat + +Once you install the app, you can connect to anybody: + +1. Create your local chat profile - it is not shared with SimpleX servers. It is local to your devices, and it will be shared with your contacts only when you connect. +2. To make a private connection, you need to create a one-time connection link or a QR code via the app. You can show the QR code to your contact in person or via a video call - this is the most secure way to create a connection - or you can share the link via any other channel. Only one user can connect via this link. +3. Once another user scans the QR code or opens the app via the link the connection will be created and you can send end-to-end encrypted messages privately, without anybody knowing you are connected. diff --git a/blog/README.md b/blog/README.md index 293dd5cf96..204ced415a 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,7 @@ # Blog +May 11, 2022 [Sending images and files in mobile apps](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220511-simplex-chat-v2-images-files) + Apr 04, 2022 [Instant notifications for SimpleX Chat mobile apps](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220404-simplex-chat-instant-notifications.md) Mar 08, 2022 [Mobile apps for iOS and Android released!](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220308-simplex-chat-mobile-apps.md) diff --git a/cabal.project b/cabal.project index a7af3451fd..d07117a669 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d38303d5f1d55a90dd95d951ba8d798e17699269 + tag: 964daf5442e1069634762450bc28cfd69a2968a1 source-repository-package type: git diff --git a/docs/rfcs/2022-04-20-video-calls.md b/docs/rfcs/2022-04-20-video-calls.md new file mode 100644 index 0000000000..38c2ad6186 --- /dev/null +++ b/docs/rfcs/2022-04-20-video-calls.md @@ -0,0 +1,57 @@ +# Adding Audio/Video Call Functionality to SimpleX Apps + +To extend the functionality of the SimpleX mobile apps in pursuit of supporting all kinds of communication, we seek to add the ability for already connected users to call each other with audio and optionally video. + +## Desired Functionality +- [ ] The content (audio/video data) of the calls is encrypted +- [ ] The setting up of the call session is secure and encrypted (via the SimpleX protocol) +- [ ] Contacts can audio call each other +- [ ] Contacts can video call each other +- [ ] When on a call users can mute/unmute their mic +- [ ] When on a video call users can show/hide their video feed +- [ ] Users will be notified of other calls when already engaged in a call +- [ ] Incoming calls trigger a notification which offers the chance to accept or reject the call. Accepting the call opens the app to a call page. +- [ ] (TBC) Calls will be entered into chat history as immutable messages with styling differing from typical messages + +## Proposed Implementation + +The calls themselves should be handled by [WebRTC](https://www.html5rocks.com/en/tutorials/webrtc/infrastructure). This requires some initial messaging to set up the details of the session (routing, codecs, message priorities) and then the data of the call is passed peer-to-peer through the WebRTC channel resulting from the session instantiation. In order to secure the communications, the initial communication to set up the session will be handled through the existing SimpleX communication channel between users. The content sent through the WebRTC session will also be encrypted using keys (exchanged through SimpleX). Full details of the workflow for setting up WebRTC calls can be found [here](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling). + +To take advantage of existing development and infrastructure, we propose to build this functionality using webviews in our apps which will have web pages using JavaScript APIs to handle WebRTC elements. + + +### Setting Up the Session + +In essence, we can use SimpleX to [handle the signalling](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling) with [ICE](https://developer.mozilla.org/en-US/docs/Glossary/ICE) agents performing negotiation at either end in the SimpleX mobile app. This requires the sharing of [Session Description Protocol](https://developer.mozilla.org/en-US/docs/Glossary/SDP) information which can be serialised as JSON. These can be passed as a new message type in the SimpleX API. + +There are a few key features required to set up a call session. +1. Generation of Offers +2. Negotiation of Offers +3. Instantiating the call over the network +4. Showing the users the video streams + +These elements can be included in a new 'call' message type which includes the information alongside the nature of the call (i.e. audio only or video). + +User state etc can be updated as calls are connected/disconnected. + +### Initial Prototype +To get off the ground, we will develop an initial prototype (not for app store release). This prototype will simply set up a video call with no changes to the SimpleX API (as we will simply pass messages as JSON through other channels). + +This prototype will demonstrate how to set up calls using WebRTC and demonstrate how to pass information to and from webviews in app. + +The workflow will be as follows +1. +2. +3. + +## Queries +**Do we need to set up and destroy virtual IP addresses for additional security?** + +For initial implementation it is sufficient to warn users that they may be exposing their IP to the recipient. + +**Is it beneficial to have an additional layer of encryption for the media content (under the principle of zero trust or otherwise)?** + +Yes. We can implement a 'frame encryption' method in the SimpleX API which given a key and some content returns the encrypted content. Similarly, we will have a decryption call. Keys can be call specific and formed using typical DH key exchange. + +**Who runs the STUN/TURN servers?** +For the initial prototype we can use publicly available servers. For a full release implementation, SimpleX will need to run its own routing servers to support ICE and possibly STUN/TURN. Open source implementations for these elements exist. \ No newline at end of file diff --git a/docs/rfcs/webrtc.mmd b/docs/rfcs/webrtc.mmd new file mode 100644 index 0000000000..4da2841e33 --- /dev/null +++ b/docs/rfcs/webrtc.mmd @@ -0,0 +1,67 @@ +sequenceDiagram + participant AW as Alice's
web view + participant AN as Alice's
app native + participant AC as Alice's
chat core + participant BC as Bob's
chat core + participant BN as Bob's
app native + participant BW as Bob's
web view + + note over AW, AC: Alice's app + note over BC, BW: Bob's app + + note over AW, BW: 1. Establishing call + + note over AN: user: start call + AN ->> AW: WCCapabilities + AW ->> AN: WRCapabilities (e2e?) + + AN ->> AC: APISendCallInvitation + AC -->> BC: XCallInv + AC ->> AN: CRCmdOk + + BC ->> BN: CRCallInvitation + note over BN: show: accept call? + + alt user accepted? + BN ->> BC: no: APIRejectCall
(sender not notified) + BC ->> BN: CRCmdOk + else + BN ->> BW: yes: WCStartCall + BW ->> BN: WCallOffer + end + + BN ->> BC: APISendCallOffer + BC -->> AC: XCallOffer + BC ->> BN: CRCmdOk + AC ->> AN: CRCallOffer + note over AN: show if no e2e: continue call? + + AN ->> AW: WCallOffer + AW ->> AN: WCallAnswer + AN ->> AC: APISendCallAnswer + AC -->> BC: XCallAnswer + AC ->> AN: CRCmdOk + BC ->> BN: CRCallAnswer + BN ->> BW: WCallAnswer + + note over AW, BW: call can be established at this point + + note over AW, BW: 2. Sending additional ice candidates
(optional, same for another side): + + BW ->> BN: WCallICE + BN ->> BC: APISendCallExtraInfo + BC -->> AC: XCallExtra + BC ->> BN: CRCmdOk + AC ->> AN: CRCallExtraInfo + AN ->> AW: WCallICE + + note over AW, BW: 3. Call termination (same for another party): + + note over AN: user: end call + AN ->> AW: WEndCall + AN ->> AC: APIEndCall + AC -->> BC: XCallEnd + AC ->> AN: CRCmdOk + BC ->> BN: CRCallEnded + note over BN: show: call ended + BN ->> BW: WEndCall diff --git a/package.yaml b/package.yaml index 835bf19a9f..6877733594 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.6.0 +version: 2.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme @@ -30,7 +30,7 @@ dependencies: - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - simple-logger == 0.1.* - - simplexmq >= 1.0 && < 1.1 + - simplexmq >= 1.1 && < 3.0 - sqlite-simple == 0.4.* - stm == 2.5.* - terminal == 0.2.* diff --git a/packages/simplex-chat-webrtc/.gitignore b/packages/simplex-chat-webrtc/.gitignore new file mode 100644 index 0000000000..87807d97c7 --- /dev/null +++ b/packages/simplex-chat-webrtc/.gitignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +dist diff --git a/packages/simplex-chat-webrtc/.prettierignore b/packages/simplex-chat-webrtc/.prettierignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/packages/simplex-chat-webrtc/.prettierignore @@ -0,0 +1 @@ +dist diff --git a/packages/simplex-chat-webrtc/.prettierrc.json b/packages/simplex-chat-webrtc/.prettierrc.json new file mode 100644 index 0000000000..cadbab25e3 --- /dev/null +++ b/packages/simplex-chat-webrtc/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "bracketSpacing": false, + "semi": false, + "printWidth": 140 +} diff --git a/packages/simplex-chat-webrtc/README.md b/packages/simplex-chat-webrtc/README.md new file mode 100644 index 0000000000..b8db78dbb1 --- /dev/null +++ b/packages/simplex-chat-webrtc/README.md @@ -0,0 +1,6 @@ +# WebView for WebRTC calls in SimpleX Chat + +``` +npm i +npm run build +``` diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy new file mode 100755 index 0000000000..9175480911 --- /dev/null +++ b/packages/simplex-chat-webrtc/copy @@ -0,0 +1,10 @@ +#!/bin/sh + +# it can be tested in the browser from dist folder +cp ./src/call.html ./dist/call.html +cp ./src/style.css ./dist/style.css + +# copy to android app +cp ./src/call.html ../../apps/android/app/src/main/assets/www/call.html +cp ./src/style.css ../../apps/android/app/src/main/assets/www/style.css +cp ./dist/call.js ../../apps/android/app/src/main/assets/www/call.js diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json new file mode 100644 index 0000000000..222c0c0a56 --- /dev/null +++ b/packages/simplex-chat-webrtc/package.json @@ -0,0 +1,25 @@ +{ + "name": "simplex-chat-webrtc", + "version": "0.0.1", + "description": "WebRTC call in browser and webview", + "main": "dist/call.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "prettier --write --ignore-unknown . && tsc && ./copy" + }, + "keywords": [ + "SimpleX", + "WebRTC" + ], + "author": "", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "husky": "^7.0.4", + "lint-staged": "^12.4.1", + "prettier": "^2.6.2", + "typescript": "^4.6.4" + }, + "lint-staged": { + "**/*": "prettier --write --ignore-unknown" + } +} diff --git a/packages/simplex-chat-webrtc/src/call.html b/packages/simplex-chat-webrtc/src/call.html new file mode 100644 index 0000000000..fd3019e8eb --- /dev/null +++ b/packages/simplex-chat-webrtc/src/call.html @@ -0,0 +1,13 @@ + + + + + + + + + +
+ +
+ diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts new file mode 100644 index 0000000000..5c0678b6e2 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -0,0 +1,623 @@ +// Inspired by +// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption + +interface WVAPICall { + corrId?: number + command: WCallCommand +} + +interface WVApiMessage { + corrId?: number + resp: WCallResponse + command?: WCallCommand +} + +type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCallAnswer | WCallIceCandidates | WCEnableMedia | WCEndCall + +type WCallResponse = WRCapabilities | WCallOffer | WCallAnswer | WCallIceCandidates | WRConnection | WRCallEnded | WROk | WRError + +type WCallCommandTag = "capabilities" | "start" | "accept" | "answer" | "ice" | "media" | "end" + +type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "ended" | "ok" | "error" + +enum CallMediaType { + Audio = "audio", + Video = "video", +} + +interface IWCallCommand { + type: WCallCommandTag +} + +interface IWCallResponse { + type: WCallResponseTag +} + +interface WCCapabilities extends IWCallCommand { + type: "capabilities" +} + +interface WCStartCall extends IWCallCommand { + type: "start" + media: CallMediaType + aesKey?: string +} + +interface WCEndCall extends IWCallCommand { + type: "end" +} + +interface WCAcceptOffer extends IWCallCommand { + type: "accept" + offer: string // JSON string for RTCSessionDescriptionInit + iceCandidates: string[] // JSON strings for RTCIceCandidateInit + media: CallMediaType + aesKey?: string +} + +interface WCallOffer extends IWCallResponse { + type: "offer" + offer: string // JSON string for RTCSessionDescriptionInit + iceCandidates: string[] // JSON strings for RTCIceCandidateInit + capabilities: CallCapabilities +} + +interface WCallAnswer extends IWCallCommand, IWCallResponse { + type: "answer" + answer: string // JSON string for RTCSessionDescriptionInit + iceCandidates: string[] // JSON strings for RTCIceCandidateInit +} + +interface WCallIceCandidates extends IWCallCommand, IWCallResponse { + type: "ice" + iceCandidates: string[] // JSON strings for RTCIceCandidateInit +} + +interface WCEnableMedia extends IWCallCommand { + type: "media" + media: CallMediaType + enable: boolean +} + +interface WRCapabilities extends IWCallResponse { + type: "capabilities" + capabilities: CallCapabilities +} + +interface CallCapabilities { + encryption: boolean +} + +interface WRConnection extends IWCallResponse { + type: "connection" + state: { + connectionState: string + iceConnectionState: string + iceGatheringState: string + signalingState: string + } +} + +interface WRCallEnded extends IWCallResponse { + type: "ended" +} + +interface WROk extends IWCallResponse { + type: "ok" +} + +interface WRError extends IWCallResponse { + type: "error" + message: string +} + +type RTCRtpSenderWithEncryption = RTCRtpSender & { + createEncodedStreams: () => TransformStream +} + +type RTCRtpReceiverWithEncryption = RTCRtpReceiver & { + createEncodedStreams: () => TransformStream +} + +type RTCConfigurationWithEncryption = RTCConfiguration & { + encodedInsertableStreams: boolean +} + +const keyAlgorithm: AesKeyAlgorithm = { + name: "AES-GCM", + length: 256, +} + +const keyUsages: KeyUsage[] = ["encrypt", "decrypt"] + +let activeCall: Call | undefined + +const IV_LENGTH = 12 + +const initialPlainTextRequired = { + key: 10, + delta: 3, + undefined: 1, +} + +interface Call { + connection: RTCPeerConnection + iceCandidates: Promise // JSON strings for RTCIceCandidate + localMedia: CallMediaType + localStream: MediaStream +} + +interface CallConfig { + peerConnectionConfig: RTCConfigurationWithEncryption + iceCandidates: { + delay: number + extrasInterval: number + extrasTimeout: number + } +} + +function defaultCallConfig(encodedInsertableStreams: boolean): CallConfig { + return { + peerConnectionConfig: { + iceServers: [{urls: ["stun:stun.l.google.com:19302"]}], + iceCandidatePoolSize: 10, + encodedInsertableStreams, + }, + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000, + }, + } +} + +async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { + const conn = new RTCPeerConnection(config.peerConnectionConfig) + const remoteStream = new MediaStream() + const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType)) + await setUpMediaStreams(conn, localStream, remoteStream, aesKey) + conn.addEventListener("connectionstatechange", connectionStateChange) + const iceCandidates = new Promise((resolve, _) => { + let candidates: RTCIceCandidate[] = [] + let resolved = false + let extrasInterval: number | undefined + let extrasTimeout: number | undefined + const delay = setTimeout(() => { + if (!resolved) { + resolveIceCandidates() + extrasInterval = setInterval(() => { + sendIceCandidates() + }, config.iceCandidates.extrasInterval) + extrasTimeout = setTimeout(() => { + clearInterval(extrasInterval) + sendIceCandidates() + }, config.iceCandidates.extrasTimeout) + } + }, config.iceCandidates.delay) + + conn.onicecandidate = ({candidate: c}) => c && candidates.push(c) + conn.onicegatheringstatechange = () => { + if (conn.iceGatheringState == "complete") { + if (resolved) { + if (extrasInterval) clearInterval(extrasInterval) + if (extrasTimeout) clearTimeout(extrasTimeout) + sendIceCandidates() + } else { + resolveIceCandidates() + } + } + } + + function resolveIceCandidates() { + if (delay) clearTimeout(delay) + resolved = true + const iceCandidates = candidates.map((c) => JSON.stringify(c)) + candidates = [] + resolve(iceCandidates) + } + + function sendIceCandidates() { + if (candidates.length === 0) return + const iceCandidates = candidates.map((c) => JSON.stringify(c)) + candidates = [] + sendMessageToNative({resp: {type: "ice", iceCandidates}}) + } + }) + + return {connection: conn, iceCandidates, localMedia: mediaType, localStream} + + function connectionStateChange() { + sendMessageToNative({ + resp: { + type: "connection", + state: { + connectionState: conn.connectionState, + iceConnectionState: conn.iceConnectionState, + iceGatheringState: conn.iceGatheringState, + signalingState: conn.signalingState, + }, + }, + }) + if (conn.connectionState == "disconnected" || conn.connectionState == "failed") { + conn.removeEventListener("connectionstatechange", connectionStateChange) + sendMessageToNative({resp: {type: "ended"}}) + conn.close() + activeCall = undefined + resetVideoElements() + } + } +} + +var sendMessageToNative = (msg: WVApiMessage) => console.log(JSON.stringify(msg)) + +async function processCommand(body: WVAPICall): Promise { + const {corrId, command} = body + const pc = activeCall?.connection + let resp: WCallResponse + try { + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams() + resp = {type: "capabilities", capabilities: {encryption}} + break + case "start": + console.log("starting call") + if (activeCall) { + resp = {type: "error", message: "start: call already started"} + } else if (!supportsInsertableStreams() && command.aesKey) { + resp = {type: "error", message: "start: encryption is not supported"} + } else { + const encryption = supportsInsertableStreams() + const {media, aesKey} = command + activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, encryption ? aesKey : undefined) + const pc = activeCall.connection + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + // for debugging, returning the command for callee to use + // resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey} + resp = { + type: "offer", + offer: JSON.stringify(offer), + iceCandidates: await activeCall.iceCandidates, + capabilities: {encryption}, + } + } + break + case "accept": + if (activeCall) { + resp = {type: "error", message: "accept: call already started"} + } else if (!supportsInsertableStreams() && command.aesKey) { + resp = {type: "error", message: "accept: encryption is not supported"} + } else { + const offer = JSON.parse(command.offer) + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)) + activeCall = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey) + const pc = activeCall.connection + await pc.setRemoteDescription(new RTCSessionDescription(offer)) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + addIceCandidates(pc, remoteIceCandidates) + // same as command for caller to use + resp = { + type: "answer", + answer: JSON.stringify(answer), + iceCandidates: await activeCall.iceCandidates, + } + } + break + case "answer": + if (!pc) { + resp = {type: "error", message: "answer: call not started"} + } else if (!pc.localDescription) { + resp = {type: "error", message: "answer: local description is not set"} + } else if (pc.currentRemoteDescription) { + resp = {type: "error", message: "answer: remote description already set"} + } else { + const answer = JSON.parse(command.answer) + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)) + await pc.setRemoteDescription(new RTCSessionDescription(answer)) + addIceCandidates(pc, remoteIceCandidates) + resp = {type: "ok"} + } + break + case "ice": + if (pc) { + const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c)) + addIceCandidates(pc, remoteIceCandidates) + resp = {type: "ok"} + } else { + resp = {type: "error", message: "ice: call not started"} + } + break + case "media": + if (!activeCall) { + resp = {type: "error", message: "media: call not started"} + } else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { + resp = {type: "error", message: "media: no video"} + } else { + enableMedia(activeCall.localStream, command.media, command.enable) + resp = {type: "ok"} + } + break + case "end": + if (pc) { + pc.close() + activeCall = undefined + resetVideoElements() + resp = {type: "ok"} + } else { + resp = {type: "error", message: "end: call not started"} + } + break + default: + resp = {type: "error", message: "unknown command"} + break + } + } catch (e) { + resp = {type: "error", message: (e as Error).message} + } + const apiResp = {corrId, resp, command} + sendMessageToNative(apiResp) + return apiResp +} + +function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { + for (const c of iceCandidates) { + conn.addIceCandidate(new RTCIceCandidate(c)) + } +} + +async function setUpMediaStreams( + pc: RTCPeerConnection, + localStream: MediaStream, + remoteStream: MediaStream, + aesKey?: string +): Promise { + const videos = getVideoElements() + if (!videos) throw Error("no video elements") + + let key: CryptoKey | undefined + if (aesKey) { + const keyData = decodeBase64(encodeAscii(aesKey)) + if (keyData) key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages) + } + for (const track of localStream.getTracks()) { + pc.addTrack(track, localStream) + } + if (key) { + console.log("set up encryption for sending") + for (const sender of pc.getSenders() as RTCRtpSenderWithEncryption[]) { + setupPeerTransform(sender, encodeFunction(key)) + } + } + // Pull tracks from remote stream as they arrive add them to remoteStream video + pc.ontrack = (event) => { + if (key) { + console.log("set up decryption for receiving") + setupPeerTransform(event.receiver as RTCRtpReceiverWithEncryption, decodeFunction(key)) + } + for (const track of event.streams[0].getTracks()) { + remoteStream.addTrack(track) + } + } + // We assume VP8 encoding in the decode/encode stages to get the initial + // bytes to pass as plaintext so we enforce that here. + // VP8 is supported by all supports of webrtc. + // Use of VP8 by default may also reduce depacketisation issues. + // We do not encrypt the first couple of bytes of the payload so that the + // video elements can work by determining video keyframes and the opus mode + // being used. This appears to be necessary for any video feed at all. + // For VP8 this is the content described in + // https://tools.ietf.org/html/rfc6386#section-9.1 + // which is 10 bytes for key frames and 3 bytes for delta frames. + // For opus (where encodedFrame.type is not set) this is the TOC byte from + // https://tools.ietf.org/html/rfc6716#section-3.1 + + const capabilities = RTCRtpSender.getCapabilities("video") + if (capabilities) { + const {codecs} = capabilities + const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8") + const selectedCodec = codecs[selectedCodecIndex] + codecs.splice(selectedCodecIndex, 1) + codecs.unshift(selectedCodec) + for (const t of pc.getTransceivers()) { + if (t.sender.track?.kind === "video") { + t.setCodecPreferences(codecs) + } + } + } + // setupVideoElement(videos.local) + // setupVideoElement(videos.remote) + videos.local.srcObject = localStream + videos.remote.srcObject = remoteStream +} + +function callMediaConstraints(mediaType: CallMediaType): MediaStreamConstraints { + switch (mediaType) { + case CallMediaType.Audio: + return {audio: true, video: false} + case CallMediaType.Video: + return { + audio: true, + video: { + frameRate: 24, + width: { + min: 480, + ideal: 720, + max: 1280, + }, + aspectRatio: 1.33, + }, + } + } +} + +function supportsInsertableStreams(): boolean { + return "createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype +} + +interface VideoElements { + local: HTMLMediaElement + remote: HTMLMediaElement +} + +function resetVideoElements() { + const videos = getVideoElements() + if (!videos) return + videos.local.srcObject = null + videos.remote.srcObject = null +} + +function getVideoElements(): VideoElements | undefined { + const local = document.getElementById("local-video-stream") + const remote = document.getElementById("remote-video-stream") + if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement)) return + return {local, remote} +} + +// function setupVideoElement(video: HTMLElement) { +// // TODO use display: none +// video.style.opacity = "0" +// video.onplaying = () => { +// video.style.opacity = "1" +// } +// } + +function enableMedia(s: MediaStream, media: CallMediaType, enable: boolean) { + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() + for (const t of tracks) t.enabled = enable +} + +/* Stream Transforms */ +function setupPeerTransform( + peer: RTCRtpSenderWithEncryption | RTCRtpReceiverWithEncryption, + transform: (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void +) { + const streams = peer.createEncodedStreams() + streams.readable.pipeThrough(new TransformStream({transform})).pipeTo(streams.writable) +} + +/* Cryptography */ +function encodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void { + return async (frame, controller) => { + const data = new Uint8Array(frame.data) + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 + const iv = randomIV() + const initial = data.subarray(0, n) + const plaintext = data.subarray(n, data.byteLength) + try { + const ciphertext = await crypto.subtle.encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext) + frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer + controller.enqueue(frame) + } catch (e) { + console.log(`encryption error ${e}`) + throw e + } + } +} + +function decodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise { + return async (frame, controller) => { + const data = new Uint8Array(frame.data) + const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 + const initial = data.subarray(0, n) + const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH) + const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength) + try { + const plaintext = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ciphertext) + frame.data = concatN(initial, new Uint8Array(plaintext)).buffer + controller.enqueue(frame) + } catch (e) { + console.log(`decryption error ${e}`) + throw e + } + } +} + +class RTCEncodedVideoFrame { + constructor(public type: "key" | "delta", public data: ArrayBuffer) {} +} + +function randomIV() { + return crypto.getRandomValues(new Uint8Array(IV_LENGTH)) +} + +const char_equal = "=".charCodeAt(0) + +function concatN(...bs: Uint8Array[]): Uint8Array { + const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0)) + bs.reduce((offset, b: Uint8Array) => { + a.set(b, offset) + return offset + b.byteLength + }, 0) + return a +} + +function encodeAscii(s: string): Uint8Array { + const a = new Uint8Array(s.length) + let i = s.length + while (i--) a[i] = s.charCodeAt(i) + return a +} + +function decodeAscii(a: Uint8Array): string { + let s = "" + for (let i = 0; i < a.length; i++) s += String.fromCharCode(a[i]) + return s +} + +const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0))) + +const base64lookup = new Array(256) as (number | undefined)[] +base64chars.forEach((c, i) => (base64lookup[c] = i)) + +function encodeBase64(a: Uint8Array): Uint8Array { + const len = a.length + const b64len = Math.ceil(len / 3) * 4 + const b64 = new Uint8Array(b64len) + + let j = 0 + for (let i = 0; i < len; i += 3) { + b64[j++] = base64chars[a[i] >> 2] + b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)] + b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)] + b64[j++] = base64chars[a[i + 2] & 63] + } + + if (len % 3) b64[b64len - 1] = char_equal + if (len % 3 === 1) b64[b64len - 2] = char_equal + + return b64 +} + +function decodeBase64(b64: Uint8Array): Uint8Array | undefined { + let len = b64.length + if (len % 4) return + let bLen = (len * 3) / 4 + + if (b64[len - 1] === char_equal) { + len-- + bLen-- + if (b64[len - 1] === char_equal) { + len-- + bLen-- + } + } + + const bytes = new Uint8Array(bLen) + + let i = 0 + let pos = 0 + while (i < len) { + const enc1 = base64lookup[b64[i++]] + const enc2 = i < len ? base64lookup[b64[i++]] : 0 + const enc3 = i < len ? base64lookup[b64[i++]] : 0 + const enc4 = i < len ? base64lookup[b64[i++]] : 0 + if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined) return + bytes[pos++] = (enc1 << 2) | (enc2 >> 4) + bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2) + bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63) + } + + return bytes +} diff --git a/packages/simplex-chat-webrtc/src/style.css b/packages/simplex-chat-webrtc/src/style.css new file mode 100644 index 0000000000..7b18648d2d --- /dev/null +++ b/packages/simplex-chat-webrtc/src/style.css @@ -0,0 +1,26 @@ +video::-webkit-media-controls { + display: none; +} +html, +body { + padding: 0; + margin: 0; + background-color: black; +} +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + bottom: 0; + right: 0; +} diff --git a/packages/simplex-chat-webrtc/tsconfig.json b/packages/simplex-chat-webrtc/tsconfig.json new file mode 100644 index 0000000000..d4d0acb3d0 --- /dev/null +++ b/packages/simplex-chat-webrtc/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2018", "DOM"], + "module": "CommonJS", + "moduleResolution": "Node", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2018" + } +} diff --git a/scripts/android/prepare.sh b/scripts/android/prepare.sh new file mode 100755 index 0000000000..363d7f7edb --- /dev/null +++ b/scripts/android/prepare.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# libsimplex.so and libsupport.so binaries should be in ~/Downloads folder +rm ./apps/android/app/src/main/cpp/libs/arm64-v8a/* +cp ~/Downloads/libsimplex.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ +cp ~/Downloads/libsupport.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/ diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 63e6c1a0bb..984e8afc7a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d38303d5f1d55a90dd95d951ba8d798e17699269" = "0gx9wlk0v8ml6aid9hzm1nqy357plk8sbnhfcnchna12dgm8v0sd"; + "https://github.com/simplex-chat/simplexmq.git"."964daf5442e1069634762450bc28cfd69a2968a1" = "1vsbiawqlvi6v48ws2rmg5cmp5qphnry3ymg6458p2w8wdm2gsng"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 65c7ea92e2..abba53b214 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.6.0 +version: 2.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -21,6 +21,7 @@ library exposed-modules: Simplex.Chat Simplex.Chat.Bot + Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Core Simplex.Chat.Help @@ -72,7 +73,7 @@ library , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , simple-logger ==0.1.* - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -109,7 +110,7 @@ executable simplex-bot , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -146,7 +147,7 @@ executable simplex-bot-advanced , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -183,7 +184,7 @@ executable simplex-chat , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* @@ -229,7 +230,7 @@ test-suite simplex-chat-test , process ==1.6.* , simple-logger ==0.1.* , simplex-chat - , simplexmq ==1.0.* + , simplexmq >=1.1 && <3.0 , sqlite-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 49c915aeb6..2bd6bdfd00 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -27,6 +27,7 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) +import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find) @@ -37,9 +38,10 @@ import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds) import Data.Time.LocalTime (getCurrentTimeZone, getZonedTime) import Data.Word (Word32) +import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages @@ -49,14 +51,17 @@ import Simplex.Chat.Store import Simplex.Chat.Types import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM, whenM) import Simplex.Messaging.Agent -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), defaultAgentConfig) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), defaultAgentConfig) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Notifications.Client (NtfServer) +import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), PushProvider (..)) import Simplex.Messaging.Parsers (base64P, parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) import qualified Simplex.Messaging.Protocol as SMP +import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (tryError, (<$?>)) import System.Exit (exitFailure, exitSuccess) import System.FilePath (combine, splitExtensions, takeFileName) @@ -75,13 +80,13 @@ defaultChatConfig = { agentConfig = defaultAgentConfig { tcpPort = undefined, -- agent does not listen to TCP - initialSMPServers = undefined, -- filled in newChatController - dbFile = undefined, -- filled in newChatController + dbFile = "simplex_v1", dbPoolSize = 1, yesToMigrations = False }, dbPoolSize = 1, yesToMigrations = False, + defaultServers = InitialAgentServers {smp = _defaultSMPServers, ntf = _defaultNtfServers}, tbqSize = 64, fileChunkSize = 15780, subscriptionConcurrency = 16, @@ -89,27 +94,30 @@ defaultChatConfig = testView = False } -defaultSMPServers :: NonEmpty SMPServer -defaultSMPServers = +_defaultSMPServers :: NonEmpty SMPServer +_defaultSMPServers = L.fromList [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im", "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im", "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im" ] +_defaultNtfServers :: [NtfServer] +_defaultNtfServers = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] + logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} ChatOpts {dbFilePrefix, smpServers, logConnections} sendToast = do +newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, logConnections} sendToast = do let f = chatStoreFile dbFilePrefix config = cfg {subscriptionEvents = logConnections} sendNotification = fromMaybe (const $ pure ()) sendToast activeTo <- newTVarIO ActiveNone firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user - initialSMPServers <- resolveServers - smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", initialSMPServers} + servers <- resolveServers defaultServers + smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db"} servers agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize @@ -118,15 +126,18 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty + currentCalls <- atomically TM.empty filesFolder <- newTVarIO Nothing - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification, filesFolder} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder} where - resolveServers :: IO (NonEmpty SMPServer) - resolveServers = case user of - Nothing -> pure $ if null smpServers then defaultSMPServers else L.fromList smpServers - Just usr -> do - userSmpServers <- getSMPServers chatStore usr - pure . fromMaybe defaultSMPServers . nonEmpty $ if null smpServers then userSmpServers else smpServers + resolveServers :: InitialAgentServers -> IO InitialAgentServers + resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of + Just smpServers' -> pure ss {smp = smpServers'} + _ -> case user of + Just usr -> do + userSmpServers <- getSMPServers chatStore usr + pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers} + _ -> pure ss runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () runChatController = race_ notificationSubscriber . agentSubscriber @@ -141,6 +152,11 @@ startChatController user = do atomically . writeTVar s $ Just a pure a +stopChatController :: MonadUnliftIO m => ChatController -> m () +stopChatController ChatController {smpAgent, agentAsync = s} = do + disconnectAgentClient smpAgent + readTVarIO s >>= mapM_ uninterruptibleCancel >> atomically (writeTVar s Nothing) + withLock :: MonadUnliftIO m => TMVar () -> m a -> m a withLock lock = E.bracket_ @@ -173,18 +189,20 @@ processChatCommand = \case asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning _ -> startChatController user $> CRChatStarted + ResubscribeAllConnections -> withUser (subscribeUserConnections resubscribeConnection) $> CRCmdOk SetFilesFolder filesFolder' -> withUser $ \_ -> do createDirectoryIfMissing True filesFolder' ff <- asks filesFolder atomically . writeTVar ff $ Just filesFolder' pure CRCmdOk - APIGetChats -> CRApiChats <$> withUser (\user -> withStore (`getChatPreviews` user)) - APIGetChat cType cId pagination -> withUser $ \user -> case cType of + APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore $ \st -> getChatPreviews st user withPCC) + APIGetChat (ChatRef cType cId) pagination -> withUser $ \user -> case cType of CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination) CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) CTContactRequest -> pure $ chatCmdError "not implemented" + CTContactConnection -> pure $ chatCmdError "not supported" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" - APISendMessage cType chatId file_ quotedItemId_ mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer ct @@ -209,11 +227,11 @@ processChatCommand = \case prepareMsg fileInvitation_ = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Just quotedItemId -> do - CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText, file} <- withStore $ \st -> getDirectChatItem st userId chatId quotedItemId (origQmc, qd, sent) <- quoteData ciContent let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - qmc = quoteContent origQmc mc + qmc = quoteContent origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where @@ -238,18 +256,18 @@ processChatCommand = \case (fileSize, chSize) <- checkSndFile file let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} - fileId <- withStore $ \st -> createSndGroupFileTransferV2 st userId gInfo file fileInvitation chSize + fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo file fileInvitation chSize let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} pure $ Just (fileInvitation, ciFile) prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) prepareMsg fileInvitation_ membership = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) Just quotedItemId -> do - CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText, file} <- withStore $ \st -> getGroupChatItem st user chatId quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData ciContent chatDir membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - qmc = quoteContent origQmc mc + qmc = quoteContent origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where @@ -258,16 +276,30 @@ processChatCommand = \case quoteData (CIRcvMsgContent qmc) (CIGroupRcv m) _ = pure (qmc, CIQGroupRcv $ Just m, False, m) quoteData _ _ _ = throwChatError CEInvalidQuote CTContactRequest -> pure $ chatCmdError "not supported" + CTContactConnection -> pure $ chatCmdError "not supported" where - quoteContent qmc = \case - MCText _ -> qmc - _ -> MCText $ msgContentText qmc + quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent + quoteContent qmc ciFile_ + | replaceContent = MCText qTextOrFile + | otherwise = case qmc of + MCImage _ image -> MCImage qTextOrFile image + MCFile _ -> MCFile qTextOrFile + _ -> qmc + where + -- if the message we're quoting with is one of the "large" MsgContents + -- we replace the quote's content with MCText + replaceContent = case mc of + MCText _ -> False + MCFile _ -> False + MCLink {} -> True + MCImage {} -> True + MCUnknown {} -> True + qText = msgContentText qmc + qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_ + qTextOrFile = if T.null qText then qFileName else qText unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) unzipMaybe t = (fst <$> t, snd <$> t) - -- TODO discontinue - APISendMessageQuote cType chatId quotedItemId mc -> - processChatCommand $ APISendMessage cType chatId Nothing (Just quotedItemId) mc - APIUpdateChatItem cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case ci of @@ -275,7 +307,7 @@ processChatCommand = \case case (ciContent, itemSharedMsgId) of (CISndMsgContent _, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc) - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) msgId + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) $ Just msgId setActive $ ActiveC c pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi _ -> throwChatError CEInvalidChatItemUpdate @@ -295,17 +327,18 @@ processChatCommand = \case _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTContactRequest -> pure $ chatCmdError "not supported" - APIDeleteChatItem cType chatId itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of + CTContactConnection -> pure $ chatCmdError "not supported" + APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do - deleteFile userId file + deleteCIFile user file toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId) - deleteFile userId file + deleteCIFile user file toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi @@ -316,29 +349,31 @@ processChatCommand = \case CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file} <- withStore $ \st -> getGroupChatItem st user chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do - deleteFile userId file + deleteCIFile user file toCi <- withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId) - deleteFile userId file + deleteCIFile user file toCi <- withStore $ \st -> deleteGroupChatItemSndBroadcast st user gInfo itemId msgId setActive $ ActiveG gName pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete CTContactRequest -> pure $ chatCmdError "not supported" + CTContactConnection -> pure $ chatCmdError "not supported" where - deleteFile :: MsgDirectionI d => UserId -> Maybe (CIFile d) -> m () - deleteFile userId file = + deleteCIFile :: MsgDirectionI d => User -> Maybe (CIFile d) -> m () + deleteCIFile user file = forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do - cancelFiles userId [(fileId, AFS msgDirection fileStatus)] + cancelFiles user [(fileId, AFS msgDirection fileStatus)] withFilesFolder $ \filesFolder -> deleteFiles filesFolder [filePath] - APIChatRead cType chatId fromToIds -> withChatLock $ case cType of + APIChatRead (ChatRef cType chatId) fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk CTContactRequest -> pure $ chatCmdError "not supported" - APIDeleteChat cType chatId -> withUser $ \User {userId} -> case cType of + CTContactConnection -> pure $ chatCmdError "not supported" + APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case @@ -346,8 +381,8 @@ processChatCommand = \case files <- withStore $ \st -> getContactFiles st userId ct conns <- withStore $ \st -> getContactConnections st userId ct withChatLock . procCmd $ do - cancelFiles userId (map (\(fId, fStatus, _) -> (fId, fStatus)) files) - withFilesFolder $ \filesFolder -> do + cancelFiles user (map (\(fId, fStatus, _) -> (fId, fStatus)) files) + withFilesFolder $ \filesFolder -> deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files) withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () @@ -355,6 +390,11 @@ processChatCommand = \case unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs + CTContactConnection -> withChatLock . procCmd $ do + conn <- withStore $ \st -> getPendingContactConnection st userId chatId + withAgent $ \a -> deleteConnection a $ aConnId' conn + withStore $ \st -> deletePendingContactConnection st userId chatId + pure $ CRContactConnectionDeleted conn CTGroup -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do @@ -367,22 +407,97 @@ processChatCommand = \case `E.finally` deleteContactRequest st userId connReqId withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq + APISendCallInvitation contactId callType@CallType {capabilities = CallCapabilities {encryption}} -> withUser $ \user@User {userId} -> do + -- party initiating call + ct <- withStore $ \st -> getContact st userId contactId + calls <- asks currentCalls + withChatLock $ do + callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing + let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} + callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} + msg@SndMessage {msgId} <- sendDirectContactMessage ct (XCallInv callId invitation) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) Nothing Nothing + let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState} + call_ <- atomically $ TM.lookupInsert contactId call' calls + forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId + toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + pure CRCmdOk + APIRejectCall contactId -> + -- party accepting call + withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of + CallInvitationReceived {} -> + let aciContent = ACIContent SMDRcv $ CIRcvCall CISCallRejected 0 + in updateDirectChatItemView userId ct chatItemId aciContent Nothing $> Nothing + _ -> throwChatError . CECallState $ callStateTag callState + APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> + -- party accepting call + withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do + -- TODO check that call type matches peerCallType + let offer = CallOffer {callType, rtcSession, callDhPubKey = localDhPubKey} + callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} + aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 + SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer) + updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId + pure $ Just call {callState = callState'} + _ -> throwChatError . CECallState $ callStateTag callState + APISendCallAnswer contactId rtcSession -> + -- party initiating call + withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey} + aciContent = ACIContent SMDSnd $ CISndCall CISCallNegotiated 0 + SndMessage {msgId} <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) + updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId + pure $ Just call {callState = callState'} + _ -> throwChatError . CECallState $ callStateTag callState + APISendCallExtraInfo contactId rtcExtraInfo -> + -- any call party + withCurrentCall contactId $ \_ ct call@Call {callId, callState} -> case callState of + CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do + -- TODO update the list of ice servers in localCallSession + _ <- sendDirectContactMessage ct (XCallExtra callId CallExtraInfo {rtcExtraInfo}) + let callState' = CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} + pure $ Just call {callState = callState'} + CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do + -- TODO update the list of ice servers in localCallSession + _ <- sendDirectContactMessage ct (XCallExtra callId CallExtraInfo {rtcExtraInfo}) + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} + pure $ Just call {callState = callState'} + _ -> throwChatError . CECallState $ callStateTag callState + APIEndCall contactId -> + -- any call party + withCurrentCall contactId $ \userId ct call@Call {callId} -> do + SndMessage {msgId} <- sendDirectContactMessage ct (XCallEnd callId) + updateCallItemStatus userId ct call WCSDisconnected $ Just msgId + pure Nothing + APICallStatus contactId receivedStatus -> + withCurrentCall contactId $ \userId ct call -> + updateCallItemStatus userId ct call receivedStatus Nothing $> Just call APIUpdateProfile profile -> withUser (`updateProfile` profile) APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text + APIRegisterToken token -> CRNtfTokenStatus <$> withUser (\_ -> withAgent (`registerNtfToken` token)) + APIVerifyToken token code nonce -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token code nonce) $> CRCmdOk + APIIntervalNofication token interval -> withUser $ \_ -> withAgent (\a -> enableNtfCron a token interval) $> CRCmdOk + APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do withStore $ \st -> overwriteSMPServers st user smpServers + ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers)) pure CRCmdOk ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMInvitation) - withStore $ \st -> createDirectConnection st userId connId + conn <- withStore $ \st -> createDirectConnection st userId connId ConnNew + toView $ CRNewContactConnection conn pure $ CRInvitation cReq Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile - withStore $ \st -> createDirectConnection st userId connId + conn <- withStore $ \st -> createDirectConnection st userId connId ConnJoined + toView $ CRNewContactConnection conn pure CRSentConfirmation Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> connectViaContact userId cReq profile @@ -391,7 +506,7 @@ processChatCommand = \case connectViaContact userId adminContactReq profile DeleteContact cName -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName - processChatCommand $ APIDeleteChat CTDirect contactId + processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId) ListContacts -> withUser $ \user -> CRContactsList <$> withStore (`getUserContacts` user) CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) @@ -414,10 +529,10 @@ processChatCommand = \case RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName processChatCommand $ APIRejectContact connReqId - SendMessage cName msg -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName + SendMessage chatName msg -> withUser $ \user -> do + chatRef <- getChatRef user chatName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTDirect contactId Nothing Nothing mc + processChatCommand . APISendMessage chatRef $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do @@ -435,16 +550,16 @@ processChatCommand = \case contactId <- withStore $ \st -> getContactIdByName st userId cName quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTDirect contactId Nothing (Just quotedItemId) mc - DeleteMessage cName deletedMsg -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName - deletedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 deletedMsg) - processChatCommand $ APIDeleteChatItem CTDirect contactId deletedItemId CIDMBroadcast - EditMessage cName editedMsg msg -> withUser $ \User {userId} -> do - contactId <- withStore $ \st -> getContactIdByName st userId cName - editedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 editedMsg) + processChatCommand . APISendMessage (ChatRef CTDirect contactId) $ ComposedMessage Nothing (Just quotedItemId) mc + DeleteMessage chatName deletedMsg -> withUser $ \user -> do + chatRef <- getChatRef user chatName + deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg + processChatCommand $ APIDeleteChatItem chatRef deletedItemId CIDMBroadcast + EditMessage chatName editedMsg msg -> withUser $ \user -> do + chatRef <- getChatRef user chatName + editedItemId <- getSentChatItemIdByText user chatRef editedMsg let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APIUpdateChatItem CTDirect contactId editedItemId mc + processChatCommand $ APIUpdateChatItem chatRef editedItemId mc NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) @@ -516,93 +631,52 @@ processChatCommand = \case pure $ CRGroupDeletedUser gInfo ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\st -> getGroupByName st user gName)) ListGroups -> CRGroupsList <$> withUser (\user -> withStore (`getUserGroupDetails` user)) - SendGroupMessage gName msg -> withUser $ \user -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTGroup groupId Nothing Nothing mc SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTGroup groupId Nothing (Just quotedItemId) mc - DeleteGroupMessage gName deletedMsg -> withUser $ \user@User {localDisplayName} -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - deletedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 deletedMsg) - processChatCommand $ APIDeleteChatItem CTGroup groupId deletedItemId CIDMBroadcast - EditGroupMessage gName editedMsg msg -> withUser $ \user@User {localDisplayName} -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - editedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 editedMsg) - let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APIUpdateChatItem CTGroup groupId editedItemId mc - -- old file protocol - -- SendFile cName f -> withUser $ \User {userId} -> do - -- contactId <- withStore $ \st -> getContactIdByName st userId cName - -- processChatCommand $ APISendMessage CTDirect contactId (Just f) Nothing (MCText "") - -- TODO replace with code above when switching from XFile - SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do - (fileSize, chSize) <- checkSndFile f - contact <- withStore $ \st -> getContactByName st userId cName - (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - let fileName = takeFileName f - fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Just fileConnReq} - fileId <- withStore $ \st -> - createSndFileTransfer st userId contact f fileInv agentConnId chSize - msg <- sendDirectContactMessage contact (XFile fileInv) - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} - ci <- saveSndChatItem user (CDDirectSnd contact) msg (CISndMsgContent $ MCText "") (Just ciFile) Nothing - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci - setActive $ ActiveC cName - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci - -- new file protocol (not used for direct files) - SendFileInv cName f -> withUser $ \user@User {userId} -> withChatLock $ do - ct <- withStore $ \st -> getContactByName st userId cName - (fileSize, chSize) <- checkSndFile f - let fileName = takeFileName f - fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} - fileId <- withStore $ \st -> createSndFileTransferV2 st userId ct f fileInvitation chSize - let mc = MCText "" - ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} - msg <- sendDirectContactMessage ct (XMsgNew (MCSimple (ExtMsgContent mc (Just fileInvitation)))) - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile Nothing - setActive $ ActiveC cName - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci - -- old file protocol - -- TODO discontinue - SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do - Group gInfo@GroupInfo {groupId, membership} members <- withStore $ \st -> getGroupByName st user gName - unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - (fileSize, chSize) <- checkSndFile f - let fileName = takeFileName f - ms <- forM (filter memberActive members) $ \m -> do - (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}) - fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize - forM_ ms $ \(m, _, fileInvitation) -> - traverse (\conn -> sendDirectMessage conn (XFile fileInvitation) (GroupId groupId)) $ memberConn m - setActive $ ActiveG gName - -- this is a hack as we have multiple direct messages instead of one per group - let msg = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = ""} - ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent $ MCText "") ciFile Nothing - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - -- new file protocol - SendGroupFileInv gName f -> withUser $ \user -> do - groupId <- withStore $ \st -> getGroupIdByName st user gName - processChatCommand $ APISendMessage CTGroup groupId (Just f) Nothing (MCText "") - ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> + processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc + LastMessages (Just chatName) count -> withUser $ \user -> do + chatRef <- getChatRef user chatName + CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count) + LastMessages Nothing count -> withUser $ \user -> withStore $ \st -> + CRLastMessages <$> getAllChatItems st user (CPLast count) + SendFile chatName f -> withUser $ \user -> do + chatRef <- getChatRef user chatName + processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "") + ReceiveFile fileId filePath_ -> withUser $ \user -> withChatLock . procCmd $ do - ft <- withStore $ \st -> getRcvFileTransfer st userId fileId - (CRRcvFileAccepted ft <$> acceptFileReceive user ft filePath_) `catchError` processError ft + ft <- withStore $ \st -> getRcvFileTransfer st user fileId + (CRRcvFileAccepted <$> acceptFileReceive user ft filePath_) `catchError` processError ft where processError ft = \case + -- TODO AChatItem in Cancelled events ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled ft ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft e -> throwError e - CancelFile fileId -> withUser $ \User {userId} -> do - ft <- withStore (\st -> getFileTransfer st userId fileId) - withChatLock . procCmd $ cancelFile userId fileId ft + CancelFile fileId -> withUser $ \user@User {userId} -> + withChatLock . procCmd $ + withStore (\st -> getFileTransfer st user fileId) >>= \case + FTSnd ftm@FileTransferMeta {cancelled} fts -> do + unless cancelled $ do + cancelSndFile user ftm fts + sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + void $ + withStore (\st -> getChatRefByFileId st user fileId) >>= \case + ChatRef CTDirect contactId -> do + contact <- withStore $ \st -> getContact st userId contactId + sendDirectContactMessage contact $ XFileCancel sharedMsgId + ChatRef CTGroup groupId -> do + Group gInfo ms <- withStore $ \st -> getGroup st user groupId + sendGroupMessage gInfo ms $ XFileCancel sharedMsgId + _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" + ci <- withStore $ \st -> getChatItemByFileId st user fileId + pure $ CRSndGroupFileCancelled ci ftm fts + FTRcv ftr@RcvFileTransfer {cancelled} -> do + unless cancelled $ cancelRcvFileTransfer user ftr + pure $ CRRcvFileCancelled ftr FileStatus fileId -> - CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) + CRFileTransferStatus <$> withUser (\user -> withStore $ \st -> getFileTransferProgress st user fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do let p = (profile :: Profile) {displayName = displayName, fullName = fullName} @@ -629,6 +703,17 @@ processChatCommand = \case -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse procCmd = id + getChatRef :: User -> ChatName -> m ChatRef + getChatRef user@User {userId} (ChatName cType name) = + ChatRef cType <$> case cType of + CTDirect -> withStore $ \st -> getContactIdByName st userId name + CTGroup -> withStore $ \st -> getGroupIdByName st user name + _ -> throwChatError $ CECommandError "not supported" + getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64 + getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of + CTDirect -> withStore $ \st -> getDirectChatItemIdByText st userId cId SMDSnd (safeDecodeUtf8 msg) + CTGroup -> withStore $ \st -> getGroupChatItemIdByText st user cId (Just localDisplayName) (safeDecodeUtf8 msg) + _ -> throwChatError $ CECommandError "not supported" connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse connectViaContact userId cReq profile = withChatLock $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq @@ -638,7 +723,8 @@ processChatCommand = \case let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) xContactId <- maybe randomXContactId pure xContactId_ connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) - withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId + conn <- withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId + toView $ CRNewContactConnection conn pure CRSentInvitation contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = @@ -675,36 +761,68 @@ processChatCommand = \case let fsFilePath = filesFolder <> "/" <> filePath removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () - cancelFiles :: UserId -> [(Int64, ACIFileStatus)] -> m () - cancelFiles userId files = - forM_ files $ \(fileId, status) -> do - case status of - AFS _ CIFSSndStored -> cancelById fileId - AFS _ CIFSRcvInvitation -> cancelById fileId - AFS _ CIFSRcvTransfer -> cancelById fileId - _ -> pure () - where - cancelById fileId = do - ft <- withStore (\st -> getFileTransfer st userId fileId) - void $ cancelFile userId fileId ft - cancelFile :: UserId -> Int64 -> FileTransfer -> m ChatResponse - cancelFile userId fileId ft = - case ft of - FTSnd ftm fts -> do - cancelFileTransfer CIFSSndCancelled - forM_ fts $ \ft' -> cancelSndFileTransfer ft' - pure $ CRSndGroupFileCancelled ftm fts - FTRcv ftr -> do - cancelFileTransfer CIFSRcvCancelled - cancelRcvFileTransfer ftr - pure $ CRRcvFileCancelled ftr - where - cancelFileTransfer :: MsgDirectionI d => CIFileStatus d -> m () - cancelFileTransfer ciFileStatus = - unless (fileTransferCancelled ft) $ - withStore $ \st -> do - updateFileCancelled st userId fileId - updateCIFileStatus st userId fileId ciFileStatus + cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () + cancelFiles user files = forM_ files $ \(fileId, AFS dir status) -> + unless (ciFileEnded status) $ + case dir of + SMDSnd -> do + (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\st -> getSndFileTransfer st user fileId) + unless cancelled $ cancelSndFile user ftm fts + SMDRcv -> do + ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + unless cancelled $ cancelRcvFileTransfer user ft + withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse + withCurrentCall ctId action = withUser $ \User {userId} -> do + ct <- withStore $ \st -> getContact st userId ctId + calls <- asks currentCalls + withChatLock $ + atomically (TM.lookup ctId calls) >>= \case + Nothing -> throwChatError CENoCurrentCall + Just call@Call {contactId} + | ctId == contactId -> do + call_ <- action userId ct call + atomically $ case call_ of + Just call' -> TM.insert ctId call' calls + _ -> TM.delete ctId calls + pure CRCmdOk + | otherwise -> throwChatError $ CECallContact contactId + +updateCallItemStatus :: ChatMonad m => UserId -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m () +updateCallItemStatus userId ct Call {chatItemId} receivedStatus msgId_ = do + aciContent_ <- callStatusItemContent userId ct chatItemId receivedStatus + forM_ aciContent_ $ \aciContent -> updateDirectChatItemView userId ct chatItemId aciContent msgId_ + +updateDirectChatItemView :: ChatMonad m => UserId -> Contact -> ChatItemId -> ACIContent -> Maybe MessageId -> m () +updateDirectChatItemView userId ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) msgId_ = do + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId_ + toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) updCi + +callStatusItemContent :: ChatMonad m => UserId -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) +callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do + CChatItem msgDir ChatItem {meta = CIMeta {updatedAt}, content} <- + withStore $ \st -> getDirectChatItem st userId contactId chatItemId + ts <- liftIO getCurrentTime + let callDuration :: Int = nominalDiffTimeToSeconds (ts `diffUTCTime` updatedAt) `div'` 1 + callStatus = case content of + CISndCall st _ -> Just st + CIRcvCall st _ -> Just st + _ -> Nothing + newState_ = case (callStatus, receivedStatus) of + (Just CISCallProgress, WCSConnected) -> Nothing -- if call in-progress received connected -> no change + (Just CISCallProgress, WCSDisconnected) -> Just (CISCallEnded, callDuration) -- calculate in-progress duration + (Just CISCallProgress, WCSFailed) -> Just (CISCallEnded, callDuration) -- whether call disconnected or failed + (Just CISCallEnded, _) -> Nothing -- if call already ended or failed -> no change + (Just CISCallError, _) -> Nothing + (Just _, WCSConnected) -> Just (CISCallProgress, 0) -- if call ended that was never connected, duration = 0 + (Just _, WCSDisconnected) -> Just (CISCallEnded, 0) + (Just _, WCSFailed) -> Just (CISCallError, 0) + (Nothing, _) -> Nothing -- some other content - we should never get here, but no exception is thrown + pure $ aciContent msgDir <$> newState_ + where + aciContent :: forall d. SMsgDirection d -> (CICallStatus, Int) -> ACIContent + aciContent msgDir (callStatus', duration) = case msgDir of + SMDSnd -> ACIContent SMDSnd $ CISndCall callStatus' duration + SMDRcv -> ACIContent SMDRcv $ CIRcvCall callStatus' duration -- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates), -- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path @@ -713,39 +831,34 @@ toFSFilePath :: ChatMonad m => FilePath -> m FilePath toFSFilePath f = maybe f (<> "/" <> f) <$> (readTVarIO =<< asks filesFolder) -acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m FilePath -acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} filePath_ = do - unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fName +acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m AChatItem +acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, grpMemberId} filePath_ = do + unless (fileStatus == RFSNew) $ case fileStatus of + RFSCancelled _ -> throwChatError $ CEFileCancelled fName + _ -> throwChatError $ CEFileAlreadyReceiving fName case fileConnReq of - -- old file protocol + -- direct file protocol Just connReq -> tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case Right agentConnId -> do filePath <- getRcvFilePath filePath_ fName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - pure filePath + withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId filePath Left e -> throwError e - -- new file protocol + -- group file protocol Nothing -> case grpMemberId of - Nothing -> do - ct <- withStore $ \st -> getContactByName st userId senderDisplayName - acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectContactMessage ct $ XFileAcptInv sharedMsgId fileInvConnReq fName + Nothing -> throwChatError $ CEFileInternal "group member not found for file transfer" Just memId -> do (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \st -> getGroupAndMember st user memId case activeConn of - Just conn -> - acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) - _ -> throwChatError $ CEFileInternal "member connection not active" -- should not happen - where - acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m FilePath - acceptFileV2 sendXFileAcptInv = do - sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId - (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) - filePath <- getRcvFilePath filePath_ fName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - void $ sendXFileAcptInv sharedMsgId fileInvConnReq - pure filePath + Just conn -> do + sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) + filePath <- getRcvFilePath filePath_ fName + ci <- withStore $ \st -> acceptRcvFileTransfer st user fileId agentConnId filePath + void $ sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) + pure ci + _ -> throwChatError $ CEFileInternal "member connection not active" where getRcvFilePath :: Maybe FilePath -> String -> m FilePath getRcvFilePath fPath_ fn = case fPath_ of @@ -794,15 +907,19 @@ agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m agentSubscriber user = do q <- asks $ subQ . smpAgent l <- asks chatLock - subscribeUserConnections user + subscribeUserConnections subscribeConnection user forever $ do (_, connId, msg) <- atomically $ readTBQueue q u <- readTVarIO =<< asks currentUser withLock l . void . runExceptT $ processAgentMessage u connId msg `catchError` (toView . CRChatError) -subscribeUserConnections :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () -subscribeUserConnections user@User {userId} = do +subscribeUserConnections :: + (MonadUnliftIO m, MonadReader ChatController m) => + (forall m'. ChatMonad m' => AgentClient -> ConnId -> ExceptT AgentErrorType m' ()) -> + User -> + m () +subscribeUserConnections agentSubscribe user@User {userId} = do n <- asks $ subscriptionConcurrency . config ce <- asks $ subscriptionEvents . config void . runExceptT $ do @@ -817,7 +934,7 @@ subscribeUserConnections user@User {userId} = do contacts <- withStore (`getUserContacts` user) toView . CRContactSubSummary =<< pooledForConcurrentlyN n contacts (\ct -> ContactSubStatus ct <$> subscribeContact ce ct) subscribeContact ce ct = - (subscribe (contactConnId ct) >> when ce (toView $ CRContactSubscribed ct) $> Nothing) + (subscribe (contactConnId ct) $> Nothing) `catchError` (\e -> when ce (toView $ CRContactSubError ct e) $> Just e) subscribeGroups n ce = do groups <- withStore (`getUserGroups` user) @@ -852,9 +969,9 @@ subscribeUserConnections user@User {userId} = do threadDelay 1000000 l <- asks chatLock a <- asks smpAgent - unless (fileStatus == FSNew) . unlessM (isFileActive fileId sndFiles) $ + when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) $ withAgentLock a . withLock l $ - sendFileChunk ft + sendFileChunk user ft subscribeRcvFile ft@RcvFileTransfer {fileStatus} = case fileStatus of RFSAccepted fInfo -> resume fInfo @@ -872,13 +989,22 @@ subscribeUserConnections user@User {userId} = do cs <- withStore (`getUserContactLinkConnections` userId) (subscribeConns n cs >> toView CRUserContactLinkSubscribed) `catchError` (toView . CRUserContactLinkSubError) - subscribe cId = withAgent (`subscribeConnection` cId) + subscribe cId = withAgent (`agentSubscribe` cId) subscribeConns n conns = withAgent $ \a -> - pooledForConcurrentlyN_ n conns $ \c -> subscribeConnection a (aConnId c) + pooledForConcurrentlyN_ n conns $ \c -> agentSubscribe a (aConnId c) processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m () processAgentMessage Nothing _ _ = throwChatError CENoActiveUser +processAgentMessage (Just User {userId}) "" agentMessage = case agentMessage of + DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" + UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" + _ -> pure () + where + serverEvent srv@SMP.ProtocolServer {host, port} conns event str = do + cs <- withStore $ \st -> getConnectionsContacts st userId conns + toView $ event srv cs + showToast ("server " <> str) (safeDecodeUtf8 . strEncode $ SrvLoc host port) processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage = (withStore (\st -> getConnectionEntity st user agentConnId) >>= updateConnStatus) >>= \case RcvDirectMsgConnection conn contact_ -> @@ -941,12 +1067,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgDel sharedMsgId -> messageDelete ct sharedMsgId msg msgMeta -- TODO discontinue XFile XFile fInv -> processFileInvitation' ct fInv msg msgMeta - XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInv ct sharedMsgId fileConnReq fName msgMeta + XFileCancel sharedMsgId -> xFileCancel ct sharedMsgId msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv XInfoProbe probe -> xInfoProbe ct probe XInfoProbeCheck probeHash -> xInfoProbeCheck ct probeHash XInfoProbeOk probe -> xInfoProbeOk ct probe + XCallInv callId invitation -> xCallInv ct callId invitation msg msgMeta + XCallOffer callId offer -> xCallOffer ct callId offer msg msgMeta + XCallAnswer callId answer -> xCallAnswer ct callId answer msg msgMeta + XCallExtra callId extraInfo -> xCallExtra ct callId extraInfo msg msgMeta + XCallEnd callId -> xCallEnd ct callId msg msgMeta _ -> pure () ackMsgDeliveryEvent conn msgMeta CONF confId connInfo -> do @@ -982,23 +1113,15 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage when (memberCategory m == GCPreMember) $ probeMatchingContacts ct SENT msgId -> do sentMsgDeliveryEvent conn msgId - chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId - case chatItemId_ of - Nothing -> pure () - Just chatItemId -> do - chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId CISSndSent + withStore (\st -> getDirectChatItemByAgentMsgId st userId contactId connId msgId) >>= \case + Just (CChatItem SMDSnd ci) -> do + chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId (chatItemId' ci) CISSndSent toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + _ -> pure () END -> do toView $ CRContactAnotherClient ct showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c - DOWN -> do - toView $ CRContactDisconnected ct - showToast (c <> "> ") "disconnected" - UP -> do - toView $ CRContactSubscribed ct - showToast (c <> "> ") "is active" - setActive $ ActiveC c -- TODO print errors MERR msgId err -> do chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId @@ -1084,6 +1207,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgDel sharedMsgId -> groupMessageDelete gInfo m sharedMsgId msg -- TODO discontinue XFile XFile fInv -> processGroupFileInvitation' gInfo m fInv msg msgMeta + XFileCancel sharedMsgId -> xFileCancelGroup gInfo m sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInvGroup gInfo m sharedMsgId fileConnReq fName msgMeta XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo @@ -1105,7 +1229,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage processSndFileConn :: ACommand 'Agent -> Connection -> SndFileTransfer -> m () processSndFileConn agentMsg conn ft@SndFileTransfer {fileId, fileName, fileStatus} = case agentMsg of - -- old file protocol + -- SMP CONF for SndFileConnection happens for direct file protocol + -- when recipient of the file "joins" connection created by the sender CONF confId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of @@ -1117,16 +1242,20 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage | otherwise -> messageError "x.file.acpt: fileName is different from expected" _ -> messageError "CONF from file connection must have x.file.acpt" CON -> do - withStore $ \st -> updateSndFileStatus st ft FSConnected - toView $ CRSndFileStart ft - sendFileChunk ft + ci <- withStore $ \st -> do + updateSndFileStatus st ft FSConnected + updateDirectCIFileStatus st user fileId CIFSSndTransfer + toView $ CRSndFileStart ci ft + sendFileChunk user ft SENT msgId -> do withStore $ \st -> updateSndFileChunkSent st ft msgId - unless (fileStatus == FSCancelled) $ sendFileChunk ft + unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do cancelSndFileTransfer ft case err of - SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ toView $ CRSndFileRcvCancelled ft + SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do + ci <- withStore $ \st -> getChatItemByFileId st user fileId + toView $ CRSndFileRcvCancelled ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ -> withAckMessage agentConnId meta $ pure () @@ -1136,22 +1265,28 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> pure () processRcvFileConn :: ACommand 'Agent -> Connection -> RcvFileTransfer -> m () - processRcvFileConn agentMsg conn ft@RcvFileTransfer {fileId, chunkSize} = + processRcvFileConn agentMsg conn ft@RcvFileTransfer {fileId, chunkSize, cancelled} = case agentMsg of - -- new file protocol + -- SMP CONF for RcvFileConnection happens for group file protocol + -- when sender of the file "joins" connection created by the recipient + -- (sender doesn't create connections for all group members) CONF confId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of XOk -> allowAgentConnection conn confId XOk _ -> pure () CON -> do - withStore $ \st -> updateRcvFileStatus st ft FSConnected - toView $ CRRcvFileStart ft + ci <- withStore $ \st -> do + updateRcvFileStatus st ft FSConnected + updateCIFileStatus st user fileId CIFSRcvTransfer + getChatItemByFileId st user fileId + toView $ CRRcvFileStart ci MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do parseFileChunk msgBody >>= \case - FileChunkCancel -> do - cancelRcvFileTransfer ft - toView $ CRRcvFileSndCancelled ft + FileChunkCancel -> + unless cancelled $ do + cancelRcvFileTransfer user ft + toView (CRRcvFileSndCancelled ft) FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -1170,7 +1305,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage appendFileChunk ft chunkNo chunk ci <- withStore $ \st -> do updateRcvFileStatus st ft FSComplete - updateCIFileStatus st userId fileId CIFSRcvComplete + updateCIFileStatus st user fileId CIFSRcvComplete deleteRcvFileChunks st ft getChatItemByFileId st user fileId toView $ CRRcvFileComplete ci @@ -1228,12 +1363,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage agentErrToItemStatus err = CISSndError err badRcvFileChunk :: RcvFileTransfer -> String -> m () - badRcvFileChunk ft@RcvFileTransfer {fileStatus} err = - case fileStatus of - RFSCancelled _ -> pure () - _ -> do - cancelRcvFileTransfer ft - throwChatError $ CEFileRcvChunk err + badRcvFileChunk ft@RcvFileTransfer {cancelled} err = + unless cancelled $ do + cancelRcvFileTransfer user ft + throwChatError $ CEFileRcvChunk err notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do @@ -1264,12 +1397,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc ciFile_ <- processFileInvitation fileInvitation_ $ \fi chSize -> withStore $ \st -> createRcvFileTransfer st userId ct fi chSize ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_ toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - checkIntegrity msgMeta $ toView . CRMsgIntegrityError showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c @@ -1284,28 +1417,22 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of - SMDRcv -> do - updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CIRcvMsgContent mc) msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi - checkIntegrity msgMeta $ toView . CRMsgIntegrityError - SMDSnd -> do - messageError "x.msg.update: contact attempted invalid message update" - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + SMDRcv -> updateDirectChatItemView userId ct itemId (ACIContent SMDRcv $ CIRcvMsgContent mc) $ Just msgId + SMDSnd -> messageError "x.msg.update: contact attempted invalid message update" messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m () messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of SMDRcv -> do -- TODO allow to locally delete items that were broadcast deleted by sender toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi - checkIntegrity msgMeta $ toView . CRMsgIntegrityError - SMDSnd -> do - messageError "x.msg.del: contact attempted invalid message delete" - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do @@ -1345,13 +1472,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError -- TODO chunk size has to be sent as part of invitation chSize <- asks $ fileChunkSize . config RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - checkIntegrity msgMeta $ toView . CRMsgIntegrityError showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c @@ -1361,52 +1488,55 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage chSize <- asks $ fileChunkSize . config RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile groupMsgToView gInfo ci msgMeta let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG g - xFileAcptInv :: Contact -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () - xFileAcptInv Contact {contactId} sharedMsgId fileConnReq fName msgMeta = do + xFileCancel :: Contact -> SharedMsgId -> MsgMeta -> m () + xFileCancel Contact {contactId} sharedMsgId msgMeta = do checkIntegrity msgMeta $ toView . CRMsgIntegrityError fileId <- withStore $ \st -> getFileIdBySharedMsgId st userId contactId sharedMsgId - withStore (\st -> getFileTransfer st userId fileId) >>= \case - FTSnd FileTransferMeta {fileName, cancelled} _ -> - if not cancelled - then - if fName == fileName - then - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case - Right acId -> - withStore $ \st -> createSndFileTransferV2Connection st userId fileId acId - Left e -> throwError e - else messageError "x.file.acpt.inv: fileName is different from expected" - else pure () -- TODO send "file cancelled" message - _ -> messageError "x.file.acpt.inv: bad file direction" + ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + unless cancelled $ do + cancelRcvFileTransfer user ft + toView $ CRRcvFileSndCancelled ft + + xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () + xFileCancelGroup GroupInfo {groupId} GroupMember {memberId} sharedMsgId msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId + CChatItem msgDir ChatItem {chatDir} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId + case (msgDir, chatDir) of + (SMDRcv, CIGroupRcv m) -> do + if sameMemberId memberId m + then do + ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) + unless cancelled $ do + cancelRcvFileTransfer user ft + toView $ CRRcvFileSndCancelled ft + else messageError "x.file.cancel: group member attempted to cancel file of another member" + (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () xFileAcptInvGroup GroupInfo {groupId} m sharedMsgId fileConnReq fName msgMeta = do checkIntegrity msgMeta $ toView . CRMsgIntegrityError fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId - withStore (\st -> getFileTransfer st userId fileId) >>= \case - FTSnd FileTransferMeta {fileName, cancelled} _ -> - if not cancelled - then - if fName == fileName - then - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case - Right acId -> - withStore $ \st -> createSndGroupFileTransferV2Connection st userId fileId acId m - Left e -> throwError e - else messageError "x.file.acpt.inv: fileName is different from expected" - else pure () -- TODO send "file cancelled" message - _ -> messageError "x.file.acpt.inv: bad file direction" + (FileTransferMeta {fileName, cancelled}, _) <- withStore (\st -> getSndFileTransfer st user fileId) + unless cancelled $ + if fName == fileName + then + tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case + Right acId -> + withStore $ \st -> createSndGroupFileTransferConnection st userId fileId acId m + Left e -> throwError e + else messageError "x.file.acpt.inv: fileName is different from expected" groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () groupMsgToView gInfo ci msgMeta = do - toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError + toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci processGroupInvitation :: Contact -> GroupInvitation -> m () processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) = do @@ -1447,6 +1577,101 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage r <- withStore $ \st -> matchSentProbe st userId c1 probe forM_ r $ \c2 -> mergeContacts c1 c2 + -- to party accepting call + xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m () + xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg@RcvMessage {msgId} msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + let CallType {capabilities = CallCapabilities {encryption}} = callType + dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing + ci <- saveCallItem CISCallPending + let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) + callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} + call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState} + calls <- asks currentCalls + -- theoretically, the new call invitation for the current contant can mark the in-progress call as ended + -- (and replace it in ChatController) + -- practically, this should not happen + call_ <- atomically (TM.lookupInsert contactId call' calls) + forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId + toView $ CRCallInvitation ct callType sharedKey + toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + where + saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) Nothing + + -- to party initiating call + xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> MsgMeta -> m () + xCallOffer ct callId CallOffer {callType, rtcSession, callDhPubKey} msg msgMeta = do + msgCurrentCall ct callId "x.call.offer" msg msgMeta $ + \call -> case callState call of + CallInvitationSent {localCallType, localDhPrivKey} -> do + let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey) + callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} + -- TODO decide if should askConfirmation + toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation = False} + pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) + _ -> do + msgCallStateError "x.call.offer" call + pure (Just call, Nothing) + + -- to party accepting call + xCallAnswer :: Contact -> CallId -> CallAnswer -> RcvMessage -> MsgMeta -> m () + xCallAnswer ct callId CallAnswer {rtcSession} msg msgMeta = do + msgCurrentCall ct callId "x.call.answer" msg msgMeta $ + \call -> case callState call of + CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession = rtcSession, sharedKey} + toView $ CRCallAnswer ct rtcSession + pure (Just call {callState = callState'}, Just . ACIContent SMDRcv $ CIRcvCall CISCallNegotiated 0) + _ -> do + msgCallStateError "x.call.answer" call + pure (Just call, Nothing) + + -- to any call party + xCallExtra :: Contact -> CallId -> CallExtraInfo -> RcvMessage -> MsgMeta -> m () + xCallExtra ct callId CallExtraInfo {rtcExtraInfo} msg msgMeta = do + msgCurrentCall ct callId "x.call.extra" msg msgMeta $ + \call -> case callState call of + CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do + -- TODO update the list of ice servers in peerCallSession + let callState' = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} + toView $ CRCallExtraInfo ct rtcExtraInfo + pure (Just call {callState = callState'}, Nothing) + CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do + -- TODO update the list of ice servers in peerCallSession + let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} + toView $ CRCallExtraInfo ct rtcExtraInfo + pure (Just call {callState = callState'}, Nothing) + _ -> do + msgCallStateError "x.call.extra" call + pure (Just call, Nothing) + + -- to any call party + xCallEnd :: Contact -> CallId -> RcvMessage -> MsgMeta -> m () + xCallEnd ct callId msg msgMeta = + msgCurrentCall ct callId "x.call.end" msg msgMeta $ \Call {chatItemId} -> do + toView $ CRCallEnded ct + (Nothing,) <$> callStatusItemContent userId ct chatItemId WCSDisconnected + + msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> MsgMeta -> (Call -> m (Maybe Call, Maybe ACIContent)) -> m () + msgCurrentCall ct@Contact {contactId = ctId'} callId' eventName RcvMessage {msgId} msgMeta action = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + calls <- asks currentCalls + atomically (TM.lookup ctId' calls) >>= \case + Nothing -> messageError $ eventName <> ": no current call" + Just call@Call {contactId, callId, chatItemId} + | contactId /= ctId' || callId /= callId' -> messageError $ eventName <> ": wrong contact or callId" + | otherwise -> do + (call_, aciContent_) <- action call + atomically $ case call_ of + Just call' -> TM.insert ctId' call' calls + _ -> TM.delete ctId' calls + forM_ aciContent_ $ \aciContent -> + updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId + + msgCallStateError :: Text -> Call -> m () + msgCallStateError eventName Call {callState} = + messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) + mergeContacts :: Contact -> Contact -> m () mergeContacts to from = do withStore $ \st -> mergeContactRecords st userId to from @@ -1557,16 +1782,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage parseChatMessage :: ByteString -> Either ChatError ChatMessage parseChatMessage = first (ChatError . CEInvalidChatMessage) . strDecode -sendFileChunk :: ChatMonad m => SndFileTransfer -> m () -sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = +sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () +sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ withStore (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo Nothing -> do - withStore $ \st -> do + ci <- withStore $ \st -> do updateSndFileStatus st ft FSComplete deleteSndFileChunks st ft - toView $ CRSndFileComplete ft + updateDirectCIFileStatus st user fileId CIFSSndComplete + toView $ CRSndFileComplete ci ft closeFileHandle fileId sndFiles withAgent (`deleteConnection` acId) @@ -1639,17 +1865,25 @@ isFileActive fileId files = do fs <- asks files isJust . M.lookup fileId <$> readTVarIO fs -cancelRcvFileTransfer :: ChatMonad m => RcvFileTransfer -> m () -cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do +cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m () +cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, fileStatus} = do closeFileHandle fileId rcvFiles withStore $ \st -> do + updateFileCancelled st user fileId CIFSRcvCancelled updateRcvFileStatus st ft FSCancelled deleteRcvFileChunks st ft case fileStatus of - RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`suspendConnection` acId) - RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`suspendConnection` acId) + RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> + withAgent (`deleteConnection` acId) + RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> + withAgent (`deleteConnection` acId) _ -> pure () +cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> m () +cancelSndFile user FileTransferMeta {fileId} fts = do + withStore $ \st -> updateFileCancelled st user fileId CIFSSndCancelled + forM_ fts $ \ft' -> cancelSndFileTransfer ft' + cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do @@ -1658,7 +1892,7 @@ cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileSt deleteSndFileChunks st ft withAgent $ \a -> do void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \_ -> pure () - suspendConnection a acId + deleteConnection a acId closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () closeFileHandle fileId files = do @@ -1760,11 +1994,10 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brok liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content file quotedItem sharedMsgId itemTs createdAt = do +mkChatItem cd ciId content file quotedItem sharedMsgId itemTs currentTs = do tz <- getCurrentTimeZone - currentTs <- liftIO getCurrentTime let itemText = ciContentToText content - meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt + meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () @@ -1879,20 +2112,31 @@ chatCommandP = ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile) <|> ("/user" <|> "/u") $> ShowActiveUser <|> "/_start" $> StartChat + <|> "/_resubscribe all" $> ResubscribeAllConnections <|> "/_files_folder " *> (SetFilesFolder <$> filePath) - <|> "/_get chats" $> APIGetChats - <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) + <|> "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)) + <|> "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) - <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) - <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) - <|> "/_update item " *> (APIUpdateChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) - <|> "/_delete item " *> (APIDeleteChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) - <|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) - <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) + <|> "/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))) + <|> "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP) + <|> "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) + <|> "/_read chat " *> (APIChatRead <$> chatRefP <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) + <|> "/_delete " *> (APIDeleteChat <$> chatRefP) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) + <|> "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP) + <|> "/_call reject @" *> (APIRejectCall <$> A.decimal) + <|> "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP) + <|> "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP) + <|> "/_call extra @" *> (APISendCallExtraInfo <$> A.decimal <* A.space <*> jsonP) + <|> "/_call end @" *> (APIEndCall <$> A.decimal) + <|> "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP) <|> "/_profile " *> (APIUpdateProfile <$> jsonP) <|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString) + <|> "/_ntf register " *> (APIRegisterToken <$> tokenP) + <|> "/_ntf verify " *> (APIVerifyToken <$> tokenP <* A.space <*> strP <* A.space <*> strP) + <|> "/_ntf interval " *> (APIIntervalNofication <$> tokenP <* A.space <*> A.decimal) + <|> "/_ntf delete " *> (APIDeleteToken <$> tokenP) <|> "/smp_servers default" $> SetUserSMPServers [] <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP) <|> "/smp_servers" $> GetUserSMPServers @@ -1909,25 +2153,20 @@ chatCommandP = <|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName) <|> ("/members #" <|> "/members " <|> "/ms #" <|> "/ms ") *> (ListMembers <$> displayName) <|> ("/groups" <|> "/gs") $> ListGroups - <|> A.char '#' *> (SendGroupMessage <$> displayName <* A.space <*> A.takeByteString) <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString) <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString) - <|> ("\\#" <|> "\\ #") *> (DeleteGroupMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!#" <|> "! #") *> (EditGroupMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> ("/contacts" <|> "/cs") $> ListContacts <|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)) <|> ("/connect" <|> "/c") $> AddContact <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName) - <|> A.char '@' *> (SendMessage <$> displayName <* A.space <*> A.takeByteString) + <|> (SendMessage <$> chatNameP <* A.space <*> A.takeByteString) <|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv) <|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd) - <|> ("\\@" <|> "\\ @") *> (DeleteMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) + <|> ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString) + <|> ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString) - <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) - <|> ("/file_v2 #" <|> "/f_v2 #") *> (SendGroupFileInv <$> displayName <* A.space <*> filePath) - <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) - <|> ("/file_v2 @" <|> "/file_v2 " <|> "/f_v2 @" <|> "/f_v2 ") *> (SendFileInv <$> displayName <* A.space <*> filePath) + <|> ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP) + <|> ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath) <|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath)) <|> ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal) <|> ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal) @@ -1949,15 +2188,18 @@ chatCommandP = where imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P)) - chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup + chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup <|> A.char ':' $> CTContactConnection chatPaginationP = (CPLast <$ "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) - msgContentP = - "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) - <|> "json " *> jsonP + mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString + msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal + tokenP = "apns " *> (DeviceToken PPApns <$> hexStringP) + hexStringP = + A.takeWhile (\c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) >>= \s -> + if even (B.length s) then pure s else fail "odd number of hex characters" displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space @@ -1980,13 +2222,15 @@ chatCommandP = n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString - filePathTagged = " file " *> (T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ')) - quotedItemIdTagged = " quoted " *> A.decimal memberRole = (" owner" $> GROwner) <|> (" admin" $> GRAdmin) <|> (" member" $> GRMember) <|> pure GRAdmin + chatNameP = ChatName <$> chatTypeP <*> displayName + chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName + chatRefP = ChatRef <$> chatTypeP <*> A.decimal + msgCountP = A.space *> A.decimal <|> pure 10 adminContactReq :: ConnReqContact adminContactReq = diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs new file mode 100644 index 0000000000..a0f3707dd4 --- /dev/null +++ b/src/Simplex/Chat/Call.hs @@ -0,0 +1,211 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Call where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import GHC.Generics (Generic) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, enumJSON) + +data Call = Call + { contactId :: Int64, + callId :: CallId, + chatItemId :: Int64, + callState :: CallState + } + +data CallStateTag + = CSTCallInvitationSent + | CSTCallInvitationReceived + | CSTCallOfferSent + | CSTCallOfferReceived + | CSTCallNegotiated + deriving (Show, Generic) + +instance ToJSON CallStateTag where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall" + +callStateTag :: CallState -> CallStateTag +callStateTag = \case + CallInvitationSent {} -> CSTCallInvitationSent + CallInvitationReceived {} -> CSTCallInvitationReceived + CallOfferSent {} -> CSTCallOfferSent + CallOfferReceived {} -> CSTCallOfferReceived + CallNegotiated {} -> CSTCallNegotiated + +data CallState + = CallInvitationSent + { localCallType :: CallType, + localDhPrivKey :: Maybe C.PrivateKeyX25519 + } + | CallInvitationReceived + { peerCallType :: CallType, + localDhPubKey :: Maybe C.PublicKeyX25519, + sharedKey :: Maybe C.Key + } + | CallOfferSent + { localCallType :: CallType, + peerCallType :: CallType, + localCallSession :: WebRTCSession, + sharedKey :: Maybe C.Key + } + | CallOfferReceived + { localCallType :: CallType, + peerCallType :: CallType, + peerCallSession :: WebRTCSession, + sharedKey :: Maybe C.Key + } + | CallNegotiated + { localCallType :: CallType, + peerCallType :: CallType, + localCallSession :: WebRTCSession, + peerCallSession :: WebRTCSession, + sharedKey :: Maybe C.Key + } + +newtype CallId = CallId ByteString + deriving (Eq, Show) + +instance StrEncoding CallId where + strEncode (CallId m) = strEncode m + strDecode s = CallId <$> strDecode s + strP = CallId <$> strP + +instance FromJSON CallId where + parseJSON = strParseJSON "CallId" + +instance ToJSON CallId where + toJSON = strToJSON + toEncoding = strToJEncoding + +data CallType = CallType + { media :: CallMedia, + capabilities :: CallCapabilities + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions + +-- | * Types for chat protocol +data CallInvitation = CallInvitation + { callType :: CallType, + callDhPubKey :: Maybe C.PublicKeyX25519 + } + deriving (Eq, Show, Generic) + +instance FromJSON CallInvitation where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON CallInvitation where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data CallMedia = CMAudio | CMVideo + deriving (Eq, Show, Generic) + +instance FromJSON CallMedia where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CM" + +instance ToJSON CallMedia where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CM" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CM" + +data CallCapabilities = CallCapabilities + { encryption :: Bool + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallCapabilities where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data CallOffer = CallOffer + { callType :: CallType, + rtcSession :: WebRTCSession, + callDhPubKey :: Maybe C.PublicKeyX25519 + } + deriving (Eq, Show, Generic) + +instance FromJSON CallOffer where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON CallOffer where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data WebRTCCallOffer = WebRTCCallOffer + { callType :: CallType, + rtcSession :: WebRTCSession + } + deriving (Eq, Show, Generic) + +instance FromJSON WebRTCCallOffer where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON WebRTCCallOffer where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data CallAnswer = CallAnswer + { rtcSession :: WebRTCSession + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallAnswer where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data CallExtraInfo = CallExtraInfo + { rtcExtraInfo :: WebRTCExtraInfo + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallExtraInfo where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data WebRTCSession = WebRTCSession + { rtcSession :: Text, + rtcIceCandidates :: [Text] + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON WebRTCSession where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data WebRTCExtraInfo = WebRTCExtraInfo + { rtcIceCandidates :: [Text] + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON WebRTCExtraInfo where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data WebRTCCallStatus = WCSConnected | WCSDisconnected | WCSFailed + deriving (Show) + +instance StrEncoding WebRTCCallStatus where + strEncode = \case + WCSConnected -> "connected" + WCSDisconnected -> "disconnected" + WCSFailed -> "failed" + strP = + A.takeTill (== ' ') >>= \case + "connected" -> pure WCSConnected + "disconnected" -> pure WCSDisconnected + "failed" -> pure WCSFailed + _ -> fail "bad WebRTCCallStatus" diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index dcd334c610..9eca5c606a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -14,7 +14,7 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -22,20 +22,25 @@ import Data.Map.Strict (Map) import Data.Text (Text) import Data.Time (ZonedTime) import Data.Version (showVersion) +import Data.Word (Word16) import GHC.Generics (Generic) import Numeric.Natural import qualified Paths_simplex_chat as SC +import Simplex.Chat.Call import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (CorrId) +import Simplex.Messaging.TMap (TMap) import System.IO (Handle) import UnliftIO.STM @@ -52,6 +57,7 @@ data ChatConfig = ChatConfig { agentConfig :: AgentConfig, dbPoolSize :: Int, yesToMigrations :: Bool, + defaultServers :: InitialAgentServers, tbqSize :: Natural, fileChunkSize :: Integer, subscriptionConcurrency :: Int, @@ -77,6 +83,7 @@ data ChatController = ChatController chatLock :: TMVar (), sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), + currentCalls :: TMap ContactId Call, config :: ChatConfig, filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps } @@ -92,20 +99,31 @@ data ChatCommand = ShowActiveUser | CreateActiveUser Profile | StartChat + | ResubscribeAllConnections | SetFilesFolder FilePath - | APIGetChats - | APIGetChat ChatType Int64 ChatPagination + | APIGetChats {pendingConnections :: Bool} + | APIGetChat ChatRef ChatPagination | APIGetChatItems Int - | APISendMessage ChatType Int64 (Maybe FilePath) (Maybe ChatItemId) MsgContent - | APISendMessageQuote ChatType Int64 ChatItemId MsgContent -- TODO discontinue - | APIUpdateChatItem ChatType Int64 ChatItemId MsgContent - | APIDeleteChatItem ChatType Int64 ChatItemId CIDeleteMode - | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) - | APIDeleteChat ChatType Int64 + | APISendMessage ChatRef ComposedMessage + | APIUpdateChatItem ChatRef ChatItemId MsgContent + | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode + | APIChatRead ChatRef (ChatItemId, ChatItemId) + | APIDeleteChat ChatRef | APIAcceptContact Int64 | APIRejectContact Int64 + | APISendCallInvitation ContactId CallType + | APIRejectCall ContactId + | APISendCallOffer ContactId WebRTCCallOffer + | APISendCallAnswer ContactId WebRTCSession + | APISendCallExtraInfo ContactId WebRTCExtraInfo + | APIEndCall ContactId + | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile Profile | APIParseMarkdown Text + | APIRegisterToken DeviceToken + | APIVerifyToken DeviceToken ByteString C.CbNonce + | APIIntervalNofication DeviceToken Word16 + | APIDeleteToken DeviceToken | GetUserSMPServers | SetUserSMPServers [SMPServer] | ChatHelp HelpSection @@ -121,11 +139,11 @@ data ChatCommand | AddressAutoAccept Bool | AcceptContact ContactName | RejectContact ContactName - | SendMessage ContactName ByteString + | SendMessage ChatName ByteString | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString} | SendMessageBroadcast ByteString - | DeleteMessage ContactName ByteString - | EditMessage {contactName :: ContactName, editedMsg :: ByteString, message :: ByteString} + | DeleteMessage ChatName ByteString + | EditMessage {chatName :: ChatName, editedMsg :: ByteString, message :: ByteString} | NewGroup GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName @@ -135,14 +153,9 @@ data ChatCommand | DeleteGroup GroupName | ListMembers GroupName | ListGroups - | SendGroupMessage GroupName ByteString | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} - | DeleteGroupMessage GroupName ByteString - | EditGroupMessage {groupName :: ContactName, editedMsg :: ByteString, message :: ByteString} - | SendFile ContactName FilePath - | SendFileInv ContactName FilePath - | SendGroupFile GroupName FilePath - | SendGroupFileInv GroupName FilePath + | LastMessages (Maybe ChatName) Int + | SendFile ChatName FilePath | ReceiveFile FileTransferId (Maybe FilePath) | CancelFile FileTransferId | FileStatus FileTransferId @@ -159,6 +172,7 @@ data ChatResponse | CRChatRunning | CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} + | CRLastMessages {chatItems :: [AChatItem]} | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRUserSMPServers {smpServers :: [SMPServer]} | CRNewChatItem {chatItem :: AChatItem} @@ -199,23 +213,23 @@ data ChatResponse | CRContactRequestAlreadyAccepted {contact :: Contact} | CRLeftMemberUser {groupInfo :: GroupInfo} | CRGroupDeletedUser {groupInfo :: GroupInfo} - | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} + | CRRcvFileAccepted {chatItem :: AChatItem} | CRRcvFileAcceptedSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileStart {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileStart {chatItem :: AChatItem} | CRRcvFileComplete {chatItem :: AChatItem} | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRSndFileStart {sndFileTransfer :: SndFileTransfer} - | CRSndFileComplete {sndFileTransfer :: SndFileTransfer} - | CRSndFileCancelled {sndFileTransfer :: SndFileTransfer} - | CRSndFileRcvCancelled {sndFileTransfer :: SndFileTransfer} - | CRSndGroupFileCancelled {fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} + | CRSndFileStart {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileComplete {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileRcvCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRContactConnecting {contact :: Contact} | CRContactConnected {contact :: Contact} | CRContactAnotherClient {contact :: Contact} - | CRContactDisconnected {contact :: Contact} - | CRContactSubscribed {contact :: Contact} + | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} + | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactSubError {contact :: Contact, chatError :: ChatError} | CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]} | CRGroupInvitation {groupInfo :: GroupInfo} @@ -236,8 +250,16 @@ data ChatResponse | CRPendingSubSummary {pendingSubStatus :: [PendingSubStatus]} | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} + | CRCallInvitation {contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key} + | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} + | CRCallAnswer {contact :: Contact, answer :: WebRTCSession} + | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo} + | CRCallEnded {contact :: Contact} | CRUserContactLinkSubscribed | CRUserContactLinkSubError {chatError :: ChatError} + | CRNtfTokenStatus {status :: NtfTknStatus} + | CRNewContactConnection {connection :: PendingContactConnection} + | CRContactConnectionDeleted {connection :: PendingContactConnection} | CRMessageError {severity :: Text, errorMessage :: Text} | CRChatCmdError {chatError :: ChatError} | CRChatError {chatError :: ChatError} @@ -276,6 +298,13 @@ instance ToJSON PendingSubStatus where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data ComposedMessage = ComposedMessage + { filePath :: Maybe FilePath, + quotedItemId :: Maybe ChatItemId, + msgContent :: MsgContent + } + deriving (Show, Generic, FromJSON) + data ChatError = ChatError {errorType :: ChatErrorType} | ChatErrorAgent {agentError :: AgentErrorType} @@ -307,6 +336,7 @@ data ChatErrorType | CEGroupInternal {message :: String} | CEFileNotFound {message :: String} | CEFileAlreadyReceiving {message :: String} + | CEFileCancelled {message :: String} | CEFileAlreadyExists {filePath :: FilePath} | CEFileRead {filePath :: FilePath, message :: String} | CEFileWrite {filePath :: FilePath, message :: String} @@ -316,6 +346,10 @@ data ChatErrorType | CEInvalidQuote | CEInvalidChatItemUpdate | CEInvalidChatItemDelete + | CEHasCurrentCall + | CENoCurrentCall + | CECallContact {contactId :: Int64} + | CECallState {currentCallState :: CallStateTag} | CEAgentVersion | CECommandError {message :: String} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b2bc8d2218..47e0aa8218 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -20,6 +20,7 @@ import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Text (Text) +import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay) import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) @@ -33,15 +34,20 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) import Simplex.Messaging.Agent.Protocol (AgentErrorType, AgentMsgId, MsgMeta (..)) -import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, singleFieldJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util ((<$?>)) -data ChatType = CTDirect | CTGroup | CTContactRequest +data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection deriving (Show, Generic) +data ChatName = ChatName ChatType Text + deriving (Show) + +data ChatRef = ChatRef ChatType Int64 + deriving (Show) + instance ToJSON ChatType where toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" @@ -50,6 +56,7 @@ data ChatInfo (c :: ChatType) where DirectChat :: Contact -> ChatInfo 'CTDirect GroupChat :: GroupInfo -> ChatInfo 'CTGroup ContactRequest :: UserContactRequest -> ChatInfo 'CTContactRequest + ContactConnection :: PendingContactConnection -> ChatInfo 'CTContactConnection deriving instance Show (ChatInfo c) @@ -57,6 +64,7 @@ data JSONChatInfo = JCInfoDirect {contact :: Contact} | JCInfoGroup {groupInfo :: GroupInfo} | JCInfoContactRequest {contactRequest :: UserContactRequest} + | JCInfoContactConnection {contactConnection :: PendingContactConnection} deriving (Generic) instance ToJSON JSONChatInfo where @@ -72,6 +80,7 @@ jsonChatInfo = \case DirectChat c -> JCInfoDirect c GroupChat g -> JCInfoGroup g ContactRequest g -> JCInfoContactRequest g + ContactConnection c -> JCInfoContactConnection c data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, @@ -196,6 +205,16 @@ instance ToJSON AChatItem where data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} deriving (Generic) +aChatItems :: AChat -> [AChatItem] +aChatItems (AChat ct Chat {chatInfo, chatItems}) = map aChatItem chatItems + where + aChatItem (CChatItem md ci) = AChatItem ct md chatInfo ci + +updateFileStatus :: forall c d. ChatItem c d -> CIFileStatus d -> ChatItem c d +updateFileStatus ci@ChatItem {file} status = case file of + Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} + Nothing -> ci + instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions @@ -210,17 +229,18 @@ data CIMeta (d :: MsgDirection) = CIMeta itemEdited :: Bool, editable :: Bool, localItemTs :: ZonedTime, - createdAt :: UTCTime + createdAt :: UTCTime, + updatedAt :: UTCTime } deriving (Show, Generic) -mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> CIMeta d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt = +mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta d +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt updatedAt = let localItemTs = utcToZonedTime tz itemTs editable = case itemContent of CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt, updatedAt} instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions @@ -280,14 +300,29 @@ instance MsgDirectionI d => ToJSON (CIFile d) where data CIFileStatus (d :: MsgDirection) where CIFSSndStored :: CIFileStatus 'MDSnd + CIFSSndTransfer :: CIFileStatus 'MDSnd CIFSSndCancelled :: CIFileStatus 'MDSnd + CIFSSndComplete :: CIFileStatus 'MDSnd CIFSRcvInvitation :: CIFileStatus 'MDRcv + CIFSRcvAccepted :: CIFileStatus 'MDRcv CIFSRcvTransfer :: CIFileStatus 'MDRcv CIFSRcvComplete :: CIFileStatus 'MDRcv CIFSRcvCancelled :: CIFileStatus 'MDRcv deriving instance Show (CIFileStatus d) +ciFileEnded :: CIFileStatus d -> Bool +ciFileEnded = \case + CIFSSndStored -> False + CIFSSndTransfer -> False + CIFSSndCancelled -> True + CIFSSndComplete -> True + CIFSRcvInvitation -> False + CIFSRcvAccepted -> False + CIFSRcvTransfer -> False + CIFSRcvCancelled -> True + CIFSRcvComplete -> True + instance MsgDirectionI d => ToJSON (CIFileStatus d) where toJSON = strToJSON toEncoding = strToJEncoding @@ -303,8 +338,11 @@ deriving instance Show ACIFileStatus instance MsgDirectionI d => StrEncoding (CIFileStatus d) where strEncode = \case CIFSSndStored -> "snd_stored" + CIFSSndTransfer -> "snd_transfer" CIFSSndCancelled -> "snd_cancelled" + CIFSSndComplete -> "snd_complete" CIFSRcvInvitation -> "rcv_invitation" + CIFSRcvAccepted -> "rcv_accepted" CIFSRcvTransfer -> "rcv_transfer" CIFSRcvComplete -> "rcv_complete" CIFSRcvCancelled -> "rcv_cancelled" @@ -315,8 +353,11 @@ instance StrEncoding ACIFileStatus where strP = A.takeTill (== ' ') >>= \case "snd_stored" -> pure $ AFS SMDSnd CIFSSndStored + "snd_transfer" -> pure $ AFS SMDSnd CIFSSndTransfer "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled + "snd_complete" -> pure $ AFS SMDSnd CIFSSndComplete "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation + "rcv_accepted" -> pure $ AFS SMDRcv CIFSRcvAccepted "rcv_transfer" -> pure $ AFS SMDRcv CIFSRcvTransfer "rcv_complete" -> pure $ AFS SMDRcv CIFSRcvComplete "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled @@ -423,6 +464,8 @@ data CIContent (d :: MsgDirection) where CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv CISndDeleted :: CIDeleteMode -> CIContent 'MDSnd CIRcvDeleted :: CIDeleteMode -> CIContent 'MDRcv + CISndCall :: CICallStatus -> Int -> CIContent 'MDSnd + CIRcvCall :: CICallStatus -> Int -> CIContent 'MDRcv deriving instance Show (CIContent d) @@ -432,6 +475,8 @@ ciContentToText = \case CIRcvMsgContent mc -> msgContentText mc CISndDeleted cidm -> ciDeleteModeToText cidm CIRcvDeleted cidm -> ciDeleteModeToText cidm + CISndCall status duration -> "outgoing call: " <> ciCallInfoText status duration + CIRcvCall status duration -> "incoming call: " <> ciCallInfoText status duration msgDirToDeletedContent_ :: SMsgDirection d -> CIDeleteMode -> CIContent d msgDirToDeletedContent_ msgDir mode = case msgDir of @@ -447,7 +492,7 @@ instance ToJSON (CIContent d) where toJSON = J.toJSON . jsonCIContent toEncoding = J.toEncoding . jsonCIContent -data ACIContent = forall d. ACIContent (SMsgDirection d) (CIContent d) +data ACIContent = forall d. MsgDirectionI d => ACIContent (SMsgDirection d) (CIContent d) deriving instance Show ACIContent @@ -464,6 +509,8 @@ data JSONCIContent | JCIRcvMsgContent {msgContent :: MsgContent} | JCISndDeleted {deleteMode :: CIDeleteMode} | JCIRcvDeleted {deleteMode :: CIDeleteMode} + | JCISndCall {status :: CICallStatus, duration :: Int} -- duration in seconds + | JCIRcvCall {status :: CICallStatus, duration :: Int} deriving (Generic) instance FromJSON JSONCIContent where @@ -479,6 +526,8 @@ jsonCIContent = \case CIRcvMsgContent mc -> JCIRcvMsgContent mc CISndDeleted cidm -> JCISndDeleted cidm CIRcvDeleted cidm -> JCIRcvDeleted cidm + CISndCall status duration -> JCISndCall {status, duration} + CIRcvCall status duration -> JCIRcvCall {status, duration} aciContentJSON :: JSONCIContent -> ACIContent aciContentJSON = \case @@ -486,6 +535,8 @@ aciContentJSON = \case JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc JCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm JCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm + JCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration + JCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration -- platform independent data DBJSONCIContent @@ -493,6 +544,8 @@ data DBJSONCIContent | DBJCIRcvMsgContent {msgContent :: MsgContent} | DBJCISndDeleted {deleteMode :: CIDeleteMode} | DBJCIRcvDeleted {deleteMode :: CIDeleteMode} + | DBJCISndCall {status :: CICallStatus, duration :: Int} + | DBJCIRcvCall {status :: CICallStatus, duration :: Int} deriving (Generic) instance FromJSON DBJSONCIContent where @@ -508,6 +561,8 @@ dbJsonCIContent = \case CIRcvMsgContent mc -> DBJCIRcvMsgContent mc CISndDeleted cidm -> DBJCISndDeleted cidm CIRcvDeleted cidm -> DBJCIRcvDeleted cidm + CISndCall status duration -> DBJCISndCall {status, duration} + CIRcvCall status duration -> DBJCIRcvCall {status, duration} aciContentDBJSON :: DBJSONCIContent -> ACIContent aciContentDBJSON = \case @@ -515,25 +570,64 @@ aciContentDBJSON = \case DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc DBJCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm DBJCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm + DBJCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration + DBJCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration + +data CICallStatus + = CISCallPending + | CISCallMissed + | CISCallRejected -- only possible for received calls, not on type level + | CISCallAccepted + | CISCallNegotiated + | CISCallProgress + | CISCallEnded + | CISCallError + deriving (Show, Generic) + +instance FromJSON CICallStatus where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CISCall" + +instance ToJSON CICallStatus where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CISCall" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CISCall" + +ciCallInfoText :: CICallStatus -> Int -> Text +ciCallInfoText status duration = case status of + CISCallPending -> "calling..." + CISCallMissed -> "missed" + CISCallRejected -> "rejected" + CISCallAccepted -> "accepted" + CISCallNegotiated -> "connecting..." + CISCallProgress -> "in progress " <> d + CISCallEnded -> "ended " <> d + CISCallError -> "error" + where + d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")" + with0 n + | n < 9 = '0' : show n + | otherwise = show n data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect SCTGroup :: SChatType 'CTGroup SCTContactRequest :: SChatType 'CTContactRequest + SCTContactConnection :: SChatType 'CTContactConnection deriving instance Show (SChatType c) instance TestEquality SChatType where testEquality SCTDirect SCTDirect = Just Refl testEquality SCTGroup SCTGroup = Just Refl + testEquality SCTContactRequest SCTContactRequest = Just Refl + testEquality SCTContactConnection SCTContactConnection = Just Refl testEquality _ _ = Nothing class ChatTypeI (c :: ChatType) where - chatType :: SChatType c + chatTypeI :: SChatType c -instance ChatTypeI 'CTDirect where chatType = SCTDirect +instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect -instance ChatTypeI 'CTGroup where chatType = SCTGroup +instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup data NewMessage = NewMessage { chatMsgEvent :: ChatMsgEvent, diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 09cc1e9f85..9c55d1b614 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -13,7 +13,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Options.Applicative import Simplex.Chat.Controller (updateStr, versionStr) -import Simplex.Messaging.Agent.Protocol (SMPServer (..)) +import Simplex.Messaging.Agent.Protocol (SMPServer) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) import System.FilePath (combine) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index b52786fe86..83b0d8c204 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -29,10 +29,11 @@ import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) +import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) -import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (fromTextField_) import Simplex.Messaging.Util ((<$?>)) data ConnectionEntity @@ -113,8 +114,9 @@ data ChatMsgEvent | XMsgDel SharedMsgId | XMsgDeleted | XFile FileInvitation -- TODO discontinue - | XFileAcpt String -- old file protocol - | XFileAcptInv SharedMsgId ConnReqInvitation String -- new file protocol + | XFileAcpt String -- direct file protocol + | XFileAcptInv SharedMsgId ConnReqInvitation String -- group file protocol + | XFileCancel SharedMsgId | XInfo Profile | XContact Profile (Maybe XContactId) | XGrpInv GroupInvitation @@ -132,6 +134,11 @@ data ChatMsgEvent | XInfoProbe Probe | XInfoProbeCheck ProbeHash | XInfoProbeOk Probe + | XCallInv CallId CallInvitation + | XCallOffer CallId CallOffer + | XCallAnswer CallId CallAnswer + | XCallExtra CallId CallExtraInfo + | XCallEnd CallId | XOk | XUnknown {event :: Text, params :: J.Object} deriving (Eq, Show) @@ -148,18 +155,20 @@ cmToQuotedMsg = \case XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCFile_ | MCUnknown_ Text instance StrEncoding MsgContentTag where strEncode = \case MCText_ -> "text" MCLink_ -> "link" MCImage_ -> "image" + MCFile_ -> "file" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ "link" -> Right MCLink_ "image" -> Right MCImage_ + "file" -> Right MCFile_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -196,6 +205,7 @@ data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} | MCImage {text :: Text, image :: ImageData} + | MCFile Text | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -204,6 +214,7 @@ msgContentText = \case MCText t -> t MCLink {text} -> text MCImage {text} -> text + MCFile t -> t MCUnknown {text} -> text msgContentTag :: MsgContent -> MsgContentTag @@ -211,6 +222,7 @@ msgContentTag = \case MCText _ -> MCText_ MCLink {} -> MCLink_ MCImage {} -> MCImage_ + MCFile {} -> MCFile_ MCUnknown {tag} -> MCUnknown_ tag data ExtMsgContent = ExtMsgContent MsgContent (Maybe FileInvitation) @@ -236,6 +248,7 @@ instance FromJSON MsgContent where text <- v .: "text" image <- v .: "image" pure MCImage {image, text} + MCFile_ -> MCFile <$> v .: "text" MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -261,11 +274,13 @@ instance ToJSON MsgContent where MCText t -> J.object ["type" .= MCText_, "text" .= t] MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview] MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image] + MCFile t -> J.object ["type" .= MCFile_, "text" .= t] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image + MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t instance ToField MsgContent where toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode @@ -281,6 +296,7 @@ data CMEventTag | XFile_ | XFileAcpt_ | XFileAcptInv_ + | XFileCancel_ | XInfo_ | XContact_ | XGrpInv_ @@ -298,6 +314,11 @@ data CMEventTag | XInfoProbe_ | XInfoProbeCheck_ | XInfoProbeOk_ + | XCallInv_ + | XCallOffer_ + | XCallAnswer_ + | XCallExtra_ + | XCallEnd_ | XOk_ | XUnknown_ Text deriving (Eq, Show) @@ -311,6 +332,7 @@ instance StrEncoding CMEventTag where XFile_ -> "x.file" XFileAcpt_ -> "x.file.acpt" XFileAcptInv_ -> "x.file.acpt.inv" + XFileCancel_ -> "x.file.cancel" XInfo_ -> "x.info" XContact_ -> "x.contact" XGrpInv_ -> "x.grp.inv" @@ -328,6 +350,11 @@ instance StrEncoding CMEventTag where XInfoProbe_ -> "x.info.probe" XInfoProbeCheck_ -> "x.info.probe.check" XInfoProbeOk_ -> "x.info.probe.ok" + XCallInv_ -> "x.call.inv" + XCallOffer_ -> "x.call.offer" + XCallAnswer_ -> "x.call.answer" + XCallExtra_ -> "x.call.extra" + XCallEnd_ -> "x.call.end" XOk_ -> "x.ok" XUnknown_ t -> encodeUtf8 t strDecode = \case @@ -338,6 +365,7 @@ instance StrEncoding CMEventTag where "x.file" -> Right XFile_ "x.file.acpt" -> Right XFileAcpt_ "x.file.acpt.inv" -> Right XFileAcptInv_ + "x.file.cancel" -> Right XFileCancel_ "x.info" -> Right XInfo_ "x.contact" -> Right XContact_ "x.grp.inv" -> Right XGrpInv_ @@ -355,6 +383,11 @@ instance StrEncoding CMEventTag where "x.info.probe" -> Right XInfoProbe_ "x.info.probe.check" -> Right XInfoProbeCheck_ "x.info.probe.ok" -> Right XInfoProbeOk_ + "x.call.inv" -> Right XCallInv_ + "x.call.offer" -> Right XCallOffer_ + "x.call.answer" -> Right XCallAnswer_ + "x.call.extra" -> Right XCallExtra_ + "x.call.end" -> Right XCallEnd_ "x.ok" -> Right XOk_ t -> Right . XUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -368,6 +401,7 @@ toCMEventTag = \case XFile _ -> XFile_ XFileAcpt _ -> XFileAcpt_ XFileAcptInv {} -> XFileAcptInv_ + XFileCancel _ -> XFileCancel_ XInfo _ -> XInfo_ XContact _ _ -> XContact_ XGrpInv _ -> XGrpInv_ @@ -385,6 +419,11 @@ toCMEventTag = \case XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ XInfoProbeOk _ -> XInfoProbeOk_ + XCallInv _ _ -> XCallInv_ + XCallOffer _ _ -> XCallOffer_ + XCallAnswer _ _ -> XCallAnswer_ + XCallExtra _ _ -> XCallExtra_ + XCallEnd _ -> XCallEnd_ XOk -> XOk_ XUnknown t _ -> XUnknown_ t @@ -416,6 +455,7 @@ appToChatMessage AppMessage {msgId, event, params} = do XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> p "fileConnReq" <*> p "fileName" + XFileCancel_ -> XFileCancel <$> p "msgId" XInfo_ -> XInfo <$> p "profile" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" XGrpInv_ -> XGrpInv <$> p "groupInvitation" @@ -433,6 +473,11 @@ appToChatMessage AppMessage {msgId, event, params} = do XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" + XCallInv_ -> XCallInv <$> p "callId" <*> p "invitation" + XCallOffer_ -> XCallOffer <$> p "callId" <*> p "offer" + XCallAnswer_ -> XCallAnswer <$> p "callId" <*> p "answer" + XCallExtra_ -> XCallExtra <$> p "callId" <*> p "extra" + XCallEnd_ -> XCallEnd <$> p "callId" XOk_ -> pure XOk XUnknown_ t -> pure $ XUnknown t params @@ -451,6 +496,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XFile fileInv -> o ["file" .= fileInvitationJSON fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] XFileAcptInv sharedMsgId fileConnReq fileName -> o ["msgId" .= sharedMsgId, "fileConnReq" .= fileConnReq, "fileName" .= fileName] + XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] XGrpInv groupInv -> o ["groupInvitation" .= groupInv] @@ -468,6 +514,11 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] + XCallInv callId inv -> o ["callId" .= callId, "invitation" .= inv] + XCallOffer callId offer -> o ["callId" .= callId, "offer" .= offer] + XCallAnswer callId answer -> o ["callId" .= callId, "answer" .= answer] + XCallExtra callId extra -> o ["callId" .= callId, "extra" .= extra] + XCallEnd callId -> o ["callId" .= callId] XOk -> JM.empty XUnknown _ ps -> ps diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index bb10843521..30e8f8a4d2 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -8,6 +8,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -52,6 +53,7 @@ module Simplex.Chat.Store getPendingConnections, getContactConnections, getConnectionEntity, + getConnectionsContacts, getGroupAndMember, updateConnectionStatus, createNewGroup, @@ -88,17 +90,15 @@ module Simplex.Chat.Store matchReceivedProbeHash, matchSentProbe, mergeContactRecords, - createSndFileTransfer, -- old file protocol - createSndFileTransferV2, - createSndFileTransferV2Connection, - createSndGroupFileTransfer, -- old file protocol - createSndGroupFileTransferV2, - createSndGroupFileTransferV2Connection, + createSndFileTransfer, + createSndGroupFileTransfer, + createSndGroupFileTransferConnection, updateFileCancelled, updateCIFileStatus, getSharedMsgIdByFileId, getFileIdBySharedMsgId, getGroupFileIdBySharedMsgId, + getChatRefByFileId, updateSndFileStatus, createSndFileChunk, updateSndFileChunkMsg, @@ -115,6 +115,7 @@ module Simplex.Chat.Store updateFileTransferChatItemId, getFileTransfer, getFileTransferProgress, + getSndFileTransfer, getContactFiles, createNewSndMessage, createSndMsgDelivery, @@ -129,15 +130,18 @@ module Simplex.Chat.Store getChatPreviews, getDirectChat, getGroupChat, + getAllChatItems, getChatItemIdByAgentMsgId, getDirectChatItem, getDirectChatItemBySharedMsgId, + getDirectChatItemByAgentMsgId, getGroupChatItem, getGroupChatItemBySharedMsgId, getDirectChatItemIdByText, getGroupChatItemIdByText, getChatItemByFileId, updateDirectChatItemStatus, + updateDirectCIFileStatus, updateDirectChatItem, deleteDirectChatItemInternal, deleteDirectChatItemRcvBroadcast, @@ -150,6 +154,8 @@ module Simplex.Chat.Store updateGroupChatItemsRead, getSMPServers, overwriteSMPServers, + getPendingContactConnection, + deletePendingContactConnection, ) where @@ -165,17 +171,18 @@ import qualified Data.Aeson as J import Data.Bifunctor (first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) -import Data.Either (rights) +import Data.Either (isRight, rights) import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, sortBy, sortOn) -import Data.Maybe (fromMaybe, listToMaybe) +import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Ord (Down (..)) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) +import Data.Type.Equality import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) @@ -195,14 +202,14 @@ import Simplex.Chat.Migrations.M20220404_files_status_fields import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..)) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (strEncode)) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Protocol (ProtocolServer (..), SMPServer, pattern SMPServer) import Simplex.Messaging.Util (liftIOEither, (<$$>)) -import System.FilePath (takeFileName) import UnliftIO.STM schemaMigrations :: [(String, Query)] @@ -294,10 +301,11 @@ setActiveUser st userId = do DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) -createConnReqConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> m () +createConnReqConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> m PendingContactConnection createConnReqConnection st userId acId cReqHash xContactId = do liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime + createdAt <- getCurrentTime + let pccConnStatus = ConnJoined DB.execute db [sql| @@ -306,7 +314,9 @@ createConnReqConnection st userId acId cReqHash xContactId = do created_at, updated_at, via_contact_uri_hash, xcontact_id ) VALUES (?,?,?,?,?,?,?,?) |] - (userId, acId, ConnNew, ConnContact, currentTs, currentTs, cReqHash, xContactId) + (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt, cReqHash, xContactId) + pccConnId <- insertedRowId db + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, createdAt, updatedAt = createdAt} getConnReqContactXContactId :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnReqUriHash -> m (Maybe Contact, Maybe XContactId) getConnReqContactXContactId st userId cReqHash = do @@ -343,11 +353,19 @@ getConnReqContactXContactId st userId cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> m () -createDirectConnection st userId agentConnId = +createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnStatus -> m PendingContactConnection +createDirectConnection st userId acId pccConnStatus = liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - void $ createContactConnection_ db userId agentConnId Nothing 0 currentTs + createdAt <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO connections + (user_id, agent_conn_id, conn_status, conn_type, created_at, updated_at) VALUES (?,?,?,?,?,?) + |] + (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt) + pccConnId <- insertedRowId db + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, createdAt, updatedAt = createdAt} createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection createContactConnection_ db userId = createConnection_ db userId ConnContact Nothing @@ -408,7 +426,7 @@ getContactGroupNames st userId Contact {contactId} = (userId, contactId) deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m () -deleteContact st userId Contact {contactId, localDisplayName} = +deleteContact st userId Contact {contactId, localDisplayName} = do liftIO . withTransaction st $ \db -> do DB.execute db @@ -421,6 +439,10 @@ deleteContact st userId Contact {contactId, localDisplayName} = ) |] (userId, contactId) + DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId) + -- in separate transaction to prevent crashes on android (race condition on integrity check?) + liftIO . withTransaction st $ \db -> do + DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) @@ -704,7 +726,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.xcontact_id, cr.created_at, cr.updated_at FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -763,7 +785,7 @@ getContactRequest_ db userId contactRequestId = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.xcontact_id, cr.created_at, cr.updated_at FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -772,12 +794,12 @@ getContactRequest_ db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, UTCTime, Maybe XContactId) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe XContactId, UTCTime, UTCTime) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, createdAt, xContactId) = do +toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, xContactId, createdAt, updatedAt) = do let profile = Profile {displayName, fullName, image} - in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt, xContactId} + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, xContactId, createdAt, updatedAt} getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 getContactRequestIdByName st userId cName = @@ -1179,6 +1201,28 @@ getConnectionEntity st User {userId, userContactId} agentConnId = userContact_ [Only cReq] = Right UserContact {userContactLinkId, connReqContact = cReq} userContact_ _ = Left SEUserContactLinkNotFound +getConnectionsContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> [ConnId] -> m [ContactRef] +getConnectionsContacts st userId agentConnIds = + liftIO . withTransaction st $ \db -> do + DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" + DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" + DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds + conns <- + map (uncurry ContactRef) + <$> DB.query + db + [sql| + SELECT ct.contact_id, ct.local_display_name + FROM contacts ct + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? + AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + AND c.conn_type = ? + |] + (userId, ConnContact) + DB.execute_ db "DROP TABLE temp.conn_ids" + pure conns + getGroupAndMember :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (GroupInfo, GroupMember) getGroupAndMember st User {userId, userContactId} groupMemberId = liftIOEither . withTransaction st $ \db -> @@ -1247,7 +1291,7 @@ createNewGroup st gVar user groupProfile = "INSERT INTO groups (local_display_name, user_id, group_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)" (displayName, uId, profileId, currentTs, currentTs) groupId <- insertedRowId db - memberId <- randomBytes gVar 12 + memberId <- encodedRandomBytes gVar 12 membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser currentTs pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs} @@ -1803,46 +1847,8 @@ createSndFileTransfer st userId Contact {contactId} filePath FileInvitation {fil (fileId, fileStatus, connId, currentTs, currentTs) pure fileId -createSndFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> Integer -> m Int64 -createSndFileTransferV2 st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} chunkSize = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) - insertedRowId db - -createSndFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> m () -createSndFileTransferV2Connection st userId fileId acId = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db userId fileId acId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, FSAccepted, connId, currentTs, currentTs) - -createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64 -createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize chunkSize = - liftIO . withTransaction st $ \db -> do - let fileName = takeFileName filePath - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) - fileId <- insertedRowId db - forM_ ms $ \(GroupMember {groupMemberId}, agentConnId, _) -> do - Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, FSNew, connId, groupMemberId, currentTs, currentTs) - pure fileId - -createSndGroupFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> m Int64 -createSndGroupFileTransferV2 st userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = +createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> m Int64 +createSndGroupFileTransfer st userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute @@ -1851,8 +1857,8 @@ createSndGroupFileTransferV2 st userId GroupInfo {groupId} filePath FileInvitati (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) insertedRowId db -createSndGroupFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () -createSndGroupFileTransferV2Connection st userId fileId acId GroupMember {groupMemberId} = +createSndGroupFileTransferConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () +createSndGroupFileTransferConnection st userId fileId acId GroupMember {groupMemberId} = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime Connection {connId} <- createSndFileConnection_ db userId fileId acId @@ -1861,17 +1867,20 @@ createSndGroupFileTransferV2Connection st userId fileId acId GroupMember {groupM "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) -updateFileCancelled :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () -updateFileCancelled st userId fileId = +updateFileCancelled :: MsgDirectionI d => MonadUnliftIO m => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m () +updateFileCancelled st User {userId} fileId ciFileStatus = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime - DB.execute db "UPDATE files SET cancelled = 1, updated_at = ? WHERE user_id = ? AND file_id = ?" (currentTs, userId, fileId) + DB.execute db "UPDATE files SET cancelled = 1, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) -updateCIFileStatus :: MsgDirectionI d => MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> CIFileStatus d -> m () -updateCIFileStatus st userId fileId ciFileStatus = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) +updateCIFileStatus :: (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m () +updateCIFileStatus st user fileId ciFileStatus = + liftIO . withTransaction st $ \db -> updateCIFileStatus_ db user fileId ciFileStatus + +updateCIFileStatus_ :: MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> IO () +updateCIFileStatus_ db User {userId} fileId ciFileStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) getSharedMsgIdByFileId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m SharedMsgId getSharedMsgIdByFileId st userId fileId = @@ -1887,7 +1896,7 @@ getSharedMsgIdByFileId st userId fileId = |] (userId, fileId) -getFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m Int64 getFileIdBySharedMsgId st userId contactId sharedMsgId = liftIOEither . withTransaction st $ \db -> firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ @@ -1901,7 +1910,7 @@ getFileIdBySharedMsgId st userId contactId sharedMsgId = |] (userId, contactId, sharedMsgId) -getGroupFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getGroupFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m Int64 getGroupFileIdBySharedMsgId st userId groupId sharedMsgId = liftIOEither . withTransaction st $ \db -> firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ @@ -1915,6 +1924,23 @@ getGroupFileIdBySharedMsgId st userId groupId sharedMsgId = |] (userId, groupId, sharedMsgId) +getChatRefByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m ChatRef +getChatRefByFileId st User {userId} fileId = do + r <- liftIO . withTransaction st $ \db -> do + DB.query + db + [sql| + SELECT contact_id, group_id + FROM files + WHERE user_id = ? AND file_id = ? + LIMIT 1 + |] + (userId, fileId) + case r of + [(Just contactId, Nothing)] -> pure $ ChatRef CTDirect contactId + [(Nothing, Just groupId)] -> pure $ ChatRef CTGroup groupId + _ -> throwError $ SEInternalError "could not retrieve chat ref by file id" + createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ db userId fileId agentConnId = do currentTs <- getCurrentTime @@ -2008,8 +2034,8 @@ createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localD (fileId, FSNew, fileConnReq, groupMemberId, currentTs, currentTs) pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} -getRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m RcvFileTransfer -getRcvFileTransfer st userId fileId = +getRcvFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m RcvFileTransfer +getRcvFileTransfer st User {userId} fileId = liftIOEither . withTransaction st $ \db -> getRcvFileTransfer_ db userId fileId @@ -2041,28 +2067,29 @@ getRcvFileTransfer_ db userId fileId = Nothing -> Left $ SERcvFileInvalid fileId Just name -> case fileStatus' of - FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, senderDisplayName = name, chunkSize, cancelled, grpMemberId} - FSAccepted -> ft name fileInv RFSAccepted fileInfo - FSConnected -> ft name fileInv RFSConnected fileInfo - FSComplete -> ft name fileInv RFSComplete fileInfo - FSCancelled -> ft name fileInv RFSCancelled fileInfo + FSNew -> ft name fileInv RFSNew + FSAccepted -> ft name fileInv . RFSAccepted =<< rfi fileInfo + FSConnected -> ft name fileInv . RFSConnected =<< rfi fileInfo + FSComplete -> ft name fileInv . RFSComplete =<< rfi fileInfo + FSCancelled -> ft name fileInv . RFSCancelled $ rfi_ fileInfo where - ft senderDisplayName fileInvitation rfs = \case - (Just filePath, Just connId, Just agentConnId) -> - let fileStatus = rfs RcvFileInfo {filePath, connId, agentConnId} - in Right RcvFileTransfer {..} - _ -> Left $ SERcvFileInvalid fileId + ft senderDisplayName fileInvitation fileStatus = + Right RcvFileTransfer {fileId, fileInvitation, fileStatus, senderDisplayName, chunkSize, cancelled, grpMemberId} + rfi fileInfo = maybe (Left $ SERcvFileInvalid fileId) Right $ rfi_ fileInfo + rfi_ = \case + (Just filePath, Just connId, Just agentConnId) -> Just RcvFileInfo {filePath, connId, agentConnId} + _ -> Nothing cancelled = fromMaybe False cancelled_ rcvFileTransfer _ = Left $ SERcvFileNotFound fileId -acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ConnId -> FilePath -> m () -acceptRcvFileTransfer st userId fileId agentConnId filePath = - liftIO . withTransaction st $ \db -> do +acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> ConnId -> FilePath -> m AChatItem +acceptRcvFileTransfer st user@User {userId} fileId agentConnId filePath = + liftIOEither . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute db "UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" - (filePath, CIFSRcvTransfer, currentTs, userId, fileId) + (filePath, CIFSRcvAccepted, currentTs, userId, fileId) DB.execute db "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" @@ -2071,6 +2098,7 @@ acceptRcvFileTransfer st userId fileId agentConnId filePath = db "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (agentConnId, ConnJoined, ConnRcvFile, fileId, userId, currentTs, currentTs) + getChatItemByFileId_ db user fileId updateRcvFileStatus :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> FileStatus -> m () updateRcvFileStatus st RcvFileTransfer {fileId} status = @@ -2135,13 +2163,13 @@ updateFileTransferChatItemId st fileId ciId = currentTs <- getCurrentTime DB.execute db "UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ?" (ciId, currentTs, fileId) -getFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m FileTransfer -getFileTransfer st userId fileId = +getFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m FileTransfer +getFileTransfer st User {userId} fileId = liftIOEither . withTransaction st $ \db -> getFileTransfer_ db userId fileId -getFileTransferProgress :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m (FileTransfer, [Integer]) -getFileTransferProgress st userId fileId = +getFileTransferProgress :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (FileTransfer, [Integer]) +getFileTransferProgress st User {userId} fileId = liftIOEither . withTransaction st $ \db -> runExceptT $ do ft <- ExceptT $ getFileTransfer_ db userId fileId liftIO $ @@ -2165,15 +2193,20 @@ getFileTransfer_ db userId fileId = (userId, fileId) where fileTransfer :: [(Maybe Int64, Maybe Int64)] -> IO (Either StoreError FileTransfer) - fileTransfer [(Nothing, Nothing)] = runExceptT $ do - fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId - pure FTSnd {fileTransferMeta, sndFileTransfers = []} - fileTransfer ((Just _, Nothing) : _) = runExceptT $ do - fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId - sndFileTransfers <- ExceptT $ getSndFileTransfers_ db userId fileId - pure FTSnd {fileTransferMeta, sndFileTransfers} fileTransfer [(Nothing, Just _)] = FTRcv <$$> getRcvFileTransfer_ db userId fileId - fileTransfer _ = pure . Left $ SEFileNotFound fileId + fileTransfer _ = runExceptT $ do + (ftm, fts) <- ExceptT $ getSndFileTransfer_ db userId fileId + pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts} + +getSndFileTransfer :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (FileTransferMeta, [SndFileTransfer]) +getSndFileTransfer st User {userId} fileId = + liftIOEither . withTransaction st $ \db -> getSndFileTransfer_ db userId fileId + +getSndFileTransfer_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (FileTransferMeta, [SndFileTransfer])) +getSndFileTransfer_ db userId fileId = runExceptT $ do + fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId + sndFileTransfers <- ExceptT $ getSndFileTransfers_ db userId fileId + pure (fileTransferMeta, sndFileTransfers) getSndFileTransfers_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError [SndFileTransfer]) getSndFileTransfers_ db userId fileId = @@ -2193,7 +2226,7 @@ getSndFileTransfers_ db userId fileId = (userId, fileId) where sndFileTransfers :: [(FileStatus, String, Integer, Integer, FilePath, Int64, AgentConnId, Maybe ContactName, Maybe ContactName)] -> Either StoreError [SndFileTransfer] - sndFileTransfers [] = Left $ SESndFileNotFound fileId + sndFileTransfers [] = Right [] sndFileTransfers fts = mapM sndFileTransfer fts sndFileTransfer (fileStatus, fileName, fileSize, chunkSize, filePath, connId, agentConnId, contactName_, memberName_) = case contactName_ <|> memberName_ of @@ -2498,20 +2531,22 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow -getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChat] -getChatPreviews st user = +getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> Bool -> m [AChat] +getChatPreviews st user withPCC = liftIO . withTransaction st $ \db -> do directChats <- getDirectChatPreviews_ db user groupChats <- getGroupChatPreviews_ db user cReqChats <- getContactRequestChatPreviews_ db user - pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats) + connChats <- getContactConnectionChatPreviews_ db user withPCC + pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats <> connChats) where ts :: AChat -> UTCTime ts (AChat _ Chat {chatItems = ci : _}) = chatItemTs ci ts (AChat _ Chat {chatInfo}) = case chatInfo of DirectChat Contact {createdAt} -> createdAt GroupChat GroupInfo {createdAt} -> createdAt - ContactRequest UserContactRequest {createdAt} -> createdAt + ContactRequest UserContactRequest {updatedAt} -> updatedAt + ContactConnection PendingContactConnection {updatedAt} -> updatedAt chatItemTs :: CChatItem d -> UTCTime chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs @@ -2533,7 +2568,7 @@ getDirectChatPreviews_ db User {userId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2598,7 +2633,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- Maybe GroupMember - sender @@ -2655,7 +2690,7 @@ getContactRequestChatPreviews_ db User {userId} = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.xcontact_id, cr.created_at, cr.updated_at FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -2669,6 +2704,63 @@ getContactRequestChatPreviews_ db User {userId} = stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats +getContactConnectionChatPreviews_ :: DB.Connection -> User -> Bool -> IO [AChat] +getContactConnectionChatPreviews_ _ _ False = pure [] +getContactConnectionChatPreviews_ db User {userId} _ = + map toContactConnectionChatPreview + <$> DB.query + db + [sql| + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at + FROM connections + WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL + |] + (userId, ConnContact) + where + toContactConnectionChatPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime, UTCTime) -> AChat + toContactConnectionChatPreview connRow = + let conn = toPendingContactConnection connRow + stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} + in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + +getPendingContactConnection :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m PendingContactConnection +getPendingContactConnection st userId connId = + liftIOEither . withTransaction st $ \db -> do + firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ + DB.query + db + [sql| + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at + FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + |] + (userId, connId, ConnContact) + +deletePendingContactConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () +deletePendingContactConnection st userId connId = + liftIO . withTransaction st $ \db -> + DB.execute + db + [sql| + DELETE FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + |] + (userId, connId, ConnContact) + +toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime, UTCTime) -> PendingContactConnection +toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, createdAt, updatedAt) = + PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, createdAt, updatedAt} + getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTDirect) getDirectChat st user contactId pagination = liftIOEither . withTransaction st $ \db -> runExceptT $ do @@ -2694,7 +2786,7 @@ getDirectChatLast_ db User {userId} contactId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2725,7 +2817,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2756,7 +2848,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -2859,7 +2951,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -2902,7 +2994,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -2945,7 +3037,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -3014,6 +3106,31 @@ getGroupInfo_ db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) +getAllChatItems :: StoreMonad m => SQLiteStore -> User -> ChatPagination -> m [AChatItem] +getAllChatItems st user pagination = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + case pagination of + CPLast count -> getAllChatItemsLast_ db user count + CPAfter _afterId _count -> throwError $ SEInternalError "not implemented" + CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented" + +getAllChatItemsLast_ :: DB.Connection -> User -> Int -> ExceptT StoreError IO [AChatItem] +getAllChatItemsLast_ db user@User {userId} count = do + itemRefs <- + liftIO $ + reverse . rights . map toChatItemRef + <$> DB.query + db + [sql| + SELECT chat_item_id, contact_id, group_id + FROM chat_items + WHERE user_id = ? + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT ? + |] + (userId, count) + mapM (uncurry $ getAChatItem_ db user) itemRefs + getGroupIdByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Int64 getGroupIdByName st user gName = liftIOEither . withTransaction st $ \db -> getGroupIdByName_ db user gName @@ -3025,24 +3142,27 @@ getGroupIdByName_ db User {userId} gName = getChatItemIdByAgentMsgId :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> m (Maybe ChatItemId) getChatItemIdByAgentMsgId st connId msgId = - liftIO . withTransaction st $ \db -> - join . listToMaybe . map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_item_messages - WHERE message_id = ( - SELECT message_id - FROM msg_deliveries - WHERE connection_id = ? AND agent_msg_id = ? - LIMIT 1 - ) - |] - (connId, msgId) + liftIO . withTransaction st $ \db -> getChatItemIdByAgentMsgId_ db connId msgId + +getChatItemIdByAgentMsgId_ :: DB.Connection -> Int64 -> AgentMsgId -> IO (Maybe ChatItemId) +getChatItemIdByAgentMsgId_ db connId msgId = + join . listToMaybe . map fromOnly + <$> DB.query + db + [sql| + SELECT chat_item_id + FROM chat_item_messages + WHERE message_id = ( + SELECT message_id + FROM msg_deliveries + WHERE connection_id = ? AND agent_msg_id = ? + LIMIT 1 + ) + |] + (connId, msgId) updateDirectChatItemStatus :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) -updateDirectChatItemStatus st userId contactId itemId itemStatus = +updateDirectChatItemStatus st userId contactId itemId itemStatus = do liftIOEither . withTransaction st $ \db -> runExceptT $ do ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId currentTs <- liftIO getCurrentTime @@ -3052,14 +3172,17 @@ updateDirectChatItemStatus st userId contactId itemId itemStatus = correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d) -updateDirectChatItem st userId contactId itemId newContent msgId = - liftIOEither . withTransaction st $ \db -> updateDirectChatItem_ db userId contactId itemId newContent msgId +updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> Maybe MessageId -> m (ChatItem 'CTDirect d) +updateDirectChatItem st userId contactId itemId newContent msgId_ = + liftIOEither . withTransaction st $ \db -> do + currentTs <- liftIO getCurrentTime + ci <- updateDirectChatItem_ db userId contactId itemId newContent currentTs + when (isRight ci) . forM_ msgId_ $ \msgId -> liftIO $ insertChatItemMessage_ db itemId msgId currentTs + pure ci -updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTDirect d)) -updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ do +updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> UTCTime -> IO (Either StoreError (ChatItem 'CTDirect d)) +updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExceptT $ do ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId - currentTs <- liftIO getCurrentTime let newText = ciContentToText newContent liftIO $ do DB.execute @@ -3070,7 +3193,6 @@ updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] (newContent, newText, currentTs, userId, contactId, itemId) - insertChatItemMessage_ db itemId msgId currentTs pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText} where correctDir :: CChatItem c -> Either StoreError (ChatItem c d) @@ -3147,16 +3269,22 @@ deleteQuote_ db itemId = |] (Only itemId) -getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect) +getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> ChatItemId -> m (CChatItem 'CTDirect) getDirectChatItem st userId contactId itemId = liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId -getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m (CChatItem 'CTDirect) +getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> SharedMsgId -> m (CChatItem 'CTDirect) getDirectChatItemBySharedMsgId st userId contactId sharedMsgId = liftIOEither . withTransaction st $ \db -> runExceptT $ do itemId <- ExceptT $ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId liftIOEither $ getDirectChatItem_ db userId contactId itemId +getDirectChatItemByAgentMsgId :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactId -> Int64 -> AgentMsgId -> m (Maybe (CChatItem 'CTDirect)) +getDirectChatItemByAgentMsgId st userId contactId connId msgId = + liftIO . withTransaction st $ \db -> do + itemId_ <- getChatItemIdByAgentMsgId_ db connId msgId + maybe (pure Nothing) (fmap eitherToMaybe . getDirectChatItem_ db userId contactId) itemId_ + getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Either StoreError Int64) getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ @@ -3183,7 +3311,7 @@ getDirectChatItem_ db userId contactId itemId = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote @@ -3313,7 +3441,7 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember @@ -3383,23 +3511,30 @@ getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId c (userId, groupId, cName, quotedMsg <> "%") getChatItemByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m AChatItem -getChatItemByFileId st user@User {userId} fileId = do - liftIOEither . withTransaction st $ \db -> runExceptT $ do - r <- ExceptT $ getChatItemIdByFileId_ db userId fileId - case r of - (itemId, Just contactId, Nothing) -> do - ct <- ExceptT $ getContact_ db userId contactId - (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId - pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - (itemId, Nothing, Just groupId) -> do - gInfo <- ExceptT $ getGroupInfo_ db user groupId - (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci - _ -> throwError $ SEChatItemNotFoundByFileId fileId +getChatItemByFileId st user fileId = + liftIOEither . withTransaction st $ \db -> + getChatItemByFileId_ db user fileId -getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, Maybe Int64, Maybe Int64)) +getChatItemByFileId_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError AChatItem) +getChatItemByFileId_ db user@User {userId} fileId = runExceptT $ do + (itemId, chatRef) <- ExceptT $ getChatItemIdByFileId_ db userId fileId + getAChatItem_ db user itemId chatRef + +getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem +getAChatItem_ db user@User {userId} itemId = \case + ChatRef CTDirect contactId -> do + ct <- ExceptT $ getContact_ db userId contactId + (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId + pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci + ChatRef CTGroup groupId -> do + gInfo <- ExceptT $ getGroupInfo_ db user groupId + (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId + pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci + _ -> throwError $ SEChatItemNotFound itemId + +getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, ChatRef)) getChatItemIdByFileId_ db userId fileId = - firstRow id (SEChatItemNotFoundByFileId fileId) $ + firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ DB.query db [sql| @@ -3411,6 +3546,22 @@ getChatItemIdByFileId_ db userId fileId = |] (userId, fileId) +updateDirectCIFileStatus :: forall d m. (MsgDirectionI d, StoreMonad m) => SQLiteStore -> User -> Int64 -> CIFileStatus d -> m AChatItem +updateDirectCIFileStatus st user fileId fileStatus = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + aci@(AChatItem cType d cInfo ci) <- ExceptT $ getChatItemByFileId_ db user fileId + case (cType, testEquality d $ msgDirection @d) of + (SCTDirect, Just Refl) -> do + liftIO $ updateCIFileStatus_ db user fileId fileStatus + pure $ AChatItem SCTDirect d cInfo $ updateFileStatus ci fileStatus + _ -> pure aci + +toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatItemId, ChatRef) +toChatItemRef = \case + (itemId, Just contactId, Nothing) -> Right (itemId, ChatRef CTDirect contactId) + (itemId, Nothing, Just groupId) -> Right (itemId, ChatRef CTGroup groupId) + (itemId, _, _) -> Left $ SEBadChatItem itemId + updateDirectChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () updateDirectChatItemsRead st contactId (fromItemId, toItemId) = do currentTs <- liftIO getCurrentTime @@ -3442,9 +3593,9 @@ toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadIt type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe ACIFileStatus) -type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) :. MaybeCIFIleRow +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime, UTCTime) :. MaybeCIFIleRow -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) :. MaybeCIFIleRow +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) @@ -3458,7 +3609,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) = +toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) = case (itemContent, itemStatus, fileStatus_) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Just (AFS SMDSnd fileStatus)) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent (maybeCIFile fileStatus) @@ -3480,11 +3631,11 @@ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStat CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d - ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt + ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt updatedAt toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] -toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. quoteRow) = - either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. quoteRow) +toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt, Just updatedAt) :. fileRow) :. quoteRow) = + either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. quoteRow) toDirectChatItemList _ _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -3500,7 +3651,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do +toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do let member_ = toMaybeGroupMember userContactId memberRow_ let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ case (itemContent, itemStatus, member_, fileStatus_) of @@ -3524,11 +3675,11 @@ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemT CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d - ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt + ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt updatedAt toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) = - either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) +toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt, Just updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) = + either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) toGroupChatItemList _ _ _ _ = [] getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] @@ -3552,7 +3703,7 @@ overwriteSMPServers st User {userId} smpServers = do liftIOEither . checkConstraint SEUniqueID . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId) - forM_ smpServers $ \SMPServer {host, port, keyHash} -> + forM_ smpServers $ \ProtocolServer {host, port, keyHash} -> DB.execute db [sql| @@ -3610,15 +3761,25 @@ createWithRandomBytes size gVar create = tryCreate 3 tryCreate :: Int -> IO (Either StoreError a) tryCreate 0 = pure $ Left SEUniqueID tryCreate n = do - id' <- randomBytes gVar size + id' <- encodedRandomBytes gVar size E.try (create id') >>= \case Right x -> pure $ Right x Left e | DB.sqlError e == DB.ErrorConstraint -> tryCreate (n - 1) | otherwise -> pure . Left . SEInternalError $ show e +encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString +encodedRandomBytes gVar = fmap B64.encode . randomBytes gVar + randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString -randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) +randomBytes gVar = atomically . stateTVar gVar . randomBytesGenerate + +listToEither :: e -> [a] -> Either e a +listToEither _ (x : _) = Right x +listToEither e _ = Left e + +firstRow' :: (a -> Either e b) -> e -> IO [a] -> IO (Either e b) +firstRow' f e a = (f <=< listToEither e) <$> a -- These error type constructors must be added to mobile apps data StoreError @@ -3644,6 +3805,7 @@ data StoreError | SESharedMsgIdNotFoundByFileId {fileId :: FileTransferId} | SEFileIdNotFoundBySharedMsgId {sharedMsgId :: SharedMsgId} | SEConnectionNotFound {agentConnId :: AgentConnId} + | SEPendingConnectionNotFound {connId :: Int64} | SEIntroNotFound | SEUniqueID | SEInternalError {message :: String} diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 4b1a6b8a4a..c1bfcc8bbd 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,9 +1,12 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Terminal where import Control.Monad.Except +import qualified Data.List.NonEmpty as L +import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) @@ -11,8 +14,24 @@ import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output +import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..)) import Simplex.Messaging.Util (raceAny_) +terminalChatConfig :: ChatConfig +terminalChatConfig = + defaultChatConfig + { defaultServers = + InitialAgentServers + { smp = + L.fromList + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im" + ], + ntf = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"] + } + } + simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () simplexChatTerminal cfg opts t = do sendToast <- initializeNotifications diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 5e8f4bcd74..e186cf69b6 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -45,11 +45,7 @@ runInputLoop ct cc = forever $ do echo s = printToTerminal ct [plain s] isMessage = \case Right SendMessage {} -> True - Right SendGroupMessage {} -> True Right SendFile {} -> True - Right SendFileInv {} -> True - Right SendGroupFile {} -> True - Right SendGroupFileInv {} -> True Right SendMessageQuote {} -> True Right SendGroupMessageQuote {} -> True Right SendMessageBroadcast {} -> True @@ -139,7 +135,6 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition Left _ -> inp Right cmd -> case cmd of SendMessage {} -> "! " <> inp - SendGroupMessage {} -> "! " <> inp SendMessageQuote {contactName, message} -> T.unpack $ "! @" <> contactName <> " " <> safeDecodeUtf8 message SendGroupMessageQuote {groupName, message} -> T.unpack $ "! #" <> groupName <> " " <> safeDecodeUtf8 message _ -> inp diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 03eda5f6f5..5f4aafc789 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -32,13 +32,12 @@ import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) -import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util ((<$?>)) class IsContact a where - contactId' :: a -> Int64 + contactId' :: a -> ContactId profile' :: a -> Profile localDisplayName' :: a -> ContactName @@ -54,7 +53,7 @@ instance IsContact Contact where data User = User { userId :: UserId, - userContactId :: Int64, + userContactId :: ContactId, localDisplayName :: ContactName, profile :: Profile, activeUser :: Bool @@ -63,17 +62,19 @@ data User = User instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions -type UserId = Int64 +type UserId = ContactId + +type ContactId = Int64 data Contact = Contact - { contactId :: Int64, + { contactId :: ContactId, localDisplayName :: ContactName, profile :: Profile, activeConn :: Connection, viaGroup :: Maybe Int64, createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON Contact where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -85,6 +86,14 @@ contactConn = activeConn contactConnId :: Contact -> ConnId contactConnId Contact {activeConn} = aConnId activeConn +data ContactRef = ContactRef + { contactId :: ContactId, + localDisplayName :: ContactName + } + deriving (Eq, Show, Generic) + +instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions + data UserContact = UserContact { userContactLinkId :: Int64, connReqContact :: ConnReqContact @@ -100,9 +109,10 @@ data UserContactRequest = UserContactRequest profileId :: Int64, profile :: Profile, createdAt :: UTCTime, + updatedAt :: UTCTime, xContactId :: Maybe XContactId } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON UserContactRequest where toEncoding = J.genericToEncoding J.defaultOptions @@ -161,7 +171,7 @@ data GroupInfo = GroupInfo membership :: GroupMember, createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -263,7 +273,7 @@ data GroupMember = GroupMember memberContactId :: Maybe Int64, activeConn :: Maybe Connection } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON GroupMember where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} @@ -307,9 +317,6 @@ instance ToJSON MemberId where data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show, Generic) -instance FromJSON InvitedBy where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" - instance ToJSON InvitedBy where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" @@ -398,25 +405,23 @@ data GroupMemberCategory | GCPostMember -- member who joined after the user to whom the user was introduced (user receives x.grp.mem.new announcing these members and then x.grp.mem.fwd with invitation from these members) deriving (Eq, Show) -instance FromField GroupMemberCategory where fromField = fromTextField_ decodeText +instance FromField GroupMemberCategory where fromField = fromTextField_ textDecode -instance ToField GroupMemberCategory where toField = toField . encodeText - -instance FromJSON GroupMemberCategory where parseJSON = textParseJSON "GroupMemberCategory" +instance ToField GroupMemberCategory where toField = toField . textEncode instance ToJSON GroupMemberCategory where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding GroupMemberCategory where - decodeText = \case + textDecode = \case "user" -> Just GCUserMember "invitee" -> Just GCInviteeMember "host" -> Just GCHostMember "pre" -> Just GCPreMember "post" -> Just GCPostMember _ -> Nothing - encodeText = \case + textEncode = \case GCUserMember -> "user" GCInviteeMember -> "invitee" GCHostMember -> "host" @@ -437,15 +442,13 @@ data GroupMemberStatus | GSMemCreator -- user member that created the group (only GCUserMember) deriving (Eq, Show, Ord) -instance FromField GroupMemberStatus where fromField = fromTextField_ decodeText +instance FromField GroupMemberStatus where fromField = fromTextField_ textDecode -instance ToField GroupMemberStatus where toField = toField . encodeText - -instance FromJSON GroupMemberStatus where parseJSON = textParseJSON "GroupMemberStatus" +instance ToField GroupMemberStatus where toField = toField . textEncode instance ToJSON GroupMemberStatus where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of @@ -476,7 +479,7 @@ memberCurrent m = case memberStatus m of GSMemCreator -> True instance TextEncoding GroupMemberStatus where - decodeText = \case + textDecode = \case "removed" -> Just GSMemRemoved "left" -> Just GSMemLeft "deleted" -> Just GSMemGroupDeleted @@ -489,7 +492,7 @@ instance TextEncoding GroupMemberStatus where "complete" -> Just GSMemComplete "creator" -> Just GSMemCreator _ -> Nothing - encodeText = \case + textEncode = \case GSMemRemoved -> "removed" GSMemLeft -> "left" GSMemGroupDeleted -> "deleted" @@ -537,7 +540,7 @@ data RcvFileTransfer = RcvFileTransfer cancelled :: Bool, grpMemberId :: Maybe Int64 } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions @@ -546,12 +549,9 @@ data RcvFileStatus | RFSAccepted RcvFileInfo | RFSConnected RcvFileInfo | RFSComplete RcvFileInfo - | RFSCancelled RcvFileInfo + | RFSCancelled (Maybe RcvFileInfo) deriving (Eq, Show, Generic) -instance FromJSON RcvFileStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" - instance ToJSON RcvFileStatus where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" @@ -561,7 +561,7 @@ data RcvFileInfo = RcvFileInfo connId :: Int64, agentConnId :: AgentConnId } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions @@ -573,9 +573,6 @@ instance StrEncoding AgentConnId where strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP -instance FromJSON AgentConnId where - parseJSON = strParseJSON "AgentConnId" - instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -592,9 +589,6 @@ instance StrEncoding AgentInvId where strDecode s = AgentInvId <$> strDecode s strP = AgentInvId <$> strP -instance FromJSON AgentInvId where - parseJSON = strParseJSON "AgentInvId" - instance ToJSON AgentInvId where toJSON = strToJSON toEncoding = strToJEncoding @@ -633,25 +627,23 @@ fileTransferCancelled (FTRcv RcvFileTransfer {cancelled}) = cancelled data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) -instance FromField FileStatus where fromField = fromTextField_ decodeText +instance FromField FileStatus where fromField = fromTextField_ textDecode -instance ToField FileStatus where toField = toField . encodeText - -instance FromJSON FileStatus where parseJSON = textParseJSON "FileStatus" +instance ToField FileStatus where toField = toField . textEncode instance ToJSON FileStatus where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding FileStatus where - decodeText = \case + textDecode = \case "new" -> Just FSNew "accepted" -> Just FSAccepted "connected" -> Just FSConnected "complete" -> Just FSComplete "cancelled" -> Just FSCancelled _ -> Nothing - encodeText = \case + textEncode = \case FSNew -> "new" FSAccepted -> "accepted" FSConnected -> "connected" @@ -675,7 +667,7 @@ data Connection = Connection entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID createdAt :: UTCTime } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show, Generic) aConnId :: Connection -> ConnId aConnId Connection {agentConnId = AgentConnId cId} = cId @@ -684,6 +676,21 @@ instance ToJSON Connection where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data PendingContactConnection = PendingContactConnection + { pccConnId :: Int64, + pccAgentConnId :: AgentConnId, + pccConnStatus :: ConnStatus, + viaContactUri :: Bool, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + deriving (Eq, Show, Generic) + +aConnId' :: PendingContactConnection -> ConnId +aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId + +instance ToJSON PendingContactConnection where toEncoding = J.genericToEncoding J.defaultOptions + data ConnStatus = -- | connection is created by initiating party with agent NEW command (createConnection) ConnNew @@ -699,20 +706,18 @@ data ConnStatus ConnReady | -- | connection deleted ConnDeleted - deriving (Eq, Show) + deriving (Eq, Show, Read) -instance FromField ConnStatus where fromField = fromTextField_ decodeText +instance FromField ConnStatus where fromField = fromTextField_ textDecode -instance ToField ConnStatus where toField = toField . encodeText - -instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus" +instance ToField ConnStatus where toField = toField . textEncode instance ToJSON ConnStatus where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding ConnStatus where - decodeText = \case + textDecode = \case "new" -> Just ConnNew "joined" -> Just ConnJoined "requested" -> Just ConnRequested @@ -721,7 +726,7 @@ instance TextEncoding ConnStatus where "ready" -> Just ConnReady "deleted" -> Just ConnDeleted _ -> Nothing - encodeText = \case + textEncode = \case ConnNew -> "new" ConnJoined -> "joined" ConnRequested -> "requested" @@ -733,25 +738,23 @@ instance TextEncoding ConnStatus where data ConnType = ConnContact | ConnMember | ConnSndFile | ConnRcvFile | ConnUserContact deriving (Eq, Show) -instance FromField ConnType where fromField = fromTextField_ decodeText +instance FromField ConnType where fromField = fromTextField_ textDecode -instance ToField ConnType where toField = toField . encodeText - -instance FromJSON ConnType where parseJSON = textParseJSON "ConnType" +instance ToField ConnType where toField = toField . textEncode instance ToJSON ConnType where - toJSON = J.String . encodeText - toEncoding = JE.text . encodeText + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode instance TextEncoding ConnType where - decodeText = \case + textDecode = \case "contact" -> Just ConnContact "member" -> Just ConnMember "snd_file" -> Just ConnSndFile "rcv_file" -> Just ConnRcvFile "user_contact" -> Just ConnUserContact _ -> Nothing - encodeText = \case + textEncode = \case ConnContact -> "contact" ConnMember -> "member" ConnSndFile -> "snd_file" @@ -812,9 +815,5 @@ data Notification = Notification {title :: Text, text :: Text} type JSONString = String -class TextEncoding a where - encodeText :: a -> Text - decodeText :: Text -> Maybe a - textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a -textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . decodeText +textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2d1132f4d1..72d374a161 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -31,6 +31,7 @@ import Simplex.Chat.Store (StoreError (..)) import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Util (bshow) @@ -49,6 +50,7 @@ responseToView testView = \case CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item + CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item) chatItems CRChatItemStatusUpdated _ -> [] CRChatItemUpdated (AChatItem _ _ chat item) -> viewItemUpdate chat item CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> viewItemDelete chat deletedItem toItem @@ -90,28 +92,27 @@ responseToView testView = \case CRUserDeletedMember g m -> [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] CRLeftMemberUser g -> [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRGroupDeletedUser g -> [ttyGroup' g <> ": you deleted the group"] - CRRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath -> - ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] + CRRcvFileAccepted ci -> savingFile' ci CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndGroupFileCancelled ftm fts -> viewSndGroupFileCancelled ftm fts + CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' CRContactUpdated c c' -> viewContactUpdated c c' CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile - CRRcvFileStart ft -> receivingFile_ "started" ft + CRRcvFileStart ci -> receivingFile_' "started" ci CRRcvFileComplete ci -> receivingFile_' "completed" ci CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndFileStart ft -> sendingFile_ "started" ft - CRSndFileComplete ft -> sendingFile_ "completed" ft - CRSndFileCancelled ft -> sendingFile_ "cancelled" ft - CRSndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} -> + CRSndFileStart _ ft -> sendingFile_ "started" ft + CRSndFileComplete _ ft -> sendingFile_ "completed" ft + CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft + CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} -> [ttyContact c <> " cancelled receiving " <> sndFile ft] CRContactConnecting _ -> [] CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"] CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"] - CRContactDisconnected c -> [ttyContact' c <> ": disconnected from server (messages will be queued)"] - CRContactSubscribed c -> [ttyContact' c <> ": connected to server"] + CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> smpServer srv <> " (" <> contactList cs <> ")"] + CRContactsSubscribed srv cs -> [plain $ "server connected " <> smpServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary summary -> [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" @@ -138,18 +139,27 @@ responseToView testView = \case ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRCallInvitation {contact} -> ["call invitation from " <> ttyContact' contact] + CRCallOffer {contact} -> ["call offer from " <> ttyContact' contact] + CRCallAnswer {contact} -> ["call answer from " <> ttyContact' contact] + CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact] + CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] + CRNewContactConnection _ -> [] + CRContactConnectionDeleted PendingContactConnection {pccConnId} -> ["connection :" <> sShow pccConnId <> " deleted"] + CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRMessageError prefix err -> [plain prefix <> ": " <> plain err] CRChatError e -> viewChatError e where testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where - toChatView :: AChat -> (Text, Text) - toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName}) items _)) = ("@" <> localDisplayName, toCIPreview items) - toChatView (AChat _ (Chat (GroupChat GroupInfo {localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items) - toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items) + toChatView :: AChat -> (Text, Text, Maybe ConnStatus) + toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items, Just $ connStatus activeConn) + toChatView (AChat _ (Chat (GroupChat GroupInfo {localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items, Nothing) + toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items, Nothing) + toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items, Just $ pccConnStatus) toCIPreview :: [CChatItem c] -> Text toCIPreview ((CChatItem _ ChatItem {meta}) : _) = itemText meta toCIPreview _ = "" @@ -169,6 +179,10 @@ responseToView testView = \case _ -> Nothing viewErrorsSummary :: [a] -> StyledString -> [StyledString] viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] + smpServer :: SMPServer -> String + smpServer SMP.ProtocolServer {host, port} = B.unpack . strEncode $ SrvLoc host port + contactList :: [ContactRef] -> String + contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case chat of @@ -176,11 +190,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha CIDirectSnd -> case content of CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] + CISndCall {} -> [] where to = ttyToContact' c CIDirectRcv -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] + CIRcvCall {} -> [] where from = ttyFromContact' c where @@ -189,11 +205,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha CIGroupSnd -> case content of CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] + CISndCall {} -> [] where to = ttyToGroup g CIGroupRcv m -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] + CIRcvCall {} -> [] where from = ttyFromGroup' g m where @@ -205,9 +223,9 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha withFile view dir l = maybe l (\f -> l <> view dir f meta) file sndMsg = msg viewSentMessage rcvMsg = msg viewReceivedMessage - msg view dir quote mc = case (msgContentText mc, file) of - ("", Just _) -> [] - -- (_, Just _) -> prependFirst " " $ ttyMsgContent mc + msg view dir quote mc = case (msgContentText mc, file, quote) of + ("", Just _, []) -> [] + ("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) meta _ -> view dir quote mc meta viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] @@ -546,6 +564,15 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 +savingFile' :: AChatItem -> [StyledString] +savingFile' (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIDirectRcv}) = + ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] +savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = + ["saving file " <> sShow fileId <> " from " <> ttyContact m <> " to " <> plain filePath] +savingFile' (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, filePath = Just filePath}}) = + ["saving file " <> sShow fileId <> " to " <> plain filePath] +savingFile' _ = ["saving file"] -- shouldn't happen + receivingFile_' :: StyledString -> AChatItem -> [StyledString] receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact c] @@ -593,7 +620,8 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSAccepted _ -> "just started" RFSConnected _ -> "progress " <> fileProgress chunksNum chunkSize fileSize RFSComplete RcvFileInfo {filePath} -> "complete, path: " <> plain filePath - RFSCancelled RcvFileInfo {filePath} -> "cancelled, received part path: " <> plain filePath + RFSCancelled (Just RcvFileInfo {filePath}) -> "cancelled, received part path: " <> plain filePath + RFSCancelled Nothing -> "cancelled" listRecipients :: [SndFileTransfer] -> StyledString listRecipients = mconcat . intersperse ", " . map (ttyContact . recipientDisplayName) @@ -625,6 +653,7 @@ viewChatError = \case CEGroupInternal s -> ["chat group bug: " <> plain s] CEFileNotFound f -> ["file not found: " <> plain f] CEFileAlreadyReceiving f -> ["file is already accepted: " <> plain f] + CEFileCancelled f -> ["file cancelled: " <> plain f] CEFileAlreadyExists f -> ["file already exists: " <> plain f] CEFileRead f e -> ["cannot read file " <> plain f, sShow e] CEFileWrite f e -> ["cannot write file " <> plain f, sShow e] @@ -634,6 +663,10 @@ viewChatError = \case CEInvalidQuote -> ["cannot reply to this message"] CEInvalidChatItemUpdate -> ["cannot update this item"] CEInvalidChatItemDelete -> ["cannot delete this item"] + CEHasCurrentCall -> ["call already in progress"] + CENoCurrentCall -> ["no call in progress"] + CECallContact _ -> [] + CECallState _ -> [] CEAgentVersion -> ["unsupported agent version"] CECommandError e -> ["bad chat command: " <> plain e] -- e -> ["chat error: " <> sShow e] @@ -654,7 +687,10 @@ viewChatError = \case SEQuotedChatItemNotFound -> ["message not found - reply is not sent"] e -> ["chat db error: " <> sShow e] ChatErrorAgent err -> case err of - SMP SMP.AUTH -> ["error: this connection is deleted"] + SMP SMP.AUTH -> + [ "error: connection authorization failed - this could happen if connection was deleted,\ + \ secured with different credentials, or due to a bug - please re-create the connection" + ] e -> ["smp agent error: " <> sShow e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] diff --git a/stack.yaml b/stack.yaml index bd9c1fda85..5b6b00b395 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: d38303d5f1d55a90dd95d951ba8d798e17699269 + commit: 964daf5442e1069634762450bc28cfd69a2968a1 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 57143e7d8f..b5a598bbcd 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} @@ -12,7 +13,7 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, bracket_) import Control.Monad.Except -import Data.List (dropWhileEnd) +import Data.List (dropWhileEnd, find) import Data.Maybe (fromJust) import qualified Data.Text as T import Network.Socket @@ -29,7 +30,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport -import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) +import System.Directory (createDirectoryIfMissing, removePathForcibly) import qualified System.Terminal as C import System.Terminal.Internal (VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal) import System.Timeout (timeout) @@ -80,10 +81,22 @@ cfg = testView = True } -virtualSimplexChat :: FilePath -> Profile -> IO TestCC -virtualSimplexChat dbFilePrefix profile = do +createTestChat :: String -> Profile -> IO TestCC +createTestChat dbPrefix profile = do + let dbFilePrefix = testDBPrefix <> dbPrefix st <- createStore (dbFilePrefix <> "_chat.db") 1 False Right user <- runExceptT $ createUser st profile True + startTestChat_ st dbFilePrefix user + +startTestChat :: String -> IO TestCC +startTestChat dbPrefix = do + let dbFilePrefix = testDBPrefix <> dbPrefix + st <- createStore (dbFilePrefix <> "_chat.db") 1 False + Just user <- find activeUser <$> getUsers st + startTestChat_ st dbFilePrefix user + +startTestChat_ :: SQLiteStore -> FilePath -> User -> IO TestCC +startTestChat_ st dbFilePrefix user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t cc <- newChatController st (Just user) cfg opts {dbFilePrefix} Nothing -- no notifications @@ -92,6 +105,18 @@ virtualSimplexChat dbFilePrefix profile = do termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ} +stopTestChat :: TestCC -> IO () +stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do + stopChatController cc + uninterruptibleCancel termAsync + uninterruptibleCancel chatAsync + +withNewTestChat :: String -> Profile -> (TestCC -> IO a) -> IO a +withNewTestChat dbPrefix profile = bracket (createTestChat dbPrefix profile) (\cc -> cc > stopTestChat cc) + +withTestChat :: String -> (TestCC -> IO a) -> IO a +withTestChat dbPrefix = bracket (startTestChat dbPrefix) (\cc -> cc > stopTestChat cc) + readTerminalOutput :: VirtualTerminal -> TQueue String -> IO () readTerminalOutput t termQ = do let w = virtualWindow t @@ -119,18 +144,18 @@ withTmpFiles :: IO () -> IO () withTmpFiles = bracket_ (createDirectoryIfMissing False "tests/tmp") - (removeDirectoryRecursive "tests/tmp") + (removePathForcibly "tests/tmp") testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO () testChatN ps test = withTmpFiles $ do - let envs = zip ps $ map ((testDBPrefix <>) . show) [(1 :: Int) ..] - tcs <- getTestCCs envs [] + tcs <- getTestCCs (zip ps [1 ..]) [] test tcs concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] + getTestCCs :: [(Profile, Int)] -> [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> virtualSimplexChat db p <*> getTestCCs envs' tcs + getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat (show db) p <*> getTestCCs envs' tcs ( Int -> Expectation ( IO a diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index dc49a12808..9a6f0b7be8 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -9,11 +9,15 @@ import ChatClient import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM -import qualified Data.ByteString as B +import Data.Aeson (ToJSON) +import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isDigit) import qualified Data.Text as T +import Simplex.Chat.Call import Simplex.Chat.Controller (ChatController (..)) -import Simplex.Chat.Types (ImageData (..), Profile (..), User (..)) +import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..)) import Simplex.Chat.Util (unlessM) import System.Directory (copyFile, doesFileExist) import Test.Hspec @@ -54,15 +58,11 @@ chatTests = do describe "sending and receiving files" $ do it "send and receive file" testFileTransfer it "send and receive a small file" testSmallFileTransfer - it "sender cancelled file transfer" testFileSndCancel + it "sender cancelled file transfer before transfer" testFileSndCancelBeforeTransfer + it "sender cancelled file transfer during transfer" testFileSndCancelDuringTransfer it "recipient cancelled file transfer" testFileRcvCancel it "send and receive file to group" testGroupFileTransfer - describe "sending and receiving files v2" $ do - it "send and receive file" testFileTransferV2 - it "send and receive a small file" testSmallFileTransferV2 - it "sender cancelled file transfer" testFileSndCancelV2 - it "recipient cancelled file transfer" testFileRcvCancelV2 - it "send and receive file to group" testGroupFileTransferV2 + it "sender cancelled group file transfer before transfer" testGroupFileSndCancelBeforeTransfer describe "messages with files" $ do it "send and receive message with file" testMessageWithFile it "send and receive image" testSendImage @@ -81,6 +81,15 @@ chatTests = do it "delete connection requests when contact link deleted" testDeleteConnectionRequests describe "SMP servers" $ it "get and set SMP servers" testGetSetSMPServers + describe "async connection handshake" $ do + it "connect when initiating client goes offline" testAsyncInitiatingOffline + it "connect when accepting client goes offline" testAsyncAcceptingOffline + it "connect, fully asynchronous (when clients are never simultaneously online)" testFullAsync + xdescribe "async sending and receiving files" $ do + it "send and receive file, fully asynchronous" testAsyncFileTransfer + it "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer + describe "webrtc calls api" $ do + it "negotiate call" testNegotiateCall testAddContact :: IO () testAddContact = @@ -112,30 +121,30 @@ testAddContact = bob <# "alice_1> hello" bob #> "@alice_1 hi" alice <# "bob_1> hi" - alice #$$> ("/_get chats", [("@bob_1", "hi"), ("@bob", "hi")]) - bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) + alice @@@ [("@bob_1", "hi"), ("@bob", "hi")] + bob @@@ [("@alice_1", "hi"), ("@alice", "hi")] -- test deleting contact alice ##> "/d bob_1" alice <## "bob_1: contact is deleted" alice ##> "@bob_1 hey" alice <## "no contact bob_1" - alice #$$> ("/_get chats", [("@bob", "hi")]) - bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) + alice @@@ [("@bob", "hi")] + bob @@@ [("@alice_1", "hi"), ("@alice", "hi")] where chatsEmpty alice bob = do - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice #$> ("/_get chat @2 count=100", chat, []) - bob #$$> ("/_get chats", [("@alice", "")]) + bob @@@ [("@alice", "")] bob #$> ("/_get chat @2 count=100", chat, []) chatsOneMessage alice bob = do - alice #$$> ("/_get chats", [("@bob", "hello 🙂")]) + alice @@@ [("@bob", "hello 🙂")] alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂")]) - bob #$$> ("/_get chats", [("@alice", "hello 🙂")]) + bob @@@ [("@alice", "hello 🙂")] bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂")]) chatsManyMessages alice bob = do - alice #$$> ("/_get chats", [("@bob", "hi")]) + alice @@@ [("@bob", "hi")] alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂"), (0, "hi")]) - bob #$$> ("/_get chats", [("@alice", "hi")]) + bob @@@ [("@alice", "hi")] bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂"), (1, "hi")]) -- pagination alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")]) @@ -149,7 +158,7 @@ testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - alice ##> "/_send @2 json {\"type\": \"text\", \"text\": \"hello! how are you?\"}" + alice ##> "/_send @2 text hello! how are you?" alice <# "@bob hello! how are you?" bob <# "alice> hello! how are you?" bob #> "@alice hi!" @@ -244,16 +253,16 @@ testDirectMessageDelete = alice #$> ("/_delete item @2 1 internal", id, "message deleted") alice #$> ("/_delete item @2 2 internal", id, "message deleted") - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice #$> ("/_get chat @2 count=100", chat, []) alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send @2 quoted 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send @2 json {\"quotedItemId\": 1, \"msgContent\": {\"type\": \"text\", \"text\": \"quoting deleted message\"}}", id, "cannot reply to this message") bob #$> ("/_update item @2 2 text hey alice", id, "message updated") alice <# "bob> [edited] hey alice" - alice #$$> ("/_get chats", [("@bob", "hey alice")]) + alice @@@ [("@bob", "hey alice")] alice #$> ("/_get chat @2 count=100", chat, [(0, "hey alice")]) -- msg id 3 @@ -269,9 +278,9 @@ testDirectMessageDelete = alice #$> ("/_delete item @2 2 broadcast", id, "cannot delete this item") alice #$> ("/_delete item @2 2 internal", id, "message deleted") - alice #$$> ("/_get chats", [("@bob", "this item is deleted (broadcast)")]) + alice @@@ [("@bob", "this item is deleted (broadcast)")] alice #$> ("/_get chat @2 count=100", chat, [(0, "this item is deleted (broadcast)")]) - bob #$$> ("/_get chats", [("@alice", "hey alice")]) + bob @@@ [("@alice", "hey alice")] bob #$> ("/_get chat @2 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hey alice"), (Just (0, "hello 🙂")))]) testGroup :: IO () @@ -363,13 +372,13 @@ testGroup = bob <##> cath where getReadChats alice bob cath = do - alice #$$> ("/_get chats", [("#team", "hey team"), ("@cath", ""), ("@bob", "")]) + alice @@@ [("#team", "hey team"), ("@cath", ""), ("@bob", "")] alice #$> ("/_get chat #1 count=100", chat, [(1, "hello"), (0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 after=1 count=100", chat, [(0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 before=3 count=100", chat, [(1, "hello"), (0, "hi there")]) - bob #$$> ("/_get chats", [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")]) + bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")] bob #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (1, "hi there"), (0, "hey team")]) - cath #$$> ("/_get chats", [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")]) + cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")] cath #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (0, "hi there"), (1, "hey team")]) alice #$> ("/_read chat #1 from=1 to=100", id, "ok") bob #$> ("/_read chat #1 from=1 to=100", id, "ok") @@ -468,6 +477,28 @@ testGroup2 = bob <##> cath dan <##> cath dan <##> alice + -- show last messages + alice ##> "/t #club 4" + alice -- these strings are expected in any order because of sorting by time and rounding of time for sent + <##? [ "#club hello", + "#club bob> hi there", + "#club cath> hey", + "#club dan> how is it going?" + ] + alice ##> "/t @dan 2" + alice + <##? [ "dan> hi", + "@dan hey" + ] + alice ##> "/t 6" + alice + <##? [ "#club hello", + "#club bob> hi there", + "#club cath> hey", + "#club dan> how is it going?", + "dan> hi", + "@dan hey" + ] -- remove member cath ##> "/rm club dan" concurrentlyN_ @@ -838,7 +869,7 @@ testGroupMessageDelete = cath #$> ("/_get chat #1 count=100", chat, [(0, "hello!")]) alice #$> ("/_update item #1 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send #1 quoted 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send #1 json {\"quotedItemId\": 1, \"msgContent\": {\"type\": \"text\", \"text\": \"quoting deleted message\"}}", id, "cannot reply to this message") threadDelay 1000000 -- msg id 2 @@ -999,25 +1030,47 @@ testSmallFileTransfer = dest <- B.readFile "./tests/tmp/test.txt" dest `shouldBe` src -testFileSndCancel :: IO () -testFileSndCancel = +testFileSndCancelBeforeTransfer :: IO () +testFileSndCancelBeforeTransfer = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - startFileTransfer alice bob + alice #> "/f @bob ./tests/fixtures/test.txt" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + alice ##> "/fc 1" + concurrentlyN_ + [ alice <## "cancelled sending file 1 (test.txt) to bob", + bob <## "alice cancelled sending file 1 (test.txt)" + ] + alice ##> "/fs 1" + alice <## "sending file 1 (test.txt) cancelled: bob" + alice <## "file transfer cancelled" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.txt) cancelled" + bob ##> "/fr 1 ./tests/tmp" + bob <## "file cancelled: test.txt" + +testFileSndCancelDuringTransfer :: IO () +testFileSndCancelDuringTransfer = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers 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 = @@ -1039,7 +1092,7 @@ testFileRcvCancel = alice ##> "/fs 1" alice <## "sending file 1 (test.jpg) cancelled: bob" ] - checkPartialTransfer + checkPartialTransfer "test.jpg" testGroupFileTransfer :: IO () testGroupFileTransfer = @@ -1057,134 +1110,6 @@ testGroupFileTransfer = cath <## "use /fr 1 [/ | ] to receive it" ] alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) not accepted") - bob ##> "/fr 1 ./tests/tmp/" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to bob" - alice <## "completed sending file 1 (test.jpg) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg):" - alice <### [" complete: bob", " not accepted: cath"], - do - bob <## "started receiving file 1 (test.jpg) from alice" - bob <## "completed receiving file 1 (test.jpg) from alice" - ] - cath ##> "/fr 1 ./tests/tmp/" - cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" - concurrentlyN_ - [ do - alice <## "started sending file 1 (test.jpg) to cath" - alice <## "completed sending file 1 (test.jpg) to cath" - alice ##> "/fs 1" - getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) complete"), - do - cath <## "started receiving file 1 (test.jpg) from alice" - cath <## "completed receiving file 1 (test.jpg) from alice" - ] - -testFileTransferV2 :: IO () -testFileTransferV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransferV2 alice bob - concurrentlyN_ - [ do - bob #> "@alice receiving here..." - bob <## "completed receiving file 1 (test.jpg) from alice", - do - alice <# "bob> receiving here..." - alice <## "completed sending file 1 (test.jpg) to bob" - ] - src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src - -testSmallFileTransferV2 :: IO () -testSmallFileTransferV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - alice `send` "/f_v2 @bob ./tests/fixtures/test.txt" - alice <# "/f @bob ./tests/fixtures/test.txt" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.txt" - concurrentlyN_ - [ do - bob <## "started receiving file 1 (test.txt) from alice" - bob <## "completed receiving file 1 (test.txt) from alice", - do - alice <## "started sending file 1 (test.txt) to bob" - alice <## "completed sending file 1 (test.txt) to bob" - ] - src <- B.readFile "./tests/fixtures/test.txt" - dest <- B.readFile "./tests/tmp/test.txt" - dest `shouldBe` src - -testFileSndCancelV2 :: IO () -testFileSndCancelV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransferV2 alice bob - alice ##> "/fc 1" - concurrentlyN_ - [ do - alice <## "cancelled sending file 1 (test.jpg) to bob" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - alice <## "file transfer cancelled", - do - bob <## "alice cancelled sending file 1 (test.jpg)" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg" - ] - checkPartialTransfer - -testFileRcvCancelV2 :: IO () -testFileRcvCancelV2 = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - startFileTransferV2 alice bob - bob ##> "/fs 1" - getTermLine bob >>= (`shouldStartWith` "receiving file 1 (test.jpg) progress") - waitFileExists "./tests/tmp/test.jpg" - bob ##> "/fc 1" - concurrentlyN_ - [ do - bob <## "cancelled receiving file 1 (test.jpg) from alice" - bob ##> "/fs 1" - bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg", - do - alice <## "bob cancelled receiving file 1 (test.jpg)" - alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled: bob" - ] - checkPartialTransfer - -testGroupFileTransferV2 :: IO () -testGroupFileTransferV2 = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - alice `send` "/f_v2 #team ./tests/fixtures/test.jpg" - alice <# "/f #team ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - concurrentlyN_ - [ do - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it", - do - cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - cath <## "use /fr 1 [/ | ] to receive it" - ] - alice ##> "/fs 1" getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg): no file transfers") bob ##> "/fr 1 ./tests/tmp/" bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" @@ -1211,12 +1136,40 @@ testGroupFileTransferV2 = cath <## "completed receiving file 1 (test.jpg) from alice" ] +testGroupFileSndCancelBeforeTransfer :: IO () +testGroupFileSndCancelBeforeTransfer = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice #> "/f #team ./tests/fixtures/test.txt" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.txt (11 bytes / 11 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + alice ##> "/fc 1" + concurrentlyN_ + [ alice <## "cancelled sending file 1 (test.txt)", + bob <## "alice cancelled sending file 1 (test.txt)", + cath <## "alice cancelled sending file 1 (test.txt)" + ] + alice ##> "/fs 1" + alice <## "sending file 1 (test.txt): no file transfers, file transfer cancelled" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.txt) cancelled" + bob ##> "/fr 1 ./tests/tmp" + bob <## "file cancelled: test.txt" + testMessageWithFile :: IO () testMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - alice ##> "/_send @2 file ./tests/fixtures/test.jpg text hi, sending a file" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}" alice <# "@bob hi, sending a file" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -1242,7 +1195,7 @@ testSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - alice ##> "/_send @2 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" @@ -1273,7 +1226,7 @@ testFilesFoldersSendImage = connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") - alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" @@ -1302,27 +1255,27 @@ testFilesFoldersImageSndDelete = \alice bob -> do connectUsers alice bob alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") - copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" + copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") - alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" - alice <# "/f @bob test.jpg" + alice ##> "/_send @2 json {\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" + alice <# "/f @bob test_1MB.pdf" alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1" - bob <## "saving file 1 from alice to test.jpg" + bob <## "saving file 1 from alice to test_1MB.pdf" concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") + (bob <## "started receiving file 1 (test_1MB.pdf) from alice") + (alice <## "started sending file 1 (test_1MB.pdf) to bob") -- deleting contact should cancel and remove file - checkActionDeletesFile "./tests/tmp/alice_app_files/test.jpg" $ do + checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" - 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: test.jpg" + bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: test_1MB.pdf" -- deleting contact should remove cancelled file - checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do + checkActionDeletesFile "./tests/tmp/bob_app_files/test_1MB.pdf" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" @@ -1333,7 +1286,7 @@ testFilesFoldersImageRcvDelete = connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") - alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" @@ -1359,7 +1312,7 @@ testSendImageWithTextAndQuote = connectUsers alice bob bob #> "@alice hi alice" alice <# "bob> hi alice" - alice ##> "/_send @2 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": 1, \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "@bob > hi alice" alice <## " hey bob" alice <# "/f @bob ./tests/fixtures/test.jpg" @@ -1377,19 +1330,57 @@ testSendImageWithTextAndQuote = (bob <## "completed receiving file 1 (test.jpg) from alice") (alice <## "completed sending file 1 (test.jpg) to bob") src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" - dest `shouldBe` src + B.readFile "./tests/tmp/test.jpg" `shouldReturn` src alice #$> ("/_get chat @2 count=100", chat'', [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) - alice #$$> ("/_get chats", [("@bob", "hey bob")]) + alice @@@ [("@bob", "hey bob")] bob #$> ("/_get chat @2 count=100", chat'', [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) - bob #$$> ("/_get chats", [("@alice", "hey bob")]) + bob @@@ [("@alice", "hey bob")] + -- quoting (file + text) with file uses quoted text + bob ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.txt\", \"quotedItemId\": 2, \"msgContent\": {\"text\":\"\",\"type\":\"file\"}}" + bob <# "@alice > hey bob" + bob <## " test.txt" + bob <# "/f @alice ./tests/fixtures/test.txt" + bob <## "use /fc 2 to cancel sending" + alice <# "bob> > hey bob" + alice <## " test.txt" + alice <# "bob> sends file test.txt (11 bytes / 11 bytes)" + alice <## "use /fr 2 [/ | ] to receive it" + alice ##> "/fr 2 ./tests/tmp" + alice <## "saving file 2 from bob to ./tests/tmp/test.txt" + concurrently_ + (alice <## "started receiving file 2 (test.txt) from bob") + (bob <## "started sending file 2 (test.txt) to alice") + concurrently_ + (alice <## "completed receiving file 2 (test.txt) from bob") + (bob <## "completed sending file 2 (test.txt) to alice") + txtSrc <- B.readFile "./tests/fixtures/test.txt" + B.readFile "./tests/tmp/test.txt" `shouldReturn` txtSrc + -- quoting (file without text) with file uses file name + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": 3, \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" + alice <# "@bob > test.txt" + alice <## " test.jpg" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 3 to cancel sending" + bob <# "alice> > test.txt" + bob <## " test.jpg" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 3 [/ | ] to receive it" + bob ##> "/fr 3 ./tests/tmp" + bob <## "saving file 3 from alice to ./tests/tmp/test_1.jpg" + concurrently_ + (bob <## "started receiving file 3 (test.jpg) from alice") + (alice <## "started sending file 3 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 3 (test.jpg) from alice") + (alice <## "completed sending file 3 (test.jpg) to bob") + B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src testGroupSendImage :: IO () testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - alice ##> "/_send #1 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" concurrentlyN_ @@ -1439,7 +1430,7 @@ testGroupSendImageWithTextAndQuote = (alice <# "#team bob> hi team") (cath <# "#team bob> hi team") threadDelay 1000000 - alice ##> "/_send #1 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": 1, \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}" alice <# "#team > bob hi team" alice <## " hey bob" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -1482,11 +1473,11 @@ testGroupSendImageWithTextAndQuote = dest2 <- B.readFile "./tests/tmp/test_1.jpg" dest2 `shouldBe` src alice #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi team"), Just "./tests/fixtures/test.jpg")]) - alice #$$> ("/_get chats", [("#team", "hey bob"), ("@bob", ""), ("@cath", "")]) + alice @@@ [("#team", "hey bob"), ("@bob", ""), ("@cath", "")] bob #$> ("/_get chat #1 count=100", chat'', [((1, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi team"), Just "./tests/tmp/test.jpg")]) - bob #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@cath", "")]) + bob @@@ [("#team", "hey bob"), ("@alice", ""), ("@cath", "")] cath #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) - cath #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@bob", "")]) + cath @@@ [("#team", "hey bob"), ("@alice", ""), ("@bob", "")] testUserContactLink :: IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ @@ -1495,24 +1486,24 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ cLink <- getContactLink alice True bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice <##> bob cath ##> ("/c " <> cLink) alice <#? cath - alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")]) + alice @@@ [("<@cath", ""), ("@bob", "hey")] alice ##> "/ac cath" alice <## "cath (Catherine): accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice @@@ [("@cath", ""), ("@bob", "hey")] alice <##> cath testUserContactLinkAutoAccept :: IO () @@ -1524,13 +1515,13 @@ testUserContactLinkAutoAccept = bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - alice #$$> ("/_get chats", [("@bob", "")]) + alice @@@ [("@bob", "")] alice <##> bob alice ##> "/auto_accept on" @@ -1542,7 +1533,7 @@ testUserContactLinkAutoAccept = concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice @@@ [("@cath", ""), ("@bob", "hey")] alice <##> cath alice ##> "/auto_accept off" @@ -1550,13 +1541,13 @@ testUserContactLinkAutoAccept = dan ##> ("/c " <> cLink) alice <#? dan - alice #$$> ("/_get chats", [("<@dan", ""), ("@cath", "hey"), ("@bob", "hey")]) + alice @@@ [("<@dan", ""), ("@cath", "hey"), ("@bob", "hey")] alice ##> "/ac dan" alice <## "dan (Daniel): accepting contact request..." concurrently_ (dan <## "alice (Alice): contact is connected") (alice <## "dan (Daniel): contact is connected") - alice #$$> ("/_get chats", [("@dan", ""), ("@cath", "hey"), ("@bob", "hey")]) + alice @@@ [("@dan", ""), ("@cath", "hey"), ("@bob", "hey")] alice <##> dan testDeduplicateContactRequests :: IO () @@ -1567,13 +1558,15 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] + bob @@@! [(":1", "", Just ConnJoined)] bob ##> ("/c " <> cLink) alice <#? bob bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] + bob @@@! [(":3", "", Just ConnJoined), (":2", "", Just ConnJoined), (":1", "", Just ConnJoined)] alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." @@ -1583,12 +1576,16 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" - alice #$$> ("/_get chats", [("@bob", "")]) - bob #$$> ("/_get chats", [("@alice", "")]) + alice @@@ [("@bob", "")] + bob @@@ [("@alice", ""), (":2", ""), (":1", "")] + bob ##> "/_delete :1" + bob <## "connection :1 deleted" + bob ##> "/_delete :2" + bob <## "connection :2 deleted" alice <##> bob - alice #$$> ("/_get chats", [("@bob", "hey")]) - bob #$$> ("/_get chats", [("@alice", "hey")]) + alice @@@ [("@bob", "hey")] + bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" @@ -1599,13 +1596,13 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ cath ##> ("/c " <> cLink) alice <#? cath - alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")]) + alice @@@ [("<@cath", ""), ("@bob", "hey")] alice ##> "/ac cath" alice <## "cath (Catherine): accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice @@@ [("@cath", ""), ("@bob", "hey")] alice <##> cath testDeduplicateContactRequestsProfileChange :: IO () @@ -1616,7 +1613,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] bob ##> "/p bob" bob <## "user full name removed (your contacts are notified)" @@ -1625,19 +1622,19 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile alice <## "bob wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] bob ##> "/p bob Bob Ross" bob <## "user full name changed to Bob Ross (your contacts are notified)" bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@bob", "")]) + alice @@@ [("<@bob", "")] bob ##> "/p robert Robert" bob <## "user profile is changed to robert (Robert) (your contacts are notified)" bob ##> ("/c " <> cLink) alice <#? bob - alice #$$> ("/_get chats", [("<@robert", "")]) + alice @@@ [("<@robert", "")] alice ##> "/ac bob" alice <## "no contact request from bob" @@ -1649,12 +1646,18 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" - alice #$$> ("/_get chats", [("@robert", "")]) - bob #$$> ("/_get chats", [("@alice", "")]) + alice @@@ [("@robert", "")] + bob @@@ [("@alice", ""), (":3", ""), (":2", ""), (":1", "")] + bob ##> "/_delete :1" + bob <## "connection :1 deleted" + bob ##> "/_delete :2" + bob <## "connection :2 deleted" + bob ##> "/_delete :3" + bob <## "connection :3 deleted" alice <##> bob - alice #$$> ("/_get chats", [("@robert", "hey")]) - bob #$$> ("/_get chats", [("@alice", "hey")]) + alice @@@ [("@robert", "hey")] + bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) bob <## "alice (Alice): contact already exists" @@ -1665,13 +1668,13 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cath ##> ("/c " <> cLink) alice <#? cath - alice #$$> ("/_get chats", [("<@cath", ""), ("@robert", "hey")]) + alice @@@ [("<@cath", ""), ("@robert", "hey")] alice ##> "/ac cath" alice <## "cath (Catherine): accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") - alice #$$> ("/_get chats", [("@cath", ""), ("@robert", "hey")]) + alice @@@ [("@cath", ""), ("@robert", "hey")] alice <##> cath testRejectContactAndDeleteUserContact :: IO () @@ -1694,7 +1697,7 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr alice <## "To create a new chat address use /ad" cath ##> ("/c " <> cLink) - cath <## "error: this connection is deleted" + cath <## "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" testDeleteConnectionRequests :: IO () testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ @@ -1730,35 +1733,234 @@ testGetSetSMPServers = alice #$> ("/smp_servers default", id, "ok") alice #$> ("/smp_servers", id, "no custom SMP servers saved") -startFileTransfer :: TestCC -> TestCC -> IO () -startFileTransfer alice bob = do - alice #> "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") +testAsyncInitiatingOffline :: IO () +testAsyncInitiatingOffline = withTmpFiles $ do + inv <- withNewTestChat "alice" aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withTestChat "alice" $ \alice -> do + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") -startFileTransferV2 :: TestCC -> TestCC -> IO () -startFileTransferV2 alice bob = do - alice `send` "/f_v2 @bob ./tests/fixtures/test.jpg" - alice <# "/f @bob ./tests/fixtures/test.jpg" - alice <## "use /fc 1 to cancel sending" - bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" - bob ##> "/fr 1 ./tests/tmp" - bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" - concurrently_ - (bob <## "started receiving file 1 (test.jpg) from alice") - (alice <## "started sending file 1 (test.jpg) to bob") +testAsyncAcceptingOffline :: IO () +testAsyncAcceptingOffline = withTmpFiles $ do + inv <- withNewTestChat "alice" aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withTestChat "alice" $ \alice -> + withTestChat "bob" $ \bob -> + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") -checkPartialTransfer :: IO () -checkPartialTransfer = do +testFullAsync :: IO () +testFullAsync = withTmpFiles $ do + inv <- withNewTestChat "alice" aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withTestChat "alice" $ \_ -> pure () + withTestChat "bob" $ \_ -> pure () + withTestChat "alice" $ \alice -> + alice <## "1 contacts connected (use /cs for the list)" + withTestChat "bob" $ \_ -> pure () + withTestChat "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "bob (Bob): contact is connected" + withTestChat "bob" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "alice (Alice): contact is connected" + +testAsyncFileTransfer :: IO () +testAsyncFileTransfer = withTmpFiles $ do + withNewTestChat "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + connectUsers alice bob + withTestChatContactConnected "alice" $ \alice -> do + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\":\"text\", \"text\": \"hi, sending a file\"}}" + alice <# "@bob hi, sending a file" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + withTestChatContactConnected "bob" $ \bob -> do + bob <# "alice> hi, sending a file" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + withTestChatContactConnected' "alice" + withTestChatContactConnected' "bob" + withTestChatContactConnected' "alice" + withTestChatContactConnected' "bob" + withTestChatContactConnected "alice" $ \alice -> do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob" + withTestChatContactConnected "bob" $ \bob -> do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" src <- B.readFile "./tests/fixtures/test.jpg" dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + +testAsyncGroupFileTransfer :: IO () +testAsyncGroupFileTransfer = withTmpFiles $ do + withNewTestChat "alice" aliceProfile $ \alice -> + withNewTestChat "bob" bobProfile $ \bob -> + withNewTestChat "cath" cathProfile $ \cath -> + createGroup3 "team" alice bob cath + withTestChatGroup3Connected "alice" $ \alice -> do + alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"text\"}}" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + withTestChatGroup3Connected "bob" $ \bob -> do + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + withTestChatGroup3Connected "cath" $ \cath -> do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + withTestChatGroup3Connected' "alice" + withTestChatGroup3Connected' "bob" + withTestChatGroup3Connected' "cath" + withTestChatGroup3Connected' "alice" + withTestChatGroup3Connected' "bob" + withTestChatGroup3Connected' "cath" + withTestChatGroup3Connected' "alice" + withTestChatGroup3Connected "bob" $ \bob -> do + bob <## "started receiving file 1 (test.jpg) from alice" + withTestChatGroup3Connected "cath" $ \cath -> do + cath <## "started receiving file 1 (test.jpg) from alice" + withTestChatGroup3Connected "alice" $ \alice -> do + alice + <### [ "started sending file 1 (test.jpg) to bob", + "completed sending file 1 (test.jpg) to bob", + "started sending file 1 (test.jpg) to cath", + "completed sending file 1 (test.jpg) to cath" + ] + withTestChatGroup3Connected "bob" $ \bob -> do + bob <## "completed receiving file 1 (test.jpg) from alice" + withTestChatGroup3Connected "cath" $ \cath -> do + cath <## "completed receiving file 1 (test.jpg) from alice" + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 `shouldBe` src + +testCallType :: CallType +testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encryption = True}} + +testWebRTCSession :: WebRTCSession +testWebRTCSession = + WebRTCSession + { rtcSession = "{}", + rtcIceCandidates = [""] + } + +testWebRTCCallOffer :: WebRTCCallOffer +testWebRTCCallOffer = + WebRTCCallOffer + { callType = testCallType, + rtcSession = testWebRTCSession + } + +serialize :: ToJSON a => a -> String +serialize = B.unpack . LB.toStrict . J.encode + +testNegotiateCall :: IO () +testNegotiateCall = + testChat2 aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + -- alice invite bob to call + alice ##> ("/_call invite @2 " <> serialize testCallType) + alice <## "ok" + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: calling...")]) + bob <## "call invitation from alice" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: calling...")]) + -- bob accepts call by sending WebRTC offer + bob ##> ("/_call offer @2 " <> serialize testWebRTCCallOffer) + bob <## "ok" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: accepted")]) + alice <## "call offer from bob" + alice <## "message updated" -- call chat item updated + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: accepted")]) + -- alice confirms call by sending WebRTC answer + alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) + alice + <### [ "ok", + "message updated" + ] + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: connecting...")]) + bob <## "call answer from alice" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: connecting...")]) + -- participants can update calls as connected + alice ##> "/_call status @2 connected" + alice + <### [ "ok", + "message updated" + ] + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: in progress (00:00)")]) + bob ##> "/_call status @2 connected" + bob <## "ok" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: in progress (00:00)")]) + -- either party can end the call + bob ##> "/_call end @2" + bob <## "ok" + bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: ended (00:00)")]) + alice <## "call with bob ended" + alice <## "message updated" + alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: ended (00:00)")]) + +withTestChatContactConnected :: String -> (TestCC -> IO a) -> IO a +withTestChatContactConnected dbPrefix action = + withTestChat dbPrefix $ \cc -> do + cc <## "1 contacts connected (use /cs for the list)" + action cc + +withTestChatContactConnected' :: String -> IO () +withTestChatContactConnected' dbPrefix = withTestChatContactConnected dbPrefix $ \_ -> pure () + +withTestChatGroup3Connected :: String -> (TestCC -> IO a) -> IO a +withTestChatGroup3Connected dbPrefix action = do + withTestChat dbPrefix $ \cc -> do + cc <## "2 contacts connected (use /cs for the list)" + cc <## "#team: connected to server(s)" + action cc + +withTestChatGroup3Connected' :: String -> IO () +withTestChatGroup3Connected' dbPrefix = withTestChatGroup3Connected dbPrefix $ \_ -> pure () + +startFileTransfer :: TestCC -> TestCC -> IO () +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 " <> fileName <> " (" <> fileSize <> ")") + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## ("saving file 1 from alice to ./tests/tmp/" <> fileName) + concurrently_ + (bob <## ("started receiving file 1 (" <> fileName <> ") from alice")) + (alice <## ("started sending file 1 (" <> fileName <> ") to bob")) + +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 @@ -1871,12 +2073,17 @@ chatF = map (\(a, _, c) -> (a, c)) . chat'' chat'' :: String -> [((Int, String), Maybe (Int, String), Maybe String)] chat'' = read -(#$$>) :: TestCC -> (String, [(String, String)]) -> Expectation -cc #$$> (cmd, res) = do - cc ##> cmd +(@@@) :: TestCC -> [(String, String)] -> Expectation +(@@@) = getChats . map $ \(ldn, msg, _) -> (ldn, msg) + +(@@@!) :: TestCC -> [(String, String, Maybe ConnStatus)] -> Expectation +(@@@!) = getChats id + +getChats :: (Eq a, Show a) => ([(String, String, Maybe ConnStatus)] -> [a]) -> TestCC -> [a] -> Expectation +getChats f cc res = do + cc ##> "/_get chats pcc=on" line <- getTermLine cc - let chats = read line - chats `shouldMatchList` res + f (read line) `shouldMatchList` res send :: TestCC -> String -> IO () send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) cmd @@ -1884,14 +2091,20 @@ send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) cm (<##) :: TestCC -> String -> Expectation cc <## line = getTermLine cc `shouldReturn` line -(<###) :: TestCC -> [String] -> Expectation -_ <### [] = pure () -cc <### ls = do - line <- getTermLine cc +getInAnyOrder :: (String -> String) -> TestCC -> [String] -> Expectation +getInAnyOrder _ _ [] = pure () +getInAnyOrder f cc ls = do + line <- f <$> getTermLine cc if line `elem` ls - then cc <### filter (/= line) ls + then getInAnyOrder f cc $ filter (/= line) ls else error $ "unexpected output: " <> line +(<###) :: TestCC -> [String] -> Expectation +(<###) = getInAnyOrder id + +(<##?) :: TestCC -> [String] -> Expectation +(<##?) = getInAnyOrder dropTime + (<#) :: TestCC -> String -> Expectation cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0ca78fa798..9e73060f9e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -53,10 +53,10 @@ testChatApiNoUser = withTmpFiles $ do testChatApi :: IO () testChatApi = withTmpFiles $ do - let f = chatStoreFile testDBPrefix + let f = chatStoreFile $ testDBPrefix <> "1" st <- createStore f 1 True Right _ <- runExceptT $ createUser st aliceProfile True - cc <- chatInit testDBPrefix + cc <- chatInit $ testDBPrefix <> "1" chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 9b37be14a9..3b615ddffd 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -15,7 +15,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Protocol (smpClientVRange) +import Simplex.Messaging.Protocol (ProtocolServer (..), smpClientVRange) import Test.Hspec protocolTests :: Spec @@ -23,7 +23,7 @@ protocolTests = decodeChatMessageTest srv :: SMPServer srv = - SMPServer + ProtocolServer { host = "smp.simplex.im", port = "5223", keyHash = C.KeyHash "\215m\248\251" @@ -103,7 +103,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) it "x.msg.new chat message " $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing)))) + ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage @@ -123,9 +123,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new forward" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing)) - it "x.msg.new simple with file" $ + it "x.msg.new simple text with file" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) + it "x.msg.new simple file with file" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCFile "") (Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileConnReq = Nothing}))) it "x.msg.new quote with file" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage @@ -166,6 +169,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.file.acpt.inv" $ "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") testConnReq "photo.jpg" + it "x.file.cancel" $ + "{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + #==# XFileCancel (SharedMsgId "\1\2\3\4") it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile diff --git a/tests/Test.hs b/tests/Test.hs index 3df06d6c60..5855fca88d 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,5 +1,6 @@ import ChatClient import ChatTests +-- import Control.Logger.Simple import MarkdownTests import MobileTests import ProtocolTests @@ -7,9 +8,15 @@ import SchemaDump import Test.Hspec main :: IO () -main = withSmpServer . hspec $ do - describe "SimpleX chat markdown" markdownTests - describe "SimpleX chat protocol" protocolTests - describe "Mobile API Tests" mobileTests - describe "SimpleX chat client" chatTests - describe "Schema dump" schemaDumpTest +main = do + -- setLogLevel LogDebug -- LogError + -- withGlobalLogging logCfg $ + withSmpServer . hspec $ do + describe "SimpleX chat markdown" markdownTests + describe "SimpleX chat protocol" protocolTests + describe "Mobile API Tests" mobileTests + describe "SimpleX chat client" chatTests + describe "Schema dump" schemaDumpTest + +-- logCfg :: LogConfig +-- logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} diff --git a/tests/fixtures/test_1MB.pdf b/tests/fixtures/test_1MB.pdf new file mode 100644 index 0000000000..13ce57492f Binary files /dev/null and b/tests/fixtures/test_1MB.pdf differ