From 546ad01fcb6395209ae88584bc4d62c4fea92aca Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 24 May 2022 19:34:27 +0100 Subject: [PATCH 01/23] ios: integrating webrtc calls with callkit (#686) * ios: integrating webrtc calls with callkit * accept call via chat item (e.g. when DND is on, and callkit blocks the call); refactor * fix remote video, support logging from ios * use callkit depending on CallController setting * call sound * update incoming call view * fixing audio encryption * refactor encryption webrtc fix * log ontrack success/error * accept / ignore call via notification * remove unused imports * remove unused file * remove comments --- apps/android/app/src/main/assets/www/call.js | 71 ++-- .../android/app/src/main/assets/www/style.css | 21 +- .../chat/simplex/app/views/chat/ChatView.kt | 24 +- apps/ios/Shared/AppDelegate.swift | 2 +- apps/ios/Shared/ContentView.swift | 17 +- apps/ios/Shared/Model/ChatModel.swift | 3 +- apps/ios/Shared/Model/NtfManager.swift | 29 +- apps/ios/Shared/Model/Shared/CallTypes.swift | 20 +- apps/ios/Shared/Model/Shared/ChatTypes.swift | 14 +- .../Shared/Model/Shared/Notifications.swift | 6 +- apps/ios/Shared/Model/SimpleXAPI.swift | 43 ++- .../Shared/Views/Call/ActiveCallView.swift | 336 ++++++++++++------ .../Shared/Views/Call/CallController.swift | 215 +++++++++++ apps/ios/Shared/Views/Call/CallManager.swift | 104 ++++++ .../Shared/Views/Call/IncomingCallView.swift | 86 +++++ apps/ios/Shared/Views/Call/SoundPlayer.swift | 45 +++ apps/ios/Shared/Views/Call/WebRTC.swift | 110 +++--- apps/ios/Shared/Views/Call/WebRTCView.swift | 95 +++-- .../Views/Chat/ChatItem/CICallItemView.swift | 15 +- apps/ios/Shared/Views/Chat/ChatView.swift | 24 +- .../Shared/Views/ChatList/ChatListView.swift | 51 --- .../Views/UserSettings/SettingsView.swift | 33 +- .../en.xcloc/Localized Contents/en.xliff | 48 +-- .../ru.xcloc/Localized Contents/ru.xliff | 48 +-- .../SimpleX NSE/ru.lproj/Localizable.strings | 8 +- apps/ios/SimpleX--iOS--Info.plist | 2 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++ apps/ios/ru.lproj/Localizable.strings | 8 +- apps/ios/sounds/ringtone2.m4a | Bin 0 -> 46459 bytes packages/simplex-chat-webrtc/src/call.ts | 69 ++-- packages/simplex-chat-webrtc/src/style.css | 21 +- src/Simplex/Chat.hs | 1 + src/Simplex/Chat/Call.hs | 4 +- 33 files changed, 1089 insertions(+), 504 deletions(-) create mode 100644 apps/ios/Shared/Views/Call/CallController.swift create mode 100644 apps/ios/Shared/Views/Call/CallManager.swift create mode 100644 apps/ios/Shared/Views/Call/IncomingCallView.swift create mode 100644 apps/ios/Shared/Views/Call/SoundPlayer.swift create mode 100644 apps/ios/sounds/ringtone2.m4a diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index a2e770e4e0..f97bd4b9ae 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -96,7 +96,8 @@ const processCommand = (function () { const pc = new RTCPeerConnection(config.peerConnectionConfig); const remoteStream = new MediaStream(); const localCamera = VideoCamera.User; - const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType, localCamera)); + const constraints = callMediaConstraints(mediaType, localCamera); + const localStream = await navigator.mediaDevices.getUserMedia(constraints); const iceCandidates = getIceCandidates(pc, config); const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker }; await setupMediaStreams(call); @@ -116,8 +117,10 @@ const processCommand = (function () { }); if (pc.connectionState == "disconnected" || pc.connectionState == "failed") { pc.removeEventListener("connectionstatechange", connectionStateChange); + if (activeCall) { + setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0); + } endCall(); - setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0); } else if (pc.connectionState == "connected") { const stats = (await pc.getStats()); @@ -133,7 +136,7 @@ const processCommand = (function () { remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId), }, }; - setTimeout(() => sendMessageToNative({ resp }), 0); + setTimeout(() => sendMessageToNative({ resp }), 500); break; } } @@ -256,19 +259,9 @@ const processCommand = (function () { if (!activeCall || !pc) { resp = { type: "error", message: "camera: call not started" }; } - else if (activeCall.localMedia == CallMediaType.Audio) { - resp = { type: "error", message: "camera: no video" }; - } else { - try { - if (command.camera != activeCall.localCamera) { - await replaceCamera(activeCall, command.camera); - } - resp = { type: "ok" }; - } - catch (e) { - resp = { type: "error", message: `camera: ${e.message}` }; - } + await replaceMedia(activeCall, command.camera); + resp = { type: "ok" }; } break; case "end": @@ -281,7 +274,7 @@ const processCommand = (function () { } } catch (e) { - resp = { type: "error", message: e.message }; + resp = { type: "error", message: `${command.type}: ${e.message}` }; } const apiResp = { corrId, resp, command }; sendMessageToNative(apiResp); @@ -323,6 +316,8 @@ const processCommand = (function () { if (call.useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); + call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); + call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data })); } } } @@ -346,14 +341,20 @@ const processCommand = (function () { // Pull tracks from remote stream as they arrive add them to remoteStream video const pc = call.connection; pc.ontrack = (event) => { - if (call.aesKey && call.key) { - console.log("set up decryption for receiving"); - setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key); - } - for (const stream of event.streams) { - for (const track of stream.getTracks()) { - call.remoteStream.addTrack(track); + try { + if (call.aesKey && call.key) { + console.log("set up decryption for receiving"); + setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key); } + for (const stream of event.streams) { + for (const track of stream.getTracks()) { + call.remoteStream.addTrack(track); + } + } + console.log(`ontrack success`); + } + catch (e) { + console.log(`ontrack error: ${e.message}`); } }; } @@ -385,7 +386,7 @@ const processCommand = (function () { } } } - async function replaceCamera(call, camera) { + async function replaceMedia(call, camera) { const videos = getVideoElements(); if (!videos) throw Error("no video elements"); @@ -401,6 +402,8 @@ const processCommand = (function () { videos.local.srcObject = localStream; } function replaceTracks(pc, tracks) { + if (!tracks.length) + return; const sender = pc.getSenders().find((s) => { var _a; return ((_a = s.track) === null || _a === void 0 ? void 0 : _a.kind) === tracks[0].kind; }); if (sender) for (const t of tracks) @@ -494,8 +497,8 @@ function callCryptoFunction() { 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; + const ciphertext = new Uint8Array(plaintext.length ? await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext) : 0); + frame.data = concatN(initial, ciphertext, iv).buffer; controller.enqueue(frame); } catch (e) { @@ -512,8 +515,8 @@ function callCryptoFunction() { 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; + const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext) : 0); + frame.data = concatN(initial, plaintext).buffer; controller.enqueue(frame); } catch (e) { @@ -619,9 +622,15 @@ function workerFunction() { // encryption using RTCRtpScriptTransform. if ("RTCTransformEvent" in self) { self.addEventListener("rtctransform", async ({ transformer }) => { - const { operation, aesKey } = transformer.options; - const { readable, writable } = transformer; - await setupTransform({ operation, aesKey, readable, writable }); + try { + const { operation, aesKey } = transformer.options; + const { readable, writable } = transformer; + await setupTransform({ operation, aesKey, readable, writable }); + self.postMessage({ result: "setupTransform success" }); + } + catch (e) { + self.postMessage({ message: `setupTransform error: ${e.message}` }); + } }); } async function setupTransform({ operation, aesKey, readable, writable }) { diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css index a59f7c39af..3d2941c71e 100644 --- a/apps/android/app/src/main/assets/www/style.css +++ b/apps/android/app/src/main/assets/www/style.css @@ -1,12 +1,10 @@ -video::-webkit-media-controls { - display: none; -} html, body { padding: 0; margin: 0; background-color: black; } + #remote-video-stream { position: absolute; width: 100%; @@ -24,3 +22,20 @@ body { top: 0; right: 0; } + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} 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 5f353069c1..ef1c52209b 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 @@ -209,18 +209,18 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: ( ) { val cInfo = chat.chatInfo toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back) -// if (cInfo is ChatInfo.Direct) { -// Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { -// Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { -// toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { -// startCall(CallMediaType.Audio) -// } -// } -// toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) { -// startCall(CallMediaType.Video) -// } -// } -// } + if (cInfo is ChatInfo.Direct) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { + toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { + startCall(CallMediaType.Audio) + } + } + toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) { + startCall(CallMediaType.Video) + } + } + } Row( Modifier .padding(horizontal = 80.dp) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 415f6348f0..95e21a2513 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -70,7 +70,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { // TODO check if app in background logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") // TODO remove - NtfManager.shared.notifyCheckingMessages() + // NtfManager.shared.notifyCheckingMessages() receiveMessages(completionHandler) } else if let smpQueue = ntfData["checkMessage"] as? String { // TODO check if app in background diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 64eeb2fc1f..e542a979fb 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -10,6 +10,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared + @ObservedObject var callController = CallController.shared @State private var showNotificationAlert = false var body: some View { @@ -17,11 +18,17 @@ struct ContentView: View { if let step = chatModel.onboardingStage { if case .onboardingComplete = step, let user = chatModel.currentUser { - ChatListView(user: user) - .onAppear { - NtfManager.shared.requestAuthorization(onDeny: { - alertManager.showAlert(notificationAlert()) - }) + ZStack(alignment: .top) { + ChatListView(user: user) + .onAppear { + NtfManager.shared.requestAuthorization(onDeny: { + alertManager.showAlert(notificationAlert()) + }) + } + if chatModel.showCallView, let call = chatModel.activeCall { + ActiveCallView(call: call) + } + IncomingCallView() } } else { OnboardingView(onboarding: step) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e754c4d38f..4d4ae6ad91 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -9,6 +9,7 @@ import Foundation import Combine import SwiftUI +import WebKit final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @@ -28,10 +29,10 @@ final class ChatModel: ObservableObject { @Published var tokenStatus = NtfTknStatus.new // current WebRTC call @Published var callInvitations: Dictionary = [:] - @Published var activeCallInvitation: ContactRef? @Published var activeCall: Call? @Published var callCommand: WCallCommand? @Published var showCallView = false + var callWebView: WKWebView? var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index dfb01bb37b..146075ded4 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -37,25 +37,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { 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.removeValue(forKey: chatId) { + let cc = CallController.shared if action == ntfActionAcceptCall { - chatModel.activeCallInvitation = nil - chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia) - chatModel.showCallView = true - chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey) + cc.answerCall(invitation: invitation) } else { - Task { - do { - try await apiRejectCall(contact) - if chatModel.activeCall?.contact.id == chatId { - DispatchQueue.main.async { - chatModel.callCommand = .end - chatModel.activeCall = nil - } - } - } - } + cc.endCall(invitation: invitation) } } else { chatModel.chatId = content.targetContentIdentifier @@ -89,6 +76,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { // this notification is deliverd from the notifications server // when the app is in foreground it does not need to be shown case ntfCategoryCheckMessage: return [] + case ntfCategoryCallInvitation: return [] default: return [.sound, .banner, .list] } } else { @@ -136,11 +124,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { actions: [ UNNotificationAction( identifier: ntfActionAcceptCall, - title: NSLocalizedString("Answer", comment: "accept incoming call via notification") + title: NSLocalizedString("Accept", comment: "accept incoming call via notification"), + options: .foreground ), UNNotificationAction( identifier: ntfActionRejectCall, - title: NSLocalizedString("Ignore", comment: "ignore incoming call via notification") + title: NSLocalizedString("Reject", comment: "reject incoming call via notification") ) ], intentIdentifiers: [], @@ -194,9 +183,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createMessageReceivedNtf(cInfo, cItem)) } - func notifyCallInvitation(_ contact: Contact, _ invitation: CallInvitation) { + func notifyCallInvitation(_ invitation: CallInvitation) { logger.debug("NtfManager.notifyCallInvitation") - addNotification(createCallInvitationNtf(contact, invitation)) + addNotification(createCallInvitationNtf(invitation)) } // TODO remove diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift index d7681541e6..402387055f 100644 --- a/apps/ios/Shared/Model/Shared/CallTypes.swift +++ b/apps/ios/Shared/Model/Shared/CallTypes.swift @@ -24,25 +24,23 @@ struct WebRTCExtraInfo: Codable { } struct CallInvitation { + var contact: Contact + var callkitUUID: UUID? var peerMedia: CallMediaType var sharedKey: String? var callTypeText: LocalizedStringKey { get { switch peerMedia { - case .video: return sharedKey == nil ? "video call (not e2e encrypted)." : "**e2e encrypted** video call." - case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)." : "**e2e encrypted** audio call." + case .video: return sharedKey == nil ? "video call (not e2e encrypted)" : "**e2e encrypted** video call" + case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)" : "**e2e encrypted** audio call" } } } - var callTitle: LocalizedStringKey { - get { - switch peerMedia { - case .video: return "Incoming video call" - case .audio: return "Incoming audio call" - } - } - } - var encryptionText: LocalizedStringKey { get { sharedKey == nil ? "no e2e encryption" : "with e2e encryption" } } + + static let sampleData = CallInvitation( + contact: Contact.sampleData, + peerMedia: .audio + ) } struct CallType: Codable { diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index f737cc8912..ee3696a1ad 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -879,13 +879,13 @@ enum CICallStatus: String, Decodable { 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 %@", comment: "call status"), CICallStatus.durationText(sec)) - case .error: return NSLocalizedString("error", comment: "call status") + case .missed: return NSLocalizedString("missed call", comment: "call status") + case .rejected: return NSLocalizedString("rejected call", comment: "call status") + case .accepted: return NSLocalizedString("accepted call", comment: "call status") + case .negotiated: return NSLocalizedString("connecting call…", comment: "call status") + case .progress: return NSLocalizedString("call in progress", comment: "call status") + case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended call %@", comment: "call status"), CICallStatus.durationText(sec)) + case .error: return NSLocalizedString("call error", comment: "call status") } } diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift index 7255d8ad60..84bb1b5bad 100644 --- a/apps/ios/Shared/Model/Shared/Notifications.swift +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -50,16 +50,16 @@ func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutable ) } -func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent { +func createCallInvitationNtf(_ invitation: CallInvitation) -> UNMutableNotificationContent { let text = invitation.peerMedia == .video ? NSLocalizedString("Incoming video call", comment: "notification") : NSLocalizedString("Incoming audio call", comment: "notification") return createNotification( categoryIdentifier: ntfCategoryCallInvitation, - title: "\(contact.chatViewName):", + title: "\(invitation.contact.chatViewName):", body: text, targetContentIdentifier: nil, - userInfo: ["chatId": contact.id] + userInfo: ["chatId": invitation.contact.id] ) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d2349e505b..8035f34b2d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -11,6 +11,7 @@ import UIKit import Dispatch import BackgroundTasks import SwiftUI +import CallKit private var chatController: chat_ctrl? @@ -629,22 +630,41 @@ func processReceivedMsg(_ res: ChatResponse) { removeFile(fileName) } case let .callInvitation(contact, callType, sharedKey): - let invitation = CallInvitation(peerMedia: callType.media, sharedKey: sharedKey) + let uuid = UUID() + var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey) m.callInvitations[contact.id] = invitation - if (m.activeCallInvitation == nil) { - m.activeCallInvitation = ContactRef(contactId: contact.apiId, localDisplayName: contact.localDisplayName) + CallController.shared.reportNewIncomingCall(invitation: invitation) { error in + if let error = error { + invitation.callkitUUID = nil + m.callInvitations[contact.id] = invitation + logger.error("reportNewIncomingCall error: \(error.localizedDescription)") + } else { + logger.debug("reportNewIncomingCall success") + } } - NtfManager.shared.notifyCallInvitation(contact, invitation) + +// This will be called from notification service extension +// CXProvider.reportNewIncomingVoIPPushPayload([ +// "displayName": contact.displayName, +// "contactId": contact.id, +// "uuid": invitation.callkitUUID +// ]) { error in +// if let error = error { +// logger.error("reportNewIncomingVoIPPushPayload error \(error.localizedDescription)") +// } else { +// logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)") +// } +// } 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) + call.callState = .offerReceived + call.peerMedia = callType.media + call.sharedKey = sharedKey m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, useWorker: true) } case let .callAnswer(contact, answer): withCall(contact) { call in - m.activeCall = call.copy(callState: .negotiated) + call.callState = .answerReceived m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) } case let .callExtraInfo(contact, extraInfo): @@ -652,9 +672,12 @@ func processReceivedMsg(_ res: ChatResponse) { m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) } case let .callEnded(contact): - m.activeCallInvitation = nil - withCall(contact) { _ in + if let invitation = m.callInvitations.removeValue(forKey: contact.id) { + CallController.shared.reportCallRemoteEnded(invitation: invitation) + } + withCall(contact) { call in m.callCommand = .end + CallController.shared.reportCallRemoteEnded(call: call) } default: logger.debug("unsupported event: \(res.responseType)") diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 760c02e6c4..f61105997c 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -7,160 +7,211 @@ // import SwiftUI +import WebKit struct ActiveCallView: View { - @EnvironmentObject var chatModel: ChatModel - @Environment(\.dismiss) private var dismiss - @State private var coordinator: WebRTCCoordinator? = nil - @State private var webViewReady: Bool = false + @EnvironmentObject var m: ChatModel + @ObservedObject var call: Call + @State private var rtcWebView: WKWebView? = nil @State private var webViewMsg: WVAPIMessage? = nil var body: some View { ZStack(alignment: .bottom) { - WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg) + WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg) .onAppear() { sendCommandToWebView() } - .onChange(of: chatModel.callCommand) { _ in sendCommandToWebView() } - .onChange(of: webViewReady) { _ in sendCommandToWebView() } + .onChange(of: m.callCommand) { _ in sendCommandToWebView() } + .onChange(of: rtcWebView) { _ in sendCommandToWebView() } .onChange(of: webViewMsg) { _ in processWebViewMessage() } .background(.black) - ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() }) + if let call = m.activeCall, let webView = rtcWebView { + ActiveCallOverlay(call: call, webView: webView) + } } .preferredColorScheme(.dark) } 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) + if m.activeCall != nil, + let wv = rtcWebView, + let cmd = m.callCommand { + m.callCommand = nil + sendCallCommand(wv, cmd) } } private func processWebViewMessage() { - let m = chatModel if let msg = webViewMsg, - let call = chatModel.activeCall { + let call = m.activeCall, + let webView = rtcWebView { 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) + switch msg.resp { + case let .capabilities(capabilities): + let callType = CallType(media: call.localMedia, capabilities: capabilities) + Task { + do { + try await apiSendCallInvitation(call.contact, callType) + } catch { + logger.error("apiSendCallInvitation \(responseError(error))") } - try await apiCallStatus(call.contact, state.connectionState) - case let .connected(connectionInfo): - m.activeCall = call.copy(callState: .connected, connectionInfo: connectionInfo) - case .ended: - m.activeCall = nil - m.activeCallInvitation = nil - m.callCommand = nil - m.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 let .camera(camera): - m.activeCall = call.copy(localCamera: camera) - case .end: - m.activeCall = nil - m.activeCallInvitation = nil - m.callCommand = nil - m.showCallView = false - default: () + DispatchQueue.main.async { + call.callState = .invitationSent + call.localCapabilities = capabilities } - case let .error(message): - logger.debug("ActiveCallView: command error: \(message)") - case let .invalid(type): - logger.debug("ActiveCallView: invalid response: \(type)") } + case let .offer(offer, iceCandidates, capabilities): + Task { + do { + try await apiSendCallOffer(call.contact, offer, iceCandidates, + media: call.localMedia, capabilities: capabilities) + } catch { + logger.error("apiSendCallOffer \(responseError(error))") + } + DispatchQueue.main.async { + call.callState = .offerSent + call.localCapabilities = capabilities + } + } + case let .answer(answer, iceCandidates): + Task { + do { + try await apiSendCallAnswer(call.contact, answer, iceCandidates) + } catch { + logger.error("apiSendCallAnswer \(responseError(error))") + } + DispatchQueue.main.async { + call.callState = .negotiated + } + } + case let .ice(iceCandidates): + Task { + do { + try await apiSendCallExtraInfo(call.contact, iceCandidates) + } catch { + logger.error("apiSendCallExtraInfo \(responseError(error))") + } + } + case let .connection(state): + if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), + case .connected = callStatus { + if case .outgoing = call.direction { + CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + } + call.callState = .connected + // CallKit doesn't work well with WKWebView + // This is a hack to enable microphone in WKWebView after CallKit takes over it + if CallController.useCallKit { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + m.callCommand = .camera(camera: call.localCamera) + } + } + } + Task { + do { + try await apiCallStatus(call.contact, state.connectionState) + } catch { + logger.error("apiCallStatus \(responseError(error))") + } + } + case let .connected(connectionInfo): + call.callState = .connected + call.connectionInfo = connectionInfo + case .ended: + closeCallView(webView) + call.callState = .ended + if let uuid = call.callkitUUID { + CallController.shared.endCall(callUUID: uuid) + } + case .ok: + switch msg.command { + case .answer: + call.callState = .negotiated + case let .camera(camera): + call.localCamera = camera + Task { + // This disables microphone if it was disabled before flipping the camera + await webView.setMicrophoneCaptureState(call.audioEnabled ? .active : .muted) + // This compensates for the bug on some devices when remote video does not appear + // await webView.setCameraCaptureState(.muted) + // await webView.setCameraCaptureState(call.videoEnabled ? .active : .muted) + } + case .end: + closeCallView(webView) + m.activeCall = nil + default: () + } + case let .error(message): + logger.debug("ActiveCallView: command error: \(message)") + case let .invalid(type): + logger.debug("ActiveCallView: invalid response: \(type)") } } } + + private func closeCallView(_ webView: WKWebView) { + m.showCallView = false + Task { + await webView.setMicrophoneCaptureState(.muted) + await webView.setCameraCaptureState(.muted) + } + } } struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel - var call: Call? - var dismiss: () -> Void + @ObservedObject var call: Call + var webView: WKWebView var body: some View { VStack { - if let call = call { - switch call.localMedia { - case .video: - callInfoView(call, .leading) - .foregroundColor(.white) - .opacity(0.8) - .padding() + switch call.localMedia { + case .video: + callInfoView(call, .leading) + .foregroundColor(.white) + .opacity(0.8) + .padding() + Spacer() + + HStack { + toggleAudioButton() Spacer() - - HStack { - controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") { - chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled) - } - Spacer() + Color.clear.frame(width: 40, height: 40) + Spacer() + endCallButton() + Spacer() + if call.videoEnabled { + flipCameraButton() + } else { Color.clear.frame(width: 40, height: 40) - Spacer() - callButton("phone.down.fill", size: 60) { dismiss() } - .foregroundColor(.red) - Spacer() - controlButton(call, "arrow.triangle.2.circlepath") { - chatModel.callCommand = .camera(camera: call.localCamera == .user ? .environment : .user) - } - Spacer() - controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") { - chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled) - } } - .padding(.horizontal, 20) - .padding(.bottom, 16) - .frame(maxWidth: .infinity, alignment: .center) - - 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) { - controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") { - chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled) - } - .frame(maxWidth: .infinity, alignment: .leading) - callButton("phone.down.fill", size: 60) { dismiss() } - .foregroundColor(.red) - } - .padding(.bottom, 60) - .padding(.horizontal, 48) + toggleVideoButton() } + .padding(.horizontal, 20) + .padding(.bottom, 16) + .frame(maxWidth: .infinity, alignment: .center) + + 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) { + toggleAudioButton() + .frame(maxWidth: .infinity, alignment: .leading) + endCallButton() + } + .padding(.bottom, 60) + .padding(.horizontal, 48) } } .frame(maxWidth: .infinity) @@ -186,6 +237,57 @@ struct ActiveCallOverlay: View { } } + private func endCallButton() -> some View { + let cc = CallController.shared + return callButton("phone.down.fill", size: 60) { + if let uuid = call.callkitUUID { + cc.endCall(callUUID: uuid) + } else { + cc.endCall(call: call) {} + } + } + .foregroundColor(.red) + } + + private func toggleAudioButton() -> some View { + controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") { + Task { + await webView.setMicrophoneCaptureState(call.audioEnabled ? .muted : .active) + DispatchQueue.main.async { + call.audioEnabled = !call.audioEnabled + } + } + } + } + + private func toggleVideoButton() -> some View { + controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") { + Task { + await webView.setCameraCaptureState(call.videoEnabled ? .muted : .active) + DispatchQueue.main.async { + call.videoEnabled = !call.videoEnabled + } + } + } + } + + @ViewBuilder private func flipCameraButton() -> some View { + let cmd = WCallCommand.camera(camera: call.localCamera == .user ? .environment : .user) + controlButton(call, "arrow.triangle.2.circlepath") { + if call.audioEnabled { + chatModel.callCommand = cmd + } else { + Task { + // Microphone has to be enabled before flipping the camera to avoid prompt for user permission when getUserMedia is called in webview + await webView.setMicrophoneCaptureState(.active) + DispatchQueue.main.async { + chatModel.callCommand = cmd + } + } + } + } + } + @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View { if call.hasMedia { callButton(imageName, size: 40, perform) @@ -211,9 +313,9 @@ struct ActiveCallOverlay: View { struct ActiveCallOverlay_Previews: PreviewProvider { static var previews: some View { Group{ - ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .video), dismiss: {}) + ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), webView: WKWebView()) .background(.black) - ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .audio), dismiss: {}) + ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), webView: WKWebView()) .background(.black) } } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift new file mode 100644 index 0000000000..ecd7633ec1 --- /dev/null +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -0,0 +1,215 @@ +// +// CallController.swift +// SimpleX (iOS) +// +// Created by Evgeny on 21/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import CallKit +import AVFoundation + +class CallController: NSObject, CXProviderDelegate, ObservableObject { + static let useCallKit = false + static let shared = CallController() + private let provider = CXProvider(configuration: CallController.configuration) + private let controller = CXCallController() + private let callManager = CallManager() + @Published var activeCallInvitation: CallInvitation? + +// PKPushRegistry will be used from notification service extension +// let registry = PKPushRegistry(queue: nil) + + static let configuration: CXProviderConfiguration = { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = true + configuration.supportedHandleTypes = [.generic] + configuration.includesCallsInRecents = true // TODO disable or add option + configuration.maximumCallsPerCallGroup = 1 + return configuration + }() + + override init() { + super.init() + self.provider.setDelegate(self, queue: nil) +// self.registry.delegate = self +// self.registry.desiredPushTypes = [.voIP] + } + + func providerDidReset(_ provider: CXProvider) { + } + + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + logger.debug("CallController.provider CXStartCallAction") + if callManager.startOutgoingCall(callUUID: action.callUUID) { + action.fulfill() + provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) + } else { + action.fail() + } + } + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + logger.debug("CallController.provider CXAnswerCallAction") + if callManager.answerIncomingCall(callUUID: action.callUUID) { + action.fulfill() + } else { + action.fail() + } + } + + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + logger.debug("CallController.provider CXEndCallAction") + callManager.endCall(callUUID: action.callUUID) { ok in + if ok { + action.fulfill() + } else { + action.fail() + } + } + } + + func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { + print("timed out", #function) + action.fulfill() + } + + func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + print("received", #function) +// do { +// try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers) +// logger.debug("audioSession category set") +// try audioSession.setActive(true) +// logger.debug("audioSession activated") +// } catch { +// print(error) +// logger.error("failed activating audio session") +// } + } + + func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + print("received", #function) + } + +// func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { +// +// } + +// This will be needed when we have notification service extension +// func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { +// if type == .voIP { +// // Extract the call information from the push notification payload +// if let displayName = payload.dictionaryPayload["displayName"] as? String, +// let contactId = payload.dictionaryPayload["contactId"] as? String, +// let uuidStr = payload.dictionaryPayload["uuid"] as? String, +// let uuid = UUID(uuidString: uuidStr) { +// let callUpdate = CXCallUpdate() +// callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: displayName) +// provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in +// if error != nil { +// let m = ChatModel.shared +// m.callInvitations.removeValue(forKey: contactId) +// } +// // Tell PushKit that the notification is handled. +// completion() +// }) +// } +// } +// } + + func reportNewIncomingCall(invitation: CallInvitation, completion: @escaping (Error?) -> Void) { + logger.debug("CallController.reportNewIncomingCall") + if CallController.useCallKit, let uuid = invitation.callkitUUID { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName) + update.hasVideo = invitation.peerMedia == .video + provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) + } else { + NtfManager.shared.notifyCallInvitation(invitation) + activeCallInvitation = invitation + } + } + + func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { + if CallController.useCallKit, let uuid = call.callkitUUID { + provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected) + } + } + + func reportCallRemoteEnded(invitation: CallInvitation) { + if CallController.useCallKit, let uuid = invitation.callkitUUID { + provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } else if invitation.contact.id == activeCallInvitation?.contact.id { + activeCallInvitation = nil + } + } + + func reportCallRemoteEnded(call: Call) { + if CallController.useCallKit, let uuid = call.callkitUUID { + provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } + } + + func startCall(_ contact: Contact, _ media: CallMediaType) { + logger.debug("CallController.startCall") + let uuid = callManager.newOutgoingCall(contact, media) + if CallController.useCallKit { + let handle = CXHandle(type: .generic, value: contact.displayName) + let action = CXStartCallAction(call: uuid, handle: handle) + action.isVideo = media == .video + requestTransaction(with: action) + } else if callManager.startOutgoingCall(callUUID: uuid) { + logger.debug("CallController.startCall: call started") + } else { + logger.error("CallController.startCall: no active call") + } + } + + func answerCall(invitation: CallInvitation) { + callManager.answerIncomingCall(invitation: invitation) + if invitation.contact.id == self.activeCallInvitation?.contact.id { + self.activeCallInvitation = nil + } + } + + func endCall(callUUID: UUID) { + if CallController.useCallKit { + requestTransaction(with: CXEndCallAction(call: callUUID)) + } else { + callManager.endCall(callUUID: callUUID) { ok in + if ok { + logger.debug("CallController.endCall: call ended") + } else { + logger.error("CallController.endCall: no actove call pr call invitation to end") + } + } + } + } + + func endCall(invitation: CallInvitation) { + callManager.endCall(invitation: invitation) { + if invitation.contact.id == self.activeCallInvitation?.contact.id { + DispatchQueue.main.async { + self.activeCallInvitation = nil + } + } + } + } + + func endCall(call: Call, completed: @escaping () -> Void) { + callManager.endCall(call: call, completed: completed) + } + + private func requestTransaction(with action: CXAction) { + let t = CXTransaction() + t.addAction(action) + controller.request(t) { error in + if let error = error { + logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)") + } else { + logger.debug("CallController.requestTransaction requested transaction successfully") + } + } + } +} diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift new file mode 100644 index 0000000000..f22535e9ad --- /dev/null +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -0,0 +1,104 @@ +// +// CallManager.swift +// SimpleX (iOS) +// +// Created by Evgeny on 22/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +class CallManager { + func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID { + let uuid = UUID() + ChatModel.shared.activeCall = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media) + return uuid + } + + func startOutgoingCall(callUUID: UUID) -> Bool { + let m = ChatModel.shared + if let call = m.activeCall, call.callkitUUID == callUUID { + m.showCallView = true + m.callCommand = .capabilities(useWorker: true) + return true + } + return false + } + + func answerIncomingCall(callUUID: UUID) -> Bool { + if let invitation = getCallInvitation(callUUID) { + answerIncomingCall(invitation: invitation) + return true + } + return false + } + + func answerIncomingCall(invitation: CallInvitation) { + let m = ChatModel.shared + m.callInvitations.removeValue(forKey: invitation.contact.id) + m.activeCall = Call( + direction: .incoming, + contact: invitation.contact, + callkitUUID: invitation.callkitUUID, + callState: .invitationAccepted, + localMedia: invitation.peerMedia, + sharedKey: invitation.sharedKey + ) + m.showCallView = true + m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) + } + + func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) { + if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { + endCall(call: call) { completed(true) } + } else if let invitation = getCallInvitation(callUUID) { + endCall(invitation: invitation) { completed(true) } + } else { + completed(false) + } + } + + func endCall(call: Call, completed: @escaping () -> Void) { + let m = ChatModel.shared + if case .ended = call.callState { + logger.debug("CallController.provider CXEndCallAction: call ended") + m.activeCall = nil + m.showCallView = false + completed() + } else { + logger.debug("CallController.provider CXEndCallAction: ending call...") + m.callCommand = .end + m.showCallView = false + Task { + do { + try await apiEndCall(call.contact) + } catch { + logger.error("CallController.provider apiEndCall error: \(responseError(error))") + } + DispatchQueue.main.async { + m.activeCall = nil + completed() + } + } + } + } + + func endCall(invitation: CallInvitation, completed: @escaping () -> Void) { + ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id) + Task { + do { + try await apiRejectCall(invitation.contact) + } catch { + logger.error("CallController.provider apiRejectCall error: \(responseError(error))") + } + completed() + } + } + + private func getCallInvitation(_ callUUID: UUID) -> CallInvitation? { + if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) { + return invitation + } + return nil + } +} diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift new file mode 100644 index 0000000000..089ffea5f5 --- /dev/null +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -0,0 +1,86 @@ +// +// IncomingCallView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct IncomingCallView: View { + @EnvironmentObject var m: ChatModel + @ObservedObject var cc = CallController.shared + + var body: some View { + let sp = SoundPlayer.shared + if let invitation = cc.activeCallInvitation { + if m.showCallView { + incomingCall(invitation) + } else { + incomingCall(invitation) + .onAppear { sp.startRingtone() } + .onDisappear { sp.stopRingtone() } + } + } + } + + private func incomingCall(_ invitation: CallInvitation) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: invitation.peerMedia == .video ? "video.fill" : "phone.fill").foregroundColor(.green) + Text(invitation.callTypeText) + } + HStack { + ProfilePreview(profileOf: invitation.contact, color: .white) + Spacer() + + callButton("Reject", "phone.down.fill", .red) { + cc.endCall(invitation: invitation) + } + + callButton("Ignore", "multiply", .accentColor) { + cc.activeCallInvitation = nil + } + + callButton("Accept", "checkmark", .green) { + if let call = m.activeCall { + cc.endCall(call: call) { + DispatchQueue.main.async { + cc.answerCall(invitation: invitation) + } + } + } else { + cc.answerCall(invitation: invitation) + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + } + + private func callButton(_ text: LocalizedStringKey, _ image: String, _ color: Color, action: @escaping () -> Void) -> some View { + Button(action: action, label: { + VStack(spacing: 2) { + Image(systemName: image) + .scaleEffect(1.24) + .foregroundColor(color) + .frame(width: 24, height: 24) + Text(text) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(minWidth: 44) + }) + } +} + +struct IncomingCallView_Previews: PreviewProvider { + static var previews: some View { + CallController.shared.activeCallInvitation = CallInvitation.sampleData + return IncomingCallView() + } +} diff --git a/apps/ios/Shared/Views/Call/SoundPlayer.swift b/apps/ios/Shared/Views/Call/SoundPlayer.swift new file mode 100644 index 0000000000..17c13ab403 --- /dev/null +++ b/apps/ios/Shared/Views/Call/SoundPlayer.swift @@ -0,0 +1,45 @@ +// +// SoundPlayer.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import AVFoundation + +class SoundPlayer { + static let shared = SoundPlayer() + private var audioPlayer: AVAudioPlayer? + + func startRingtone() { + audioPlayer?.stop() + logger.debug("startRingtone") + guard let path = Bundle.main.path(forResource: "ringtone2", ofType: "m4a", inDirectory: "sounds") else { + logger.debug("startRingtone: file not found") + return + } + do { + let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) + if player.prepareToPlay() { + audioPlayer = player + } + } catch { + logger.debug("startRingtone: AVAudioPlayer error \(error.localizedDescription)") + } + + Task { + while let player = audioPlayer { + player.play() + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + _ = try? await Task.sleep(nanoseconds: UInt64(player.duration * 1_000_000_000)) + } + } + } + + func stopRingtone() { + audioPlayer?.stop() + audioPlayer = nil + } +} diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index 066dcc7798..49a3782eb7 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -9,70 +9,39 @@ import Foundation import SwiftUI -class Call: Equatable { +class Call: ObservableObject, Equatable { static func == (lhs: Call, rhs: Call) -> Bool { lhs.contact.apiId == rhs.contact.apiId } + var direction: CallDirection var contact: Contact - var callState: CallState + var callkitUUID: UUID? var localMedia: CallMediaType - var localCapabilities: CallCapabilities? - var peerMedia: CallMediaType? - var sharedKey: String? - var audioEnabled: Bool - var videoEnabled: Bool - var localCamera: VideoCamera - var connectionInfo: ConnectionInfo? + @Published var callState: CallState + @Published var localCapabilities: CallCapabilities? + @Published var peerMedia: CallMediaType? + @Published var sharedKey: String? + @Published var audioEnabled = true + @Published var videoEnabled: Bool + @Published var localCamera = VideoCamera.user + @Published var connectionInfo: ConnectionInfo? init( + direction: CallDirection, contact: Contact, + callkitUUID: UUID?, callState: CallState, localMedia: CallMediaType, - localCapabilities: CallCapabilities? = nil, - peerMedia: CallMediaType? = nil, - sharedKey: String? = nil, - audioEnabled: Bool? = nil, - videoEnabled: Bool? = nil, - localCamera: VideoCamera = .user, - connectionInfo: ConnectionInfo? = nil + sharedKey: String? = nil ) { + self.direction = direction self.contact = contact + self.callkitUUID = callkitUUID self.callState = callState self.localMedia = localMedia - self.localCapabilities = localCapabilities - self.peerMedia = peerMedia self.sharedKey = sharedKey - self.audioEnabled = audioEnabled ?? true - self.videoEnabled = videoEnabled ?? (localMedia == .video) - self.localCamera = localCamera - self.connectionInfo = connectionInfo - } - - 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, - localCamera: VideoCamera? = nil, - connectionInfo: ConnectionInfo? = 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, - localCamera: localCamera ?? self.localCamera, - connectionInfo: connectionInfo ?? self.connectionInfo - ) + self.videoEnabled = localMedia == .video } var encrypted: Bool { get { localEncrypted && sharedKey != nil } } @@ -82,7 +51,7 @@ class Call: Equatable { switch callState { case .waitCapabilities: return "" case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption" - case .invitationReceived: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption" + case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption" default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted" } } @@ -90,24 +59,33 @@ class Call: Equatable { var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } } } +enum CallDirection { + case incoming + case outgoing +} + enum CallState { - case waitCapabilities - case invitationSent - case invitationReceived - case offerSent - case offerReceived - case negotiated + case waitCapabilities // outgoing call started + case invitationSent // outgoing call - sent invitation + case invitationAccepted // incoming call started + case offerSent // incoming - webrtc started and offer sent + case offerReceived // outgoing - webrtc offer received via API + case answerReceived // incoming - webrtc answer received via API + case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed case connected + case ended var text: LocalizedStringKey { switch self { case .waitCapabilities: return "starting…" case .invitationSent: return "waiting for answer…" - case .invitationReceived: return "starting…" + case .invitationAccepted: return "starting…" case .offerSent: return "waiting for confirmation…" case .offerReceived: return "received answer…" + case .answerReceived: return "received confirmation…" case .negotiated: return "connecting…" case .connected: return "connected" + case .ended: return "ended" } } } @@ -275,16 +253,16 @@ enum WCallResponse: Equatable, Decodable { var respType: String { get { switch self { - case .capabilities: return("capabilities") - case .offer: return("offer") - case .answer: return("answer") - case .ice: return("ice") - case .connection: return("connection") - case .connected: return("connected") - case .ended: return("ended") - case .ok: return("ok") - case .error: return("error") - case .invalid: return("invalid") + case .capabilities: return "capabilities" + case .offer: return "offer" + case .answer: return "answer" + case .ice: return "ice" + case .connection: return "connection" + case .connected: return "connected" + case .ended: return "ended" + case .ok: return "ok" + case .error: return "error" + case .invalid: return "invalid" } } } diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift index fc4d7310b4..428ef130b5 100644 --- a/apps/ios/Shared/Views/Call/WebRTCView.swift +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -9,20 +9,24 @@ import SwiftUI import WebKit -class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - var webViewReady: Binding +class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate { + var rtcWebView: Binding var webViewMsg: Binding - private var webView: WKWebView? - internal init(webViewReady: Binding, webViewMsg: Binding) { - self.webViewReady = webViewReady + internal init(rtcWebView: Binding, webViewMsg: Binding) { + self.rtcWebView = rtcWebView self.webViewMsg = webViewMsg } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { webView.allowsBackForwardNavigationGestures = false - self.webView = webView - webViewReady.wrappedValue = true + self.rtcWebView.wrappedValue = webView + ChatModel.shared.callWebView = webView + } + + func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin : WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision { + print("webView", #function) + return .grant } // receive message from WKWebView @@ -31,34 +35,37 @@ class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler didReceive message: WKScriptMessage ) { logger.debug("WebRTCCoordinator.userContentController") - if let msgStr = message.body as? String, - let msg: WVAPIMessage = decodeJSON(msgStr) { - webViewMsg.wrappedValue = msg - if case .invalid = msg.resp { - logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") + switch message.name { + case "webrtc": + if let msgStr = message.body as? String, + let msg: WVAPIMessage = decodeJSON(msgStr) { + // this is the binding that communicates messages from webview to swift view + webViewMsg.wrappedValue = msg + if case .invalid = msg.resp { + logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") + } + } else { + logger.error("WebRTCCoordinator.userContentController: message parsing error \(String(describing: message.body))") } - } else { - logger.error("WebRTCCoordinator.userContentController: message parsing error \(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) + case "logger": + if let msgStr = message.body as? String { + logger.error("WebRTCCoordinator console.log: \(msgStr)") + } else { + logger.error("WebRTCCoordinator console.log: \(String(describing: message.body))") + } + default: + logger.error("WebRTCCoordinator.userContentController: invalid message.name \(message.name)") } } } struct WebRTCView: UIViewRepresentable { - @Binding var coordinator: WebRTCCoordinator? - @Binding var webViewReady: Bool + @State private var coordinator: WebRTCCoordinator? + @Binding var rtcWebView: WKWebView? @Binding var webViewMsg: WVAPIMessage? func makeCoordinator() -> WebRTCCoordinator { - WebRTCCoordinator(webViewReady: $webViewReady, webViewMsg: $webViewMsg) + WebRTCCoordinator(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg) } func makeUIView(context: Context) -> WKWebView { @@ -72,10 +79,14 @@ struct WebRTCView: UIViewRepresentable { 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 addScript = { (handler: String, source: String) in + let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false) + wkController.addUserScript(script) + wkController.add(wkCoordinator, name: handler) + } + + addScript("webrtc", "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))") + addScript("logger", "console.log = (arg) => webkit.messageHandlers.logger.postMessage(JSON.stringify(arg))") let wkWebView = WKWebView(frame: .zero, configuration: cfg) wkWebView.navigationDelegate = wkCoordinator @@ -93,16 +104,22 @@ struct WebRTCView: UIViewRepresentable { } } +func sendCallCommand(_ webView: WKWebView, _ command: WCallCommand) { + logger.debug("sendCallCommand: \(command.cmdType)") + let apiCmd = encodeJSON(WVAPICall(command: command)) + let js = "processCommand(\(apiCmd))" + webView.evaluateJavaScript(js) +} + struct CallViewDebug: View { - @State private var coordinator: WebRTCCoordinator? = nil @State private var commandStr = "" - @State private var webViewReady: Bool = false + @State private var rtcWebView: WKWebView? = nil @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) + WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg).frame(maxHeight: 260) .onChange(of: webViewMsg) { _ in if let resp = webViewMsg { commandStr = encodeJSON(resp) @@ -130,21 +147,21 @@ struct CallViewDebug: View { commandStr = "" } Button("Send") { - if let c = coordinator, + if let wv = rtcWebView, let command: WCallCommand = decodeJSON(commandStr) { - c.sendCommand(command: command) + sendCallCommand(wv, command) } } } HStack(spacing: 20) { Button("Capabilities") { - if let c = coordinator { - c.sendCommand(command: .capabilities(useWorker: true)) + if let wv = rtcWebView { + sendCallCommand(wv, .capabilities(useWorker: true)) } } Button("Start") { - if let c = coordinator { - c.sendCommand(command: .start(media: .video)) + if let wv = rtcWebView { + sendCallCommand(wv, .start(media: .video)) } } Button("Accept") { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 96465ae387..6b3498900b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -26,7 +26,7 @@ struct CICallItemView: View { acceptCallButton() } case .missed: missedCallIcon(sent).foregroundColor(.red) - case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary) + case .rejected: Image(systemName: "phone.down").foregroundColor(.red) case .accepted: connectingCallIcon() case .negotiated: connectingCallIcon() case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green) @@ -61,16 +61,9 @@ struct CICallItemView: View { @ViewBuilder private func acceptCallButton() -> some View { if case let .direct(contact) = chatInfo { Button { - if let invitation = m.callInvitations.removeValue(forKey: contact.id) { - m.activeCallInvitation = nil - m.activeCall = Call( - contact: contact, - callState: .invitationReceived, - localMedia: invitation.peerMedia, - sharedKey: invitation.sharedKey - ) - m.showCallView = true - m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) + if let invitation = m.callInvitations[contact.id] { + CallController.shared.answerCall(invitation: invitation) + logger.debug("acceptCallButton call answered") } else { AlertManager.shared.showAlertMsg(title: "Call already ended!") } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1f1bf1104a..579069435a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -105,27 +105,21 @@ 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") -// } -// } -// } + 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 - ) - chatModel.showCallView = true - chatModel.callCommand = .capabilities(useWorker: true) + CallController.shared.startCall(contact, media) } label: { Image(systemName: imageName) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9f6da6c6d2..746763583a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -49,29 +49,6 @@ struct ChatListView: View { NewChatButton() } } - .fullScreenCover(isPresented: $chatModel.showCallView) { - ActiveCallView() - } - .onChange(of: chatModel.showCallView) { _ in - if (chatModel.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[contactRef.id] { - answerCallAlert(contact, invitation) - } - } } .navigationViewStyle(.stack) @@ -95,34 +72,6 @@ struct ChatListView: View { $0.chatInfo.chatViewName.localizedLowercase.contains(s) } } - - private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) { - return AlertManager.shared.showAlert(Alert( - title: Text(invitation.callTitle), - message: Text(contact.profile.displayName).bold() + - Text(" wants to connect with you via ") + - Text(invitation.callTypeText), - primaryButton: .default(Text("Answer")) { - if let activeCallInvitation = chatModel.activeCallInvitation { - chatModel.callInvitations.removeValue(forKey: activeCallInvitation.id) - chatModel.activeCallInvitation = nil - chatModel.activeCall = Call( - contact: contact, - callState: .invitationReceived, - localMedia: invitation.peerMedia, - sharedKey: invitation.sharedKey - ) - chatModel.showCallView = true - chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) - } else { - DispatchQueue.main.async { - AlertManager.shared.showAlertMsg(title: "Call already ended!") - } - } - }, - secondaryButton: .cancel() - )) - } } struct ChatListView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 05af85086b..684e16928a 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -38,18 +38,7 @@ struct SettingsView: View { UserProfile() .navigationTitle("Your chat profile") } label: { - HStack { - ProfileImage(imageStr: user.image) - .frame(width: 44, height: 44) - .padding(.trailing, 6) - .padding(.vertical, 6) - VStack(alignment: .leading) { - Text(user.displayName) - .fontWeight(.bold) - .font(.title2) - Text(user.fullName) - } - } + ProfilePreview(profileOf: user) .padding(.leading, -8) } NavigationLink { @@ -242,6 +231,26 @@ struct SettingsView: View { } } +struct ProfilePreview: View { + var profileOf: NamedChat + var color = Color(uiColor: .tertiarySystemGroupedBackground) + + var body: some View { + HStack { + ProfileImage(imageStr: profileOf.image, color: color) + .frame(width: 44, height: 44) + .padding(.trailing, 6) + .padding(.vertical, 6) + VStack(alignment: .leading) { + Text(profileOf.displayName) + .fontWeight(.bold) + .font(.title2) + Text(profileOf.fullName) + } + } + } +} + struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() 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 4bdf3d3203..0d2ab9e89b 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -90,14 +90,14 @@ **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. - - **e2e encrypted** audio call. - **e2e encrypted** audio call. + + **e2e encrypted** audio call + **e2e encrypted** audio call No comment provided by engineer. - - **e2e encrypted** video call. - **e2e encrypted** video call. + + **e2e encrypted** video call + **e2e encrypted** video call No comment provided by engineer. @@ -1118,9 +1118,9 @@ SimpleX servers cannot see your profile. accepted call status - - audio call (not e2e encrypted). - audio call (not e2e encrypted). + + audio call (not e2e encrypted) + audio call (not e2e encrypted) No comment provided by engineer. @@ -1279,9 +1279,9 @@ SimpleX servers cannot see your profile. via relay No comment provided by engineer. - - video call (not e2e encrypted). - video call (not e2e encrypted). + + video call (not e2e encrypted) + video call (not e2e encrypted) No comment provided by engineer. @@ -1380,14 +1380,14 @@ SimpleX servers cannot see your profile. %@ wants to connect! notification title - - **e2e encrypted** audio call. - **e2e encrypted** audio call. + + **e2e encrypted** audio call + **e2e encrypted** audio call No comment provided by engineer. - - **e2e encrypted** video call. - **e2e encrypted** video call. + + **e2e encrypted** video call + **e2e encrypted** video call No comment provided by engineer. @@ -1415,9 +1415,9 @@ SimpleX servers cannot see your profile. accepted call status - - audio call (not e2e encrypted). - audio call (not e2e encrypted). + + audio call (not e2e encrypted) + audio call (not e2e encrypted) No comment provided by engineer. @@ -1491,9 +1491,9 @@ SimpleX servers cannot see your profile. via one-time link chat list item description - - video call (not e2e encrypted). - video call (not e2e encrypted). + + video call (not e2e encrypted) + video call (not e2e encrypted) No comment provided by engineer. 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 8bbbb124c6..c26a7bb81e 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -90,14 +90,14 @@ **Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка. No comment provided by engineer. - - **e2e encrypted** audio call. - **e2e зашифрованный** аудиозвонок. + + **e2e encrypted** audio call + **e2e зашифрованный** аудиозвонок No comment provided by engineer. - - **e2e encrypted** video call. - **e2e зашифрованный** видеозвонок. + + **e2e encrypted** video call + **e2e зашифрованный** видеозвонок No comment provided by engineer. @@ -1118,9 +1118,9 @@ SimpleX серверы не могут получить доступ к ваше принятый звонок call status - - audio call (not e2e encrypted). - аудиозвонок (не e2e зашифрованный). + + audio call (not e2e encrypted) + аудиозвонок (не e2e зашифрованный) No comment provided by engineer. @@ -1279,9 +1279,9 @@ SimpleX серверы не могут получить доступ к ваше через relay сервер No comment provided by engineer. - - video call (not e2e encrypted). - видеозвонок (не e2e зашифрованный). + + video call (not e2e encrypted) + видеозвонок (не e2e зашифрованный) No comment provided by engineer. @@ -1380,14 +1380,14 @@ SimpleX серверы не могут получить доступ к ваше %@ хочет соединиться! notification title - - **e2e encrypted** audio call. - **e2e зашифрованный** аудиозвонок. + + **e2e encrypted** audio call + **e2e зашифрованный** аудиозвонок No comment provided by engineer. - - **e2e encrypted** video call. - **e2e зашифрованный** видеозвонок. + + **e2e encrypted** video call + **e2e зашифрованный** видеозвонок No comment provided by engineer. @@ -1415,9 +1415,9 @@ SimpleX серверы не могут получить доступ к ваше принятый звонок call status - - audio call (not e2e encrypted). - аудиозвонок (не e2e зашифрованный). + + audio call (not e2e encrypted) + аудиозвонок (не e2e зашифрованный) No comment provided by engineer. @@ -1491,9 +1491,9 @@ SimpleX серверы не могут получить доступ к ваше через одноразовую ссылку chat list item description - - video call (not e2e encrypted). - видеозвонок (не e2e зашифрованный). + + video call (not e2e encrypted) + видеозвонок (не e2e зашифрованный) No comment provided by engineer. diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index fc8627b30f..ab483cb82e 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -1,8 +1,8 @@ /* No comment provided by engineer. */ -"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок."; +"**e2e encrypted** audio call" = "**e2e зашифрованный** аудиозвонок"; /* No comment provided by engineer. */ -"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок."; +"**e2e encrypted** video call" = "**e2e зашифрованный** видеозвонок"; /* notification title */ "%@ is connected!" = "соединение с %@ установлено!"; @@ -17,7 +17,7 @@ "accepted" = "принятый звонок"; /* No comment provided by engineer. */ -"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный)."; +"audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; /* call status */ "calling…" = "входящий звонок…"; @@ -69,7 +69,7 @@ "via one-time link" = "через одноразовую ссылку"; /* No comment provided by engineer. */ -"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный)."; +"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)"; /* No comment provided by engineer. */ "with e2e encryption" = "e2e зашифровано"; diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 1287772e17..01f16a5a1c 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -23,8 +23,10 @@ UIBackgroundModes + audio fetch remote-notification + voip diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index f4f5559a2f..c028015df9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; + 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.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 */; }; @@ -27,6 +28,10 @@ 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 */; }; + 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; + 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; + 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; + 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; 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 */; }; @@ -130,6 +135,7 @@ 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; + 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.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 = ""; }; @@ -146,6 +152,10 @@ 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 = ""; }; + 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; + 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; + 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = ""; }; + 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; 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 = ""; }; @@ -271,6 +281,10 @@ 3C714776281C081000CB4D4B /* WebRTCView.swift */, 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */, 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */, + 5C029EA9283942EA004A9677 /* CallController.swift */, + 5C55A91E283AD0E400C4E99E /* CallManager.swift */, + 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */, + 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */, ); path = Call; sourceTree = ""; @@ -362,6 +376,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 5C55A92D283D0FDE00C4E99E /* sounds */, 3C714779281C0F6800CB4D4B /* www */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, @@ -625,6 +640,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C55A92E283D0FDE00C4E99E /* sounds in Resources */, 3C71477A281C0F6800CB4D4B /* www in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, @@ -657,6 +673,7 @@ files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */, + 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, @@ -667,6 +684,7 @@ 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, + 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, @@ -701,10 +719,12 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */, 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */, 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 459f5515bf..16563e6163 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -38,10 +38,10 @@ "**Create link / QR code** for your contact to use." = "**Создать ссылку / QR код** для вашего контакта."; /* No comment provided by engineer. */ -"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок."; +"**e2e encrypted** audio call" = "**e2e зашифрованный** аудиозвонок"; /* No comment provided by engineer. */ -"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок."; +"**e2e encrypted** video call" = "**e2e зашифрованный** видеозвонок"; /* No comment provided by engineer. */ "**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**."; @@ -122,7 +122,7 @@ "Attach" = "Прикрепить"; /* No comment provided by engineer. */ -"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный)."; +"audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; /* No comment provided by engineer. */ "bold" = "жирный"; @@ -678,7 +678,7 @@ "via relay" = "через relay сервер"; /* No comment provided by engineer. */ -"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный)."; +"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)"; /* No comment provided by engineer. */ "waiting for answer…" = "ожидается ответ…"; diff --git a/apps/ios/sounds/ringtone2.m4a b/apps/ios/sounds/ringtone2.m4a new file mode 100644 index 0000000000000000000000000000000000000000..73c79ec94cff535485395b3a017dc37b9b7579bf GIT binary patch literal 46459 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 literal 0 HcmV?d00001 diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index b8b7cfdb2f..f0d707f03f 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -289,7 +289,8 @@ const processCommand = (function () { const pc = new RTCPeerConnection(config.peerConnectionConfig) const remoteStream = new MediaStream() const localCamera = VideoCamera.User - const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType, localCamera)) + const constraints = callMediaConstraints(mediaType, localCamera) + const localStream = await navigator.mediaDevices.getUserMedia(constraints) const iceCandidates = getIceCandidates(pc, config) const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker} await setupMediaStreams(call) @@ -310,8 +311,10 @@ const processCommand = (function () { }) if (pc.connectionState == "disconnected" || pc.connectionState == "failed") { pc.removeEventListener("connectionstatechange", connectionStateChange) + if (activeCall) { + setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) + } endCall() - setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) } else if (pc.connectionState == "connected") { const stats = (await pc.getStats()) as Map for (const stat of stats.values()) { @@ -326,7 +329,7 @@ const processCommand = (function () { remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId), }, } - setTimeout(() => sendMessageToNative({resp}), 0) + setTimeout(() => sendMessageToNative({resp}), 500) break } } @@ -442,17 +445,9 @@ const processCommand = (function () { case "camera": if (!activeCall || !pc) { resp = {type: "error", message: "camera: call not started"} - } else if (activeCall.localMedia == CallMediaType.Audio) { - resp = {type: "error", message: "camera: no video"} } else { - try { - if (command.camera != activeCall.localCamera) { - await replaceCamera(activeCall, command.camera) - } - resp = {type: "ok"} - } catch (e) { - resp = {type: "error", message: `camera: ${(e as Error).message}`} - } + await replaceMedia(activeCall, command.camera) + resp = {type: "ok"} } break case "end": @@ -464,7 +459,7 @@ const processCommand = (function () { break } } catch (e) { - resp = {type: "error", message: (e as Error).message} + resp = {type: "error", message: `${command.type}: ${(e as Error).message}`} } const apiResp = {corrId, resp, command} sendMessageToNative(apiResp) @@ -506,6 +501,9 @@ const processCommand = (function () { if (call.useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) + call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => + console.log(JSON.stringify({error, filename, lineno, message})) + call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } @@ -532,14 +530,19 @@ const processCommand = (function () { // Pull tracks from remote stream as they arrive add them to remoteStream video const pc = call.connection pc.ontrack = (event) => { - if (call.aesKey && call.key) { - console.log("set up decryption for receiving") - setupPeerTransform(TransformOperation.Decrypt, event.receiver as RTCRtpReceiverWithEncryption, call.worker, call.aesKey, call.key) - } - for (const stream of event.streams) { - for (const track of stream.getTracks()) { - call.remoteStream.addTrack(track) + try { + if (call.aesKey && call.key) { + console.log("set up decryption for receiving") + setupPeerTransform(TransformOperation.Decrypt, event.receiver as RTCRtpReceiverWithEncryption, call.worker, call.aesKey, call.key) } + for (const stream of event.streams) { + for (const track of stream.getTracks()) { + call.remoteStream.addTrack(track) + } + } + console.log(`ontrack success`) + } catch (e) { + console.log(`ontrack error: ${(e as Error).message}`) } } } @@ -573,7 +576,7 @@ const processCommand = (function () { } } - async function replaceCamera(call: Call, camera: VideoCamera): Promise { + async function replaceMedia(call: Call, camera: VideoCamera): Promise { const videos = getVideoElements() if (!videos) throw Error("no video elements") const pc = call.connection @@ -588,6 +591,7 @@ const processCommand = (function () { } function replaceTracks(pc: RTCPeerConnection, tracks: MediaStreamTrack[]) { + if (!tracks.length) return const sender = pc.getSenders().find((s) => s.track?.kind === tracks[0].kind) if (sender) for (const t of tracks) sender.replaceTrack(t) } @@ -713,8 +717,10 @@ function callCryptoFunction(): CallCrypto { 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 + const ciphertext = new Uint8Array( + plaintext.length ? await crypto.subtle.encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext) : 0 + ) + frame.data = concatN(initial, ciphertext, iv).buffer controller.enqueue(frame) } catch (e) { console.log(`encryption error ${e}`) @@ -731,8 +737,8 @@ function callCryptoFunction(): CallCrypto { 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 + const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ciphertext) : 0) + frame.data = concatN(initial, plaintext).buffer controller.enqueue(frame) } catch (e) { console.log(`decryption error ${e}`) @@ -864,9 +870,14 @@ function workerFunction() { // encryption using RTCRtpScriptTransform. if ("RTCTransformEvent" in self) { self.addEventListener("rtctransform", async ({transformer}: any) => { - const {operation, aesKey} = transformer.options - const {readable, writable} = transformer - await setupTransform({operation, aesKey, readable, writable}) + try { + const {operation, aesKey} = transformer.options + const {readable, writable} = transformer + await setupTransform({operation, aesKey, readable, writable}) + self.postMessage({result: "setupTransform success"}) + } catch (e) { + self.postMessage({message: `setupTransform error: ${(e as Error).message}`}) + } }) } diff --git a/packages/simplex-chat-webrtc/src/style.css b/packages/simplex-chat-webrtc/src/style.css index a59f7c39af..3d2941c71e 100644 --- a/packages/simplex-chat-webrtc/src/style.css +++ b/packages/simplex-chat-webrtc/src/style.css @@ -1,12 +1,10 @@ -video::-webkit-media-controls { - display: none; -} html, body { padding: 0; margin: 0; background-color: black; } + #remote-video-stream { position: absolute; width: 100%; @@ -24,3 +22,20 @@ body { top: 0; right: 0; } + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b30ec9da7a..046a5abfb0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -880,6 +880,7 @@ callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do (Just CISCallPending, WCSDisconnected) -> Just (CISCallMissed, 0) (Just CISCallEnded, _) -> Nothing -- if call already ended or failed -> no change (Just CISCallError, _) -> Nothing + (Just _, WCSConnecting) -> Just (CISCallNegotiated, 0) (Just _, WCSConnected) -> Just (CISCallProgress, 0) -- if call ended that was never connected, duration = 0 (Just _, WCSDisconnected) -> Just (CISCallEnded, 0) (Just _, WCSFailed) -> Just (CISCallError, 0) diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 1ad9ba65bc..c40351aef4 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -202,16 +202,18 @@ instance ToJSON WebRTCExtraInfo where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions -data WebRTCCallStatus = WCSConnected | WCSDisconnected | WCSFailed +data WebRTCCallStatus = WCSConnecting | WCSConnected | WCSDisconnected | WCSFailed deriving (Show) instance StrEncoding WebRTCCallStatus where strEncode = \case + WCSConnecting -> "connecting" WCSConnected -> "connected" WCSDisconnected -> "disconnected" WCSFailed -> "failed" strP = A.takeTill (== ' ') >>= \case + "connecting" -> pure WCSConnecting "connected" -> pure WCSConnected "disconnected" -> pure WCSDisconnected "failed" -> pure WCSFailed From 2696086faa5dccd067567d27e66af7c714920caa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 25 May 2022 09:13:14 +0100 Subject: [PATCH 02/23] update webrtc npm package v0.0.5 --- packages/simplex-chat-webrtc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index 4d1e7a267d..057d2f7095 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/webrtc", - "version": "0.0.4", + "version": "0.0.5", "description": "WebRTC call in browser and webview for SimpleX Chat clients", "main": "dist/call.js", "types": "dist/call.d.ts", From 9e83b54b854f835c6187a26708a7099018c2d272 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 25 May 2022 14:28:04 +0100 Subject: [PATCH 03/23] android: update colors (#698) * android: update colors * update color --- .../app/src/main/java/chat/simplex/app/ui/theme/Color.kt | 6 ++++-- .../app/src/main/java/chat/simplex/app/ui/theme/Theme.kt | 7 +++---- .../app/src/main/java/chat/simplex/app/ui/theme/Type.kt | 2 +- .../chat/simplex/app/views/chatlist/ChatListNavLinkView.kt | 7 +++++-- .../chat/simplex/app/views/chatlist/ChatPreviewView.kt | 4 +++- .../simplex/app/views/chatlist/ContactConnectionView.kt | 6 ++++-- .../chat/simplex/app/views/chatlist/ContactRequestView.kt | 5 +++-- 7 files changed, 23 insertions(+), 14 deletions(-) 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 05e66ffb87..262279053e 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 @@ -12,9 +12,11 @@ val SimplexGreen = Color(98, 196, 103, 255) val SecretColor = Color(0x40808080) val LightGray = Color(241, 242, 246, 255) val DarkGray = Color(43, 44, 46, 255) -val HighOrLowlight = Color(134, 135, 139, 255) +val HighOrLowlight = Color(139, 135, 134, 255) +val MessagePreviewDark = Color(179, 175, 174, 255) +val MessagePreviewLight = Color(49, 45, 44, 255) val ToolbarLight = Color(220, 220, 220, 20) val ToolbarDark = Color(80, 80, 80, 20) -val WarningOrange = Color(255, 149, 0, 255) +val WarningOrange = Color(255, 127, 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/ui/theme/Theme.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt index 8b1ccfeded..a7ee4c9fb7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt @@ -3,6 +3,7 @@ package chat.simplex.app.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.* import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color private val DarkColorPalette = darkColors( primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files @@ -13,10 +14,8 @@ private val DarkColorPalette = darkColors( // background = Color(0xFF121212), // surface = Color(0xFF121212), // error = Color(0xFFCF6679), -// onPrimary = Color.Black, -// onSecondary = Color.Black, -// onBackground = Color.White, -// onSurface = Color.White, + onBackground = Color(0xFFFFFBFA), + onSurface = Color(0xFFFFFBFA), // onError: Color = Color.Black, ) private val LightColorPalette = lightColors( diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt index 680a6d3d89..fda4d6949b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt @@ -30,7 +30,7 @@ val Typography = Typography( h3 = TextStyle( fontFamily = Inter, fontWeight = FontWeight.Normal, - fontSize = 19.sp + fontSize = 18.5.sp ), body1 = TextStyle( fontFamily = Inter, 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 35faab717f..55f1931e6f 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 @@ -16,6 +16,7 @@ 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.ui.theme.WarningOrange import chat.simplex.app.views.chat.clearChatDialog import chat.simplex.app.views.chat.deleteContactDialog import chat.simplex.app.views.chat.item.ItemAction @@ -103,7 +104,8 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState Date: Fri, 27 May 2022 06:30:01 +0100 Subject: [PATCH 04/23] core: add timestamp to call invitation (#700) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/Messages.hs | 6 ++++++ src/Simplex/Chat/Store.hs | 3 --- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 046a5abfb0..95ab009c3f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1678,7 +1678,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- practically, this should not happen call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected Nothing - toView $ CRCallInvitation ct callType sharedKey + toView . CRCallInvitation ct callType sharedKey $ chatItemTs' ci toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci where saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) Nothing diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 331bb61e4e..b3bf5c53e9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -21,6 +21,7 @@ import Data.Int (Int64) import Data.Map.Strict (Map) import Data.Text (Text) import Data.Time (ZonedTime) +import Data.Time.Clock (UTCTime) import Data.Version (showVersion) import Data.Word (Word16) import GHC.Generics (Generic) @@ -259,7 +260,7 @@ data ChatResponse | CRPendingSubSummary {pendingSubStatus :: [PendingSubStatus]} | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} - | CRCallInvitation {contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key} + | CRCallInvitation {contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, callTs :: UTCTime} | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} | CRCallAnswer {contact :: Contact, answer :: WebRTCSession} | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 9d181dadbd..258873c39c 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -152,6 +152,12 @@ instance ToJSON (CChatItem c) where chatItemId' :: ChatItem c d -> ChatItemId chatItemId' ChatItem {meta = CIMeta {itemId}} = itemId +chatItemTs :: CChatItem c -> UTCTime +chatItemTs (CChatItem _ ci) = chatItemTs' ci + +chatItemTs' :: ChatItem c d -> UTCTime +chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs + data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 49851dbc8e..f1bd801c71 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2640,9 +2640,6 @@ getChatPreviews st user withPCC = ts (AChat _ Chat {chatInfo, chatItems = ci : _}) = max (chatItemTs ci) (chatInfoUpdatedAt chatInfo) ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo -chatItemTs :: CChatItem d -> UTCTime -chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs - getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] getDirectChatPreviews_ db User {userId} = do tz <- getCurrentTimeZone From 387aec859356286b88b6e329112da6c794763ff7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 27 May 2022 08:43:15 +0100 Subject: [PATCH 05/23] android: webrtc calls notification and alert (#699) * android: webrtc calls notification and alert * add ringtone to incoming call * incoming call on full screen * enable notification ringtone * remove text * use translated strings in call notification --- apps/android/app/src/main/AndroidManifest.xml | 4 + .../java/chat/simplex/app/MainActivity.kt | 29 ++- .../main/java/chat/simplex/app/SimplexApp.kt | 3 +- .../java/chat/simplex/app/model/ChatModel.kt | 24 +-- .../java/chat/simplex/app/model/NtfManager.kt | 124 ++++++----- .../java/chat/simplex/app/model/SimpleXAPI.kt | 75 +++---- .../java/chat/simplex/app/ui/theme/Color.kt | 4 +- .../simplex/app/views/call/CallManager.kt | 90 ++++++++ .../chat/simplex/app/views/call/CallView.kt | 34 ++- .../app/views/call/IncomingCallActivity.kt | 201 ++++++++++++++++++ .../app/views/call/IncomingCallAlertView.kt | 107 ++++++++++ .../simplex/app/views/call/SoundPlayer.kt | 39 ++++ .../chat/simplex/app/views/call/WebRTC.kt | 16 +- .../chat/simplex/app/views/chat/ChatView.kt | 10 +- .../{CICallViewItem.kt => CICallItemView.kt} | 7 +- .../app/views/helpers/ChatInfoImage.kt | 6 +- .../chat/simplex/app/views/helpers/Util.kt | 6 +- .../app/views/onboarding/SimpleXInfo.kt | 19 +- .../app/views/usersettings/SettingsView.kt | 28 +-- .../app/src/main/res/drawable-hdpi/icon.png | Bin 0 -> 8996 bytes .../app/src/main/res/raw/ring_once.mp3 | Bin 0 -> 72269 bytes .../app/src/main/res/values-ru/strings.xml | 10 +- .../app/src/main/res/values/strings.xml | 22 +- apps/ios/Shared/Views/Call/CallManager.swift | 4 +- apps/ios/Shared/Views/Call/WebRTCView.swift | 138 ++++++------ 25 files changed, 727 insertions(+), 273 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt rename apps/android/app/src/main/java/chat/simplex/app/views/chat/item/{CICallViewItem.kt => CICallItemView.kt} (96%) create mode 100644 apps/android/app/src/main/res/drawable-hdpi/icon.png create mode 100644 apps/android/app/src/main/res/raw/ring_once.mp3 diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 7cbf6ab6bd..d75a79b49f 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ + + + SplashView() - onboarding == OnboardingStage.OnboardingComplete && userCreated -> - if (chatModel.showCallView.value) ActiveCallView(chatModel) - else if (chatModel.chatId.value == null) ChatListView(chatModel) - else ChatView(chatModel) - onboarding == OnboardingStage.Step1_SimpleXInfo -> + onboarding == OnboardingStage.OnboardingComplete && userCreated -> { + Box { + if (chatModel.showCallView.value) ActiveCallView(chatModel) + else if (chatModel.chatId.value == null) ChatListView(chatModel) + else ChatView(chatModel) + + val invitation = chatModel.activeCallInvitation.value + if (invitation != null) IncomingCallAlertView(invitation, chatModel) + } + } onboarding == OnboardingStage.Step1_SimpleXInfo -> Box(Modifier.padding(horizontal = 20.dp)) { SimpleXInfo(chatModel, onboarding = true) } @@ -122,6 +129,16 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) { chatModel.chatId.value = null chatModel.clearOverlays.value = true } + NtfManager.AcceptCallAction -> { + val chatId = intent.getStringExtra("chatId") + Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId") + val invitation = chatModel.callInvitations[chatId] + if (invitation == null) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended)) + } else { + chatModel.callManager.acceptIncomingCall(invitation = invitation) + } + } } } 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 9559f9fd70..bdb8fb5d09 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 @@ -5,8 +5,7 @@ import android.net.LocalServerSocket import android.util.Log 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.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage import java.io.BufferedReader import java.io.InputStreamReader 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 7f7c8ec89c..824701dae4 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 @@ -8,8 +8,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* 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.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.onboarding.OnboardingStage @@ -41,11 +40,13 @@ class ChatModel(val controller: ChatController) { val runServiceInBackground = mutableStateOf(true) // current WebRTC call + val callManager = CallManager(this) val callInvitations = mutableStateMapOf() - val activeCallInvitation = mutableStateOf(null) + val activeCallInvitation = mutableStateOf(null) val activeCall = mutableStateOf(null) val callCommand = mutableStateOf(null) val showCallView = mutableStateOf(false) + val switchingCall = mutableStateOf(false) fun updateUserProfile(profile: Profile) { val user = currentUser.value @@ -457,10 +458,10 @@ class Connection(val connId: Long, val connStatus: ConnStatus) { @Serializable class Profile( - val displayName: String, - val fullName: String, - val image: String? = null - ) { + override val displayName: String, + override val fullName: String, + override val image: String? = null +): NamedChat { companion object { val sampleData = Profile( displayName = "alice", @@ -687,13 +688,6 @@ data class ChatItem ( if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName else null - val isMsgContent: Boolean get() = - when (content) { - is CIContent.SndMsgContent -> true - is CIContent.RcvMsgContent -> true - else -> false - } - val isDeletedContent: Boolean get() = when (content) { is CIContent.SndDeleted -> true @@ -1080,7 +1074,7 @@ enum class FormatColor(val color: String) { val uiColor: Color @Composable get() = when (this) { red -> Color.Red - green -> Color.Green + green -> SimplexGreen blue -> SimplexBlue yellow -> Color.Yellow cyan -> Color.Cyan 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 1b7aa8438d..0ba0004d71 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 @@ -1,14 +1,17 @@ package chat.simplex.app.model import android.app.* -import android.content.Context -import android.content.Intent +import android.content.* +import android.graphics.BitmapFactory +import android.media.AudioAttributes +import android.net.Uri import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import chat.simplex.app.* -import chat.simplex.app.views.call.CallInvitation -import chat.simplex.app.views.call.CallMediaType +import chat.simplex.app.views.call.* +import chat.simplex.app.views.helpers.base64ToBitmap +import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock class NtfManager(val context: Context) { @@ -17,6 +20,12 @@ class NtfManager(val context: Context) { const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION" const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT" const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS" + + // DO NOT change notification channel settings / names + const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION" + const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION" + const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL" + const val CallNotificationId: Int = -1 } private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -24,11 +33,22 @@ class NtfManager(val context: Context) { private val msgNtfTimeoutMs = 30000L init { - manager.createNotificationChannel(NotificationChannel( - MessageChannel, - "SimpleX Chat messages", - NotificationManager.IMPORTANCE_HIGH - )) + manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH)) + manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH)) + manager.createNotificationChannel(callNotificationChannel()) + } + + private fun callNotificationChannel(): NotificationChannel { + val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH) + val attrs = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build() + val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once) + Log.d(TAG,"callNotificationChannel sound: $soundUri") + callChannel.setSound(soundUri, attrs) + callChannel.enableVibration(true) + return callChannel } fun cancelNotificationsForChat(chatId: String) { @@ -58,7 +78,7 @@ class NtfManager(val context: Context) { .setSmallIcon(R.drawable.ntf_icon) .setColor(0x88FFFF) .setAutoCancel(true) - .setContentIntent(getMsgPendingIntent(cInfo.id)) + .setContentIntent(chatPendingIntent(OpenChatAction, cInfo.id)) .setSilent(recentNotification) .build() @@ -68,7 +88,7 @@ class NtfManager(val context: Context) { .setGroup(MessageGroup) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setGroupSummary(true) - .setContentIntent(getSummaryNtfIntent()) + .setContentIntent(chatPendingIntent(ShowChatsAction)) .build() with(NotificationManagerCompat.from(context)) { @@ -78,37 +98,51 @@ class NtfManager(val context: Context) { } } - fun notifyCallInvitation(contact: Contact, invitation: CallInvitation) { - Log.d(TAG, "notifyCallInvitationReceived ${contact.id}") - - val notification = NotificationCompat.Builder(context, MessageChannel) - .setContentTitle(contact.displayName) - .setContentText("Incoming ${invitation.peerMedia} call (${if (invitation.sharedKey == null) "not e2e encrypted" else "e2e encrypted"})") + fun notifyCallInvitation(invitation: CallInvitation) { + if (isAppOnForeground(context)) return + val contactId = invitation.contact.id + Log.d(TAG, "notifyCallInvitation $contactId") + val keyguardManager = getKeyguardManager(context) + val image = invitation.contact.image + var ntfBuilder = + if (keyguardManager.isDeviceLocked) { + val fullScreenIntent = Intent(context, IncomingCallActivity::class.java) + val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT) + NotificationCompat.Builder(context, LockScreenCallChannel) + .setFullScreenIntent(fullScreenPendingIntent, true) + .setSilent(true) + } else { + val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once) + NotificationCompat.Builder(context, CallChannel) + .setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id)) + .addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId)) + .setSound(soundUri) + } + val text = generalGetString( + if (invitation.peerMedia == CallMediaType.Video) { + if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call + } else { + if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call + } + ) + ntfBuilder = ntfBuilder + .setContentTitle(invitation.contact.displayName) + .setContentText(text) .setPriority(NotificationCompat.PRIORITY_HIGH) - .setGroup(MessageGroup) - .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + .setCategory(NotificationCompat.CATEGORY_CALL) .setSmallIcon(R.drawable.ntf_icon) + .setLargeIcon(if (image == null) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else base64ToBitmap(image)) .setColor(0x88FFFF) .setAutoCancel(true) - .setContentIntent(getMsgPendingIntent(contact.id)) - .setSilent(false) - .build() - -// val summary = NotificationCompat.Builder(context, MessageChannel) -// .setSmallIcon(R.drawable.ntf_icon) -// .setColor(0x88FFFF) -// .setGroup(MessageGroup) -// .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) -// .setGroupSummary(true) -// .setContentIntent(getSummaryNtfIntent()) -// .build() - with(NotificationManagerCompat.from(context)) { - notify(0, notification) -// notify(0, summary) + notify(CallNotificationId, ntfBuilder.build()) } } + fun cancelCallNotification() { + manager.cancel(CallNotificationId) + } + private fun hideSecrets(cItem: ChatItem) : String { val md = cItem.formattedText return if (md == null) { @@ -126,25 +160,13 @@ class NtfManager(val context: Context) { } } - private fun getMsgPendingIntent(chatId: String) : PendingIntent{ - Log.d(TAG, "getMsgPendingIntent $chatId") + private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent { + Log.d(TAG, "chatPendingIntent for $intentAction") val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt() - val intent = Intent(context, MainActivity::class.java) + var intent = Intent(context, MainActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - .putExtra("chatId", chatId) - .setAction(OpenChatAction) - return TaskStackBuilder.create(context).run { - addNextIntentWithParentStack(intent) - getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE) - } - } - - private fun getSummaryNtfIntent() : PendingIntent{ - Log.d(TAG, "getSummaryNtfIntent") - val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt() - val intent = Intent(context, MainActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) - .setAction(ShowChatsAction) + .setAction(intentAction) + if (chatId != null) intent = intent.putExtra("chatId", chatId) return TaskStackBuilder.create(context).run { addNextIntentWithParentStack(intent) getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE) 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 21f5722e0c..6c525e531a 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 @@ -23,8 +23,7 @@ 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.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* @@ -34,6 +33,18 @@ import kotlin.concurrent.thread typealias ChatCtrl = Long +fun isAppOnForeground(context: Context): Boolean { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val appProcesses = activityManager.runningAppProcesses ?: return false + val packageName = context.packageName + for (appProcess in appProcesses) { + if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) { + return true + } + } + return false +} + open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context) { var chatModel = ChatModel(this) private val sharedPreferences: SharedPreferences = appContext.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) @@ -69,22 +80,10 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager fun startReceiver() { Log.d(TAG, "ChatController startReceiver") thread(name="receiver") { - withApi { recvMspLoop() } + GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } } } } - open fun isAppOnForeground(context: Context): Boolean { - val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val appProcesses = activityManager.runningAppProcesses ?: return false - val packageName = context.packageName - for (appProcess in appProcesses) { - if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) { - return true - } - } - return false - } - suspend fun sendCmd(cmd: CC): CR { return withContext(Dispatchers.IO) { val c = cmd.cmdString @@ -454,7 +453,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } } - if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) { + if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(cInfo, cItem) } } @@ -501,36 +500,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } is CR.CallInvitation -> { - val invitation = CallInvitation(r.callType.media, r.sharedKey) - chatModel.callInvitations[r.contact.id] = invitation - if (chatModel.activeCallInvitation.value == null) { - chatModel.activeCallInvitation.value = ContactRef(r.contact.apiId, r.contact.localDisplayName) - } - ntfManager.notifyCallInvitation(r.contact, invitation) - AlertManager.shared.showAlertDialog( - title = invitation.callTitle, - text = String.format(generalGetString(R.string.contact_wants_to_connect_via_call), r.contact.displayName) + " " + invitation.callTypeText + ".", - confirmText = generalGetString(R.string.answer), - onConfirm = { - if (chatModel.activeCallInvitation.value == null) { - AlertManager.shared.hideAlert() - withApi { AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended)) } - } else { - chatModel.activeCallInvitation.value = null - chatModel.activeCall.value = Call( - contact = r.contact, - callState = CallState.InvitationReceived, - localMedia = invitation.peerMedia, - sharedKey = invitation.sharedKey - ) - chatModel.callCommand.value = WCallCommand.Start(media = invitation.peerMedia, aesKey = invitation.sharedKey) - chatModel.showCallView.value = true - } - }, - onDismiss = { - chatModel.activeCallInvitation.value = null - } - ) + val invitation = CallInvitation(r.contact, r.callType.media, r.sharedKey) + chatModel.callManager.reportNewIncomingCall(invitation) } is CR.CallOffer -> { // TODO askConfirmation? @@ -542,7 +513,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } is CR.CallAnswer -> { withCall(r, r.contact) { call -> - chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived) chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates) } } @@ -552,15 +523,17 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } is CR.CallEnded -> { - withCall(r, r.contact) { _ -> + val invitation = chatModel.callInvitations.remove(r.contact.id) + if (invitation != null) { + chatModel.callManager.reportCallRemoteEnded(invitation = invitation) + } + withCall(r, r.contact) { call -> chatModel.callCommand.value = WCallCommand.End withApi { chatModel.activeCall.value = null - chatModel.callCommand.value = null + chatModel.showCallView.value = false } } - chatModel.activeCallInvitation.value = null - chatModel.showCallView.value = false } else -> Log.d(TAG , "unsupported event: ${r.responseType}") 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 262279053e..14e7f20054 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 @@ -8,7 +8,7 @@ val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) val Gray = Color(0x22222222) val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files -val SimplexGreen = Color(98, 196, 103, 255) +val SimplexGreen = Color(77, 218, 103, 255) val SecretColor = Color(0x40808080) val LightGray = Color(241, 242, 246, 255) val DarkGray = Color(43, 44, 46, 255) @@ -17,6 +17,8 @@ val MessagePreviewDark = Color(179, 175, 174, 255) val MessagePreviewLight = Color(49, 45, 44, 255) val ToolbarLight = Color(220, 220, 220, 20) val ToolbarDark = Color(80, 80, 80, 20) +val IncomingCallLight = Color(239, 237, 236, 255) +val IncomingCallDark = Color(34, 30, 29, 255) val WarningOrange = Color(255, 127, 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/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt new file mode 100644 index 0000000000..13bad493b1 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt @@ -0,0 +1,90 @@ +package chat.simplex.app.views.call + +import android.util.Log +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.views.helpers.withApi + +class CallManager(val chatModel: ChatModel) { + fun reportNewIncomingCall(invitation: CallInvitation) { + Log.d(TAG, "CallManager.reportNewIncomingCall") + with (chatModel) { + callInvitations[invitation.contact.id] = invitation + activeCallInvitation.value = invitation + controller.ntfManager.notifyCallInvitation(invitation) + } + } + + fun acceptIncomingCall(invitation: CallInvitation) { + val call = chatModel.activeCall.value + if (call == null) { + justAcceptIncomingCall(invitation = invitation) + } else { + withApi { + chatModel.switchingCall.value = true + try { + endCall(call = call) + justAcceptIncomingCall(invitation = invitation) + } finally { + withApi { chatModel.switchingCall.value = false } + } + } + } + } + + private fun justAcceptIncomingCall(invitation: CallInvitation) { + with (chatModel) { + activeCall.value = Call( + contact = invitation.contact, + callState = CallState.InvitationAccepted, + localMedia = invitation.peerMedia, + sharedKey = invitation.sharedKey + ) + showCallView.value = true + callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey) + callInvitations.remove(invitation.contact.id) + if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { + activeCallInvitation.value = null + controller.ntfManager.cancelCallNotification() + } + } + } + + suspend fun endCall(call: Call) { + with (chatModel) { + if (call.callState == CallState.Ended) { + Log.d(TAG, "CallManager.endCall: call ended") + activeCall.value = null + showCallView.value = false + } else { + Log.d(TAG, "CallManager.endCall: ending call...") + callCommand.value = WCallCommand.End + showCallView.value = false + controller.apiEndCall(call.contact) + activeCall.value = null + } + } + } + + fun endCall(invitation: CallInvitation) { + with (chatModel) { + callInvitations.remove(invitation.contact.id) + if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { + activeCallInvitation.value = null + controller.ntfManager.cancelCallNotification() + } + withApi { + if (!controller.apiRejectCall(invitation.contact)) { + Log.e(TAG, "apiRejectCall error") + } + } + } + } + + fun reportCallRemoteEnded(invitation: CallInvitation) { + if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) { + chatModel.activeCallInvitation.value = null + chatModel.controller.ntfManager.cancelCallNotification() + } + } +} \ 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 index 57a816eb3c..391ca33061 100644 --- 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 @@ -1,20 +1,12 @@ package chat.simplex.app.views.call import android.Manifest -import android.content.ClipData -import android.content.ClipboardManager -import android.graphics.fonts.FontStyle -import android.os.Build -import android.service.controls.templates.ControlButton import android.util.Log import android.view.ViewGroup import android.webkit.* import androidx.activity.compose.BackHandler -import androidx.annotation.RequiresApi import androidx.annotation.StringRes -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.magnifier import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -28,11 +20,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.capitalize import androidx.compose.ui.tooling.preview.Preview 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 @@ -41,7 +31,6 @@ import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.chat.ChatInfoLayout import chat.simplex.app.views.helpers.* import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.coroutines.delay @@ -94,15 +83,25 @@ fun ActiveCallView(chatModel: ChatModel) { is WCallResponse.Connected -> { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) } - is WCallResponse.Ended -> endCall() + is WCallResponse.Ended -> { + chatModel.activeCall.value = call.copy(callState = CallState.Ended) + endCall() + } is WCallResponse.Ok -> when (val cmd = apiMsg.command) { + is WCallCommand.Answer -> + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) is WCallCommand.Media -> { when (cmd.media) { CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable) CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable) } } - is WCallCommand.Camera -> chatModel.activeCall.value = call.copy(localCamera = cmd.camera) + is WCallCommand.Camera -> { + chatModel.activeCall.value = call.copy(localCamera = cmd.camera) + if (!call.audioEnabled) { + chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false) + } + } is WCallCommand.End -> endCall() else -> {} } @@ -153,10 +152,11 @@ private fun ActiveCallOverlayLayout( IconButton(onClick = dismiss) { Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp)) } - ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera) if (call.videoEnabled) { + ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera) ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo) } else { + Spacer(Modifier.size(40.dp)) ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo) } } @@ -208,7 +208,7 @@ private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) { } @Composable -private fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { +fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { @Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) = Text(text, color = Color(0xFFFFFFD8), style = style) Column(horizontalAlignment = alignment) { @@ -273,8 +273,6 @@ private fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { //} @Composable -// for debugging -// fun WebRTCView(callCommand: MutableState, onResponse: (String) -> Unit) { fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { val webView = remember { mutableStateOf(null) } val permissionsState = rememberMultiplePermissionsState( @@ -359,8 +357,6 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa } } } - } else { - Text("NEED PERMISSIONS") } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt new file mode 100644 index 0000000000..0226a4dacd --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt @@ -0,0 +1,201 @@ +package chat.simplex.app.views.call + +import android.app.KeyguardManager +import android.content.* +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.background +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.filled.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +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.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.* +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.Contact +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.ProfileImage +import chat.simplex.app.views.onboarding.SimpleXLogo + +class IncomingCallActivity: ComponentActivity() { + private val vm by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val activity = this + setContent { IncomingCallActivityView(vm.chatModel, activity) } + unlockForIncomingCall() + } + + override fun onDestroy() { + super.onDestroy() + lockAfterIncomingCall() + } + + private fun unlockForIncomingCall() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + window.addFlags(activityFlags) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + getKeyguardManager(this).requestDismissKeyguard(this, null) + } + } + + private fun lockAfterIncomingCall() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(false) + setTurnScreenOn(false) + } else { + window.clearFlags(activityFlags) + } + } + + companion object { + const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + } +} + +fun getKeyguardManager(context: Context): KeyguardManager = + context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + +@Composable +fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) { + val switchingCall = m.switchingCall.value + val invitation = m.activeCallInvitation.value + val call = m.activeCall.value + val showCallView = m.showCallView.value + LaunchedEffect(invitation, call, switchingCall, showCallView) { + if (!switchingCall && invitation == null && (!showCallView || call == null)) { + Log.d(TAG, "IncomingCallActivityView: finishing activity") + activity.finish() + } + } + SimpleXTheme(darkTheme = true) { + Surface( + Modifier + .background(MaterialTheme.colors.background) + .fillMaxSize()) { + if (showCallView) { + Box { + ActiveCallView(m) + if (invitation != null) IncomingCallAlertView(invitation, m) + } + } else if (invitation != null) { + IncomingCallLockScreenAlert(invitation, m) + } + } + } +} + +@Composable +fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel) { + val cm = chatModel.callManager + val cxt = LocalContext.current + val scope = rememberCoroutineScope() + LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) } + DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } } + IncomingCallLockScreenAlertLayout( + invitation, + rejectCall = { cm.endCall(invitation = invitation) }, + ignoreCall = { chatModel.activeCallInvitation.value = null }, + acceptCall = { cm.acceptIncomingCall(invitation = invitation) } + ) +} + +@Composable +fun IncomingCallLockScreenAlertLayout( + invitation: CallInvitation, + rejectCall: () -> Unit, + ignoreCall: () -> Unit, + acceptCall: () -> Unit +) { + Column( + Modifier + .padding(30.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + IncomingCallInfo(invitation) + Spacer( + Modifier + .fillMaxHeight() + .weight(1f)) + ProfileImage(size = 192.dp, image = invitation.contact.profile.image) + Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2) + Spacer( + Modifier + .fillMaxHeight() + .weight(1f)) + Row { + LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall) + Spacer(Modifier.size(48.dp)) + LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall) + Spacer(Modifier.size(48.dp)) + LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall) + } + } +} + +@Composable +private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) { + Surface( + shape = RoundedCornerShape(10.dp), + color = Color.Transparent + ) { + Column( + Modifier.defaultMinSize(minWidth = 50.dp).padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + IconButton(action) { + Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f)) + } + Spacer(Modifier.height(16.dp)) + Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight) + } + } +} + +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true +) +@Composable +fun PreviewIncomingCallLockScreenAlert() { + SimpleXTheme(true) { + Surface(Modifier.background(MaterialTheme.colors.background).fillMaxSize()) { + IncomingCallLockScreenAlertLayout( + invitation = CallInvitation( + contact = Contact.sampleData, + peerMedia = CallMediaType.Audio, + sharedKey = null + ), + rejectCall = {}, + ignoreCall = {}, + acceptCall = {} + ) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt new file mode 100644 index 0000000000..269381281e --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt @@ -0,0 +1,107 @@ +package chat.simplex.app.views.call + +import androidx.annotation.StringRes +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.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +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.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.Contact +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.usersettings.ProfilePreview + +@Composable +fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) { + val cm = chatModel.callManager + val cxt = LocalContext.current + val scope = rememberCoroutineScope() + LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = !chatModel.showCallView.value) } + DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } } + IncomingCallAlertLayout( + invitation, + rejectCall = { cm.endCall(invitation = invitation) }, + ignoreCall = { chatModel.activeCallInvitation.value = null }, + acceptCall = { cm.acceptIncomingCall(invitation = invitation) } + ) +} + +@Composable +fun IncomingCallAlertLayout( + invitation: CallInvitation, + rejectCall: () -> Unit, + ignoreCall: () -> Unit, + acceptCall: () -> Unit +) { + val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight + Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) { + IncomingCallInfo(invitation) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White) + Spacer(Modifier.fillMaxWidth().weight(1f)) + CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall) + CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall) + CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall) + } + } +} + +@Composable +fun IncomingCallInfo(invitation: CallInvitation) { + @Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen) + Row { + if (invitation.peerMedia == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call)) + else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call)) + Spacer(Modifier.width(4.dp)) + Text(invitation.callTypeText) + } +} + +@Composable +private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) { + Surface( + shape = RoundedCornerShape(10.dp), + color = Color.Transparent + ) { + Column( + Modifier + .clickable(onClick = action) + .defaultMinSize(minWidth = 50.dp) + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f)) + Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight) + } + } +} + +@Preview +@Composable +fun PreviewIncomingCallAlertLayout() { + SimpleXTheme { + IncomingCallAlertLayout( + invitation = CallInvitation( + contact = Contact.sampleData, + peerMedia = CallMediaType.Audio, + sharedKey = null + ), + rejectCall = {}, + ignoreCall = {}, + acceptCall = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt new file mode 100644 index 0000000000..f451330a7a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt @@ -0,0 +1,39 @@ +package chat.simplex.app.views.call + +import android.content.Context +import android.media.MediaPlayer +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.core.content.ContextCompat +import chat.simplex.app.R +import chat.simplex.app.views.helpers.withScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay + +class SoundPlayer { + var player: MediaPlayer? = null + var playing = false + + fun start(cxt: Context, scope: CoroutineScope, sound: Boolean) { + if (sound) player = MediaPlayer.create(cxt, R.raw.ring_once) + val vibrator = ContextCompat.getSystemService(cxt, Vibrator::class.java) + val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE) + playing = true + withScope(scope) { + while (playing) { + if (sound) player?.start() + vibrator?.vibrate(effect) + delay(3500) + } + } + } + + fun stop() { + playing = false + player?.stop() + } + + companion object { + val shared = SoundPlayer() + } +} \ No newline at end of file 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 index a415710319..f2a8173bf7 100644 --- 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 @@ -26,7 +26,7 @@ data class Call( val encryptionStatus: String @Composable get() = when(callState) { CallState.WaitCapabilities -> "" CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption) - CallState.InvitationReceived -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption) + CallState.InvitationAccepted -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption) else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted) } @@ -36,20 +36,24 @@ data class Call( enum class CallState { WaitCapabilities, InvitationSent, - InvitationReceived, + InvitationAccepted, OfferSent, OfferReceived, + AnswerReceived, Negotiated, - Connected; + Connected, + Ended; val text: String @Composable get() = when(this) { WaitCapabilities -> stringResource(R.string.callstate_starting) InvitationSent -> stringResource(R.string.callstate_waiting_for_answer) - InvitationReceived -> stringResource(R.string.callstate_starting) + InvitationAccepted -> stringResource(R.string.callstate_starting) OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation) OfferReceived -> stringResource(R.string.callstate_received_answer) + AnswerReceived -> stringResource(R.string.callstate_received_confirmation) Negotiated -> stringResource(R.string.callstate_connecting) Connected -> stringResource(R.string.callstate_connected) + Ended -> stringResource(R.string.callstate_ended) } } @@ -85,7 +89,7 @@ sealed class WCallResponse { @Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) @Serializable class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) -@Serializable class CallInvitation(val peerMedia: CallMediaType, val sharedKey: String?) { +@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?) { val callTypeText: String get() = generalGetString(when(peerMedia) { CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call @@ -96,7 +100,7 @@ sealed class WCallResponse { }) } @Serializable class CallCapabilities(val encryption: Boolean) -@Serializable class ConnectionInfo(val localCandidate: RTCIceCandidate?, val remoteCandidate: RTCIceCandidate) { +@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { val text: String @Composable get() = when { localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host -> stringResource(R.string.call_connection_peer_to_peer) 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 ef1c52209b..2a9825bd1c 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 @@ -126,15 +126,7 @@ fun ChatView(chatModel: ChatModel) { if (invitation == null) { AlertManager.shared.showAlertMsg("Call already ended!") } else { - chatModel.activeCallInvitation.value = null - chatModel.activeCall.value = Call( - contact = contact, - callState = CallState.InvitationReceived, - localMedia = invitation.peerMedia, - sharedKey = invitation.sharedKey - ) - chatModel.showCallView.value = true - chatModel.callCommand.value = WCallCommand.Start(media = invitation.peerMedia, aesKey = invitation.sharedKey) + chatModel.callManager.acceptIncomingCall(invitation = invitation) } } ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallViewItem.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt similarity index 96% rename from apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallViewItem.kt rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt index a2c0e166c0..65e85e0b75 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallViewItem.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt @@ -15,8 +15,7 @@ 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.SimpleButton +import chat.simplex.app.ui.theme.* @Composable fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) { @@ -33,10 +32,10 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat AcceptCallButton(cInfo, acceptCall) } CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red) - CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = HighOrLowlight) + CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red) CICallStatus.Accepted -> ConnectingCallIcon() CICallStatus.Negotiated -> ConnectingCallIcon() - CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = Color.Green) + CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen) CICallStatus.Ended -> Row { Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp)) Text(status.duration(duration), color = HighOrLowlight) 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 b19f17a2be..58310a2856 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 @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.SupervisedUserCircle import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale @@ -34,14 +35,15 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) { fun ProfileImage( size: Dp, image: String? = null, - icon: ImageVector = Icons.Filled.AccountCircle + icon: ImageVector = Icons.Filled.AccountCircle, + color: Color = MaterialTheme.colors.secondary ) { Box(Modifier.size(size)) { if (image == null) { Icon( icon, contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder), - tint = MaterialTheme.colors.secondary, + tint = color, modifier = Modifier.fillMaxSize() ) } else { 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 478bc7b5fb..2235a82cbc 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 @@ -34,8 +34,10 @@ import java.util.* import kotlin.math.log2 import kotlin.math.pow -fun withApi(action: suspend CoroutineScope.() -> Unit): Job = - GlobalScope.launch { withContext(Dispatchers.Main, action) } +fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action) + +fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job = + scope.launch { withContext(Dispatchers.Main, action) } enum class KeyboardState { Opened, Closed 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 index 3825954bbc..8c3f467aac 100644 --- 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 @@ -40,13 +40,7 @@ fun SimpleXInfoLayout( 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) - ) + SimpleXLogo() Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp)) @@ -79,6 +73,17 @@ fun SimpleXInfoLayout( } } +@Composable +fun SimpleXLogo() { + Image( + painter = painterResource(R.drawable.logo), + contentDescription = stringResource(R.string.image_descr_simplex_logo), + modifier = Modifier + .padding(vertical = 20.dp) + .fillMaxWidth(0.80f) + ) +} + @Composable private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) { Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) { 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 9ab041d283..6431988fa4 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 @@ -10,6 +10,7 @@ import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -19,8 +20,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.app.BuildConfig import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.Profile +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.TerminalView @@ -84,16 +84,7 @@ fun SettingsLayout( Spacer(Modifier.height(30.dp)) SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) { - ProfileImage(size = 60.dp, profile.image) - Spacer(Modifier.padding(horizontal = 4.dp)) - Column { - Text( - profile.displayName, - style = MaterialTheme.typography.caption, - fontWeight = FontWeight.Bold, - ) - Text(profile.fullName) - } + ProfilePreview(profile) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showModal { UserAddressView(it) }) { @@ -215,6 +206,19 @@ fun SettingsLayout( } } +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary) { + ProfileImage(size = size, image = profileOf.image, color = color) + Spacer(Modifier.padding(horizontal = 4.dp)) + Column { + Text( + profileOf.displayName, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + ) + Text(profileOf.fullName) + } +} + @Composable fun SettingsSectionView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) { val modifier = Modifier diff --git a/apps/android/app/src/main/res/drawable-hdpi/icon.png b/apps/android/app/src/main/res/drawable-hdpi/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9c6efe05895c97dff5a1dac719d9296a3f432d44 GIT binary patch literal 8996 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 literal 0 HcmV?d00001 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 624094c39d..5b665e6a1b 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -294,18 +294,20 @@ пропущенный звонок отклоненный звонок принятый звонок - соединяется… + звонок соединяется… активный звонок звонок завершён %1$s - ошибка соединения + ошибка звонка инициализация… ожидается ответ… ожидается подтверждение… получен ответ… + получено подтверждение… соединяется… соединено + завершен Новое поколение приватных сообщений @@ -348,7 +350,9 @@ e2e зашифрованный видеозвонок аудиозвонок (не e2e зашифрованный) e2e зашифрованный аудиозвонок - Принять + Принять + Отклонить + Закрыть Звонок уже завершен! видеозвонок аудиозвонок diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 3996718c6a..0c96aff28f 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -290,23 +290,25 @@ colored secret - + calling… - missed - rejected - accepted - connecting… - in progress - ended %1$s - error + missed call + rejected call + accepted call + connecting call… + call in progress + call ended %1$s + call error starting… waiting for answer… waiting for confirmation… received answer… + received confirmation… connecting… connected + ended The next generation of private messaging @@ -349,7 +351,9 @@ e2e encrypted video call audio call (not e2e encrypted) e2e encrypted audio call - Answer + Accept + Reject + Ignore Call already ended! video call audio call diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index f22535e9ad..19d049e80d 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -61,12 +61,12 @@ class CallManager { func endCall(call: Call, completed: @escaping () -> Void) { let m = ChatModel.shared if case .ended = call.callState { - logger.debug("CallController.provider CXEndCallAction: call ended") + logger.debug("CallManager.endCall: call ended") m.activeCall = nil m.showCallView = false completed() } else { - logger.debug("CallController.provider CXEndCallAction: ending call...") + logger.debug("CallManager.endCall: ending call...") m.callCommand = .end m.showCallView = false Task { diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift index 428ef130b5..93a61c3f4d 100644 --- a/apps/ios/Shared/Views/Call/WebRTCView.swift +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -111,78 +111,72 @@ func sendCallCommand(_ webView: WKWebView, _ command: WCallCommand) { webView.evaluateJavaScript(js) } -struct CallViewDebug: View { - @State private var commandStr = "" - @State private var rtcWebView: WKWebView? = nil - @State private var webViewMsg: WVAPIMessage? = nil - @FocusState private var keyboardVisible: Bool - - var body: some View { - VStack(spacing: 30) { - WebRTCView(rtcWebView: $rtcWebView, 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 wv = rtcWebView, - let command: WCallCommand = decodeJSON(commandStr) { - sendCallCommand(wv, command) - } - } - } - HStack(spacing: 20) { - Button("Capabilities") { - if let wv = rtcWebView { - sendCallCommand(wv, .capabilities(useWorker: true)) - } - } - Button("Start") { - if let wv = rtcWebView { - sendCallCommand(wv, .start(media: .video)) - } - } - Button("Accept") { - - } - Button("Answer") { - - } - Button("ICE") { - - } - Button("End") { - - } - } - } - } -} +//struct CallViewDebug: View { +// @State private var commandStr = "" +// @State private var rtcWebView: WKWebView? = nil +// @State private var webViewMsg: WVAPIMessage? = nil +// @FocusState private var keyboardVisible: Bool // -//struct CallViewDebug_Previews: PreviewProvider { -// static var previews: some View { -// CallViewDebug() +// var body: some View { +// VStack(spacing: 30) { +// WebRTCView(rtcWebView: $rtcWebView, 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 wv = rtcWebView, +// let command: WCallCommand = decodeJSON(commandStr) { +// sendCallCommand(wv, command) +// } +// } +// } +// HStack(spacing: 20) { +// Button("Capabilities") { +// if let wv = rtcWebView { +// sendCallCommand(wv, .capabilities(useWorker: true)) +// } +// } +// Button("Start") { +// if let wv = rtcWebView { +// sendCallCommand(wv, .start(media: .video)) +// } +// } +// Button("Accept") { +// +// } +// Button("Answer") { +// +// } +// Button("ICE") { +// +// } +// Button("End") { +// +// } +// } +// } // } //} From 79d9e90ab73d46ef2d8cbceccc94fdf8273502b5 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 27 May 2022 18:21:35 +0400 Subject: [PATCH 06/23] mobile: local authentication (#696) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/android/app/build.gradle | 4 + .../java/chat/simplex/app/MainActivity.kt | 171 ++++++++++++++++-- .../java/chat/simplex/app/model/ChatModel.kt | 4 + .../java/chat/simplex/app/model/SimpleXAPI.kt | 61 +++++++ .../app/views/chatlist/ChatListNavLinkView.kt | 7 +- .../app/views/chatlist/ChatListView.kt | 41 +---- .../app/views/helpers/LocalAuthentication.kt | 112 ++++++++++++ .../app/views/usersettings/SettingsView.kt | 57 ++++-- .../app/src/main/res/values-ru/strings.xml | 26 ++- .../app/src/main/res/values/strings.xml | 23 ++- apps/ios/Shared/ContentView.swift | 4 +- apps/ios/Shared/SimpleXApp.swift | 47 ++++- .../Helpers/LocalAuthenticationUtils.swift | 51 ++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 10 + 14 files changed, 540 insertions(+), 78 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt create mode 100644 apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 2fd984a2bc..d6487da9c8 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -76,6 +76,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' implementation 'androidx.lifecycle:lifecycle-process:2.4.1' implementation 'androidx.activity:activity-compose:1.4.0' + implementation 'androidx.fragment:fragment:1.4.1' implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2' implementation "androidx.compose.material:material-icons-extended:$compose_version" @@ -103,6 +104,9 @@ dependencies { // Link Previews implementation 'org.jsoup:jsoup:1.13.1' + // Biometric authentication + implementation 'androidx.biometric:biometric:1.2.0-alpha04' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.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 bfc2160398..797e51efd4 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 @@ -5,18 +5,17 @@ import android.content.* import android.net.Uri import android.os.Bundle import android.util.Log -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.AndroidViewModel +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.ChatModel import chat.simplex.app.model.NtfManager @@ -31,18 +30,21 @@ 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 kotlinx.coroutines.delay import java.util.concurrent.TimeUnit -//import kotlinx.serialization.decodeFromString - -class MainActivity: ComponentActivity() { +class MainActivity: FragmentActivity(), LifecycleEventObserver { private val vm by viewModels() private val chatController by lazy { (application as SimplexApp).chatController } + private val userAuthorized = mutableStateOf(null) + private val lastLA = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + ProcessLifecycleOwner.get().lifecycle.addObserver(this) // testJson() - processNotificationIntent(intent, vm.chatModel) + val m = vm.chatModel + processNotificationIntent(intent, m) setContent { SimpleXTheme { Surface( @@ -50,7 +52,7 @@ class MainActivity: ComponentActivity() { .background(MaterialTheme.colors.background) .fillMaxSize() ) { - MainPage(vm.chatModel) + MainPage(m, userAuthorized, ::setPerformLA, showLANotice = { m.controller.showLANotice(this) }) } } } @@ -62,6 +64,47 @@ class MainActivity: ComponentActivity() { processIntent(intent, vm.chatModel) } + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + withApi { + when (event) { + Lifecycle.Event.ON_START -> { + // perform local authentication if needed + val m = vm.chatModel + val lastLAVal = lastLA.value + if ( + m.controller.getPerformLA() + && (lastLAVal == null || (System.nanoTime() - lastLAVal >= 30 * 1e+9)) + ) { + userAuthorized.value = false + authenticate( + generalGetString(R.string.auth_access_chats), + generalGetString(R.string.auth_log_in_using_credential), + this@MainActivity, + completed = { laResult -> + when (laResult) { + LAResult.Success -> { + userAuthorized.value = true + lastLA.value = System.nanoTime() + } + is LAResult.Error -> laErrorToast(applicationContext, laResult.errString) + LAResult.Failed -> laFailedToast(applicationContext) + LAResult.Unavailable -> { + userAuthorized.value = true + m.performLA.value = false + m.controller.setPerformLA(false) + laUnavailableTurningOffAlert() + } + } + } + ) + } else { + userAuthorized.value = true + } + } + } + } + } + private fun schedulePeriodicServiceRestartWorker() { val workerVersion = chatController.getAutoRestartWorkerVersion() val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) { @@ -79,6 +122,73 @@ class MainActivity: ComponentActivity() { Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes") WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work) } + + private fun setPerformLA(on: Boolean) { + val m = vm.chatModel + if (on) { + m.controller.setLANoticeShown(true) + authenticate( + generalGetString(R.string.auth_enable), + generalGetString(R.string.auth_confirm_credential), + this@MainActivity, + completed = { laResult -> + when (laResult) { + LAResult.Success -> { + m.performLA.value = true + m.controller.setPerformLA(true) + userAuthorized.value = true + lastLA.value = System.nanoTime() + laTurnedOnAlert() + } + is LAResult.Error -> { + m.performLA.value = false + m.controller.setPerformLA(false) + laErrorToast(applicationContext, laResult.errString) + } + LAResult.Failed -> { + m.performLA.value = false + m.controller.setPerformLA(false) + laFailedToast(applicationContext) + } + LAResult.Unavailable -> { + m.performLA.value = false + m.controller.setPerformLA(false) + laUnavailableInstructionAlert() + } + } + } + ) + } else { + authenticate( + generalGetString(R.string.auth_disable), + generalGetString(R.string.auth_confirm_credential), + this@MainActivity, + completed = { laResult -> + when (laResult) { + LAResult.Success -> { + m.performLA.value = false + m.controller.setPerformLA(false) + } + is LAResult.Error -> { + m.performLA.value = true + m.controller.setPerformLA(true) + laErrorToast(applicationContext, laResult.errString) + } + LAResult.Failed -> { + m.performLA.value = true + m.controller.setPerformLA(true) + laFailedToast(applicationContext) + } + LAResult.Unavailable -> { + m.performLA.value = false + m.controller.setPerformLA(false) + laUnavailableTurningOffAlert() + } + } + } + ) + } + } } class SimplexViewModel(application: Application): AndroidViewModel(application) { @@ -87,22 +197,54 @@ class SimplexViewModel(application: Application): AndroidViewModel(application) } @Composable -fun MainPage(chatModel: ChatModel) { +fun MainPage( + chatModel: ChatModel, + userAuthorized: MutableState, + setPerformLA: (Boolean) -> Unit, + showLANotice: () -> Unit +) { + // this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication + var chatsAccessAuthorized by remember { mutableStateOf(false) } + LaunchedEffect(userAuthorized.value) { + delay(500L) + chatsAccessAuthorized = userAuthorized.value == true + } + var showAdvertiseLAAlert by remember { mutableStateOf(false) } + LaunchedEffect(showAdvertiseLAAlert) { + if ( + !chatModel.controller.getLANoticeShown() + && showAdvertiseLAAlert + && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete + && chatModel.chats.isNotEmpty() + && chatModel.activeCallInvitation.value == null + ) { + showLANotice() + } + } + LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) { + if (chatModel.showAdvertiseLAUnavailableAlert.value) { + laUnavailableInstructionAlert() + } + } Box { val onboarding = chatModel.onboardingStage.value val userCreated = chatModel.userCreated.value when { onboarding == null || userCreated == null -> SplashView() + !chatsAccessAuthorized -> SplashView() onboarding == OnboardingStage.OnboardingComplete && userCreated -> { Box { if (chatModel.showCallView.value) ActiveCallView(chatModel) - else if (chatModel.chatId.value == null) ChatListView(chatModel) - else ChatView(chatModel) - + else { + showAdvertiseLAAlert = true + if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) }) + else ChatView(chatModel) + } val invitation = chatModel.activeCallInvitation.value if (invitation != null) IncomingCallAlertView(invitation, chatModel) } - } onboarding == OnboardingStage.Step1_SimpleXInfo -> + } + onboarding == OnboardingStage.Step1_SimpleXInfo -> Box(Modifier.padding(horizontal = 20.dp)) { SimpleXInfo(chatModel, onboarding = true) } @@ -180,7 +322,6 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) { } } } - //fun testJson() { // val str: String = """ // """.trimIndent() 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 824701dae4..02a475e751 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 @@ -37,7 +37,11 @@ class ChatModel(val controller: ChatController) { // set when app is opened via contact or invitation URI val appOpenUrl = mutableStateOf(null) + + // preferences val runServiceInBackground = mutableStateOf(true) + val performLA = mutableStateOf(false) + val showAdvertiseLAUnavailableAlert = mutableStateOf(false) // current WebRTC call val callManager = CallManager(this) 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 6c525e531a..58607d7700 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.views.call.* @@ -51,6 +52,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager init { chatModel.runServiceInBackground.value = getRunServiceInBackground() + chatModel.performLA.value = getPerformLA() } suspend fun startChat(user: User) { @@ -691,6 +693,49 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager ) } + fun showLANotice(activity: FragmentActivity) { + Log.d(TAG, "showLANotice") + if (!getLANoticeShown()) { + setLANoticeShown(true) + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.la_notice_title), + text = generalGetString(R.string.la_notice_text), + confirmText = generalGetString(R.string.la_notice_turn_on), + onConfirm = { + authenticate( + generalGetString(R.string.auth_enable), + generalGetString(R.string.auth_confirm_credential), + activity, + completed = { laResult -> + when (laResult) { + LAResult.Success -> { + chatModel.performLA.value = true + setPerformLA(true) + laTurnedOnAlert() + } + is LAResult.Error -> { + chatModel.performLA.value = false + setPerformLA(false) + laErrorToast(appContext, laResult.errString) + } + LAResult.Failed -> { + chatModel.performLA.value = false + setPerformLA(false) + laFailedToast(appContext) + } + LAResult.Unavailable -> { + chatModel.performLA.value = false + setPerformLA(false) + chatModel.showAdvertiseLAUnavailableAlert.value = true + } + } + } + ) + } + ) + } + } + fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) fun setAutoRestartWorkerVersion(version: Int) = @@ -738,12 +783,28 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } + fun getPerformLA(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_PERFORM_LA, false) + + fun setPerformLA(performLA: Boolean) = + sharedPreferences.edit() + .putBoolean(SHARED_PREFS_PERFORM_LA, performLA) + .apply() + + fun getLANoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_LA_NOTICE_SHOWN, false) + + fun setLANoticeShown(shown: Boolean) = + sharedPreferences.edit() + .putBoolean(SHARED_PREFS_LA_NOTICE_SHOWN, shown) + .apply() + companion object { private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" + private const val SHARED_PREFS_PERFORM_LA = "PerformLA" + private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown" } } 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 55f1931e6f..10f0fa17e9 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 @@ -22,7 +22,6 @@ import chat.simplex.app.views.chat.deleteContactDialog import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.datetime.Clock @Composable @@ -31,10 +30,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { var showMarkRead by remember { mutableStateOf(false) } LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) { showMenu.value = false - launch { - delay(500L) - showMarkRead = chat.chatStats.unreadCount > 0 - } + delay(500L) + showMarkRead = chat.chatStats.unreadCount > 0 } when (chat.chatInfo) { is ChatInfo.Direct -> 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 c207f3de46..4ac07f6dd4 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 @@ -64,7 +64,7 @@ fun scaffoldController(): ScaffoldController { } @Composable -fun ChatListView(chatModel: ChatModel) { +fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val scaffoldCtrl = scaffoldController() if (chatModel.clearOverlays.value) { scaffoldCtrl.collapse() @@ -73,7 +73,7 @@ fun ChatListView(chatModel: ChatModel) { } BottomSheetScaffold( scaffoldState = scaffoldCtrl.state, - drawerContent = { SettingsView(chatModel) }, + drawerContent = { SettingsView(chatModel, setPerformLA) }, sheetPeekHeight = 0.dp, sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) }, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), @@ -104,43 +104,6 @@ fun ChatListView(chatModel: ChatModel) { } } -@Composable -fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) { - Column( - Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(16.dp) - ) { - val welcomeMsg = if (displayName != null) { - String.format(stringResource(R.string.personal_welcome), displayName) - } else stringResource(R.string.welcome) - Text( - text = welcomeMsg, - Modifier.padding(bottom = 24.dp), - style = MaterialTheme.typography.h1, - color = MaterialTheme.colors.onBackground - ) - ChatHelpView { scaffoldCtrl.toggleSheet() } - Row( - Modifier.padding(top = 30.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - stringResource(R.string.this_text_is_available_in_settings), - color = MaterialTheme.colors.onBackground - ) - Icon( - Icons.Outlined.Settings, - stringResource(R.string.icon_descr_settings), - tint = MaterialTheme.colors.onBackground, - modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() }) - ) - } - } -} - @Composable fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { Row( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt new file mode 100644 index 0000000000..7c193551ff --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt @@ -0,0 +1,112 @@ +package chat.simplex.app.views.helpers + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import android.widget.Toast +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.* +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import chat.simplex.app.R + +sealed class LAResult { + object Success: LAResult() + class Error(val errString: CharSequence): LAResult() + object Failed: LAResult() + object Unavailable: LAResult() +} + +fun authenticate( + promptTitle: String, + promptSubtitle: String, + activity: FragmentActivity, + completed: (LAResult) -> Unit +) { + when { + SDK_INT in 28..29 -> + // KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types + authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL) + SDK_INT > 29 -> + authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + else -> + completed(LAResult.Unavailable) + } +} + +private fun authenticateWithBiometricManager( + promptTitle: String, + promptSubtitle: String, + activity: FragmentActivity, + completed: (LAResult) -> Unit, + authenticators: Int +) { + val biometricManager = BiometricManager.from(activity) + when (biometricManager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> { + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = BiometricPrompt( + activity, + executor, + object: BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) { + super.onAuthenticationError(errorCode, errString) + completed(LAResult.Error(errString)) + } + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + completed(LAResult.Success) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + completed(LAResult.Failed) + } + } + ) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(promptTitle) + .setSubtitle(promptSubtitle) + .setAllowedAuthenticators(authenticators) + .setConfirmationRequired(false) + .build() + biometricPrompt.authenticate(promptInfo) + } + else -> { + completed(LAResult.Unavailable) + } + } +} + +fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg( + generalGetString(R.string.auth_turned_on), + generalGetString(R.string.auth_turned_on_desc) +) + +fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText( + context, + if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error), + Toast.LENGTH_SHORT +).show() + +fun laFailedToast(context: Context) = Toast.makeText( + context, + generalGetString(R.string.auth_failed), + Toast.LENGTH_SHORT +).show() + +fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg( + generalGetString(R.string.auth_unavailable), + generalGetString(R.string.auth_unavailable_instruction_desc) +) + +fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg( + generalGetString(R.string.auth_unavailable), + generalGetString(R.string.auth_unavailable_turning_off_desc) +) 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 6431988fa4..c8dbbe88c8 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 @@ -28,20 +28,25 @@ import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.SimpleXInfo @Composable -fun SettingsView(chatModel: ChatModel) { +fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val user = chatModel.currentUser.value + + fun setRunServiceInBackground(on: Boolean) { + chatModel.controller.setRunServiceInBackground(on) + if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { + chatModel.controller.setBackgroundServiceNoticeShown(false) + } + chatModel.controller.showBackgroundServiceNoticeIfNeeded() + chatModel.runServiceInBackground.value = on + } + if (user != null) { SettingsLayout( profile = user.profile, runServiceInBackground = chatModel.runServiceInBackground, - setRunServiceInBackground = { on -> - chatModel.controller.setRunServiceInBackground(on) - if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { - chatModel.controller.setBackgroundServiceNoticeShown(false) - } - chatModel.controller.showBackgroundServiceNoticeIfNeeded() - chatModel.runServiceInBackground.value = on - }, + setRunServiceInBackground = ::setRunServiceInBackground, + performLA = chatModel.performLA, + setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } } @@ -58,6 +63,8 @@ fun SettingsLayout( profile: Profile, runServiceInBackground: MutableState, setRunServiceInBackground: (Boolean) -> Unit, + performLA: MutableState, + setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showTerminal: () -> Unit, @@ -168,7 +175,8 @@ fun SettingsLayout( stringResource(R.string.private_notifications), Modifier .padding(end = 24.dp) .fillMaxWidth() - .weight(1F)) + .weight(1F) + ) Switch( checked = runServiceInBackground.value, onCheckedChange = { setRunServiceInBackground(it) }, @@ -180,6 +188,29 @@ fun SettingsLayout( ) } Divider(Modifier.padding(horizontal = 8.dp)) + SettingsSectionView() { + Icon( + Icons.Outlined.Lock, + contentDescription = stringResource(R.string.chat_lock), + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(R.string.chat_lock), Modifier + .padding(end = 24.dp) + .fillMaxWidth() + .weight(1F) + ) + Switch( + checked = performLA.value, + onCheckedChange = { setPerformLA(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + modifier = Modifier.padding(end = 8.dp) + ) + } + Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), @@ -246,8 +277,10 @@ fun PreviewSettingsLayout() { profile = Profile.sampleData, runServiceInBackground = remember { mutableStateOf(true) }, setRunServiceInBackground = {}, - showModal = {{}}, - showCustomModal = {{}}, + performLA = remember { mutableStateOf(false) }, + setPerformLA = {}, + showModal = { {} }, + showCustomModal = { {} }, showTerminal = {}, // showVideoChatPrototype = {} ) 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 5b665e6a1b..706f32ecbb 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -46,13 +46,13 @@ Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью. Невозможно удалить контакт! Контакт %1$s! не может быть удален, так как является членом групп(ы) %2$s. - Мгновенные уведомления Ошибка удаления контакта Ошибка удаления группы Ошибка удаления запроса Ошибка удаления ожидаемого соединения + Мгновенные уведомления Приватные мгновенные уведомления! Приватные уведомления выключены! Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает фоновый сервис SimpleX, который потребляет несколько процентов батареи в день. @@ -64,6 +64,26 @@ SimpleX Chat сервис Приём сообщений… + + Блокировка SimpleX + Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки. + Включить + + + Блокировка SimpleX включена + Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. + Разблокировать SimpleX + Пройдите аутентификацию + Включить блокировку SimpleX + Отключить блокировку SimpleX + Пройдите аутентификацию + Ошибка аутентификации + Ошибка аутентификации: %1$s + Ошибка аутентификации + Аутентификация недоступна + На устройстве не включена аутентификация. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. + На устройстве выключена аутентификация. Отключение блокировки SimpleX Chat. + Ответить Поделиться @@ -219,8 +239,7 @@ Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку. Поделиться ссылкой Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. - - + Настройки Ваш SimpleX адрес @@ -231,6 +250,7 @@ Соединиться с разработчиками Отправить email Приватные уведомления + Блокировка SimpleX Консоль SMP серверы 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 0c96aff28f..b733759864 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -46,13 +46,13 @@ 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 + Instant notifications Private instant notifications! Private notifications disabled! To preserve your privacy, instead of push notifications the app has a SimpleX background service – it uses a few percent of the battery per day. @@ -64,6 +64,26 @@ SimpleX Chat service Receiving messages… + + SimpleX Lock + To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled. + Turn on + + + SimpleX Lock turned on + You will be required to authenticate when you start or resume the app after 30 seconds in background. + Access chats + Log in using your credential + Enable SimpleX Lock + Disable SimpleX Lock + Confirm your credential + Authentication error + Authentication error: %1$s + Authentication failed + Authentication unavailable + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Device authentication is disabled. Turning off SimpleX Lock. + Reply Share @@ -236,6 +256,7 @@ Connect to the developers Send us email Private notifications + SimpleX Lock Chat console SMP servers Install SimpleX Chat for terminal diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index e542a979fb..158392ec23 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -12,11 +12,13 @@ struct ContentView: View { @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared @State private var showNotificationAlert = false + @Binding var userAuthorized: Bool? var body: some View { ZStack { if let step = chatModel.onboardingStage { - if case .onboardingComplete = step, + if userAuthorized == true, + case .onboardingComplete = step, let user = chatModel.currentUser { ZStack(alignment: .top) { ChatListView(user: user) diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 8dac273ab8..246c01cc74 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -15,6 +15,9 @@ struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @Environment(\.scenePhase) var scenePhase + @State private var userAuthorized: Bool? = nil + @State private var doAuthenticate: Bool? = nil + @State private var lastLA: Double? = nil init() { hs_init(0, nil) @@ -24,7 +27,7 @@ struct SimpleXApp: App { var body: some Scene { return WindowGroup { - ContentView() + ContentView(userAuthorized: $userAuthorized) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") @@ -32,14 +35,54 @@ struct SimpleXApp: App { } .onAppear() { initializeChat() + doAuthenticate = true } .onChange(of: scenePhase) { phase in logger.debug("scenePhase \(String(describing: scenePhase))") setAppState(phase) - if phase == .background { + switch (phase) { + case .background: BGManager.shared.schedule() + doAuthenticate = true + case .inactive: + authenticateUser() + case .active: + authenticateUser() + default: + break } } } } + + private func authenticateUser() { + if doAuthenticate == true, + authenticationExpired() { + doAuthenticate = false + userAuthorized = false + authenticate() { laResult in + switch (laResult) { + case .success: + userAuthorized = true + lastLA = ProcessInfo.processInfo.systemUptime + case .failed: + laFailedAlert() + case .unavailable: + userAuthorized = true + laUnavailableAlert() + } + } + } + } + + private func authenticationExpired() -> Bool { + if (lastLA == nil) { + return true + } + else if let lastLA = lastLA, ProcessInfo.processInfo.systemUptime - lastLA >= 30 { + return true + } else { + return false + } + } } diff --git a/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift new file mode 100644 index 0000000000..2cc1885640 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift @@ -0,0 +1,51 @@ +// +// LocalAuthenticationUtils.swift +// SimpleX (iOS) +// +// Created by Efim Poberezkin on 26.05.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import LocalAuthentication + +enum LAResult { + case success + case failed(authError: String?) + case unavailable(authError: String?) +} + +func authenticate(completed: @escaping (LAResult) -> Void) { + let laContext = LAContext() + var authAvailabilityError: NSError? + if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) { + let reason = "Access chats" + laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in + DispatchQueue.main.async { + if success { + completed(LAResult.success) + } else { + logger.error("authentication error: \(authError.debugDescription)") + completed(LAResult.failed(authError: authError?.localizedDescription)) + } + } + } + } else { + logger.error("authentication availability error: \(authAvailabilityError.debugDescription)") + completed(LAResult.unavailable(authError: authAvailabilityError?.localizedDescription)) + } +} + +func laFailedAlert() { + AlertManager.shared.showAlertMsg( + title: "Authentication failed", + message: "You could not be verified; please try again." + ) +} + +func laUnavailableAlert() { + AlertManager.shared.showAlertMsg( + title: "Authentication unavailable", + message: "Your device is not configured for authentication." + ) +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c028015df9..8e80c6a4be 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -100,6 +100,8 @@ 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; }; + 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; }; + 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.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 */; }; @@ -221,6 +223,8 @@ 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; + 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; }; + 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.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 = ""; }; @@ -241,6 +245,7 @@ buildActionMask = 2147483647; files = ( 64A6908928376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */, + 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */, 64A6908528376BBA0076573F /* libgmpxx.a in Frameworks */, 64A6908728376BBA0076573F /* libgmp.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, @@ -333,6 +338,7 @@ 5C764E7A279C71D4000C6508 /* Frameworks */ = { isa = PBXGroup; children = ( + 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */, 5CDCAD6028187D7900503DA2 /* libz.tbd */, 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */, 5C764E7C279C71DB000C6508 /* libz.tbd */, @@ -369,6 +375,7 @@ 648010AA281ADD15009009B9 /* CIFileView.swift */, 6454036E2822A9750090DDFF /* ComposeFileView.swift */, 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */, + 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */, ); path = Helpers; sourceTree = ""; @@ -714,6 +721,7 @@ 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, + 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, @@ -950,6 +958,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; 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; @@ -992,6 +1001,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; 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; From da13e6614bdb27ace17932fc02edd61c95e96060 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 27 May 2022 16:36:33 +0100 Subject: [PATCH 07/23] mobile: call settings, request camera on iOS on call start (#701) * mobile: call settings, request camera on iOS on call start * refactor preferences * fix typo --- apps/android/.idea/codeStyles/Project.xml | 5 + .../.idea/codeStyles/codeStyleConfig.xml | 1 - apps/android/app/src/main/assets/www/call.js | 18 +- .../java/chat/simplex/app/MainActivity.kt | 28 +- .../main/java/chat/simplex/app/SimplexApp.kt | 2 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 95 ++--- .../chat/simplex/app/views/call/CallView.kt | 2 +- .../app/views/call/IncomingCallActivity.kt | 78 ++-- .../app/views/usersettings/CallSettings.kt | 63 ++++ .../app/views/usersettings/SettingsView.kt | 353 +++++++++++------- .../app/src/main/res/values-ru/strings.xml | 10 + .../app/src/main/res/values/strings.xml | 10 + apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- apps/ios/Shared/SimpleXApp.swift | 1 + apps/ios/Shared/Views/Call/CallManager.swift | 6 +- apps/ios/Shared/Views/Call/WebRTC.swift | 8 +- .../Views/UserSettings/CallSettings.swift | 25 ++ .../Views/UserSettings/SettingsView.swift | 13 + .../en.xcloc/Localized Contents/en.xliff | 179 ++++----- .../SimpleX NSE/en.lproj/Localizable.strings | Bin 2794 -> 3004 bytes .../ru.xcloc/Localized Contents/ru.xliff | 175 ++++----- .../SimpleX NSE/en.lproj/Localizable.strings | Bin 2794 -> 3004 bytes .../SimpleX NSE/ru.lproj/Localizable.strings | 32 +- apps/ios/SimpleX--iOS--Info.plist | 2 - apps/ios/SimpleX.xcodeproj/project.pbxproj | 5 +- .../xcschemes/SimpleX (iOS).xcscheme | 3 +- apps/ios/ru.lproj/Localizable.strings | 63 ++-- packages/simplex-chat-webrtc/src/call.ts | 18 +- 28 files changed, 686 insertions(+), 513 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt create mode 100644 apps/ios/Shared/Views/UserSettings/CallSettings.swift diff --git a/apps/android/.idea/codeStyles/Project.xml b/apps/android/.idea/codeStyles/Project.xml index 9dd7fe45d6..3e0df2b1b6 100644 --- a/apps/android/.idea/codeStyles/Project.xml +++ b/apps/android/.idea/codeStyles/Project.xml @@ -1,5 +1,8 @@ + + - - wants to connect with you via - wants to connect with you via - No comment provided by engineer. - !1 colored! !1 colored! @@ -133,7 +128,8 @@ Accept Accept - accept contact request via notification + accept contact request via notification + accept incoming call via notification Accept contact @@ -165,11 +161,6 @@ Already connected? No comment provided by engineer. - - Answer - Answer - accept incoming call via notification - Answer call Answer call @@ -185,16 +176,16 @@ Call already ended! No comment provided by engineer. + + Call settings + Call settings + No comment provided by engineer. + Cancel Cancel No comment provided by engineer. - - Capabilities - Capabilities - No comment provided by engineer. - Chat console Chat console @@ -275,6 +266,11 @@ Connect via one-time link? No comment provided by engineer. + + Connect via relay + Connect via relay + No comment provided by engineer. + Connect with the developers Connect with the developers @@ -460,11 +456,6 @@ Enable notifications? (BETA) No comment provided by engineer. - - End - End - No comment provided by engineer. - Enter one SMP server per line: Enter one SMP server per line: @@ -540,11 +531,6 @@ How to use markdown No comment provided by engineer. - - ICE - ICE - 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. @@ -558,7 +544,7 @@ Ignore Ignore - ignore incoming call via notification + No comment provided by engineer. Image will be received when your contact is online, please wait or check later! @@ -748,7 +734,7 @@ Reject Reject - No comment provided by engineer. + reject incoming call via notification Reject contact (sender NOT notified) @@ -795,11 +781,6 @@ Scan contact's QR code No comment provided by engineer. - - Send - Send - No comment provided by engineer. - Server connected Server connected @@ -830,11 +811,6 @@ Show pending connections No comment provided by engineer. - - Start - Start - No comment provided by engineer. - Take picture Take picture @@ -1113,9 +1089,9 @@ SimpleX servers cannot see your profile. above, then choose: No comment provided by engineer. - - accepted - accepted + + accepted call + accepted call call status @@ -1128,6 +1104,16 @@ SimpleX servers cannot see your profile. bold No comment provided by engineer. + + call error + call error + call status + + + call in progress + call in progress + call status + calling… calling… @@ -1148,11 +1134,15 @@ SimpleX servers cannot see your profile. connected No comment provided by engineer. + + connecting call… + connecting call… + call status + connecting… connecting… - call status - chat list item title + chat list item title connection established @@ -1184,19 +1174,14 @@ SimpleX servers cannot see your profile. e2e encrypted No comment provided by engineer. - - ended %@ - ended %@ - call status + + ended + ended + No comment provided by engineer. - - error - error - call status - - - in progress - in progress + + ended call %@ + ended call %@ call status @@ -1209,9 +1194,9 @@ SimpleX servers cannot see your profile. italic No comment provided by engineer. - - missed - missed + + missed call + missed call call status @@ -1234,9 +1219,14 @@ SimpleX servers cannot see your profile. received answer… No comment provided by engineer. - - rejected - rejected + + received confirmation… + received confirmation… + No comment provided by engineer. + + + rejected call + rejected call call status @@ -1299,11 +1289,6 @@ SimpleX servers cannot see your profile. wants to connect to you! No comment provided by engineer. - - with e2e encryption - with e2e encryption - No comment provided by engineer. - you shared one-time link you shared one-time link @@ -1410,9 +1395,9 @@ SimpleX servers cannot see your profile. You can now send messages to %@ notification body - - accepted - accepted + + accepted call + accepted call call status @@ -1420,16 +1405,30 @@ SimpleX servers cannot see your profile. audio call (not e2e encrypted) No comment provided by engineer. + + call error + call error + call status + + + call in progress + call in progress + call status + calling… calling… call status + + connecting call… + connecting call… + call status + connecting… connecting… - call status - chat list item title + chat list item title connection established @@ -1446,19 +1445,9 @@ SimpleX servers cannot see your profile. deleted deleted chat item - - ended %@ - ended %@ - call status - - - error - error - call status - - - in progress - in progress + + ended call %@ + ended call %@ call status @@ -1466,19 +1455,14 @@ SimpleX servers cannot see your profile. invited to connect chat list item title - - missed - missed + + missed call + missed call call status - - no e2e encryption - no e2e encryption - No comment provided by engineer. - - - rejected - rejected + + rejected call + rejected call call status @@ -1496,11 +1480,6 @@ SimpleX servers cannot see your profile. video call (not e2e encrypted) No comment provided by engineer. - - with e2e encryption - with e2e encryption - No comment provided by engineer. - you shared one-time link you shared one-time link 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 index d4c7fc1677b12c90f9e166798496597c70d48fce..a5899f2a06ad0c0ed1fecd3d6188e28523f212a7 100644 GIT binary patch delta 262 zcmaDQx<`D22-9RGHj&9JOahaG*d!)5uqsTBVd9WiU`S;s0>XSCR$@?KumxfzhGd3B zh8!SNn4HdP2~x+XFnKgej4co=F{Co&F{A*=6d(^MGue?zUpAGY2nh3m7+D=u45VW6EhdG@EX*pK``At~ mPEO(w16#X+LtyeL4v-TBCKs^COuoRWF}a3QYjX}~022UFCnb#l 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 c26a7bb81e..c3c3ce1e2c 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -20,11 +20,6 @@ (можно скопировать) No comment provided by engineer. - - wants to connect with you via - хочет связаться с вами через - No comment provided by engineer. - !1 colored! !1 цвет! @@ -133,7 +128,8 @@ Accept Принять - accept contact request via notification + accept contact request via notification + accept incoming call via notification Accept contact @@ -165,11 +161,6 @@ Соединение уже установлено? No comment provided by engineer. - - Answer - Ответить - accept incoming call via notification - Answer call Принять звонок @@ -185,16 +176,16 @@ Звонок уже завершен! No comment provided by engineer. + + Call settings + Настройки звонков + No comment provided by engineer. + Cancel Отменить No comment provided by engineer. - - Capabilities - Возможности - No comment provided by engineer. - Chat console Консоль @@ -275,6 +266,11 @@ Соединиться через одноразовую ссылку? No comment provided by engineer. + + Connect via relay + Соединяться через сервер (relay) + No comment provided by engineer. + Connect with the developers Соединиться с разработчиками @@ -460,11 +456,6 @@ Включить уведомления? (БЕТА) No comment provided by engineer. - - End - Завершить - No comment provided by engineer. - Enter one SMP server per line: Введите SMP серверы, каждый на отдельной строке: @@ -540,11 +531,6 @@ Как форматировать No comment provided by engineer. - - ICE - ICE - No comment provided by engineer. - If you can't meet in person, **show QR code in the video call**, or share the link. Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой. @@ -558,7 +544,7 @@ Ignore Не отвечать - ignore incoming call via notification + No comment provided by engineer. Image will be received when your contact is online, please wait or check later! @@ -748,7 +734,7 @@ Reject Отклонить - No comment provided by engineer. + reject incoming call via notification Reject contact (sender NOT notified) @@ -795,11 +781,6 @@ Сосканировать QR код контакта No comment provided by engineer. - - Send - Отправить - No comment provided by engineer. - Server connected Установлено соединение с сервером @@ -830,11 +811,6 @@ Показать ожидаемые соединения No comment provided by engineer. - - Start - Начать - No comment provided by engineer. - Take picture Сделать фото @@ -1113,9 +1089,9 @@ SimpleX серверы не могут получить доступ к ваше наверху, затем выберите: No comment provided by engineer. - - accepted - принятый звонок + + accepted call + принятый звонок call status @@ -1128,6 +1104,16 @@ SimpleX серверы не могут получить доступ к ваше жирный No comment provided by engineer. + + call error + ошибка звонка + call status + + + call in progress + активный звонок + call status + calling… входящий звонок… @@ -1148,11 +1134,15 @@ SimpleX серверы не могут получить доступ к ваше соединение установлено No comment provided by engineer. + + connecting call… + звонок соединяется… + call status + connecting… соединяется… - call status - chat list item title + chat list item title connection established @@ -1184,19 +1174,14 @@ SimpleX серверы не могут получить доступ к ваше e2e зашифровано No comment provided by engineer. - - ended %@ - завершен %@ - call status + + ended + завершён + No comment provided by engineer. - - error - ошибка соединения - call status - - - in progress - активный звонок + + ended call %@ + завершённый звонок %@ call status @@ -1209,8 +1194,8 @@ SimpleX серверы не могут получить доступ к ваше курсив No comment provided by engineer. - - missed + + missed call пропущенный звонок call status @@ -1234,9 +1219,14 @@ SimpleX серверы не могут получить доступ к ваше получен ответ… No comment provided by engineer. - - rejected - отклоненный звонок + + received confirmation… + получено подтверждение… + No comment provided by engineer. + + + rejected call + отклонённый звонок call status @@ -1299,11 +1289,6 @@ SimpleX серверы не могут получить доступ к ваше хочет соединиться с вами! No comment provided by engineer. - - with e2e encryption - e2e зашифровано - No comment provided by engineer. - you shared one-time link вы создали ссылку @@ -1410,9 +1395,9 @@ SimpleX серверы не могут получить доступ к ваше Вы можете отправлять сообщения %@ notification body - - accepted - принятый звонок + + accepted call + принятный звонок call status @@ -1420,16 +1405,30 @@ SimpleX серверы не могут получить доступ к ваше аудиозвонок (не e2e зашифрованный) No comment provided by engineer. + + call error + ошибка звонка + call status + + + call in progress + активный звонок + call status + calling… входящий звонок… call status + + connecting call… + звонок соединяется… + call status + connecting… соединяется… - call status - chat list item title + chat list item title connection established @@ -1446,19 +1445,9 @@ SimpleX серверы не могут получить доступ к ваше удалено deleted chat item - - ended %@ - завершен %@ - call status - - - error - ошибка соединения - call status - - - in progress - активный звонок + + ended call %@ + завершённый звонок %@ call status @@ -1466,19 +1455,14 @@ SimpleX серверы не могут получить доступ к ваше приглашение соединиться chat list item title - - missed + + missed call пропущенный звонок call status - - no e2e encryption - нет e2e шифрования - No comment provided by engineer. - - - rejected - отклоненный звонок + + rejected call + отклонённый звонок call status @@ -1496,11 +1480,6 @@ SimpleX серверы не могут получить доступ к ваше видеозвонок (не e2e зашифрованный) No comment provided by engineer. - - with e2e encryption - e2e зашифровано - No comment provided by engineer. - you shared one-time link вы создали одноразовую ссылку 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 index d4c7fc1677b12c90f9e166798496597c70d48fce..a5899f2a06ad0c0ed1fecd3d6188e28523f212a7 100644 GIT binary patch delta 262 zcmaDQx<`D22-9RGHj&9JOahaG*d!)5uqsTBVd9WiU`S;s0>XSCR$@?KumxfzhGd3B zh8!SNn4HdP2~x+XFnKgej4co=F{Co&F{A*=6d(^MGue?zUpAGY2nh3m7+D=u45VW6EhdG@EX*pK``At~ mPEO(w16#X+LtyeL4v-TBCKs^COuoRWF}a3QYjX}~022UFCnb#l diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index ab483cb82e..53a4bbf9b8 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -14,16 +14,24 @@ "Accept contact request from %@?" = "Принять запрос на соединение от %@?"; /* call status */ -"accepted" = "принятый звонок"; +"accepted call" = "принятный звонок"; /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; +/* call status */ +"call error" = "ошибка звонка"; + +/* call status */ +"call in progress" = "активный звонок"; + /* call status */ "calling…" = "входящий звонок…"; -/* call status - chat list item title */ +/* call status */ +"connecting call…" = "звонок соединяется…"; + +/* chat list item title */ "connecting…" = "соединяется…"; /* chat list item title (it should not be shown */ @@ -36,13 +44,7 @@ "deleted" = "удалено"; /* call status */ -"ended %@" = "завершен %@"; - -/* call status */ -"error" = "ошибка соединения"; - -/* call status */ -"in progress" = "активный звонок"; +"ended call %@" = "завершённый звонок %@"; /* notification */ "Incoming audio call" = "Входящий аудиозвонок"; @@ -54,13 +56,10 @@ "invited to connect" = "приглашение соединиться"; /* call status */ -"missed" = "пропущенный звонок"; - -/* No comment provided by engineer. */ -"no e2e encryption" = "нет e2e шифрования"; +"missed call" = "пропущенный звонок"; /* call status */ -"rejected" = "отклоненный звонок"; +"rejected call" = "отклонённый звонок"; /* chat list item description */ "via contact address link" = "через ссылку-контакт"; @@ -71,9 +70,6 @@ /* No comment provided by engineer. */ "video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)"; -/* No comment provided by engineer. */ -"with e2e encryption" = "e2e зашифровано"; - /* notification body */ "You can now send messages to %@" = "Вы можете отправлять сообщения %@"; diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 01f16a5a1c..1287772e17 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -23,10 +23,8 @@ UIBackgroundModes - audio fetch remote-notification - voip diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8e80c6a4be..9e8ffd56d2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; }; + 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.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 */; }; @@ -138,6 +139,7 @@ 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = ""; }; + 5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.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 = ""; }; @@ -463,6 +465,7 @@ children = ( 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, + 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, @@ -624,7 +627,6 @@ hasScannedForEncodings = 0; knownRegions = ( en, - Base, ru, ); mainGroup = 5CA059BD279559F40002BEB4; @@ -717,6 +719,7 @@ 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, + 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */, diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index a90949ee3b..ecdec033c9 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -49,8 +49,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" - allowLocationSimulation = "YES" - showNonLocalizedStrings = "YES"> + allowLocationSimulation = "YES"> { + const constraints = callMediaConstraints(mediaType, facingMode) + return navigator.mediaDevices.getUserMedia(constraints) + } + function callMediaConstraints(mediaType: CallMediaType, facingMode: VideoCamera): MediaStreamConstraints { switch (mediaType) { case CallMediaType.Audio: From ce2f3c03714e7512dfc86c47f1f2d1218a109ac4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 28 May 2022 09:06:38 +0100 Subject: [PATCH 08/23] mobile: timeout call invitations, more android options (#703) * mobile: timeout call invitations, more android options * close overlays when call is accepted via notification * show incoming call above modals, dismiss modals when call is accepted * fix clickable area of create profile button * fix pending intent for rullscreen notification, update settings --- .../java/chat/simplex/app/MainActivity.kt | 42 ++++-- .../main/java/chat/simplex/app/SimplexApp.kt | 10 +- .../java/chat/simplex/app/model/NtfManager.kt | 26 ++-- .../java/chat/simplex/app/model/SimpleXAPI.kt | 137 +++++++++++------- .../chat/simplex/app/views/WelcomeView.kt | 2 +- .../simplex/app/views/call/CallManager.kt | 16 +- .../chat/simplex/app/views/call/CallView.kt | 30 ++-- .../app/views/call/IncomingCallActivity.kt | 19 +-- .../app/views/call/IncomingCallAlertView.kt | 4 +- .../chat/simplex/app/views/call/WebRTC.kt | 3 +- .../app/views/chatlist/ChatListView.kt | 6 +- .../simplex/app/views/helpers/ModalView.kt | 4 + .../app/views/usersettings/CallSettings.kt | 33 ++++- .../app/views/usersettings/SettingsView.kt | 16 +- .../app/src/main/res/values-ru/strings.xml | 11 +- .../app/src/main/res/values/strings.xml | 5 +- apps/ios/Shared/Model/Shared/APITypes.swift | 4 +- apps/ios/Shared/Model/Shared/CallTypes.swift | 4 +- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Shared/Views/Call/CallController.swift | 4 +- .../Views/UserSettings/CallSettings.swift | 27 +++- .../Views/UserSettings/SettingsView.swift | 10 +- 22 files changed, 271 insertions(+), 146 deletions(-) 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 1e5cb7dd62..4646474f0a 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 @@ -72,7 +72,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { val m = vm.chatModel val lastLAVal = lastLA.value if ( - m.controller.prefPerformLA.get() + m.controller.appPrefs.performLA.get() && (lastLAVal == null || (System.nanoTime() - lastLAVal >= 30 * 1e+9)) ) { userAuthorized.value = false @@ -91,7 +91,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { LAResult.Unavailable -> { userAuthorized.value = true m.performLA.value = false - m.controller.prefPerformLA.set(false) + m.controller.appPrefs.performLA.set(false) laUnavailableTurningOffAlert() } } @@ -106,13 +106,13 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { } private fun schedulePeriodicServiceRestartWorker() { - val workerVersion = chatController.prefAutoRestartWorkerVersion.get() + val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get() val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) { Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy") ExistingPeriodicWorkPolicy.KEEP } else { Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy") - chatController.prefAutoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION) + chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION) ExistingPeriodicWorkPolicy.REPLACE } val work = PeriodicWorkRequestBuilder(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES) @@ -126,33 +126,34 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { private fun setPerformLA(on: Boolean) { val m = vm.chatModel if (on) { - m.controller.prefLANoticeShown.set(true) + m.controller.appPrefs.laNoticeShown.set(true) authenticate( generalGetString(R.string.auth_enable), generalGetString(R.string.auth_confirm_credential), this@MainActivity, completed = { laResult -> + val prefPerformLA = m.controller.appPrefs.performLA when (laResult) { LAResult.Success -> { m.performLA.value = true - m.controller.prefPerformLA.set(true) + prefPerformLA.set(true) userAuthorized.value = true lastLA.value = System.nanoTime() laTurnedOnAlert() } is LAResult.Error -> { m.performLA.value = false - m.controller.prefPerformLA.set(false) + prefPerformLA.set(false) laErrorToast(applicationContext, laResult.errString) } LAResult.Failed -> { m.performLA.value = false - m.controller.prefPerformLA.set(false) + prefPerformLA.set(false) laFailedToast(applicationContext) } LAResult.Unavailable -> { m.performLA.value = false - m.controller.prefPerformLA.set(false) + prefPerformLA.set(false) laUnavailableInstructionAlert() } } @@ -164,24 +165,25 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { generalGetString(R.string.auth_confirm_credential), this@MainActivity, completed = { laResult -> + val prefPerformLA = m.controller.appPrefs.performLA when (laResult) { LAResult.Success -> { m.performLA.value = false - m.controller.prefPerformLA.set(false) + prefPerformLA.set(false) } is LAResult.Error -> { m.performLA.value = true - m.controller.prefPerformLA.set(true) + prefPerformLA.set(true) laErrorToast(applicationContext, laResult.errString) } LAResult.Failed -> { m.performLA.value = true - m.controller.prefPerformLA.set(true) + prefPerformLA.set(true) laFailedToast(applicationContext) } LAResult.Unavailable -> { m.performLA.value = false - m.controller.prefPerformLA.set(false) + prefPerformLA.set(false) laUnavailableTurningOffAlert() } } @@ -212,7 +214,7 @@ fun MainPage( var showAdvertiseLAAlert by remember { mutableStateOf(false) } LaunchedEffect(showAdvertiseLAAlert) { if ( - !chatModel.controller.prefLANoticeShown.get() + !chatModel.controller.appPrefs.laNoticeShown.get() && showAdvertiseLAAlert && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && chatModel.chats.isNotEmpty() @@ -226,6 +228,12 @@ fun MainPage( laUnavailableInstructionAlert() } } + LaunchedEffect(chatModel.clearOverlays.value) { + if (chatModel.clearOverlays.value) { + ModalManager.shared.closeModals() + chatModel.clearOverlays.value = false + } + } Box { val onboarding = chatModel.onboardingStage.value val userCreated = chatModel.userCreated.value @@ -240,8 +248,6 @@ fun MainPage( if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) }) else ChatView(chatModel) } - val invitation = chatModel.activeCallInvitation.value - if (invitation != null) IncomingCallAlertView(invitation, chatModel) } } onboarding == OnboardingStage.Step1_SimpleXInfo -> @@ -251,6 +257,8 @@ fun MainPage( onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) } ModalManager.shared.showInView() + val invitation = chatModel.activeCallInvitation.value + if (invitation != null) IncomingCallAlertView(invitation, chatModel) AlertManager.shared.showInView() } } @@ -273,7 +281,9 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) { } NtfManager.AcceptCallAction -> { val chatId = intent.getStringExtra("chatId") + if (chatId == null || chatId == "") return Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId") + chatModel.clearOverlays.value = true val invitation = chatModel.callInvitations[chatId] if (invitation == null) { AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended)) 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 1a7988fdd7..a200f99cc3 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 @@ -29,7 +29,7 @@ external fun chatRecvMsg(ctrl: ChatCtrl) : String class SimplexApp: Application(), LifecycleEventObserver { val chatController: ChatController by lazy { val ctrl = chatInit(getFilesDirectory(applicationContext)) - ChatController(ctrl, ntfManager, applicationContext) + ChatController(ctrl, ntfManager, applicationContext, appPreferences) } val chatModel: ChatModel by lazy { @@ -37,7 +37,11 @@ class SimplexApp: Application(), LifecycleEventObserver { } private val ntfManager: NtfManager by lazy { - NtfManager(applicationContext) + NtfManager(applicationContext, appPreferences) + } + + private val appPreferences: AppPreferences by lazy { + AppPreferences(applicationContext) } override fun onCreate() { @@ -61,7 +65,7 @@ class SimplexApp: Application(), LifecycleEventObserver { withApi { when (event) { Lifecycle.Event.ON_STOP -> - if (!chatController.prefRunServiceInBackground.get()) SimplexService.stop(applicationContext) + if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext) Lifecycle.Event.ON_START -> SimplexService.start(applicationContext) Lifecycle.Event.ON_RESUME -> 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 0ba0004d71..404839d9db 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 @@ -1,6 +1,7 @@ package chat.simplex.app.model import android.app.* +import android.app.Notification.VISIBILITY_PUBLIC import android.content.* import android.graphics.BitmapFactory import android.media.AudioAttributes @@ -14,7 +15,7 @@ import chat.simplex.app.views.helpers.base64ToBitmap import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock -class NtfManager(val context: Context) { +class NtfManager(val context: Context, private val appPreferences: AppPreferences) { companion object { const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION" const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION" @@ -64,21 +65,25 @@ class NtfManager(val context: Context) { } fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) { - Log.d(TAG, "notifyMessageReceived ${cInfo.id}") + notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) + } + + fun notifyMessageReceived(chatId: String, displayName: String, msgText: String) { + Log.d(TAG, "notifyMessageReceived $chatId") val now = Clock.System.now().toEpochMilliseconds() - val recentNotification = (now - prevNtfTime.getOrDefault(cInfo.id, 0) < msgNtfTimeoutMs) - prevNtfTime[cInfo.id] = now + val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs) + prevNtfTime[chatId] = now val notification = NotificationCompat.Builder(context, MessageChannel) - .setContentTitle(cInfo.displayName) - .setContentText(hideSecrets(cItem)) + .setContentTitle(displayName) + .setContentText(msgText) .setPriority(NotificationCompat.PRIORITY_HIGH) .setGroup(MessageGroup) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setSmallIcon(R.drawable.ntf_icon) .setColor(0x88FFFF) .setAutoCancel(true) - .setContentIntent(chatPendingIntent(OpenChatAction, cInfo.id)) + .setContentIntent(chatPendingIntent(OpenChatAction, chatId)) .setSilent(recentNotification) .build() @@ -93,7 +98,7 @@ class NtfManager(val context: Context) { with(NotificationManagerCompat.from(context)) { // using cInfo.id only shows one notification per chat and updates it when the message arrives - notify(cInfo.id.hashCode(), notification) + notify(chatId.hashCode(), notification) notify(0, summary) } } @@ -105,11 +110,12 @@ class NtfManager(val context: Context) { val keyguardManager = getKeyguardManager(context) val image = invitation.contact.image var ntfBuilder = - if (keyguardManager.isDeviceLocked) { + if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) { val fullScreenIntent = Intent(context, IncomingCallActivity::class.java) - val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) NotificationCompat.Builder(context, LockScreenCallChannel) .setFullScreenIntent(fullScreenPendingIntent, true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setSilent(true) } else { val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once) 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 c834779461..3f9d86b532 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 @@ -46,22 +46,76 @@ fun isAppOnForeground(context: Context): Boolean { return false } -open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context) { - var chatModel = ChatModel(this) - private val sharedPreferences: SharedPreferences = appContext.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) +enum class CallOnLockScreen { + DISABLE, + SHOW, + ACCEPT; - val prefRunServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) - val prefBackgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) - private val prefBackgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) - val prefAutoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) - val prefWebrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true) - val prefAcceptCallsFromLockScreen = mkBoolPreference(SHARED_PREFS_WEBRTC_ACCEPT_CALLS_FROM_LOCK_SCREEN, false) - val prefPerformLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false) - val prefLANoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false) + companion object { + val default = SHOW + } +} + +class AppPreferences(val context: Context) { + private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) + + val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) + val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) + val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) + val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) + val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true) + private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name) + val callOnLockScreen: Preference = Preference( + get = fun(): CallOnLockScreen { + val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default + return try { + CallOnLockScreen.valueOf(value) + } catch (e: Error) { + CallOnLockScreen.default + } + }, + set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) } + ) + val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false) + val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false) + + private fun mkIntPreference(prefName: String, default: Int) = + Preference( + get = fun() = sharedPreferences.getInt(prefName, default), + set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply() + ) + + private fun mkBoolPreference(prefName: String, default: Boolean) = + Preference( + get = fun() = sharedPreferences.getBoolean(prefName, default), + set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply() + ) + + private fun mkStrPreference(prefName: String, default: String?): Preference = + Preference( + get = fun() = sharedPreferences.getString(prefName, default), + set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply() + ) + + companion object { + private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" + private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" + private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" + private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" + private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" + private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay" + private const val SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN = "CallsOnLockScreen" + private const val SHARED_PREFS_PERFORM_LA = "PerformLA" + private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown" + } +} + +open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) { + val chatModel = ChatModel(this) init { - chatModel.runServiceInBackground.value = prefRunServiceInBackground.get() - chatModel.performLA.value = prefPerformLA.get() + chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get() + chatModel.performLA.value = appPrefs.performLA.get() } suspend fun startChat(user: User) { @@ -511,7 +565,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } is CR.CallInvitation -> { - val invitation = CallInvitation(r.contact, r.callType.media, r.sharedKey) + val invitation = CallInvitation(r.contact, r.callType.media, r.sharedKey, r.callTs) chatModel.callManager.reportNewIncomingCall(invitation) } is CR.CallOffer -> { @@ -519,7 +573,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager // TODO check encryption is compatible withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey) - chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey) + val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get() + chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, relay = useRelay) } } is CR.CallAnswer -> { @@ -592,7 +647,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager fun showBackgroundServiceNoticeIfNeeded() { Log.d(TAG, "showBackgroundServiceNoticeIfNeeded") - if (!prefBackgroundServiceNoticeShown.get()) { + if (!appPrefs.backgroundServiceNoticeShown.get()) { // the branch for the new users who has never seen service notice if (isIgnoringBatteryOptimizations(appContext)) { showBGServiceNotice() @@ -600,20 +655,20 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager showBGServiceNoticeIgnoreOptimization() } // set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice - prefBackgroundServiceNoticeShown.set(true) - prefBackgroundServiceBatteryNoticeShown.set(true) - } else if (!isIgnoringBatteryOptimizations(appContext) && prefRunServiceInBackground.get()) { + appPrefs.backgroundServiceNoticeShown.set(true) + appPrefs.backgroundServiceBatteryNoticeShown.set(true) + } else if (!isIgnoringBatteryOptimizations(appContext) && appPrefs.runServiceInBackground.get()) { // the branch for users who have app installed, and have seen the service notice, // but the battery optimization for the app is on (Android 12) AND the service is running - if (prefBackgroundServiceBatteryNoticeShown.get()) { + if (appPrefs.backgroundServiceBatteryNoticeShown.get()) { // users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service showDisablingServiceNotice() - prefRunServiceInBackground.set(false) + appPrefs.runServiceInBackground.set(false) chatModel.runServiceInBackground.value = false } else { // show battery optimization notice showBGServiceNoticeIgnoreOptimization() - prefBackgroundServiceBatteryNoticeShown.set(true) + appPrefs.backgroundServiceBatteryNoticeShown.set(true) } } } @@ -704,8 +759,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager fun showLANotice(activity: FragmentActivity) { Log.d(TAG, "showLANotice") - if (!prefLANoticeShown.get()) { - prefLANoticeShown.set(true) + if (!appPrefs.laNoticeShown.get()) { + appPrefs.laNoticeShown.set(true) AlertManager.shared.showAlertDialog( title = generalGetString(R.string.la_notice_title), text = generalGetString(R.string.la_notice_text), @@ -719,22 +774,22 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager when (laResult) { LAResult.Success -> { chatModel.performLA.value = true - prefPerformLA.set(true) + appPrefs.performLA.set(true) laTurnedOnAlert() } is LAResult.Error -> { chatModel.performLA.value = false - prefPerformLA.set(false) + appPrefs.performLA.set(false) laErrorToast(appContext, laResult.errString) } LAResult.Failed -> { chatModel.performLA.value = false - prefPerformLA.set(false) + appPrefs.performLA.set(false) laFailedToast(appContext) } LAResult.Unavailable -> { chatModel.performLA.value = false - prefPerformLA.set(false) + appPrefs.performLA.set(false) chatModel.showAdvertiseLAUnavailableAlert.value = true } } @@ -763,30 +818,6 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager context.startActivity(this) } } - - private fun mkIntPreference(prefName: String, default: Int) = - Preference( - get = fun() = sharedPreferences.getInt(prefName, default), - set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply() - ) - - private fun mkBoolPreference(prefName: String, default: Boolean) = - Preference( - get = fun() = sharedPreferences.getBoolean(prefName, default), - set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply() - ) - - companion object { - private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" - private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" - private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" - private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" - private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" - private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay" - private const val SHARED_PREFS_WEBRTC_ACCEPT_CALLS_FROM_LOCK_SCREEN = "AcceptCallsFromLockScreen" - private const val SHARED_PREFS_PERFORM_LA = "PerformLA" - private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown" - } } class Preference(val get: () -> T, val set: (T) -> Unit) @@ -982,7 +1013,7 @@ sealed class 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? = null): CR() + @Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant): CR() @Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, 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() 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 6703df1aa0..833977b982 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 @@ -87,7 +87,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { val createModifier: Modifier val createColor: Color if (enabled) { - createModifier = Modifier.padding(8.dp).clickable { createProfile(chatModel, displayName.value, fullName.value) } + createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp) createColor = MaterialTheme.colors.primary } else { createModifier = Modifier.padding(8.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt index 13bad493b1..b0c8a442be 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt @@ -3,19 +3,28 @@ package chat.simplex.app.views.call import android.util.Log import chat.simplex.app.TAG import chat.simplex.app.model.ChatModel +import chat.simplex.app.views.helpers.ModalManager import chat.simplex.app.views.helpers.withApi +import kotlinx.datetime.Clock +import kotlin.time.Duration.Companion.minutes class CallManager(val chatModel: ChatModel) { fun reportNewIncomingCall(invitation: CallInvitation) { Log.d(TAG, "CallManager.reportNewIncomingCall") with (chatModel) { callInvitations[invitation.contact.id] = invitation - activeCallInvitation.value = invitation - controller.ntfManager.notifyCallInvitation(invitation) + if (Clock.System.now() - invitation.callTs <= 3.minutes) { + activeCallInvitation.value = invitation + controller.ntfManager.notifyCallInvitation(invitation) + } else { + val contact = invitation.contact + controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText) + } } } fun acceptIncomingCall(invitation: CallInvitation) { + ModalManager.shared.closeModals() val call = chatModel.activeCall.value if (call == null) { justAcceptIncomingCall(invitation = invitation) @@ -41,7 +50,8 @@ class CallManager(val chatModel: ChatModel) { sharedKey = invitation.sharedKey ) showCallView.value = true - callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey) + val useRelay = controller.appPrefs.webrtcPolicyRelay.get() + callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey, relay = useRelay) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null 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 index 9830f685c1..340acd96c0 100644 --- 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 @@ -39,14 +39,10 @@ import kotlinx.serialization.encodeToString @Composable fun ActiveCallView(chatModel: ChatModel) { - val endCall = { - Log.d(TAG, "ActiveCallView: endCall") - chatModel.activeCall.value = null - chatModel.activeCallInvitation.value = null - chatModel.callCommand.value = null - chatModel.showCallView.value = false - } - BackHandler(onBack = endCall) + BackHandler(onBack = { + val call = chatModel.activeCall.value + if (call != null) withApi { chatModel.callManager.endCall(call) } + }) Box(Modifier.fillMaxSize()) { WebRTCView(chatModel.callCommand) { apiMsg -> Log.d(TAG, "received from WebRTCView: $apiMsg") @@ -85,7 +81,8 @@ fun ActiveCallView(chatModel: ChatModel) { } is WCallResponse.Ended -> { chatModel.activeCall.value = call.copy(callState = CallState.Ended) - endCall() + withApi { chatModel.callManager.endCall(call) } + chatModel.showCallView.value = false } is WCallResponse.Ok -> when (val cmd = apiMsg.command) { is WCallCommand.Answer -> @@ -102,7 +99,8 @@ fun ActiveCallView(chatModel: ChatModel) { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false) } } - is WCallCommand.End -> endCall() + is WCallCommand.End -> + chatModel.showCallView.value = false else -> {} } is WCallResponse.Error -> { @@ -112,21 +110,15 @@ fun ActiveCallView(chatModel: ChatModel) { } } val call = chatModel.activeCall.value - if (call != null) ActiveCallOverlay(call, chatModel, endCall) + if (call != null) ActiveCallOverlay(call, chatModel) } } @Composable -private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, endCall: () -> Unit) { +private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) { ActiveCallOverlayLayout( call = call, - dismiss = { - chatModel.callCommand.value = WCallCommand.End - withApi { - chatModel.controller.apiEndCall(call.contact) - endCall() - } - }, + dismiss = { withApi { chatModel.callManager.endCall(call) } }, toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) }, toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) }, flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt index 54b6d20351..1a185daca6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.* import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.Contact +import chat.simplex.app.model.* import chat.simplex.app.model.NtfManager.Companion.OpenChatAction import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.ProfileImage import chat.simplex.app.views.onboarding.SimpleXLogo +import kotlinx.datetime.Clock class IncomingCallActivity: ComponentActivity() { private val vm by viewModels() @@ -115,12 +115,12 @@ fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel val cm = chatModel.callManager val cxt = LocalContext.current val scope = rememberCoroutineScope() - var acceptCallsFromLockScreen by remember { mutableStateOf(chatModel.controller.prefAcceptCallsFromLockScreen.get()) } + var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) } LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) } DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } } IncomingCallLockScreenAlertLayout( invitation, - acceptCallsFromLockScreen, + callOnLockScreen, rejectCall = { cm.endCall(invitation = invitation) }, ignoreCall = { chatModel.activeCallInvitation.value = null }, acceptCall = { cm.acceptIncomingCall(invitation = invitation) }, @@ -141,7 +141,7 @@ fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel @Composable fun IncomingCallLockScreenAlertLayout( invitation: CallInvitation, - acceptCallsFromLockScreen: Boolean, + callOnLockScreen: CallOnLockScreen?, rejectCall: () -> Unit, ignoreCall: () -> Unit, acceptCall: () -> Unit, @@ -155,7 +155,7 @@ fun IncomingCallLockScreenAlertLayout( ) { IncomingCallInfo(invitation) Spacer(Modifier.fillMaxHeight().weight(1f)) - if (acceptCallsFromLockScreen) { + if (callOnLockScreen == CallOnLockScreen.ACCEPT) { ProfileImage(size = 192.dp, image = invitation.contact.profile.image) Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2) Spacer(Modifier.fillMaxHeight().weight(1f)) @@ -166,7 +166,7 @@ fun IncomingCallLockScreenAlertLayout( Spacer(Modifier.size(48.dp)) LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall) } - } else { + } else if (callOnLockScreen == CallOnLockScreen.SHOW) { SimpleXLogo() Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp) Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp) @@ -212,9 +212,10 @@ fun PreviewIncomingCallLockScreenAlert() { invitation = CallInvitation( contact = Contact.sampleData, peerMedia = CallMediaType.Audio, - sharedKey = null + sharedKey = null, + callTs = Clock.System.now() ), - acceptCallsFromLockScreen = false, + callOnLockScreen = null, rejectCall = {}, ignoreCall = {}, acceptCall = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt index 269381281e..703fea35bd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt @@ -22,6 +22,7 @@ import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Contact import chat.simplex.app.ui.theme.* import chat.simplex.app.views.usersettings.ProfilePreview +import kotlinx.datetime.Clock @Composable fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) { @@ -97,7 +98,8 @@ fun PreviewIncomingCallAlertLayout() { invitation = CallInvitation( contact = Contact.sampleData, peerMedia = CallMediaType.Audio, - sharedKey = null + sharedKey = null, + callTs = Clock.System.now() ), rejectCall = {}, ignoreCall = {}, 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 index f2a8173bf7..2c8919f3b8 100644 --- 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 @@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource import chat.simplex.app.R import chat.simplex.app.model.Contact import chat.simplex.app.views.helpers.generalGetString +import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -89,7 +90,7 @@ sealed class WCallResponse { @Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) @Serializable class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) -@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?) { +@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?, val callTs: Instant) { val callTypeText: String get() = generalGetString(when(peerMedia) { CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call 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 4ac07f6dd4..c7252a5dae 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 @@ -66,10 +66,8 @@ fun scaffoldController(): ScaffoldController { @Composable fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val scaffoldCtrl = scaffoldController() - if (chatModel.clearOverlays.value) { - scaffoldCtrl.collapse() - ModalManager.shared.closeModal() - chatModel.clearOverlays.value = false + LaunchedEffect(chatModel.clearOverlays.value) { + if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse() } BottomSheetScaffold( scaffoldState = scaffoldCtrl.state, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt index 6349354c84..ea720b631f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt @@ -48,6 +48,10 @@ class ModalManager { modalCount.value = modalViews.count() } + fun closeModals() { + while (modalViews.isNotEmpty()) closeModal() + } + @Composable fun showInView() { if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index 2176249aac..5037647184 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -5,7 +5,10 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* @@ -14,28 +17,38 @@ import chat.simplex.app.ui.theme.HighOrLowlight @Composable fun CallSettingsView(m: ChatModel) { CallSettingsLayout( - webrtcPolicyRelay = m.controller.prefWebrtcPolicyRelay, - acceptCallsFromLockScreen = m.controller.prefAcceptCallsFromLockScreen + webrtcPolicyRelay = m.controller.appPrefs.webrtcPolicyRelay, + callOnLockScreen = m.controller.appPrefs.callOnLockScreen ) } @Composable fun CallSettingsLayout( webrtcPolicyRelay: Preference, - acceptCallsFromLockScreen: Preference, + callOnLockScreen: Preference, ) { Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { + val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) } Text( stringResource(R.string.call_settings), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1 ) SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay) - SharedPreferenceToggle(stringResource(R.string.accept_calls_from_lock_screen), acceptCallsFromLockScreen) + Column { + Text(stringResource(R.string.call_on_lock_screen)) + Row { + SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE) + Spacer(Modifier.fillMaxWidth().weight(1f)) + SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW) + Spacer(Modifier.fillMaxWidth().weight(1f)) + SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT) + } + } } } @@ -61,3 +74,15 @@ fun SharedPreferenceToggle( ) } } + +@Composable +fun SharedPreferenceRadioButton(text: String, prefState: MutableState, preference: Preference, value: T) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text) + val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary) + RadioButton(selected = prefState.value == value, colors = colors, onClick = { + preference.set(value) + prefState.value = value + }) + } +} 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 30fedfb726..acbeb5b237 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 @@ -34,9 +34,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val user = chatModel.currentUser.value fun setRunServiceInBackground(on: Boolean) { - chatModel.controller.prefRunServiceInBackground.set(on) + chatModel.controller.appPrefs.runServiceInBackground.set(on) if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { - chatModel.controller.prefBackgroundServiceNoticeShown.set(false) + chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false) } chatModel.controller.showBackgroundServiceNoticeIfNeeded() chatModel.runServiceInBackground.value = on @@ -135,6 +135,7 @@ fun SettingsLayout( Icon( Icons.Outlined.QrCode, contentDescription = stringResource(R.string.icon_descr_address), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(R.string.your_simplex_contact_address)) @@ -146,6 +147,7 @@ fun SettingsLayout( Icon( Icons.Outlined.Videocam, contentDescription = stringResource(R.string.call_settings), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(R.string.call_settings)) @@ -157,6 +159,7 @@ fun SettingsLayout( Icon( Icons.Outlined.HelpOutline, contentDescription = stringResource(R.string.icon_descr_help), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(R.string.how_to_use_simplex_chat)) @@ -168,6 +171,7 @@ fun SettingsLayout( Icon( Icons.Outlined.Info, contentDescription = stringResource(R.string.icon_descr_help), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(R.string.about_simplex_chat)) @@ -179,6 +183,7 @@ fun SettingsLayout( Icon( Icons.Outlined.TextFormat, contentDescription = stringResource(R.string.markdown_help), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(R.string.markdown_in_messages)) @@ -190,6 +195,7 @@ fun SettingsLayout( Icon( Icons.Outlined.Tag, contentDescription = stringResource(R.string.icon_descr_simplex_team), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( @@ -204,6 +210,7 @@ fun SettingsLayout( Icon( Icons.Outlined.Email, contentDescription = stringResource(R.string.icon_descr_email), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( @@ -218,6 +225,7 @@ fun SettingsLayout( Icon( Icons.Outlined.Dns, contentDescription = stringResource(R.string.smp_servers), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(R.string.smp_servers)) @@ -233,6 +241,7 @@ fun SettingsLayout( Icon( Icons.Outlined.Bolt, contentDescription = stringResource(R.string.private_notifications), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( @@ -261,6 +270,7 @@ fun SettingsLayout( Icon( Icons.Outlined.Lock, contentDescription = stringResource(R.string.chat_lock), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( @@ -287,6 +297,7 @@ fun SettingsLayout( Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), contentDescription = stringResource(R.string.chat_console), + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(R.string.chat_console)) @@ -298,6 +309,7 @@ fun SettingsLayout( Icon( painter = painterResource(id = R.drawable.ic_github), contentDescription = "GitHub", + tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) 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 b339b2e153..f8b1242652 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -380,12 +380,15 @@ Настройки звонков Соединяться через сервер (relay) - Принимать с экрана блокировки + Звонки на экране блокировки: + Принимать + Показывать + Выключить - Open SimpleX Chat\nto accept call - You can allow accepting calls from lock screen via Settings. - Open + Откройте SimpleX Chat\nчтобы принять звонок + Вы можете разрешить принимать звонки на экране блокировки через Настройки. + Открыть e2e зашифровано diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index ab91313ff0..cb1d5cc80e 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -382,7 +382,10 @@ Call settings Connect via relay - Accept calls from lock screen + Calls on lock screen: + Accept + Show + Disable Open SimpleX Chat to accept call diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index 2a651222e1..c4e5f5eabe 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -199,7 +199,7 @@ enum ChatResponse: Decodable, Error { 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 callInvitation(contact: Contact, callType: CallType, sharedKey: String?, callTs: Date) case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) case callAnswer(contact: Contact, answer: WebRTCSession) case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) @@ -322,7 +322,7 @@ enum ChatResponse: Decodable, Error { 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 .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))" diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift index 402387055f..acb14f2199 100644 --- a/apps/ios/Shared/Model/Shared/CallTypes.swift +++ b/apps/ios/Shared/Model/Shared/CallTypes.swift @@ -28,6 +28,7 @@ struct CallInvitation { var callkitUUID: UUID? var peerMedia: CallMediaType var sharedKey: String? + var callTs: Date var callTypeText: LocalizedStringKey { get { switch peerMedia { @@ -39,7 +40,8 @@ struct CallInvitation { static let sampleData = CallInvitation( contact: Contact.sampleData, - peerMedia: .audio + peerMedia: .audio, + callTs: .now ) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index aa3ce27e6f..56edef0d2d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -629,9 +629,9 @@ func processReceivedMsg(_ res: ChatResponse) { let fileName = cItem.file?.filePath { removeFile(fileName) } - case let .callInvitation(contact, callType, sharedKey): + case let .callInvitation(contact, callType, sharedKey, callTs): let uuid = UUID() - var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey) + var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey, callTs: callTs) m.callInvitations[contact.id] = invitation CallController.shared.reportNewIncomingCall(invitation: invitation) { error in if let error = error { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index ecd7633ec1..008db03e9f 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -127,7 +127,9 @@ class CallController: NSObject, CXProviderDelegate, ObservableObject { provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) } else { NtfManager.shared.notifyCallInvitation(invitation) - activeCallInvitation = invitation + if invitation.callTs.timeIntervalSinceNow >= -180 { + activeCallInvitation = invitation + } } } diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index ddcdb7cd6f..516b237538 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -12,8 +12,31 @@ struct CallSettings: View { @AppStorage(DEFAULT_WEBRTC_POLICY_RELAY) private var webrtcPolicyRelay = true var body: some View { - List { - Toggle("Connect via relay", isOn: $webrtcPolicyRelay) + VStack { + List { + Section("Settings") { + Toggle("Connect via relay", isOn: $webrtcPolicyRelay) + } + + Section("Limitations") { + VStack(alignment: .leading, spacing: 8) { + textListItem("1.", "Do NOT use SimpleX for emergency calls.") + textListItem("2.", "Pre-arrange the calls, as notifications arrive with a delay (we are improving it).") + textListItem("3.", "The microphone does not work when the app is in the background.") + textListItem("4.", "To prevent the call interruption, enable Do Not Disturb mode.") + textListItem("5.", "If the video fails to connect, flip the camera to resolve it.") + } + .font(.callout) + .padding(.vertical, 8) + } + } + } + } + + private func textListItem(_ n: String, _ text: LocalizedStringKey) -> some View { + ZStack(alignment: .topLeading) { + Text(n) + Text(text).frame(maxWidth: .infinity, alignment: .leading).padding(.leading, 20) } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 9608676742..214e9182f0 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -119,6 +119,7 @@ struct SettingsView: View { Image(colorScheme == .dark ? "github_light" : "github") .resizable() .frame(width: 24, height: 24) + .opacity(0.5) Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") .padding(.leading, indent) } @@ -128,12 +129,7 @@ struct SettingsView: View { // notificationsToggle(token) // } // } -// NavigationLink { -// CallViewDebug() -// .frame(maxHeight: .infinity, alignment: .top) -// } label: { - Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") -// } + Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") } } .navigationTitle("Your settings") @@ -142,7 +138,7 @@ struct SettingsView: View { private func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View { ZStack(alignment: .leading) { - Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary) content().padding(.leading, indent) } } From 5e476516cbee07e73e16c7e97cec44903b7b5010 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 28 May 2022 14:58:52 +0400 Subject: [PATCH 09/23] ios: lock toggle; android: fix lock timer (#702) --- .../.idea/codeStyles/codeStyleConfig.xml | 1 + .../java/chat/simplex/app/MainActivity.kt | 62 +++++++-------- .../app/src/main/res/values-ru/strings.xml | 2 +- .../app/src/main/res/values/strings.xml | 2 +- apps/ios/Shared/ContentView.swift | 48 +++++------ apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/SimpleXApp.swift | 56 +++++++------ .../Helpers/LocalAuthenticationUtils.swift | 27 +++++-- .../Views/UserSettings/SettingsView.swift | 79 +++++++++++++++++++ 9 files changed, 190 insertions(+), 88 deletions(-) diff --git a/apps/android/.idea/codeStyles/codeStyleConfig.xml b/apps/android/.idea/codeStyles/codeStyleConfig.xml index 79ee123c2b..6e6eec1148 100644 --- a/apps/android/.idea/codeStyles/codeStyleConfig.xml +++ b/apps/android/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +1,6 @@ \ No newline at end of file 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 4646474f0a..f8a21c2cd3 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 @@ -4,6 +4,7 @@ import android.app.Application import android.content.* import android.net.Uri import android.os.Bundle +import android.os.SystemClock.elapsedRealtime import android.util.Log import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -37,12 +38,12 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { private val vm by viewModels() private val chatController by lazy { (application as SimplexApp).chatController } private val userAuthorized = mutableStateOf(null) - private val lastLA = mutableStateOf(null) + private val enteredBackground = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ProcessLifecycleOwner.get().lifecycle.addObserver(this) -// testJson() + // testJson() val m = vm.chatModel processNotificationIntent(intent, m) setContent { @@ -67,38 +68,37 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { withApi { when (event) { + Lifecycle.Event.ON_STOP -> { + enteredBackground.value = elapsedRealtime() + } Lifecycle.Event.ON_START -> { // perform local authentication if needed val m = vm.chatModel - val lastLAVal = lastLA.value - if ( - m.controller.appPrefs.performLA.get() - && (lastLAVal == null || (System.nanoTime() - lastLAVal >= 30 * 1e+9)) - ) { - userAuthorized.value = false - authenticate( - generalGetString(R.string.auth_access_chats), - generalGetString(R.string.auth_log_in_using_credential), - this@MainActivity, - completed = { laResult -> - when (laResult) { - LAResult.Success -> { - userAuthorized.value = true - lastLA.value = System.nanoTime() - } - is LAResult.Error -> laErrorToast(applicationContext, laResult.errString) - LAResult.Failed -> laFailedToast(applicationContext) - LAResult.Unavailable -> { - userAuthorized.value = true - m.performLA.value = false - m.controller.appPrefs.performLA.set(false) - laUnavailableTurningOffAlert() + val enteredBackgroundVal = enteredBackground.value + if (!m.controller.appPrefs.performLA.get()) { + userAuthorized.value = true + } else { + if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) { + userAuthorized.value = false + authenticate( + generalGetString(R.string.auth_unlock), + generalGetString(R.string.auth_log_in_using_credential), + this@MainActivity, + completed = { laResult -> + when (laResult) { + LAResult.Success -> userAuthorized.value = true + is LAResult.Error -> laErrorToast(applicationContext, laResult.errString) + LAResult.Failed -> laFailedToast(applicationContext) + LAResult.Unavailable -> { + userAuthorized.value = true + m.performLA.value = false + m.controller.appPrefs.performLA.set(false) + laUnavailableTurningOffAlert() + } } } - } - ) - } else { - userAuthorized.value = true + ) + } } } } @@ -137,8 +137,6 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { LAResult.Success -> { m.performLA.value = true prefPerformLA.set(true) - userAuthorized.value = true - lastLA.value = System.nanoTime() laTurnedOnAlert() } is LAResult.Error -> { @@ -206,7 +204,7 @@ fun MainPage( showLANotice: () -> Unit ) { // this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication - var chatsAccessAuthorized by remember { mutableStateOf(false) } + var chatsAccessAuthorized by remember { mutableStateOf(false) } LaunchedEffect(userAuthorized.value) { delay(500L) chatsAccessAuthorized = userAuthorized.value == true 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 f8b1242652..6382394249 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -72,7 +72,7 @@ Блокировка SimpleX включена Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. - Разблокировать SimpleX + Разблокировать Пройдите аутентификацию Включить блокировку SimpleX Отключить блокировку SimpleX diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index cb1d5cc80e..cad76c4ab8 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -72,7 +72,7 @@ SimpleX Lock turned on You will be required to authenticate when you start or resume the app after 30 seconds in background. - Access chats + Unlock Log in using your credential Enable SimpleX Lock Disable SimpleX Lock diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 158392ec23..408edf0911 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -11,29 +11,29 @@ struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared - @State private var showNotificationAlert = false @Binding var userAuthorized: Bool? var body: some View { ZStack { - if let step = chatModel.onboardingStage { - if userAuthorized == true, - case .onboardingComplete = step, - let user = chatModel.currentUser { - ZStack(alignment: .top) { - ChatListView(user: user) - .onAppear { - NtfManager.shared.requestAuthorization(onDeny: { - alertManager.showAlert(notificationAlert()) - }) + if userAuthorized == true { + if let step = chatModel.onboardingStage { + if case .onboardingComplete = step, + let user = chatModel.currentUser { + ZStack(alignment: .top) { + ChatListView(user: user) + .onAppear { + NtfManager.shared.requestAuthorization(onDeny: { + alertManager.showAlert(notificationAlert()) + }) + } + if chatModel.showCallView, let call = chatModel.activeCall { + ActiveCallView(call: call) + } + IncomingCallView() } - if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call) - } - IncomingCallView() + } else { + OnboardingView(onboarding: step) } - } else { - OnboardingView(onboarding: step) } } } @@ -99,11 +99,15 @@ final class AlertManager: ObservableObject { } func showAlertMsg(title: LocalizedStringKey, message: LocalizedStringKey? = nil) { - if let message = message { - showAlert(Alert(title: Text(title), message: Text(message))) - } else { - showAlert(Alert(title: Text(title))) - } + showAlert(mkAlert(title: title, message: message)) + } +} + +func mkAlert(title: LocalizedStringKey, message: LocalizedStringKey? = nil) -> Alert { + if let message = message { + return Alert(title: Text(title), message: Text(message)) + } else { + return Alert(title: Text(title)) } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4d4ae6ad91..0337b2be80 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -14,6 +14,7 @@ import WebKit final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var currentUser: User? + @Published var performLA: Bool = false // list of chat "previews" @Published var chats: [Chat] = [] // current chat diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 0575fd587a..291befb628 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -14,10 +14,12 @@ let logger = Logger() struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared + @ObservedObject var alertManager = AlertManager.shared @Environment(\.scenePhase) var scenePhase + @AppStorage(DEFAULT_PERFORM_LA) private var performLA = false @State private var userAuthorized: Bool? = nil - @State private var doAuthenticate: Bool? = nil - @State private var lastLA: Double? = nil + @State private var doAuthenticate: Bool = true + @State private var enteredBackground: Double? = nil init() { hs_init(0, nil) @@ -35,8 +37,8 @@ struct SimpleXApp: App { chatModel.appOpenUrl = url } .onAppear() { + chatModel.performLA = performLA initializeChat() - doAuthenticate = true } .onChange(of: scenePhase) { phase in logger.debug("scenePhase \(String(describing: scenePhase))") @@ -45,10 +47,11 @@ struct SimpleXApp: App { case .background: BGManager.shared.schedule() doAuthenticate = true + enteredBackground = ProcessInfo.processInfo.systemUptime case .inactive: - authenticateUser() + authenticateOnPhaseChange() case .active: - authenticateUser() + authenticateOnPhaseChange() default: break } @@ -56,34 +59,37 @@ struct SimpleXApp: App { } } - private func authenticateUser() { - if doAuthenticate == true, - authenticationExpired() { + private func authenticateOnPhaseChange() { + if doAuthenticate { doAuthenticate = false - userAuthorized = false - authenticate() { laResult in - switch (laResult) { - case .success: - userAuthorized = true - lastLA = ProcessInfo.processInfo.systemUptime - case .failed: - laFailedAlert() - case .unavailable: - userAuthorized = true - laUnavailableAlert() + if !performLA { + userAuthorized = true + } else { + if authenticationExpired() { + userAuthorized = false + authenticate(reason: "Unlock") { laResult in + switch (laResult) { + case .success: + userAuthorized = true + case .failed: + AlertManager.shared.showAlert(laFailedAlert()) + case .unavailable: + userAuthorized = true + performLA = false + chatModel.performLA = false + AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) + } + } } } } } private func authenticationExpired() -> Bool { - if (lastLA == nil) { - return true - } - else if let lastLA = lastLA, ProcessInfo.processInfo.systemUptime - lastLA >= 30 { - return true + if let enteredBackground = enteredBackground { + return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30 } else { - return false + return true } } } diff --git a/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift index 2cc1885640..4776f660c3 100644 --- a/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift +++ b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift @@ -15,11 +15,10 @@ enum LAResult { case unavailable(authError: String?) } -func authenticate(completed: @escaping (LAResult) -> Void) { +func authenticate(reason: String, completed: @escaping (LAResult) -> Void) { let laContext = LAContext() var authAvailabilityError: NSError? if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) { - let reason = "Access chats" laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in DispatchQueue.main.async { if success { @@ -36,16 +35,30 @@ func authenticate(completed: @escaping (LAResult) -> Void) { } } -func laFailedAlert() { - AlertManager.shared.showAlertMsg( +func laTurnedOnAlert() -> Alert { + mkAlert( + title: "SimpleX Lock turned on", + message: "You will be required to authenticate when you start or resume the app after 30 seconds in background." + ) +} + +func laFailedAlert() -> Alert { + mkAlert( title: "Authentication failed", message: "You could not be verified; please try again." ) } -func laUnavailableAlert() { - AlertManager.shared.showAlertMsg( +func laUnavailableInstructionAlert() -> Alert { + mkAlert( title: "Authentication unavailable", - message: "Your device is not configured for authentication." + message: "Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." + ) +} + +func laUnavailableTurningOffAlert() -> Alert { + mkAlert( + title: "Authentication unavailable", + message: "Device authentication is disabled. Turning off SimpleX Lock." ) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 214e9182f0..38d506b352 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -14,11 +14,13 @@ let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionS let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String +let DEFAULT_PERFORM_LA = "performLocalAuthentication" let DEFAULT_USE_NOTIFICATIONS = "useNotifications" let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let appDefaults: [String:Any] = [ + DEFAULT_PERFORM_LA: false, DEFAULT_USE_NOTIFICATIONS: false, DEFAULT_PENDING_CONNECTIONS: true, DEFAULT_WEBRTC_POLICY_RELAY: true @@ -30,10 +32,22 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool + @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false + @State private var performLAToggleReset = false @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 + @State var alert: SettingsViewAlert? = nil + + enum SettingsViewAlert: Identifiable { + case laTurnedOnAlert + case laFailedAlert + case laUnavailableInstructionAlert + case laUnavailableTurningOffAlert + + var id: SettingsViewAlert { get { self } } + } var body: some View { let user: User = chatModel.currentUser! @@ -57,6 +71,9 @@ struct SettingsView: View { } Section("Settings") { + settingsRow("lock") { + Toggle("SimpleX Lock", isOn: $chatModel.performLA) + } settingsRow("link") { Toggle("Show pending connections", isOn: $pendingConnections) } @@ -133,6 +150,68 @@ struct SettingsView: View { } } .navigationTitle("Your settings") + .onChange(of: chatModel.performLA) { performLAToggle in + if performLAToggleReset { + performLAToggleReset = false + } else { + if performLAToggle { + enableLA() + } else { + disableLA() + } + } + } + .alert(item: $alert) { alertItem in + switch alertItem { + case .laTurnedOnAlert: return laTurnedOnAlert() + case .laFailedAlert: return laFailedAlert() + case .laUnavailableInstructionAlert: return laUnavailableInstructionAlert() + case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert() + } + } + } + } + + private func enableLA() { + authenticate(reason: "Enable SimpleX Lock") { laResult in + switch laResult { + case .success: + prefPerformLA = true + alert = .laTurnedOnAlert + case .failed: + prefPerformLA = false + withAnimation() { + chatModel.performLA = false + } + performLAToggleReset = true + alert = .laFailedAlert + case .unavailable: + prefPerformLA = false + withAnimation() { + chatModel.performLA = false + } + performLAToggleReset = true + alert = .laUnavailableInstructionAlert + } + } + } + + private func disableLA() { + authenticate(reason: "Disable SimpleX Lock") { laResult in + switch (laResult) { + case .success: + prefPerformLA = false + case .failed: + prefPerformLA = true + withAnimation() { + chatModel.performLA = true + } + performLAToggleReset = true + alert = .laFailedAlert + case .unavailable: + prefPerformLA = false + alert = .laUnavailableTurningOffAlert + } } } From b56ad775023e80e9b5a7f7189495e07c5aabed8a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 28 May 2022 12:34:40 +0100 Subject: [PATCH 10/23] core: mark accepted and rejected call items read (#704) --- src/Simplex/Chat.hs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 95ab009c3f..377d27552c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -470,9 +470,10 @@ processChatCommand = \case APIRejectCall contactId -> -- party accepting call withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of - CallInvitationReceived {} -> + CallInvitationReceived {} -> do let aciContent = ACIContent SMDRcv $ CIRcvCall CISCallRejected 0 - in updateDirectChatItemView userId ct chatItemId aciContent Nothing $> Nothing + withStore $ \st -> updateDirectChatItemsRead st contactId $ Just (chatItemId, chatItemId) + updateDirectChatItemView userId ct chatItemId aciContent Nothing $> Nothing _ -> throwChatError . CECallState $ callStateTag callState APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> -- party accepting call @@ -483,6 +484,7 @@ processChatCommand = \case callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer) + withStore $ \st -> updateDirectChatItemsRead st contactId $ Just (chatItemId, chatItemId) updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState From c3c712aa02d29fb73ac5ecc10fc97616599f9f6e Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 28 May 2022 22:09:46 +0400 Subject: [PATCH 11/23] ios: show local authentication notice; ios & android: retry authentication button (#706) * advertisement * refactor * advertisement state machine * simplify * ios: retry * remove log * android: retry * Update apps/ios/Shared/ContentView.swift Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * Update apps/ios/Shared/Views/UserSettings/SettingsView.swift Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/MainActivity.kt | 235 +++++++++++------- .../app/src/main/res/values-ru/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + apps/ios/Shared/ContentView.swift | 80 +++++- apps/ios/Shared/Model/ChatModel.swift | 1 - apps/ios/Shared/SimpleXApp.swift | 47 +--- .../Views/UserSettings/SettingsButton.swift | 3 +- .../Views/UserSettings/SettingsView.swift | 19 +- 8 files changed, 248 insertions(+), 139 deletions(-) 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 f8a21c2cd3..a67173534e 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 @@ -12,14 +12,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Replay import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.ChatModel import chat.simplex.app.model.NtfManager +import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.SplashView import chat.simplex.app.views.call.ActiveCallView @@ -39,6 +44,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { private val chatController by lazy { (application as SimplexApp).chatController } private val userAuthorized = mutableStateOf(null) private val enteredBackground = mutableStateOf(null) + private val laFailed = mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -53,7 +59,14 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { .background(MaterialTheme.colors.background) .fillMaxSize() ) { - MainPage(m, userAuthorized, ::setPerformLA, showLANotice = { m.controller.showLANotice(this) }) + MainPage( + m, + userAuthorized, + laFailed, + ::runAuthenticate, + ::setPerformLA, + showLANotice = { m.controller.showLANotice(this) } + ) } } } @@ -72,39 +85,50 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { enteredBackground.value = elapsedRealtime() } Lifecycle.Event.ON_START -> { - // perform local authentication if needed - val m = vm.chatModel val enteredBackgroundVal = enteredBackground.value - if (!m.controller.appPrefs.performLA.get()) { - userAuthorized.value = true - } else { - if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) { - userAuthorized.value = false - authenticate( - generalGetString(R.string.auth_unlock), - generalGetString(R.string.auth_log_in_using_credential), - this@MainActivity, - completed = { laResult -> - when (laResult) { - LAResult.Success -> userAuthorized.value = true - is LAResult.Error -> laErrorToast(applicationContext, laResult.errString) - LAResult.Failed -> laFailedToast(applicationContext) - LAResult.Unavailable -> { - userAuthorized.value = true - m.performLA.value = false - m.controller.appPrefs.performLA.set(false) - laUnavailableTurningOffAlert() - } - } - } - ) - } + if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) { + runAuthenticate() } } } } } + private fun runAuthenticate() { + val m = vm.chatModel + if (!m.controller.appPrefs.performLA.get()) { + userAuthorized.value = true + } else { + userAuthorized.value = false + authenticate( + generalGetString(R.string.auth_unlock), + generalGetString(R.string.auth_log_in_using_credential), + this@MainActivity, + completed = { laResult -> + when (laResult) { + LAResult.Success -> { + userAuthorized.value = true + } + is LAResult.Error -> { + laFailed.value = true + laErrorToast(applicationContext, laResult.errString) + } + LAResult.Failed -> { + laFailed.value = true + laFailedToast(applicationContext) + } + LAResult.Unavailable -> { + userAuthorized.value = true + m.performLA.value = false + m.controller.appPrefs.performLA.set(false) + laUnavailableTurningOffAlert() + } + } + } + ) + } + } + private fun schedulePeriodicServiceRestartWorker() { val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get() val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) { @@ -124,70 +148,79 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { } private fun setPerformLA(on: Boolean) { - val m = vm.chatModel + vm.chatModel.controller.appPrefs.laNoticeShown.set(true) if (on) { - m.controller.appPrefs.laNoticeShown.set(true) - authenticate( - generalGetString(R.string.auth_enable), - generalGetString(R.string.auth_confirm_credential), - this@MainActivity, - completed = { laResult -> - val prefPerformLA = m.controller.appPrefs.performLA - when (laResult) { - LAResult.Success -> { - m.performLA.value = true - prefPerformLA.set(true) - laTurnedOnAlert() - } - is LAResult.Error -> { - m.performLA.value = false - prefPerformLA.set(false) - laErrorToast(applicationContext, laResult.errString) - } - LAResult.Failed -> { - m.performLA.value = false - prefPerformLA.set(false) - laFailedToast(applicationContext) - } - LAResult.Unavailable -> { - m.performLA.value = false - prefPerformLA.set(false) - laUnavailableInstructionAlert() - } - } - } - ) + enableLA() } else { - authenticate( - generalGetString(R.string.auth_disable), - generalGetString(R.string.auth_confirm_credential), - this@MainActivity, - completed = { laResult -> - val prefPerformLA = m.controller.appPrefs.performLA - when (laResult) { - LAResult.Success -> { - m.performLA.value = false - prefPerformLA.set(false) - } - is LAResult.Error -> { - m.performLA.value = true - prefPerformLA.set(true) - laErrorToast(applicationContext, laResult.errString) - } - LAResult.Failed -> { - m.performLA.value = true - prefPerformLA.set(true) - laFailedToast(applicationContext) - } - LAResult.Unavailable -> { - m.performLA.value = false - prefPerformLA.set(false) - laUnavailableTurningOffAlert() - } + disableLA() + } + } + + private fun enableLA() { + val m = vm.chatModel + authenticate( + generalGetString(R.string.auth_enable), + generalGetString(R.string.auth_confirm_credential), + this@MainActivity, + completed = { laResult -> + val prefPerformLA = m.controller.appPrefs.performLA + when (laResult) { + LAResult.Success -> { + m.performLA.value = true + prefPerformLA.set(true) + laTurnedOnAlert() + } + is LAResult.Error -> { + m.performLA.value = false + prefPerformLA.set(false) + laErrorToast(applicationContext, laResult.errString) + } + LAResult.Failed -> { + m.performLA.value = false + prefPerformLA.set(false) + laFailedToast(applicationContext) + } + LAResult.Unavailable -> { + m.performLA.value = false + prefPerformLA.set(false) + laUnavailableInstructionAlert() } } - ) - } + } + ) + } + + private fun disableLA() { + val m = vm.chatModel + authenticate( + generalGetString(R.string.auth_disable), + generalGetString(R.string.auth_confirm_credential), + this@MainActivity, + completed = { laResult -> + val prefPerformLA = m.controller.appPrefs.performLA + when (laResult) { + LAResult.Success -> { + m.performLA.value = false + prefPerformLA.set(false) + } + is LAResult.Error -> { + m.performLA.value = true + prefPerformLA.set(true) + laErrorToast(applicationContext, laResult.errString) + } + LAResult.Failed -> { + m.performLA.value = true + prefPerformLA.set(true) + laFailedToast(applicationContext) + } + LAResult.Unavailable -> { + m.performLA.value = false + prefPerformLA.set(false) + laUnavailableTurningOffAlert() + } + } + } + ) } } @@ -200,6 +233,8 @@ class SimplexViewModel(application: Application): AndroidViewModel(application) fun MainPage( chatModel: ChatModel, userAuthorized: MutableState, + laFailed: MutableState, + runAuthenticate: () -> Unit, setPerformLA: (Boolean) -> Unit, showLANotice: () -> Unit ) { @@ -232,12 +267,36 @@ fun MainPage( chatModel.clearOverlays.value = false } } + + @Composable + fun retryAuthView() { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(R.string.auth_retry), + icon = Icons.Outlined.Replay, + click = { + laFailed.value = false + runAuthenticate() + } + ) + } + } + Box { val onboarding = chatModel.onboardingStage.value val userCreated = chatModel.userCreated.value when { onboarding == null || userCreated == null -> SplashView() - !chatsAccessAuthorized -> SplashView() + !chatsAccessAuthorized -> { + if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) { + retryAuthView() + } else { + SplashView() + } + } onboarding == OnboardingStage.OnboardingComplete && userCreated -> { Box { if (chatModel.showCallView.value) ActiveCallView(chatModel) 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 6382394249..c3d3ba12a3 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -83,6 +83,7 @@ Аутентификация недоступна На устройстве не включена аутентификация. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. На устройстве выключена аутентификация. Отключение блокировки 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 cad76c4ab8..e058b748b2 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ Authentication unavailable Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. Device authentication is disabled. Turning off SimpleX Lock. + Retry Reply diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 408edf0911..871dd1f41e 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -11,7 +11,13 @@ struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared - @Binding var userAuthorized: Bool? + @Binding var doAuthenticate: Bool + @Binding var enteredBackground: Double? + @State private var userAuthorized: Bool? + @State private var laFailed: Bool = false + @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false + @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false + @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false var body: some View { ZStack { @@ -25,6 +31,12 @@ struct ContentView: View { NtfManager.shared.requestAuthorization(onDeny: { alertManager.showAlert(notificationAlert()) }) + // Local Authentication notice is to be shown on next start after onboarding is complete + if (!prefLANoticeShown && prefShowLANotice) { + prefLANoticeShown = true + alertManager.showAlert(laNoticeAlert()) + } + prefShowLANotice = true } if chatModel.showCallView, let call = chatModel.activeCall { ActiveCallView(call: call) @@ -35,11 +47,77 @@ struct ContentView: View { OnboardingView(onboarding: step) } } + } else if prefPerformLA && laFailed { + retryAuthView() + } + } + .onChange(of: doAuthenticate) { doAuth in + if doAuth, authenticationExpired() { + runAuthenticate() } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } + private func retryAuthView() -> some View { + Button { + laFailed = false + runAuthenticate() + } label: { Label("Retry", systemImage: "arrow.counterclockwise") } + } + + private func runAuthenticate() { + if !prefPerformLA { + userAuthorized = true + } else { + userAuthorized = false + authenticate(reason: "Unlock") { laResult in + switch (laResult) { + case .success: + userAuthorized = true + case .failed: + laFailed = true + AlertManager.shared.showAlert(laFailedAlert()) + case .unavailable: + userAuthorized = true + prefPerformLA = false + AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) + } + } + } + } + + private func authenticationExpired() -> Bool { + if let enteredBackground = enteredBackground { + return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30 + } else { + return true + } + } + + func laNoticeAlert() -> Alert { + Alert( + title: Text("SimpleX Lock"), + message: Text("To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled."), + primaryButton: .default(Text("Turn on")) { + authenticate(reason: "Enable SimpleX Lock") { laResult in + switch laResult { + case .success: + prefPerformLA = true + alertManager.showAlert(laTurnedOnAlert()) + case .failed: + prefPerformLA = false + alertManager.showAlert(laFailedAlert()) + case .unavailable: + prefPerformLA = false + alertManager.showAlert(laUnavailableInstructionAlert()) + } + } + }, + secondaryButton: .cancel() + ) + } + func notificationAlert() -> Alert { Alert( title: Text("Notifications are disabled!"), diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 0337b2be80..4d4ae6ad91 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -14,7 +14,6 @@ import WebKit final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var currentUser: User? - @Published var performLA: Bool = false // list of chat "previews" @Published var chats: [Chat] = [] // current chat diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 291befb628..e1a54748b3 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -16,9 +16,9 @@ struct SimpleXApp: App { @StateObject private var chatModel = ChatModel.shared @ObservedObject var alertManager = AlertManager.shared @Environment(\.scenePhase) var scenePhase - @AppStorage(DEFAULT_PERFORM_LA) private var performLA = false + @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var userAuthorized: Bool? = nil - @State private var doAuthenticate: Bool = true + @State private var doAuthenticate: Bool = false @State private var enteredBackground: Double? = nil init() { @@ -30,14 +30,13 @@ struct SimpleXApp: App { var body: some Scene { return WindowGroup { - ContentView(userAuthorized: $userAuthorized) + ContentView(doAuthenticate: $doAuthenticate, enteredBackground: $enteredBackground) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") chatModel.appOpenUrl = url } .onAppear() { - chatModel.performLA = performLA initializeChat() } .onChange(of: scenePhase) { phase in @@ -46,50 +45,14 @@ struct SimpleXApp: App { switch (phase) { case .background: BGManager.shared.schedule() - doAuthenticate = true + doAuthenticate = false enteredBackground = ProcessInfo.processInfo.systemUptime - case .inactive: - authenticateOnPhaseChange() case .active: - authenticateOnPhaseChange() + doAuthenticate = true default: break } } } } - - private func authenticateOnPhaseChange() { - if doAuthenticate { - doAuthenticate = false - if !performLA { - userAuthorized = true - } else { - if authenticationExpired() { - userAuthorized = false - authenticate(reason: "Unlock") { laResult in - switch (laResult) { - case .success: - userAuthorized = true - case .failed: - AlertManager.shared.showAlert(laFailedAlert()) - case .unavailable: - userAuthorized = true - performLA = false - chatModel.performLA = false - AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) - } - } - } - } - } - } - - private func authenticationExpired() -> Bool { - if let enteredBackground = enteredBackground { - return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30 - } else { - return true - } - } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift index 7292fd4373..23033fd7cf 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift @@ -11,13 +11,14 @@ import SwiftUI struct SettingsButton: View { @EnvironmentObject var chatModel: ChatModel @State private var showSettings = false + @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false var body: some View { Button { showSettings = true } label: { Image(systemName: "gearshape") } .sheet(isPresented: $showSettings, content: { - SettingsView(showSettings: $showSettings) + SettingsView(showSettings: $showSettings, performLA: prefPerformLA) }) } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 38d506b352..b3b1b8bc1f 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -14,12 +14,16 @@ let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionS let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String +let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice" +let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown" let DEFAULT_PERFORM_LA = "performLocalAuthentication" let DEFAULT_USE_NOTIFICATIONS = "useNotifications" let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let appDefaults: [String:Any] = [ + DEFAULT_SHOW_LA_NOTICE: false, + DEFAULT_LA_NOTICE_SHOWN: false, DEFAULT_PERFORM_LA: false, DEFAULT_USE_NOTIFICATIONS: false, DEFAULT_PENDING_CONNECTIONS: true, @@ -32,10 +36,12 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool + @State var performLA: Bool = false + @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false - @State private var performLAToggleReset = false @AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true + @State private var performLAToggleReset = false @State var showNotificationsAlert: Bool = false @State var whichNotificationsAlert = NotificationAlert.enable @State var alert: SettingsViewAlert? = nil @@ -72,7 +78,7 @@ struct SettingsView: View { Section("Settings") { settingsRow("lock") { - Toggle("SimpleX Lock", isOn: $chatModel.performLA) + Toggle("SimpleX Lock", isOn: $performLA) } settingsRow("link") { Toggle("Show pending connections", isOn: $pendingConnections) @@ -150,7 +156,8 @@ struct SettingsView: View { } } .navigationTitle("Your settings") - .onChange(of: chatModel.performLA) { performLAToggle in + .onChange(of: performLA) { performLAToggle in + prefLANoticeShown = true if performLAToggleReset { performLAToggleReset = false } else { @@ -181,14 +188,14 @@ struct SettingsView: View { case .failed: prefPerformLA = false withAnimation() { - chatModel.performLA = false + performLA = false } performLAToggleReset = true alert = .laFailedAlert case .unavailable: prefPerformLA = false withAnimation() { - chatModel.performLA = false + performLA = false } performLAToggleReset = true alert = .laUnavailableInstructionAlert @@ -204,7 +211,7 @@ struct SettingsView: View { case .failed: prefPerformLA = true withAnimation() { - chatModel.performLA = true + performLA = true } performLAToggleReset = true alert = .laFailedAlert From 89908ef5dc80013f5d7ae3bfb2013f3040cc2c33 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 28 May 2022 19:13:07 +0100 Subject: [PATCH 12/23] core: chat item on skipped messages (#705) * core: chat item integrity * create chat item on skipped messages (but only on content items) * report skipped messages on all messages, not only content messages * remove type signature * remove migration * update rfc --- docs/rfcs/2022-05-28-chat-item-integrity.md | 17 +++++++ src/Simplex/Chat.hs | 50 ++++++++++++--------- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Messages.hs | 30 ++++++++++++- src/Simplex/Chat/Store.hs | 9 ++++ src/Simplex/Chat/View.hs | 5 +++ 6 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 docs/rfcs/2022-05-28-chat-item-integrity.md diff --git a/docs/rfcs/2022-05-28-chat-item-integrity.md b/docs/rfcs/2022-05-28-chat-item-integrity.md new file mode 100644 index 0000000000..ad009d1f78 --- /dev/null +++ b/docs/rfcs/2022-05-28-chat-item-integrity.md @@ -0,0 +1,17 @@ +# Chat item integrity + +## Problem + +SMP agent reports skipped, duplicate, incorrect IDs and bad message hashes, and this event is also shown to the terminal users. + +This is not shown to mobile app users at the moment, as there is nothing in the data model to persist this information. + +While message hash violations have never happened so far, skipped messages happen every time we restart the server, until we introduce server redundancy. + +It would be helpful to the users to know when they have skipped messages rather than to check with all their contacts if they do. + +## Solution + +The proposed types/data model differentiates the integrity errors that are related to a particular item (they are saved to item meta-data, and should be shown as item status in the UI) and the errors that indicate skipped messages (these are created as separate chat items, and should be shown in the UI as a separate chat item). + +This [PR #705](https://github.com/simplex-chat/simplex-chat/pull/705) only implements chat item for skipped messages, for the remaining message integrity errors it still uses the event CRMsgIntegrityError that is only displayed in the terminal. diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 377d27552c..e91a4c4180 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1467,7 +1467,7 @@ 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 + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc ciFile_ <- processFileInvitation fileInvitation_ $ \fi chSize -> withStore $ \st -> createRcvFileTransfer st userId ct fi chSize @@ -1487,7 +1487,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta = do - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta updateRcvChatItem `catchError` \e -> case e of (ChatErrorStore (SEChatItemSharedMsgIdNotFound _)) -> do @@ -1508,7 +1508,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m () messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta deleteRcvChatItem `catchError` \e -> case e of (ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound ct sMsgId @@ -1528,7 +1528,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage ciFile_ <- processFileInvitation fileInvitation_ $ \fi chSize -> withStore $ \st -> createRcvGroupFileTransfer st userId m fi chSize ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) ciFile_ - groupMsgToView gInfo ci msgMeta + groupMsgToView gInfo m ci msgMeta let g = groupName' gInfo showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText setActive $ ActiveG g @@ -1560,8 +1560,7 @@ 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 + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta 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} @@ -1577,14 +1576,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage 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 $ MCFile "") ciFile - groupMsgToView gInfo ci msgMeta + groupMsgToView gInfo m ci msgMeta let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG g xFileCancel :: Contact -> SharedMsgId -> MsgMeta -> m () - xFileCancel Contact {contactId} sharedMsgId msgMeta = do - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + xFileCancel ct@Contact {contactId} sharedMsgId msgMeta = do + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta fileId <- withStore $ \st -> getFileIdBySharedMsgId st userId contactId sharedMsgId ft@RcvFileTransfer {cancelled} <- withStore (\st -> getRcvFileTransfer st user fileId) unless cancelled $ do @@ -1592,8 +1591,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRRcvFileSndCancelled ft xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m () - xFileCancelGroup GroupInfo {groupId} GroupMember {memberId} sharedMsgId msgMeta = do - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + xFileCancelGroup g@GroupInfo {groupId} mem@GroupMember {memberId} sharedMsgId msgMeta = do + checkIntegrityCreateItem (CDGroupRcv g mem) msgMeta fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId CChatItem msgDir ChatItem {chatDir} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId case (msgDir, chatDir) of @@ -1608,8 +1607,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage (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 + xFileAcptInvGroup g@GroupInfo {groupId} m sharedMsgId fileConnReq fName msgMeta = do + checkIntegrityCreateItem (CDGroupRcv g m) msgMeta fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId (FileTransferMeta {fileName, cancelled}, _) <- withStore (\st -> getSndFileTransfer st user fileId) unless cancelled $ @@ -1621,9 +1620,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage 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 - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + groupMsgToView :: GroupInfo -> GroupMember -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () + groupMsgToView gInfo m ci msgMeta = do + checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci processGroupInvitation :: Contact -> GroupInvitation -> m () @@ -1634,10 +1633,19 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRReceivedGroupInvitation gInfo ct memRole showToast ("#" <> gName <> " " <> c <> "> ") "invited you to join the group" - checkIntegrity :: MsgMeta -> (MsgErrorType -> m ()) -> m () - checkIntegrity MsgMeta {integrity} action = case integrity of - MsgError e -> action e + checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m () + checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of MsgOk -> pure () + MsgError e -> case e of + MsgSkipped {} -> createIntegrityErrorItem e + _ -> toView $ CRMsgIntegrityError e + where + createIntegrityErrorItem e = do + createdAt <- liftIO getCurrentTime + let content = CIRcvIntegrityError e + ciId <- withStore $ \st -> createNewChatItemNoMsg st user cd content brokerTs createdAt + ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing brokerTs createdAt + toView $ CRNewChatItem $ AChatItem (chatTypeI @c) SMDRcv (toChatInfo cd) ci xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (p == p') $ do @@ -1668,7 +1676,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- to party accepting call xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m () xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg msgMeta = do - checkIntegrity msgMeta $ toView . CRMsgIntegrityError + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) @@ -1741,7 +1749,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage 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 + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta calls <- asks currentCalls atomically (TM.lookup ctId' calls) >>= \case Nothing -> messageError $ eventName <> ": no current call" diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b3bf5c53e9..9abaa4cdda 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -189,7 +189,7 @@ data ChatResponse | CRChatItemDeleted {deletedChatItem :: AChatItem, toChatItem :: AChatItem} | CRChatItemDeletedNotFound {contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent MsgContent Int ZonedTime - | CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile + | CRMsgIntegrityError {msgError :: MsgErrorType} | CRCmdAccepted {corr :: CorrId} | CRCmdOk | CRChatHelp {helpSection :: HelpSection} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 258873c39c..48c1cb337a 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -33,7 +33,7 @@ import Simplex.Chat.Markdown 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.Protocol (AgentErrorType, AgentMsgId, MsgErrorType (..), MsgMeta (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) @@ -171,6 +171,13 @@ toCIDirection = \case CDGroupSnd _ -> CIGroupSnd CDGroupRcv _ m -> CIGroupRcv m +toChatInfo :: ChatDirection c d -> ChatInfo c +toChatInfo = \case + CDDirectSnd c -> DirectChat c + CDDirectRcv c -> DirectChat c + CDGroupSnd g -> GroupChat g + CDGroupRcv g _ -> GroupChat g + data NewChatItem d = NewChatItem { createdByMsgId :: Maybe MessageId, itemSent :: SMsgDirection d, @@ -240,6 +247,7 @@ instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions +-- This type is not saved to DB, so all JSON encodings are platform-specific data CIMeta (d :: MsgDirection) = CIMeta { itemId :: ChatItemId, itemTs :: ChatItemTs, @@ -435,7 +443,7 @@ instance StrEncoding ACIStatus where "snd_new" -> pure $ ACIStatus SMDSnd CISSndNew "snd_sent" -> pure $ ACIStatus SMDSnd CISSndSent "snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth - "snd_error" -> ACIStatus SMDSnd <$> (A.space *> strP) + "snd_error" -> ACIStatus SMDSnd . CISSndError <$> (A.space *> strP) "rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew "rcv_read" -> pure $ ACIStatus SMDRcv CISRcvRead _ -> fail "bad status" @@ -487,6 +495,7 @@ ciDeleteModeToText = \case CIDMBroadcast -> "this item is deleted (broadcast)" CIDMInternal -> "this item is deleted (internal)" +-- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API data CIContent (d :: MsgDirection) where CISndMsgContent :: MsgContent -> CIContent 'MDSnd CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv @@ -494,6 +503,7 @@ data CIContent (d :: MsgDirection) where CIRcvDeleted :: CIDeleteMode -> CIContent 'MDRcv CISndCall :: CICallStatus -> Int -> CIContent 'MDSnd CIRcvCall :: CICallStatus -> Int -> CIContent 'MDRcv + CIRcvIntegrityError :: MsgErrorType -> CIContent 'MDRcv deriving instance Show (CIContent d) @@ -505,6 +515,16 @@ ciContentToText = \case CIRcvDeleted cidm -> ciDeleteModeToText cidm CISndCall status duration -> "outgoing call: " <> ciCallInfoText status duration CIRcvCall status duration -> "incoming call: " <> ciCallInfoText status duration + CIRcvIntegrityError err -> msgIntegrityError err + +msgIntegrityError :: MsgErrorType -> Text +msgIntegrityError = \case + MsgSkipped fromId toId + | fromId == toId -> "1 skipped message" + | otherwise -> T.pack (show $ toId - fromId + 1) <> " skipped messages" + MsgBadId msgId -> "unexpected message ID " <> T.pack (show msgId) + MsgBadHash -> "incorrect message hash" + MsgDuplicate -> "duplicate message ID" msgDirToDeletedContent_ :: SMsgDirection d -> CIDeleteMode -> CIContent d msgDirToDeletedContent_ msgDir mode = case msgDir of @@ -539,6 +559,7 @@ data JSONCIContent | JCIRcvDeleted {deleteMode :: CIDeleteMode} | JCISndCall {status :: CICallStatus, duration :: Int} -- duration in seconds | JCIRcvCall {status :: CICallStatus, duration :: Int} + | JCIRcvIntegrityError {msgError :: MsgErrorType} deriving (Generic) instance FromJSON JSONCIContent where @@ -556,6 +577,7 @@ jsonCIContent = \case CIRcvDeleted cidm -> JCIRcvDeleted cidm CISndCall status duration -> JCISndCall {status, duration} CIRcvCall status duration -> JCIRcvCall {status, duration} + CIRcvIntegrityError err -> JCIRcvIntegrityError err aciContentJSON :: JSONCIContent -> ACIContent aciContentJSON = \case @@ -565,6 +587,7 @@ aciContentJSON = \case JCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm JCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration JCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration + JCIRcvIntegrityError err -> ACIContent SMDRcv $ CIRcvIntegrityError err -- platform independent data DBJSONCIContent @@ -574,6 +597,7 @@ data DBJSONCIContent | DBJCIRcvDeleted {deleteMode :: CIDeleteMode} | DBJCISndCall {status :: CICallStatus, duration :: Int} | DBJCIRcvCall {status :: CICallStatus, duration :: Int} + | DBJCIRcvIntegrityError {msgError :: MsgErrorType} deriving (Generic) instance FromJSON DBJSONCIContent where @@ -591,6 +615,7 @@ dbJsonCIContent = \case CIRcvDeleted cidm -> DBJCIRcvDeleted cidm CISndCall status duration -> DBJCISndCall {status, duration} CIRcvCall status duration -> DBJCIRcvCall {status, duration} + CIRcvIntegrityError err -> DBJCIRcvIntegrityError err aciContentDBJSON :: DBJSONCIContent -> ACIContent aciContentDBJSON = \case @@ -600,6 +625,7 @@ aciContentDBJSON = \case DBJCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm DBJCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration DBJCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration + DBJCIRcvIntegrityError err -> ACIContent SMDRcv $ CIRcvIntegrityError err data CICallStatus = CISCallPending diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index f1bd801c71..aadac15c92 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -131,6 +131,7 @@ module Simplex.Chat.Store deletePendingGroupMessage, createNewSndChatItem, createNewRcvChatItem, + createNewChatItemNoMsg, getChatPreviews, getDirectChat, getGroupChat, @@ -2531,6 +2532,14 @@ createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent} shar CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> (Just $ Just userMemberId == memberId, memberId) +createNewChatItemNoMsg :: forall c d m. (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> m ChatItemId +createNewChatItemNoMsg st user chatDirection ciContent itemTs createdAt = + liftIO . withTransaction st $ \db -> + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow itemTs createdAt + where + quoteRow :: NewQuoteRow + quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) + createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemTs createdAt = do DB.execute diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4679fca8bb..6f7f31a66c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -208,6 +208,7 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] CIRcvCall {} -> [] + CIRcvIntegrityError err -> viewRcvIntegrityError from err meta where from = ttyFromContact' c where @@ -223,6 +224,7 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] CIRcvCall {} -> [] + CIRcvIntegrityError err -> viewRcvIntegrityError from err meta where from = ttyFromGroup' g m where @@ -295,6 +297,9 @@ msgPreview = msgPlain . preview . msgContentText | T.length t <= 120 = t | otherwise = T.take 120 t <> "..." +viewRcvIntegrityError :: StyledString -> MsgErrorType -> CIMeta 'MDRcv -> [StyledString] +viewRcvIntegrityError from msgErr meta = receivedWithTime_ from [] meta $ viewMsgIntegrityError msgErr + viewMsgIntegrityError :: MsgErrorType -> [StyledString] viewMsgIntegrityError err = msgError $ case err of MsgSkipped fromId toId -> From 7c1d573a17df2055452b21e1ffd72a5e61f4294e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 29 May 2022 08:06:56 +0100 Subject: [PATCH 13/23] mobile: show skipped messages in the UI (#707) * mobile: show skipped messages in the UI * ios: skipped messages alert and translations * android: skipped messages alert * android translation keys * more keys Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../java/chat/simplex/app/MainActivity.kt | 4 +- .../java/chat/simplex/app/model/ChatModel.kt | 25 ++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 6 +- .../app/views/chat/item/ChatItemView.kt | 1 + .../views/chat/item/IntegrityErrorItemView.kt | 67 +++++++ .../app/views/helpers/LocalAuthentication.kt | 8 +- .../app/src/main/res/values-ru/strings.xml | 24 ++- .../app/src/main/res/values/strings.xml | 24 ++- apps/ios/Shared/ContentView.swift | 4 +- apps/ios/Shared/Model/Shared/ChatTypes.swift | 29 +++ .../Views/Chat/ChatItem/DeletedItemView.swift | 6 - .../ChatItem/IntegrityErrorItemView.swift | 53 ++++++ apps/ios/Shared/Views/Chat/ChatItemView.swift | 1 + .../Views/UserSettings/SettingsView.swift | 16 +- .../en.xcloc/Localized Contents/en.xliff | 167 ++++++++++++++++++ .../SimpleX NSE/en.lproj/Localizable.strings | Bin 3004 -> 3612 bytes .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../ru.xcloc/Localized Contents/ru.xliff | 167 ++++++++++++++++++ .../SimpleX NSE/en.lproj/Localizable.strings | Bin 3004 -> 3612 bytes .../en.lproj/SimpleX--iOS--InfoPlist.strings | 2 + .../SimpleX NSE/ru.lproj/Localizable.strings | 12 ++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/ru.lproj/Localizable.strings | 78 ++++++++ .../ru.lproj/SimpleX--iOS--InfoPlist.strings | 3 + 24 files changed, 658 insertions(+), 45 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift 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 a67173534e..000b037224 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 @@ -159,7 +159,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { private fun enableLA() { val m = vm.chatModel authenticate( - generalGetString(R.string.auth_enable), + generalGetString(R.string.auth_enable_simplex_lock), generalGetString(R.string.auth_confirm_credential), this@MainActivity, completed = { laResult -> @@ -193,7 +193,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { private fun disableLA() { val m = vm.chatModel authenticate( - generalGetString(R.string.auth_disable), + generalGetString(R.string.auth_disable_simplex_lock), generalGetString(R.string.auth_confirm_credential), this@MainActivity, completed = { laResult -> 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 02a475e751..8f27073ca4 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 @@ -843,10 +843,11 @@ sealed class CIContent: ItemContent { @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("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null } override val text: String get() = when(this) { is SndMsgContent -> msgContent.text @@ -855,6 +856,7 @@ sealed class CIContent: ItemContent { is RcvDeleted -> generalGetString(R.string.deleted_description) is SndCall -> status.text(duration) is RcvCall -> status.text(duration) + is RcvIntegrityError -> msgError.text } } @@ -1118,3 +1120,18 @@ enum class CICallStatus { fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60) } + +@Serializable +sealed class MsgErrorType() { + @Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType() + @Serializable @SerialName("msgBadId") class MsgBadId(val msgId: Long): MsgErrorType() + @Serializable @SerialName("msgBadHash") class MsgBadHash(): MsgErrorType() + @Serializable @SerialName("msgDuplicate") class MsgDuplicate(): MsgErrorType() + + val text: String get() = when (this) { + is MsgSkipped -> String.format(generalGetString(R.string.integrity_msg_skipped), toMsgId - fromMsgId + 1) + is MsgBadHash -> generalGetString(R.string.integrity_msg_bad_hash) // not used now + is MsgBadId -> generalGetString(R.string.integrity_msg_bad_id) // not used now + is MsgDuplicate -> generalGetString(R.string.integrity_msg_duplicate) // not used now + } +} 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 3f9d86b532..9b36e04522 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 @@ -762,12 +762,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager if (!appPrefs.laNoticeShown.get()) { appPrefs.laNoticeShown.set(true) AlertManager.shared.showAlertDialog( - title = generalGetString(R.string.la_notice_title), - text = generalGetString(R.string.la_notice_text), + title = generalGetString(R.string.la_notice_title_simplex_lock), + text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled), confirmText = generalGetString(R.string.la_notice_turn_on), onConfirm = { authenticate( - generalGetString(R.string.auth_enable), + generalGetString(R.string.auth_enable_simplex_lock), generalGetString(R.string.auth_confirm_credential), activity, completed = { laResult -> 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 58b3e38504..c81adc6a0c 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 @@ -144,6 +144,7 @@ fun ChatItemView( is CIContent.RcvDeleted -> DeletedItem() is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt new file mode 100644 index 0000000000..2e6fb52c95 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt @@ -0,0 +1,67 @@ +package chat.simplex.app.views.chat.item + +import android.content.res.Configuration +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.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.* +import androidx.compose.ui.text.font.FontStyle +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.ChatItem +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.AlertManager +import chat.simplex.app.views.helpers.generalGetString + +@Composable +fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) { + Surface( + Modifier.clickable(onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.alert_title_skipped_messages), + text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when) + ) + }), + shape = RoundedCornerShape(18.dp), + color = ReceivedColorLight, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + buildAnnotatedString { + appendSender(this, if (showMember) ci.memberDisplayName else null, true) + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + modifier = Modifier.padding(end = 8.dp) + ) + CIMetaView(ci) + } + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "Dark Mode" +) +@Composable +fun IntegrityErrorItemViewView() { + SimpleXTheme { + IntegrityErrorItemView( + ChatItem.getDeletedContentSampleData() + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt index 7c193551ff..75f97be140 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt @@ -85,8 +85,8 @@ private fun authenticateWithBiometricManager( } fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg( - generalGetString(R.string.auth_turned_on), - generalGetString(R.string.auth_turned_on_desc) + generalGetString(R.string.auth_simplex_lock_turned_on), + generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume) ) fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText( @@ -103,10 +103,10 @@ fun laFailedToast(context: Context) = Toast.makeText( fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg( generalGetString(R.string.auth_unavailable), - generalGetString(R.string.auth_unavailable_instruction_desc) + generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled) ) fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg( generalGetString(R.string.auth_unavailable), - generalGetString(R.string.auth_unavailable_turning_off_desc) + generalGetString(R.string.auth_device_authentication_is_disabled_turning_off) ) 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 c3d3ba12a3..1f1121ef43 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -65,24 +65,24 @@ Приём сообщений… - Блокировка SimpleX - Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки. + Блокировка SimpleX + Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки. Включить - Блокировка SimpleX включена - Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. + Блокировка SimpleX включена + Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. Разблокировать Пройдите аутентификацию - Включить блокировку SimpleX - Отключить блокировку SimpleX + Включить блокировку SimpleX + Отключить блокировку SimpleX Пройдите аутентификацию Ошибка аутентификации Ошибка аутентификации: %1$s Ошибка аутентификации Аутентификация недоступна - На устройстве не включена аутентификация. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. - На устройстве выключена аутентификация. Отключение блокировки SimpleX Chat. + Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. + Аутентификация устройства выключена. Отключение блокировки SimpleX Chat. Повторить @@ -413,4 +413,12 @@ Текущий звонок Звонок завершен Принять звонок + + + %1$d пропущенных сообщений" + ошибка хэш сообщения + ошибка ID сообщения + повторное сообщение + Пропущенные сообщения + Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения. diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index e058b748b2..b5b808d43b 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -65,24 +65,24 @@ Receiving messages… - SimpleX Lock - To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled. + SimpleX Lock + To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled. Turn on - SimpleX Lock turned on - You will be required to authenticate when you start or resume the app after 30 seconds in background. + SimpleX Lock turned on + You will be required to authenticate when you start or resume the app after 30 seconds in background. Unlock Log in using your credential - Enable SimpleX Lock - Disable SimpleX Lock + Enable SimpleX Lock + Disable SimpleX Lock Confirm your credential Authentication error Authentication error: %1$s Authentication failed Authentication unavailable - Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. - Device authentication is disabled. Turning off SimpleX Lock. + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Device authentication is disabled. Turning off SimpleX Lock. Retry @@ -415,4 +415,12 @@ Call in progress Call ended Answer call + + + %1$d skipped message(s)" + bad message hash + bad message ID + duplicate message + Skipped messages + It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages. diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 871dd1f41e..f9ba1bbaaf 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -71,7 +71,7 @@ struct ContentView: View { userAuthorized = true } else { userAuthorized = false - authenticate(reason: "Unlock") { laResult in + authenticate(reason: NSLocalizedString("Unlock", comment: "authentication reason")) { laResult in switch (laResult) { case .success: userAuthorized = true @@ -100,7 +100,7 @@ struct ContentView: View { title: Text("SimpleX Lock"), message: Text("To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled."), primaryButton: .default(Text("Turn on")) { - authenticate(reason: "Enable SimpleX Lock") { laResult in + authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: prefPerformLA = true diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index ee3696a1ad..69966d8a59 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -520,6 +520,16 @@ struct ChatItem: Identifiable, Decodable { file: nil ) } + + static func getIntegrityErrorSample (_ status: CIStatus = .rcvRead, fromMsgId: Int64 = 1, toMsgId: Int64 = 2) -> ChatItem { + ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, .now, "1 skipped message", status, false, false, false), + content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: fromMsgId, toMsgId: toMsgId)), + quotedItem: nil, + file: nil + ) + } } enum CIDirection: Decodable { @@ -601,6 +611,7 @@ enum CIContent: Decodable, ItemContent { case rcvDeleted(deleteMode: CIDeleteMode) case sndCall(status: CICallStatus, duration: Int) case rcvCall(status: CICallStatus, duration: Int) + case rcvIntegrityError(msgError: MsgErrorType) var text: String { get { @@ -611,6 +622,7 @@ enum CIContent: Decodable, ItemContent { 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) + case let .rcvIntegrityError(msgError): return msgError.text } } } @@ -893,3 +905,20 @@ enum CICallStatus: String, Decodable { String(format: "%02d:%02d", sec / 60, sec % 60) } } + +enum MsgErrorType: Decodable { + case msgSkipped(fromMsgId: Int64, toMsgId: Int64) + case msgBadId(msgId: Int64) + case msgBadHash + case msgDuplicate + + var text: String { + switch self { + case let .msgSkipped(fromMsgId, toMsgId): + return String.localizedStringWithFormat(NSLocalizedString("%d skipped message(s)", comment: "integrity error chat item"), toMsgId - fromMsgId + 1) + case .msgBadHash: return NSLocalizedString("bad message hash", comment: "integrity error chat item") // not used now + case .msgBadId: return NSLocalizedString("bad message ID", comment: "integrity error chat item") // not used now + case .msgDuplicate: return NSLocalizedString("duplicate message", comment: "integrity error chat item") // not used now + } + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index ae58e84cd8..210a4dfd2d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -9,7 +9,6 @@ import SwiftUI struct DeletedItemView: View { - @Environment(\.colorScheme) var colorScheme var chatItem: ChatItem var showMember = false @@ -29,11 +28,6 @@ struct DeletedItemView: View { .background(Color(uiColor: .tertiarySystemGroupedBackground)) .cornerRadius(18) .textSelection(.disabled) -// .background(Color(uiColor: .systemBackground)) -// .overlay( -// RoundedRectangle(cornerRadius: 18) -// .stroke(.quaternary, lineWidth: 1) -// ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift new file mode 100644 index 0000000000..3a75789210 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -0,0 +1,53 @@ +// +// IntegrityErrorItemView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 28/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct IntegrityErrorItemView: View { + var chatItem: ChatItem + var showMember = false + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + if showMember, let member = chatItem.memberDisplayName { + Text(member).fontWeight(.medium) + Text(": ") + } + Text(chatItem.content.text) + .foregroundColor(.red) + .italic() + CIMetaView(chatItem: chatItem) + .padding(.horizontal, 12) + } + .padding(.leading, 12) + .padding(.vertical, 6) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .cornerRadius(18) + .textSelection(.disabled) + .onTapGesture { skippedMessagesAlert() } + } + + private func skippedMessagesAlert() { + AlertManager.shared.showAlertMsg( + title: "Skipped messages", + message: """ + It can happen when: + 1. The messages expire on the server if they were not received for 30 days, + 2. The server you use to receive the messages from this contact was updated and restarted. + 3. The connection is compromised. + Please connect to the developers via Settings to receive the updates about the servers. + We will be adding server redundancy to prevent lost messages. + """ + ) + } +} + +struct IntegrityErrorItemView_Previews: PreviewProvider { + static var previews: some View { + IntegrityErrorItemView(chatItem: ChatItem.getIntegrityErrorSample()) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index f86f4d7550..d826856953 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -22,6 +22,7 @@ struct ChatItemView: View { case .rcvDeleted: deletedItemView() case let .sndCall(status, duration): callItemView(status, duration) case let .rcvCall(status, duration): callItemView(status, duration) + case .rcvIntegrityError: IntegrityErrorItemView(chatItem: chatItem, showMember: showMember) } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index b3b1b8bc1f..980d29c7ec 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -77,6 +77,12 @@ struct SettingsView: View { } Section("Settings") { + NavigationLink { + CallSettings() + .navigationTitle("Call settings") + } label: { + settingsRow("video") { Text("Call settings") } + } settingsRow("lock") { Toggle("SimpleX Lock", isOn: $performLA) } @@ -89,12 +95,6 @@ struct SettingsView: View { } label: { settingsRow("server.rack") { Text("SMP servers") } } - NavigationLink { - CallSettings() - .navigationTitle("Call settings") - } label: { - settingsRow("video") { Text("Call settings") } - } } Section("Help") { @@ -180,7 +180,7 @@ struct SettingsView: View { } private func enableLA() { - authenticate(reason: "Enable SimpleX Lock") { laResult in + authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: prefPerformLA = true @@ -204,7 +204,7 @@ struct SettingsView: View { } private func disableLA() { - authenticate(reason: "Disable SimpleX Lock") { laResult in + authenticate(reason: NSLocalizedString("Disable SimpleX Lock", comment: "authentication reason")) { laResult in switch (laResult) { case .success: prefPerformLA = false 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 9889d069f0..7437693637 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -45,6 +45,11 @@ %@ wants to connect! notification title + + %d skipped message(s) + %d skipped message(s) + integrity error chat item + %lld %lld @@ -171,6 +176,16 @@ Attach No comment provided by engineer. + + Authentication failed + Authentication failed + No comment provided by engineer. + + + Authentication unavailable + Authentication unavailable + No comment provided by engineer. + Call already ended! Call already ended! @@ -441,16 +456,41 @@ Develop No comment provided by engineer. + + Device authentication is disabled. Turning off SimpleX Lock. + Device authentication is disabled. Turning off SimpleX Lock. + No comment provided by engineer. + + + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + No comment provided by engineer. + + + Disable SimpleX Lock + Disable SimpleX Lock + authentication reason + Display name Display name No comment provided by engineer. + + Do NOT use SimpleX for emergency calls. + Do NOT use SimpleX for emergency calls. + No comment provided by engineer. + Edit Edit No comment provided by engineer. + + Enable SimpleX Lock + Enable SimpleX Lock + authentication reason + Enable notifications? (BETA) Enable notifications? (BETA) @@ -531,6 +571,11 @@ How to use markdown No comment provided by engineer. + + If the video fails to connect, flip the camera to resolve it. + If the video fails to connect, flip the camera to resolve it. + 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. @@ -586,6 +631,21 @@ Invalid connection link No comment provided by engineer. + + It can happen when: +1. The messages expire on the server if they were not received for 30 days, +2. The server you use to receive the messages from this contact was updated and restarted. +3. The connection is compromised. +Please connect to the developers via Settings to receive the updates about the servers. +We will be adding server redundancy to prevent lost messages. + It can happen when: +1. The messages expire on the server if they were not received for 30 days, +2. The server you use to receive the messages from this contact was updated and restarted. +3. The connection is compromised. +Please connect to the developers via Settings to receive the updates about the servers. +We will be adding server redundancy to prevent lost messages. + 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 (%@). @@ -601,6 +661,11 @@ Large file! No comment provided by engineer. + + Limitations + Limitations + No comment provided by engineer. + Make a private connection Make a private connection @@ -706,6 +771,11 @@ Please check your network connection and try again. No comment provided by engineer. + + Pre-arrange the calls, as notifications arrive with a delay (we are improving it). + Pre-arrange the calls, as notifications arrive with a delay (we are improving it). + No comment provided by engineer. + Privacy redefined Privacy redefined @@ -751,6 +821,11 @@ Reply No comment provided by engineer. + + Retry + Retry + No comment provided by engineer. + SMP servers SMP servers @@ -811,6 +886,21 @@ Show pending connections No comment provided by engineer. + + SimpleX Lock + SimpleX Lock + No comment provided by engineer. + + + SimpleX Lock turned on + SimpleX Lock turned on + No comment provided by engineer. + + + Skipped messages + Skipped messages + No comment provided by engineer. + Take picture Take picture @@ -853,6 +943,11 @@ The contact you shared this link with will NOT be able to connect! No comment provided by engineer. + + The microphone does not work when the app is in the background. + The microphone does not work when the app is in the background. + No comment provided by engineer. + The next generation of private messaging The next generation of private messaging @@ -888,11 +983,23 @@ To make your first private connection, choose **one of the following**: No comment provided by engineer. + + To prevent the call interruption, enable Do Not Disturb mode. + To prevent the call interruption, enable Do Not Disturb mode. + 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. + + To protect your information, turn on SimpleX Lock. +You will be prompted to complete authentication before this feature is enabled. + To protect your information, turn on SimpleX Lock. +You will be prompted to complete authentication before this feature is enabled. + No comment provided by engineer. + Trying to connect to the server used to receive messages from this contact (error: %@). Trying to connect to the server used to receive messages from this contact (error: %@). @@ -903,6 +1010,11 @@ Trying to connect to the server used to receive messages from this contact. No comment provided by engineer. + + Turn on + Turn on + No comment provided by engineer. + Unexpected error: %@ Unexpected error: %@ @@ -915,6 +1027,11 @@ To connect, please ask your contact to create another connection link and check 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. + + Unlock + Unlock + authentication reason + Use SimpleX Chat servers? Use SimpleX Chat servers? @@ -985,6 +1102,11 @@ To connect, please ask your contact to create another connection link and check 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 could not be verified; please try again. + You could not be verified; please try again. + No comment provided by engineer. + You invited your contact You invited your contact @@ -1000,6 +1122,11 @@ To connect, please ask your contact to create another connection link and check You will be connected when your contact's device is online, please wait or check later! No comment provided by engineer. + + You will be required to authenticate when you start or resume the app after 30 seconds in background. + You will be required to authenticate when you start or resume the app after 30 seconds in background. + No comment provided by engineer. + Your SMP servers Your SMP servers @@ -1099,6 +1226,16 @@ SimpleX servers cannot see your profile. audio call (not e2e encrypted) No comment provided by engineer. + + bad message ID + bad message ID + integrity error chat item + + + bad message hash + bad message hash + integrity error chat item + bold bold @@ -1169,6 +1306,11 @@ SimpleX servers cannot see your profile. deleted deleted chat item + + duplicate message + duplicate message + integrity error chat item + e2e encrypted e2e encrypted @@ -1316,6 +1458,11 @@ SimpleX servers cannot see your profile. SimpleX needs camera access to scan QR codes to connect to other users and for video calls. Privacy - Camera Usage Description + + SimpleX uses Face ID for local authentication + SimpleX uses Face ID for local authentication + Privacy - Face ID Usage Description + SimpleX needs microphone access for audio and video calls. SimpleX needs microphone access for audio and video calls. @@ -1365,6 +1512,11 @@ SimpleX servers cannot see your profile. %@ wants to connect! notification title + + %d skipped message(s) + %d skipped message(s) + integrity error chat item + **e2e encrypted** audio call **e2e encrypted** audio call @@ -1405,6 +1557,16 @@ SimpleX servers cannot see your profile. audio call (not e2e encrypted) No comment provided by engineer. + + bad message ID + bad message ID + integrity error chat item + + + bad message hash + bad message hash + integrity error chat item + call error call error @@ -1445,6 +1607,11 @@ SimpleX servers cannot see your profile. deleted deleted chat item + + duplicate message + duplicate message + integrity error chat item + ended call %@ ended call %@ 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 index a5899f2a06ad0c0ed1fecd3d6188e28523f212a7..34577cd8ef60dbd981023e5ff3479c5e5059ce17 100644 GIT binary patch delta 499 zcmdlZK1XJQ3ZrZ$Lmop3Ln=c$LlHwJkX6Z`0OS<`VLn6A delta 33 ocmbOuvqyY`3gg5+sm;$Ag%~G)VA0s@z_N-N$WYk4h@*-X0Mo(@9RL6T 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 60ab866015..46a4489998 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 @@ -2,6 +2,8 @@ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls."; /* Privacy - Photo Library Additions Usage Description */ 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 c3c3ce1e2c..9fbd918ca5 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -45,6 +45,11 @@ %@ хочет соединиться! notification title + + %d skipped message(s) + %d пропущенных сообщение(й) + integrity error chat item + %lld %lld @@ -171,6 +176,16 @@ Прикрепить No comment provided by engineer. + + Authentication failed + Ошибка аутентификации + No comment provided by engineer. + + + Authentication unavailable + Аутентификация недоступна + No comment provided by engineer. + Call already ended! Звонок уже завершен! @@ -441,16 +456,41 @@ Для разработчиков No comment provided by engineer. + + Device authentication is disabled. Turning off SimpleX Lock. + Аутентификация устройства выключена. Отключение блокировки SimpleX Chat. + No comment provided by engineer. + + + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. + No comment provided by engineer. + + + Disable SimpleX Lock + Отключить блокировку SimpleX + authentication reason + Display name Имя профиля No comment provided by engineer. + + Do NOT use SimpleX for emergency calls. + Не используйте SimpleX для экстренных звонков + No comment provided by engineer. + Edit Редактировать No comment provided by engineer. + + Enable SimpleX Lock + Включить блокировку SimpleX + authentication reason + Enable notifications? (BETA) Включить уведомления? (БЕТА) @@ -531,6 +571,11 @@ Как форматировать No comment provided by engineer. + + If the video fails to connect, flip the camera to resolve it. + Если видео не соединилось, переключите камеру. + No comment provided by engineer. + If you can't meet in person, **show QR code in the video call**, or share the link. Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой. @@ -586,6 +631,21 @@ Ошибка в ссылке контакта No comment provided by engineer. + + It can happen when: +1. The messages expire on the server if they were not received for 30 days, +2. The server you use to receive the messages from this contact was updated and restarted. +3. The connection is compromised. +Please connect to the developers via Settings to receive the updates about the servers. +We will be adding server redundancy to prevent lost messages. + Это может случится, когда: +1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней. +2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен. +3. Соединение компроментировано. +Пожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах. +Мы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения. + 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 (%@). Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@). @@ -601,6 +661,11 @@ Большой файл! No comment provided by engineer. + + Limitations + Ограничения + No comment provided by engineer. + Make a private connection Добавьте контакт @@ -706,6 +771,11 @@ Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз. No comment provided by engineer. + + Pre-arrange the calls, as notifications arrive with a delay (we are improving it). + Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это) + No comment provided by engineer. + Privacy redefined Более конфиденциальный @@ -751,6 +821,11 @@ Ответить No comment provided by engineer. + + Retry + Повторить + No comment provided by engineer. + SMP servers SMP серверы @@ -811,6 +886,21 @@ Показать ожидаемые соединения No comment provided by engineer. + + SimpleX Lock + Блокировка SimpleX + No comment provided by engineer. + + + SimpleX Lock turned on + Блокировка SimpleX включена + No comment provided by engineer. + + + Skipped messages + Пропущенные сообщения + No comment provided by engineer. + Take picture Сделать фото @@ -853,6 +943,11 @@ Контакт, которому вы отправили эту ссылку, не сможет соединиться! No comment provided by engineer. + + The microphone does not work when the app is in the background. + Микрофон не работает, когда приложение в фоновом режиме. + No comment provided by engineer. + The next generation of private messaging Новое поколение приватных сообщений @@ -888,11 +983,23 @@ Чтобы добавить ваш первый контакт, выберите **одно из**: No comment provided by engineer. + + To prevent the call interruption, enable Do Not Disturb mode. + Чтобы избежать прерывания звонков, включите режим Не Беспокоить. + 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. + + To protect your information, turn on SimpleX Lock. +You will be prompted to complete authentication before this feature is enabled. + Чтобы защитить вашу информацию, включите блокировку SimpleX Chat. +Вам будет нужно пройти аутентификацию для включения блокировки. + No comment provided by engineer. + Trying to connect to the server used to receive messages from this contact (error: %@). Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@). @@ -903,6 +1010,11 @@ Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта. No comment provided by engineer. + + Turn on + Включить + No comment provided by engineer. + Unexpected error: %@ Неожиданная ошибка: %@ @@ -915,6 +1027,11 @@ To connect, please ask your contact to create another connection link and check Чтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью. No comment provided by engineer. + + Unlock + Разблокировать + authentication reason + Use SimpleX Chat servers? Использовать серверы предосталенные SimpleX Chat? @@ -985,6 +1102,11 @@ To connect, please ask your contact to create another connection link and check Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки. No comment provided by engineer. + + You could not be verified; please try again. + Верификация не удалась; пожалуйста, попробуйте ещё раз. + No comment provided by engineer. + You invited your contact Вы пригласили ваш контакт @@ -1000,6 +1122,11 @@ To connect, please ask your contact to create another connection link and check Соединение будет установлено, когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! No comment provided by engineer. + + You will be required to authenticate when you start or resume the app after 30 seconds in background. + Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. + No comment provided by engineer. + Your SMP servers Ваши SMP серверы @@ -1099,6 +1226,16 @@ SimpleX серверы не могут получить доступ к ваше аудиозвонок (не e2e зашифрованный) No comment provided by engineer. + + bad message ID + ошибка ID сообщения + integrity error chat item + + + bad message hash + ошибка хэш сообщения + integrity error chat item + bold жирный @@ -1169,6 +1306,11 @@ SimpleX серверы не могут получить доступ к ваше удалено deleted chat item + + duplicate message + повторное сообщение + integrity error chat item + e2e encrypted e2e зашифровано @@ -1316,6 +1458,11 @@ SimpleX серверы не могут получить доступ к ваше SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями и для видео звонков. Privacy - Camera Usage Description + + SimpleX uses Face ID for local authentication + SimpleX использует Face ID для аутентификации + Privacy - Face ID Usage Description + SimpleX needs microphone access for audio and video calls. SimpleX использует микрофон для аудио и видео звонков. @@ -1365,6 +1512,11 @@ SimpleX серверы не могут получить доступ к ваше %@ хочет соединиться! notification title + + %d skipped message(s) + %d пропущенных сообщений + integrity error chat item + **e2e encrypted** audio call **e2e зашифрованный** аудиозвонок @@ -1405,6 +1557,16 @@ SimpleX серверы не могут получить доступ к ваше аудиозвонок (не e2e зашифрованный) No comment provided by engineer. + + bad message ID + ошибка ID сообщения + integrity error chat item + + + bad message hash + ошибка хэш сообщения + integrity error chat item + call error ошибка звонка @@ -1445,6 +1607,11 @@ SimpleX серверы не могут получить доступ к ваше удалено deleted chat item + + duplicate message + повторное сообщение + integrity error chat item + ended call %@ завершённый звонок %@ 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 index a5899f2a06ad0c0ed1fecd3d6188e28523f212a7..34577cd8ef60dbd981023e5ff3479c5e5059ce17 100644 GIT binary patch delta 499 zcmdlZK1XJQ3ZrZ$Lmop3Ln=c$LlHwJkX6Z`0OS<`VLn6A delta 33 ocmbOuvqyY`3gg5+sm;$Ag%~G)VA0s@z_N-N$WYk4h@*-X0Mo(@9RL6T 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 60ab866015..46a4489998 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 @@ -2,6 +2,8 @@ "CFBundleName" = "SimpleX"; /* Privacy - Camera Usage Description */ "NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication"; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls."; /* Privacy - Photo Library Additions Usage Description */ diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index 53a4bbf9b8..e08978ade2 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -10,6 +10,9 @@ /* notification title */ "%@ wants to connect!" = "%@ хочет соединиться!"; +/* integrity error chat item */ +"%d skipped message(s)" = "%d пропущенных сообщений"; + /* notification body */ "Accept contact request from %@?" = "Принять запрос на соединение от %@?"; @@ -19,6 +22,12 @@ /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; +/* integrity error chat item */ +"bad message hash" = "ошибка хэш сообщения"; + +/* integrity error chat item */ +"bad message ID" = "ошибка ID сообщения"; + /* call status */ "call error" = "ошибка звонка"; @@ -43,6 +52,9 @@ /* deleted chat item */ "deleted" = "удалено"; +/* integrity error chat item */ +"duplicate message" = "повторное сообщение"; + /* call status */ "ended call %@" = "завершённый звонок %@"; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9e8ffd56d2..1cafbbcfcd 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 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 */; }; + 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -154,6 +155,7 @@ 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 = ""; }; + 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.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 = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; @@ -524,6 +526,7 @@ 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, 5C029EA72837DBB3004A9677 /* CICallItemView.swift */, + 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */, ); path = ChatItem; sourceTree = ""; @@ -693,6 +696,7 @@ 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, + 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 9e8a5d2c55..04638574d2 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -61,6 +61,9 @@ /* notification title */ "%@ wants to connect!" = "%@ хочет соединиться!"; +/* integrity error chat item */ +"%d skipped message(s)" = "%d пропущенных сообщение(й)"; + /* No comment provided by engineer. */ "%lld" = "%lld"; @@ -119,6 +122,18 @@ /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; +/* No comment provided by engineer. */ +"Authentication failed" = "Ошибка аутентификации"; + +/* No comment provided by engineer. */ +"Authentication unavailable" = "Аутентификация недоступна"; + +/* integrity error chat item */ +"bad message hash" = "ошибка хэш сообщения"; + +/* integrity error chat item */ +"bad message ID" = "ошибка ID сообщения"; + /* No comment provided by engineer. */ "bold" = "жирный"; @@ -323,9 +338,24 @@ /* No comment provided by engineer. */ "Develop" = "Для разработчиков"; +/* No comment provided by engineer. */ +"Device authentication is disabled. Turning off SimpleX Lock." = "Аутентификация устройства выключена. Отключение блокировки SimpleX Chat."; + +/* No comment provided by engineer. */ +"Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication." = "Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации."; + +/* authentication reason */ +"Disable SimpleX Lock" = "Отключить блокировку SimpleX"; + /* No comment provided by engineer. */ "Display name" = "Имя профиля"; +/* No comment provided by engineer. */ +"Do NOT use SimpleX for emergency calls." = "Не используйте SimpleX для экстренных звонков"; + +/* integrity error chat item */ +"duplicate message" = "повторное сообщение"; + /* No comment provided by engineer. */ "e2e encrypted" = "e2e зашифровано"; @@ -335,6 +365,9 @@ /* No comment provided by engineer. */ "Enable notifications? (BETA)" = "Включить уведомления? (БЕТА)"; +/* authentication reason */ +"Enable SimpleX Lock" = "Включить блокировку SimpleX"; + /* No comment provided by engineer. */ "ended" = "завершён"; @@ -386,6 +419,9 @@ /* No comment provided by engineer. */ "How to use markdown" = "Как форматировать"; +/* No comment provided by engineer. */ +"If the video fails to connect, flip the camera to resolve it." = "Если видео не соединилось, переключите камеру."; + /* No comment provided by engineer. */ "If you can't meet in person, **show QR code in the video call**, or share the link." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой."; @@ -422,6 +458,9 @@ /* chat list item title */ "invited to connect" = "приглашение"; +/* No comment provided by engineer. */ +"It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages." = "Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения."; + /* 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 (%@)." = "Возможно, вы уже соединились через эту ссылку. Если это не так, то это ошибка (%@)."; @@ -434,6 +473,9 @@ /* No comment provided by engineer. */ "Large file!" = "Большой файл!"; +/* No comment provided by engineer. */ +"Limitations" = "Ограничения"; + /* No comment provided by engineer. */ "Make a private connection" = "Добавьте контакт"; @@ -509,6 +551,9 @@ /* No comment provided by engineer. */ "Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз."; +/* No comment provided by engineer. */ +"Pre-arrange the calls, as notifications arrive with a delay (we are improving it)." = "Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это)"; + /* No comment provided by engineer. */ "Privacy redefined" = "Более конфиденциальный"; @@ -545,6 +590,9 @@ /* No comment provided by engineer. */ "Reply" = "Ответить"; +/* No comment provided by engineer. */ +"Retry" = "Повторить"; + /* No comment provided by engineer. */ "Save" = "Сохранить"; @@ -581,6 +629,15 @@ /* No comment provided by engineer. */ "Show pending connections" = "Показать ожидаемые соединения"; +/* No comment provided by engineer. */ +"SimpleX Lock" = "Блокировка SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Lock turned on" = "Блокировка SimpleX включена"; + +/* No comment provided by engineer. */ +"Skipped messages" = "Пропущенные сообщения"; + /* No comment provided by engineer. */ "SMP servers" = "SMP серверы"; @@ -614,6 +671,9 @@ /* No comment provided by engineer. */ "The contact you shared this link with will NOT be able to connect!" = "Контакт, которому вы отправили эту ссылку, не сможет соединиться!"; +/* No comment provided by engineer. */ +"The microphone does not work when the app is in the background." = "Микрофон не работает, когда приложение в фоновом режиме."; + /* No comment provided by engineer. */ "The next generation of private messaging" = "Новое поколение приватных сообщений"; @@ -635,15 +695,24 @@ /* No comment provided by engineer. */ "To make your first private connection, choose **one of the following**:" = "Чтобы добавить ваш первый контакт, выберите **одно из**:"; +/* No comment provided by engineer. */ +"To prevent the call interruption, enable Do Not Disturb mode." = "Чтобы избежать прерывания звонков, включите режим Не Беспокоить."; + /* 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. */ +"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки."; + /* No comment provided by engineer. */ "Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@)."; /* No comment provided by engineer. */ "Trying to connect to the server used to receive messages from this contact." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта."; +/* No comment provided by engineer. */ +"Turn on" = "Включить"; + /* No comment provided by engineer. */ "Unexpected error: %@" = "Неожиданная ошибка: %@"; @@ -653,6 +722,9 @@ /* 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Чтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью."; +/* authentication reason */ +"Unlock" = "Разблокировать"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; @@ -719,6 +791,9 @@ /* No comment provided by engineer. */ "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 could not be verified; please try again." = "Верификация не удалась; пожалуйста, попробуйте ещё раз."; + /* No comment provided by engineer. */ "You invited your contact" = "Вы пригласили ваш контакт"; @@ -731,6 +806,9 @@ /* No comment provided by engineer. */ "You will be connected when your contact's device is online, please wait or check later!" = "Соединение будет установлено, когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!"; +/* No comment provided by engineer. */ +"You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме."; + /* No comment provided by engineer. */ "Your chat address" = "Ваш SimpleX адрес"; diff --git a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings index 4dc64e9475..85a0405435 100644 --- a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings @@ -4,6 +4,9 @@ /* Privacy - Camera Usage Description */ "NSCameraUsageDescription" = "SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями и для видео звонков."; +/* Privacy - Face ID Usage Description */ +"NSFaceIDUsageDescription" = "SimpleX использует Face ID для аутентификации"; + /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX использует микрофон для аудио и видео звонков."; From 29e2c00811320e46f30cf92dc0e262dc33142579 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 30 May 2022 08:59:04 +0100 Subject: [PATCH 14/23] mobile: settings for auto-accepting images, link previews, spinner for link previews; privacy settings (#708) * ios: settings for auto-accepting images, link previews, spinner for link previews * android: settings for auto-accepting images, link previews, spinner for link previews, privacy settings * update translation Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * translation Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * translation Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 20 ++- .../chat/simplex/app/views/TerminalView.kt | 6 +- .../chat/simplex/app/views/chat/ChatView.kt | 29 ++-- .../simplex/app/views/chat/ComposeView.kt | 19 ++- .../simplex/app/views/chat/SendMsgView.kt | 6 +- .../app/views/chat/item/CIImageView.kt | 23 +++- .../app/views/chat/item/ChatItemView.kt | 11 +- .../app/views/chat/item/FramedItemView.kt | 2 +- .../simplex/app/views/helpers/LinkPreviews.kt | 48 +++++-- .../app/views/usersettings/CallSettings.kt | 3 +- .../app/views/usersettings/PrivacySettings.kt | 65 +++++++++ .../app/views/usersettings/SettingsView.kt | 23 +++- .../app/src/main/res/values-ru/strings.xml | 11 +- .../app/src/main/res/values/strings.xml | 11 +- apps/ios/Shared/Model/SimpleXAPI.swift | 3 +- .../Chat/ComposeMessage/ComposeView.swift | 8 +- .../Shared/Views/Helpers/CIImageView.swift | 22 ++- .../Views/Helpers/ComposeLinkView.swift | 44 ++++-- .../Views/UserSettings/PrivacySettings.swift | 125 ++++++++++++++++++ .../Views/UserSettings/SettingsButton.swift | 3 +- .../Views/UserSettings/SettingsView.swift | 108 +++------------ .../en.xcloc/Localized Contents/en.xliff | 44 +++++- .../en.lproj/Localizable.strings | 3 - .../ru.xcloc/Localized Contents/ru.xliff | 46 +++++-- .../en.lproj/Localizable.strings | 3 - apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/en.lproj/Localizable.strings | 3 - apps/ios/ru.lproj/Localizable.strings | 28 +++- 28 files changed, 508 insertions(+), 213 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt create mode 100644 apps/ios/Shared/Views/UserSettings/PrivacySettings.swift 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 9b36e04522..cb2ed9d2ca 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 @@ -78,6 +78,8 @@ class AppPreferences(val context: Context) { ) val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false) val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false) + val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) + val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) private fun mkIntPreference(prefName: String, default: Int) = Preference( @@ -107,6 +109,8 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN = "CallsOnLockScreen" private const val SHARED_PREFS_PERFORM_LA = "PerformLA" private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown" + private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" + private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" } } @@ -510,13 +514,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) val file = cItem.file - 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 (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) { + withApi { receiveFile(file.fileId) } } if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(cInfo, cItem) @@ -615,6 +614,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } + suspend fun receiveFile(fileId: Long) { + val chatItem = apiReceiveFile(fileId) + if (chatItem != null) { + chatItemSimpleUpdate(chatItem) + } + } + private fun chatItemSimpleUpdate(aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem 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 08c86f7cb7..52340e2ac6 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 @@ -26,7 +26,7 @@ import kotlinx.coroutines.launch @Composable fun TerminalView(chatModel: ChatModel, close: () -> Unit) { - val composeState = remember { mutableStateOf(ComposeState()) } + val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) } BackHandler(onBack = close) TerminalLayout( chatModel.terminalItems, @@ -35,7 +35,7 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) { withApi { // show "in progress" chatModel.controller.sendCmd(CC.Console(composeState.value.message)) - composeState.value = ComposeState() + composeState.value = ComposeState(useLinkPreviews = false) // hide "in progress" } }, @@ -120,7 +120,7 @@ fun PreviewTerminalLayout() { SimpleXTheme { TerminalLayout( terminalItems = TerminalItem.sampleData, - composeState = remember { mutableStateOf(ComposeState()) }, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, sendCommand = {}, close = {} ) 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 2a9825bd1c..6d94fd8c74 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 @@ -44,7 +44,8 @@ import kotlinx.datetime.Clock 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 useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() + val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) } val attachmentOption = remember { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() @@ -83,6 +84,7 @@ fun ChatView(chatModel: ChatModel) { scope, attachmentBottomSheetState, chatModel.chatItems, + useLinkPreviews = useLinkPreviews, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -104,14 +106,7 @@ fun ChatView(chatModel: ChatModel) { } }, receiveFile = { fileId -> - withApi { - val chatItem = chatModel.controller.apiReceiveFile(fileId) - if (chatItem != null) { - val cInfo = chatItem.chatInfo - val cItem = chatItem.chatItem - chatModel.upsertChatItem(cInfo, cItem) - } - } + withApi { chatModel.controller.receiveFile(fileId) } }, startCall = { media -> val cInfo = chat.chatInfo @@ -143,6 +138,7 @@ fun ChatLayout( scope: CoroutineScope, attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, + useLinkPreviews: Boolean, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, @@ -175,7 +171,7 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile, acceptCall) + ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall) } } } @@ -261,6 +257,7 @@ fun ChatItemsList( chat: Chat, composeState: MutableState, chatItems: List, + useLinkPreviews: Boolean, openDirectChat: (Long) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, @@ -302,11 +299,11 @@ fun ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp)) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) } } } else { // direct message @@ -317,7 +314,7 @@ fun ChatItemsList( end = if (sent) 12.dp else 76.dp, ) ) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall) } } } @@ -375,12 +372,13 @@ fun PreviewChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - composeState = remember { mutableStateOf(ComposeState()) }, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, + useLinkPreviews = true, back = {}, info = {}, openDirectChat = {}, @@ -421,12 +419,13 @@ fun PreviewGroupChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - composeState = remember { mutableStateOf(ComposeState()) }, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, + useLinkPreviews = true, back = {}, info = {}, openDirectChat = {}, 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 bd87d253de..c3852b38c1 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 @@ -45,7 +45,7 @@ import java.io.File sealed class ComposePreview { object NoPreview: ComposePreview() - class CLinkPreview(val linkPreview: LinkPreview): ComposePreview() + class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() class ImagePreview(val image: String): ComposePreview() class FilePreview(val fileName: String): ComposePreview() } @@ -60,12 +60,14 @@ data class ComposeState( val message: String = "", val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, - val inProgress: Boolean = false + val inProgress: Boolean = false, + val useLinkPreviews: Boolean ) { - constructor(editingItem: ChatItem): this( + constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this ( editingItem.content.text, chatItemPreview(editingItem), - ComposeContextItem.EditingItem(editingItem) + ComposeContextItem.EditingItem(editingItem), + useLinkPreviews = useLinkPreviews ) val editing: Boolean @@ -88,7 +90,7 @@ data class ComposeState( when (preview) { is ComposePreview.ImagePreview -> false is ComposePreview.FilePreview -> false - else -> true + else -> useLinkPreviews } val linkPreview: LinkPreview? get() = @@ -124,6 +126,7 @@ fun ComposeView( val prevLinkUrl = remember { mutableStateOf(null) } val pendingLinkUrl = remember { mutableStateOf(null) } val cancelledLinks = remember { mutableSetOf() } + val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } // attachments @@ -239,6 +242,7 @@ fun ComposeView( fun loadLinkPreview(url: String, wait: Long? = null) { if (pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) withApi { if (wait != null) delay(wait) val lp = getLinkPreview(url) @@ -277,7 +281,7 @@ fun ComposeView( is ComposePreview.CLinkPreview -> { val url = parseMessage(cs.message) val lp = composePreview.linkPreview - if (url == lp.uri) { + if (lp != null && url == lp.uri) { MsgContent.MCLink(cs.message, preview = lp) } else { MsgContent.MCText(cs.message) @@ -299,7 +303,7 @@ fun ComposeView( } fun clearState() { - composeState.value = ComposeState() + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) textStyle.value = smallFont chosenImage.value = null chosenFile.value = null @@ -397,6 +401,7 @@ fun ComposeView( if (uri != null) { cancelledLinks.add(uri) } + pendingLinkUrl.value = null composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) } 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 b4d5aa24f4..b88f5230cc 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 @@ -111,7 +111,7 @@ fun PreviewSendMsgView() { val textStyle = remember { mutableStateOf(smallFont) } SimpleXTheme { SendMsgView( - composeState = remember { mutableStateOf(ComposeState()) }, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, sendMessage = {}, onMessageChange = { _ -> }, textStyle = textStyle @@ -129,7 +129,7 @@ fun PreviewSendMsgView() { fun PreviewSendMsgViewEditing() { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } - val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData()) + val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true) SimpleXTheme { SendMsgView( composeState = remember { mutableStateOf(composeStateEditing) }, @@ -150,7 +150,7 @@ fun PreviewSendMsgViewEditing() { 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) + val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true) SimpleXTheme { SendMsgView( composeState = remember { mutableStateOf(composeStateInProgress) }, 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 e98f6b2b90..139e8e31a9 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 @@ -26,7 +26,8 @@ import chat.simplex.app.views.helpers.* fun CIImageView( image: String, file: CIFile?, - showMenu: MutableState + showMenu: MutableState, + receiveFile: (Long) -> Unit ) { @Composable fun loadingIndicator() { @@ -98,11 +99,21 @@ fun CIImageView( }) } 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) - ) + if (file != null) { + when (file.fileStatus) { + CIFileStatus.RcvInvitation -> + receiveFile(file.fileId) + CIFileStatus.RcvAccepted -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_image), + generalGetString(R.string.image_will_be_received_when_contact_is_online) + ) + CIFileStatus.RcvTransfer -> {} // ? + CIFileStatus.RcvComplete -> {} // ? + CIFileStatus.RcvCancelled -> {} // TODO + else -> {} + } + } }) } loadingIndicator() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index c81adc6a0c..8ff0593d65 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 @@ -36,6 +36,7 @@ fun ChatItemView( cxt: Context, uriHandler: UriHandler? = null, showMember: Boolean = false, + useLinkPreviews: Boolean, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, acceptCall: (Contact) -> Unit @@ -69,7 +70,7 @@ fun ChatItemView( ) { ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem)) + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) } else { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } @@ -98,7 +99,7 @@ fun ChatItemView( } if (cItem.meta.editable) { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { - composeState.value = ComposeState(editingItem = cItem) + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false }) } @@ -203,7 +204,8 @@ fun PreviewChatItemView() { ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), - composeState = remember { mutableStateOf(ComposeState()) }, + useLinkPreviews = true, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, cxt = LocalContext.current, deleteMessage = { _, _ -> }, receiveFile = {}, @@ -220,7 +222,8 @@ fun PreviewChatItemViewDeletedContent() { User.sampleData, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), - composeState = remember { mutableStateOf(ComposeState()) }, + useLinkPreviews = true, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, cxt = LocalContext.current, 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 2ebd299f92..9654107e08 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 @@ -121,7 +121,7 @@ fun FramedItemView( Column(Modifier.fillMaxWidth()) { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, showMenu) + CIImageView(image = mc.image, file = ci.file, showMenu, receiveFile) if (mc.text == "") { metaColor = Color.White } else { 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 e4afa68202..b9ae1c1140 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 @@ -11,6 +11,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.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource @@ -66,23 +67,36 @@ suspend fun getLinkPreview(url: String): LinkPreview? { @Composable -fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { +fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) { Row( Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight), verticalAlignment = Alignment.CenterVertically ) { - val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() - Image( - imageBitmap, - stringResource(R.string.image_descr_link_preview), - modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) - ) - Column(Modifier.fillMaxWidth().weight(1F)) { - Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body2 + if (linkPreview == null) { + Box( + Modifier.fillMaxWidth().weight(1f).height(60.dp).padding(start = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + CircularProgressIndicator( + Modifier.size(16.dp), + color = HighOrLowlight, + strokeWidth = 2.dp + ) + } + } else { + val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() + Image( + imageBitmap, + stringResource(R.string.image_descr_link_preview), + modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) ) + Column(Modifier.fillMaxWidth().weight(1F)) { + Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } } IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) { Icon( @@ -139,4 +153,12 @@ fun PreviewComposeLinkView() { SimpleXTheme { ComposeLinkView(LinkPreview.sampleData) { -> } } -} \ No newline at end of file +} + +@Preview(showBackground = true) +@Composable +fun PreviewComposeLinkViewLoading() { + SimpleXTheme { + ComposeLinkView(null) { -> } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index 5037647184..d93bf927c2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -34,7 +34,7 @@ fun CallSettingsLayout( ) { val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) } Text( - stringResource(R.string.call_settings), + stringResource(R.string.your_calls), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1 ) @@ -71,6 +71,7 @@ fun SharedPreferenceToggle( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight ), + modifier = Modifier.padding(end = 6.dp) ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt new file mode 100644 index 0000000000..5e6ae30448 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt @@ -0,0 +1,65 @@ +package chat.simplex.app.views.usersettings + +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.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.ui.theme.HighOrLowlight + +@Composable +fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { + @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.your_privacy), + style = MaterialTheme.typography.h1, + modifier = Modifier.padding(start = 8.dp, bottom = 24.dp) + ) + ChatLockSection(chatModel.performLA, setPerformLA) + Spacer(Modifier.height(24.dp)) + + AutoAcceptImagesSection(chatModel.controller.appPrefs.privacyAcceptImages) + divider() + LinkPreviewsSection(chatModel.controller.appPrefs.privacyLinkPreviews) + divider() + } +} + +@Composable private fun AutoAcceptImagesSection(prefAcceptImages: Preference) { + SettingsSectionView() { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.Image, + contentDescription = stringResource(R.string.auto_accept_images), + tint = HighOrLowlight, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + SharedPreferenceToggle(stringResource(R.string.auto_accept_images), prefAcceptImages) + } + } +} + +@Composable private fun LinkPreviewsSection(prefLinkPreviews: Preference) { + SettingsSectionView() { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.TravelExplore, + contentDescription = stringResource(R.string.send_link_previews), + tint = HighOrLowlight, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + SharedPreferenceToggle(stringResource(R.string.send_link_previews), prefLinkPreviews) + } + } +} \ No newline at end of file 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 acbeb5b237..0436233d39 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 @@ -47,7 +47,6 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { profile = user.profile, runServiceInBackground = chatModel.runServiceInBackground, setRunServiceInBackground = ::setRunServiceInBackground, - performLA = chatModel.performLA, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, @@ -65,7 +64,6 @@ fun SettingsLayout( profile: Profile, runServiceInBackground: MutableState, setRunServiceInBackground: (Boolean) -> Unit, - performLA: MutableState, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), @@ -103,7 +101,7 @@ fun SettingsLayout( CallSettingsSection(showModal) divider() - ChatLockSection(performLA, setPerformLA) + PrivacySettingsSection(showModal, setPerformLA) divider() PrivateNotificationsSection(runServiceInBackground, setRunServiceInBackground) divider() @@ -154,6 +152,18 @@ fun SettingsLayout( } } +@Composable private fun PrivacySettingsSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), setPerformLA: (Boolean) -> Unit) { + SettingsSectionView(showModal { PrivacySettingsView(it, setPerformLA) }) { + Icon( + Icons.Outlined.Lock, + contentDescription = stringResource(R.string.privacy_and_security), + tint = HighOrLowlight, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(R.string.privacy_and_security)) + } +} + @Composable private fun HelpViewSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { SettingsSectionView(showModal { HelpView(it) }) { Icon( @@ -258,13 +268,13 @@ fun SettingsLayout( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight ), - modifier = Modifier.padding(end = 8.dp) + modifier = Modifier.padding(end = 6.dp) ) } } } -@Composable private fun ChatLockSection(performLA: MutableState, setPerformLA: (Boolean) -> Unit) { +@Composable fun ChatLockSection(performLA: MutableState, setPerformLA: (Boolean) -> Unit) { SettingsSectionView() { Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -286,7 +296,7 @@ fun SettingsLayout( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight ), - modifier = Modifier.padding(end = 8.dp) + modifier = Modifier.padding(end = 6.dp) ) } } @@ -362,7 +372,6 @@ fun PreviewSettingsLayout() { profile = Profile.sampleData, runServiceInBackground = remember { mutableStateOf(true) }, setRunServiceInBackground = {}, - performLA = remember { mutableStateOf(false) }, setPerformLA = {}, showModal = { {} }, showCustomModal = { {} }, 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 1f1121ef43..458c0e8c19 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -168,7 +168,7 @@ Разрешение не получено! Камера Галерея - Файлы\n(v2.0) + Файлы Спасибо, что установили SimpleX Chat! @@ -379,7 +379,8 @@ аудиозвонок - Настройки звонков + Аудио- и видеозвонки + Ваши звонки Соединяться через сервер (relay) Звонки на экране блокировки: Принимать @@ -421,4 +422,10 @@ повторное сообщение Пропущенные сообщения Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения. + + + Конфиденциальность + Конфиденциальность + Автоприем изображений + Отправлять картинки ссылок diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index b5b808d43b..fe61acfaa1 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -169,7 +169,7 @@ Permission Denied! Use Camera From Gallery - Choose file\n(new in v2.0) + Choose file Thank you for installing SimpleX Chat! @@ -381,7 +381,8 @@ audio call - Call settings + Audio & video calls + Your calls Connect via relay Calls on lock screen: Accept @@ -423,4 +424,10 @@ duplicate message Skipped messages It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages. + + + Privacy & security + Your privacy + Auto-accept images + Send link previews diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 56edef0d2d..e44e40ec5e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -578,7 +578,8 @@ func processReceivedMsg(_ res: ChatResponse) { m.addChatItem(cInfo, cItem) if case .image = cItem.content.msgContent, let file = cItem.file, - file.fileSize <= maxImageSize { + file.fileSize <= maxImageSize, + UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) { Task { await receiveFile(fileId: file.fileId) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3c3b1c1454..68024cd5e0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -10,7 +10,7 @@ import SwiftUI enum ComposePreview { case noPreview - case linkPreview(linkPreview: LinkPreview) + case linkPreview(linkPreview: LinkPreview?) case imagePreview(imagePreview: String) case filePreview(fileName: String) } @@ -26,6 +26,7 @@ struct ComposeState { var preview: ComposePreview var contextItem: ComposeContextItem var inProgress: Bool = false + var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) init( message: String = "", @@ -80,7 +81,7 @@ struct ComposeState { case .filePreview: return false default: - return true + return useLinkPreviews } } @@ -406,11 +407,13 @@ struct ComposeView: View { if let uri = composeState.linkPreview()?.uri.absoluteString { cancelledLinks.insert(uri) } + pendingLinkUrl = nil composeState = composeState.copy(preview: .noPreview) } private func loadLinkPreview(_ url: URL) { if pendingLinkUrl == url { + composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) getLinkPreview(url: url) { linkPreview in if let linkPreview = linkPreview, pendingLinkUrl == url { @@ -432,6 +435,7 @@ struct ComposeView: View { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): if let url = parseMessage(composeState.message), + let linkPreview = linkPreview, url == linkPreview.uri { return .link(text: composeState.message, preview: linkPreview) } else { diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift index 7fd1b6ded9..7b8d2efdfd 100644 --- a/apps/ios/Shared/Views/Helpers/CIImageView.swift +++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift @@ -47,11 +47,23 @@ struct CIImageView: View { 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!" - ) + if let file = file { + switch file.fileStatus { + case .rcvInvitation: + Task { + await receiveFile(fileId: file.fileId) + // TODO image accepted alert? + } + case .rcvAccepted: + AlertManager.shared.showAlertMsg( + title: "Waiting for image", + message: "Image will be received when your contact is online, please wait or check later!" + ) + case .rcvTransfer: () // ? + case .rcvComplete: () // ? + case .rcvCancelled: () // TODO + default: () + } } } } diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift index 6ab5e49980..7b24a06f82 100644 --- a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift @@ -41,10 +41,32 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { struct ComposeLinkView: View { @Environment(\.colorScheme) var colorScheme - let linkPreview: LinkPreview + let linkPreview: LinkPreview? var cancelPreview: (() -> Void)? = nil var body: some View { + HStack(alignment: .center, spacing: 8) { + if let linkPreview = linkPreview { + linkPreviewView(linkPreview) + } else { + ProgressView() + .padding(.leading, 12) + .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60, alignment: .leading) + } + if let cancelPreview = cancelPreview { + Button { cancelPreview() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 1) + .padding(.trailing, 12) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } + + private func linkPreviewView(_ linkPreview: LinkPreview) -> some View { HStack(alignment: .center, spacing: 8) { if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)), let uiImage = UIImage(data: data) { @@ -62,18 +84,8 @@ struct ComposeLinkView: View { .foregroundColor(.secondary) } .padding(.vertical, 5) - .frame(maxWidth: .infinity) - if let cancelPreview = cancelPreview { - Button { cancelPreview() } label: { - Image(systemName: "multiply") - } - } + .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60) } - .padding(.vertical, 1) - .padding(.trailing, 12) - .background(colorScheme == .light ? sentColorLight : sentColorDark) - .frame(maxWidth: .infinity) - .padding(.top, 8) } } @@ -85,7 +97,11 @@ struct SmallLinkPreview_Previews: PreviewProvider { description: "", image: "data:image/jpg;base64,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" ) - ComposeLinkView(linkPreview: preview, cancelPreview: {}) - .previewLayout(.fixed(width: 360, height: 200)) + Group { + ComposeLinkView(linkPreview: preview, cancelPreview: {}) + .previewLayout(.fixed(width: 360, height: 200)) + ComposeLinkView(linkPreview: nil, cancelPreview: {}) + .previewLayout(.fixed(width: 360, height: 200)) + } } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift new file mode 100644 index 0000000000..33794cfbd5 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -0,0 +1,125 @@ +// +// PrivacySettings.swift +// SimpleX (iOS) +// +// Created by Evgeny on 29/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct PrivacySettings: View { + @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true + @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true + + var body: some View { + VStack { + List { + Section("Device") { + SimplexLockSetting() + } + Section("Chats") { + settingsRow("photo") { + Toggle("Auto-accept images", isOn: $autoAcceptImages) + } + settingsRow("network") { + Toggle("Send link previews", isOn: $useLinkPreviews) + } + } + } + } + } +} + +struct SimplexLockSetting: View { + @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false + @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false + @State var performLA: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) + @State private var performLAToggleReset = false + @State var laAlert: laSettingViewAlert? = nil + + enum laSettingViewAlert: Identifiable { + case laTurnedOnAlert + case laFailedAlert + case laUnavailableInstructionAlert + case laUnavailableTurningOffAlert + + var id: laSettingViewAlert { get { self } } + } + + var body: some View { + settingsRow("lock") { + Toggle("SimpleX Lock", isOn: $performLA) + } + .onChange(of: performLA) { performLAToggle in + prefLANoticeShown = true + if performLAToggleReset { + performLAToggleReset = false + } else { + if performLAToggle { + enableLA() + } else { + disableLA() + } + } + } + .alert(item: $laAlert) { alertItem in + switch alertItem { + case .laTurnedOnAlert: return laTurnedOnAlert() + case .laFailedAlert: return laFailedAlert() + case .laUnavailableInstructionAlert: return laUnavailableInstructionAlert() + case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert() + } + } + + } + + private func enableLA() { + authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in + switch laResult { + case .success: + prefPerformLA = true + laAlert = .laTurnedOnAlert + case .failed: + prefPerformLA = false + withAnimation() { + performLA = false + } + performLAToggleReset = true + laAlert = .laFailedAlert + case .unavailable: + prefPerformLA = false + withAnimation() { + performLA = false + } + performLAToggleReset = true + laAlert = .laUnavailableInstructionAlert + } + } + } + + private func disableLA() { + authenticate(reason: NSLocalizedString("Disable SimpleX Lock", comment: "authentication reason")) { laResult in + switch (laResult) { + case .success: + prefPerformLA = false + case .failed: + prefPerformLA = true + withAnimation() { + performLA = true + } + performLAToggleReset = true + laAlert = .laFailedAlert + case .unavailable: + prefPerformLA = false + laAlert = .laUnavailableTurningOffAlert + } + } + } +} + +struct PrivacySettings_Previews: PreviewProvider { + static var previews: some View { + PrivacySettings() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift index 23033fd7cf..7292fd4373 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift @@ -11,14 +11,13 @@ import SwiftUI struct SettingsButton: View { @EnvironmentObject var chatModel: ChatModel @State private var showSettings = false - @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false var body: some View { Button { showSettings = true } label: { Image(systemName: "gearshape") } .sheet(isPresented: $showSettings, content: { - SettingsView(showSettings: $showSettings, performLA: prefPerformLA) + SettingsView(showSettings: $showSettings) }) } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 980d29c7ec..4ef9730c69 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -20,6 +20,8 @@ let DEFAULT_PERFORM_LA = "performLocalAuthentication" let DEFAULT_USE_NOTIFICATIONS = "useNotifications" let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" +let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" +let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" let appDefaults: [String:Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -27,7 +29,9 @@ let appDefaults: [String:Any] = [ DEFAULT_PERFORM_LA: false, DEFAULT_USE_NOTIFICATIONS: false, DEFAULT_PENDING_CONNECTIONS: true, - DEFAULT_WEBRTC_POLICY_RELAY: true + DEFAULT_WEBRTC_POLICY_RELAY: true, + DEFAULT_PRIVACY_ACCEPT_IMAGES: true, + DEFAULT_PRIVACY_LINK_PREVIEWS: true ] private var indent: CGFloat = 36 @@ -36,24 +40,10 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool - @State var performLA: Bool = false - @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false - @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true - @State private var performLAToggleReset = false @State var showNotificationsAlert: Bool = false @State var whichNotificationsAlert = NotificationAlert.enable - @State var alert: SettingsViewAlert? = nil - - enum SettingsViewAlert: Identifiable { - case laTurnedOnAlert - case laFailedAlert - case laUnavailableInstructionAlert - case laUnavailableTurningOffAlert - - var id: SettingsViewAlert { get { self } } - } var body: some View { let user: User = chatModel.currentUser! @@ -79,12 +69,15 @@ struct SettingsView: View { Section("Settings") { NavigationLink { CallSettings() - .navigationTitle("Call settings") + .navigationTitle("Your calls") } label: { - settingsRow("video") { Text("Call settings") } + settingsRow("video") { Text("Audio & video calls") } } - settingsRow("lock") { - Toggle("SimpleX Lock", isOn: $performLA) + NavigationLink { + PrivacySettings() + .navigationTitle("Your privacy") + } label: { + settingsRow("lock") { Text("Privacy & security") } } settingsRow("link") { Toggle("Show pending connections", isOn: $pendingConnections) @@ -156,76 +149,6 @@ struct SettingsView: View { } } .navigationTitle("Your settings") - .onChange(of: performLA) { performLAToggle in - prefLANoticeShown = true - if performLAToggleReset { - performLAToggleReset = false - } else { - if performLAToggle { - enableLA() - } else { - disableLA() - } - } - } - .alert(item: $alert) { alertItem in - switch alertItem { - case .laTurnedOnAlert: return laTurnedOnAlert() - case .laFailedAlert: return laFailedAlert() - case .laUnavailableInstructionAlert: return laUnavailableInstructionAlert() - case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert() - } - } - } - } - - private func enableLA() { - authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in - switch laResult { - case .success: - prefPerformLA = true - alert = .laTurnedOnAlert - case .failed: - prefPerformLA = false - withAnimation() { - performLA = false - } - performLAToggleReset = true - alert = .laFailedAlert - case .unavailable: - prefPerformLA = false - withAnimation() { - performLA = false - } - performLAToggleReset = true - alert = .laUnavailableInstructionAlert - } - } - } - - private func disableLA() { - authenticate(reason: NSLocalizedString("Disable SimpleX Lock", comment: "authentication reason")) { laResult in - switch (laResult) { - case .success: - prefPerformLA = false - case .failed: - prefPerformLA = true - withAnimation() { - performLA = true - } - performLAToggleReset = true - alert = .laFailedAlert - case .unavailable: - prefPerformLA = false - alert = .laUnavailableTurningOffAlert - } - } - } - - private func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View { - ZStack(alignment: .leading) { - Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary) - content().padding(.leading, indent) } } @@ -326,6 +249,13 @@ struct SettingsView: View { } } +func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View { + ZStack(alignment: .leading) { + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary) + content().padding(.leading, indent) + } +} + struct ProfilePreview: View { var profileOf: NamedChat var color = Color(uiColor: .tertiarySystemGroupedBackground) 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 7437693637..ad1421a492 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -176,6 +176,11 @@ Attach No comment provided by engineer. + + Audio & video calls + Audio & video calls + No comment provided by engineer. + Authentication failed Authentication failed @@ -186,16 +191,16 @@ Authentication unavailable No comment provided by engineer. + + Auto-accept images + Auto-accept images + No comment provided by engineer. + Call already ended! Call already ended! No comment provided by engineer. - - Call settings - Call settings - No comment provided by engineer. - Cancel Cancel @@ -227,8 +232,8 @@ notification - Choose file (new in v2.0) - Choose file (new in v2.0) + Choose file + Choose file No comment provided by engineer. @@ -456,6 +461,11 @@ Develop No comment provided by engineer. + + Device + Device + No comment provided by engineer. + Device authentication is disabled. Turning off SimpleX Lock. Device authentication is disabled. Turning off SimpleX Lock. @@ -776,6 +786,11 @@ We will be adding server redundancy to prevent lost messages. Pre-arrange the calls, as notifications arrive with a delay (we are improving it). No comment provided by engineer. + + Privacy & security + Privacy & security + No comment provided by engineer. + Privacy redefined Privacy redefined @@ -856,6 +871,11 @@ We will be adding server redundancy to prevent lost messages. Scan contact's QR code No comment provided by engineer. + + Send link previews + Send link previews + No comment provided by engineer. + Server connected Server connected @@ -1137,6 +1157,11 @@ To connect, please ask your contact to create another connection link and check Your SimpleX contact address No comment provided by engineer. + + Your calls + Your calls + No comment provided by engineer. + Your chat address Your chat address @@ -1174,6 +1199,11 @@ You can cancel this connection and remove the contact (and try later with a new Your contact sent a file that is larger than currently supported maximum size (%@). No comment provided by engineer. + + Your privacy + Your privacy + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. 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 a87ccd3341..813c6f5981 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,3 @@ -/* No comment provided by engineer. */ -"Choose file" = "Choose file (new in v2.0)"; - /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; 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 9fbd918ca5..665b324bb8 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -176,6 +176,11 @@ Прикрепить No comment provided by engineer. + + Audio & video calls + Аудио- и видеозвонки + No comment provided by engineer. + Authentication failed Ошибка аутентификации @@ -186,16 +191,16 @@ Аутентификация недоступна No comment provided by engineer. + + Auto-accept images + Автоприем изображений + No comment provided by engineer. + Call already ended! Звонок уже завершен! No comment provided by engineer. - - Call settings - Настройки звонков - No comment provided by engineer. - Cancel Отменить @@ -213,7 +218,7 @@ Chats - Назад + Чаты back button to return to chats list @@ -227,8 +232,8 @@ notification - Choose file (new in v2.0) - Выбрать файл (v2.0) + Choose file + Выбрать файл No comment provided by engineer. @@ -456,6 +461,11 @@ Для разработчиков No comment provided by engineer. + + Device + Устройство + No comment provided by engineer. + Device authentication is disabled. Turning off SimpleX Lock. Аутентификация устройства выключена. Отключение блокировки SimpleX Chat. @@ -776,6 +786,11 @@ We will be adding server redundancy to prevent lost messages. Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это) No comment provided by engineer. + + Privacy & security + Конфиденциальность + No comment provided by engineer. + Privacy redefined Более конфиденциальный @@ -856,6 +871,11 @@ We will be adding server redundancy to prevent lost messages. Сосканировать QR код контакта No comment provided by engineer. + + Send link previews + Отправлять картинки ссылок + No comment provided by engineer. + Server connected Установлено соединение с сервером @@ -1137,6 +1157,11 @@ To connect, please ask your contact to create another connection link and check Ваш SimpleX адрес No comment provided by engineer. + + Your calls + Ваши звонки + No comment provided by engineer. + Your chat address Ваш SimpleX адрес @@ -1174,6 +1199,11 @@ You can cancel this connection and remove the contact (and try later with a new Ваш контакт отправил файл, размер которого превышает максимальный размер (%@). No comment provided by engineer. + + Your privacy + Конфиденциальность + No comment provided by engineer. + Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. 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 a87ccd3341..813c6f5981 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,3 @@ -/* No comment provided by engineer. */ -"Choose file" = "Choose file (new in v2.0)"; - /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1cafbbcfcd..0fa41d3d1b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; + 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -156,6 +157,7 @@ 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 = ""; }; 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.swift; sourceTree = ""; }; + 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.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 = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; @@ -468,6 +470,7 @@ 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, + 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, @@ -684,6 +687,7 @@ buildActionMask = 2147483647; files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, + 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */, diff --git a/apps/ios/en.lproj/Localizable.strings b/apps/ios/en.lproj/Localizable.strings index a87ccd3341..813c6f5981 100644 --- a/apps/ios/en.lproj/Localizable.strings +++ b/apps/ios/en.lproj/Localizable.strings @@ -1,6 +1,3 @@ -/* No comment provided by engineer. */ -"Choose file" = "Choose file (new in v2.0)"; - /* No comment provided by engineer. */ "Connecting server…" = "Connecting to server…"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 04638574d2..ec3d6440db 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -119,6 +119,9 @@ /* No comment provided by engineer. */ "Attach" = "Прикрепить"; +/* No comment provided by engineer. */ +"Audio & video calls" = "Аудио- и видеозвонки"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; @@ -128,6 +131,9 @@ /* No comment provided by engineer. */ "Authentication unavailable" = "Аутентификация недоступна"; +/* No comment provided by engineer. */ +"Auto-accept images" = "Автоприем изображений"; + /* integrity error chat item */ "bad message hash" = "ошибка хэш сообщения"; @@ -146,9 +152,6 @@ /* call status */ "call in progress" = "активный звонок"; -/* No comment provided by engineer. */ -"Call settings" = "Настройки звонков"; - /* call status */ "calling…" = "входящий звонок…"; @@ -162,7 +165,7 @@ "Chat with the developers" = "Соединиться с разработчиками"; /* back button to return to chats list */ -"Chats" = "Назад"; +"Chats" = "Чаты"; /* No comment provided by engineer. */ "Check messages" = "Проверять сообщения"; @@ -171,7 +174,7 @@ "Checking new messages..." = "Проверяются новые сообщения..."; /* No comment provided by engineer. */ -"Choose file" = "Выбрать файл (v2.0)"; +"Choose file" = "Выбрать файл"; /* No comment provided by engineer. */ "Choose from library" = "Выбрать из библиотеки"; @@ -338,6 +341,9 @@ /* No comment provided by engineer. */ "Develop" = "Для разработчиков"; +/* No comment provided by engineer. */ +"Device" = "Устройство"; + /* No comment provided by engineer. */ "Device authentication is disabled. Turning off SimpleX Lock." = "Аутентификация устройства выключена. Отключение блокировки SimpleX Chat."; @@ -554,6 +560,9 @@ /* No comment provided by engineer. */ "Pre-arrange the calls, as notifications arrive with a delay (we are improving it)." = "Назначайте звонки заранее, поскольку уведомления приходят с задержкой (мы улучшаем это)"; +/* No comment provided by engineer. */ +"Privacy & security" = "Конфиденциальность"; + /* No comment provided by engineer. */ "Privacy redefined" = "Более конфиденциальный"; @@ -611,6 +620,9 @@ /* No comment provided by engineer. */ "secret" = "секрет"; +/* No comment provided by engineer. */ +"Send link previews" = "Отправлять картинки ссылок"; + /* No comment provided by engineer. */ "Server connected" = "Установлено соединение с сервером"; @@ -809,6 +821,9 @@ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме."; +/* No comment provided by engineer. */ +"Your calls" = "Ваши звонки"; + /* No comment provided by engineer. */ "Your chat address" = "Ваш SimpleX адрес"; @@ -830,6 +845,9 @@ /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ваш контакт отправил файл, размер которого превышает максимальный размер (%@)."; +/* No comment provided by engineer. */ +"Your privacy" = "Конфиденциальность"; + /* 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 серверы не могут получить доступ к вашему профилю."; From 71fa2bfec060bb0deb77a29d6741b2ca3c29be40 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 30 May 2022 09:03:41 +0100 Subject: [PATCH 15/23] android: settings sections (#709) --- .../java/chat/simplex/app/ui/theme/Color.kt | 2 + .../simplex/app/views/helpers/ModalView.kt | 20 ++- .../app/views/usersettings/CallSettings.kt | 27 +-- .../app/views/usersettings/PrivacySettings.kt | 28 +-- .../app/views/usersettings/SettingsView.kt | 162 ++++++++++-------- .../app/src/main/res/values-ru/strings.xml | 8 + .../app/src/main/res/values/strings.xml | 8 + 7 files changed, 153 insertions(+), 102 deletions(-) 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 14e7f20054..4fb1673df6 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 @@ -17,6 +17,8 @@ val MessagePreviewDark = Color(179, 175, 174, 255) val MessagePreviewLight = Color(49, 45, 44, 255) val ToolbarLight = Color(220, 220, 220, 20) val ToolbarDark = Color(80, 80, 80, 20) +val SettingsBackgroundLight = Color(220, 216, 215, 90) +val GroupDark = Color(80, 80, 80, 60) val IncomingCallLight = Color(239, 237, 236, 255) val IncomingCallDark = Color(34, 30, 29, 255) val WarningOrange = Color(255, 127, 0, 255) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt index ea720b631f..f417fb3d93 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt @@ -9,20 +9,22 @@ import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import chat.simplex.app.TAG @Composable -fun ModalView(close: () -> Unit, content: @Composable () -> Unit) { +fun ModalView( + close: () -> Unit, + background: Color = MaterialTheme.colors.background, + modifier: Modifier = Modifier.padding(horizontal = 16.dp), + content: @Composable () -> Unit, +) { BackHandler(onBack = close) - Surface( - Modifier - .background(MaterialTheme.colors.background) - .fillMaxSize() - ) { - Column { + Surface(Modifier.fillMaxSize()) { + Column(Modifier.background(background)) { CloseSheetBar(close) - Box(Modifier.padding(horizontal = 16.dp)) { content() } + Box(modifier) { content() } } } } @@ -32,7 +34,7 @@ class ModalManager { private val modalCount = mutableStateOf(0) fun showModal(content: @Composable () -> Unit) { - showCustomModal { close -> ModalView(close, content) } + showCustomModal { close -> ModalView(close, content = content) } } fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index d93bf927c2..1b37f151bf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -32,21 +32,28 @@ fun CallSettingsLayout( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { + @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) } Text( stringResource(R.string.your_calls), - Modifier.padding(bottom = 24.dp), + Modifier.padding(start = 16.dp, bottom = 24.dp), style = MaterialTheme.typography.h1 ) - SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay) - Column { - Text(stringResource(R.string.call_on_lock_screen)) - Row { - SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE) - Spacer(Modifier.fillMaxWidth().weight(1f)) - SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW) - Spacer(Modifier.fillMaxWidth().weight(1f)) - SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT) + SettingsSectionView(stringResource(R.string.settings_section_title_settings)) { + Box(Modifier.padding(start = 10.dp)) { + SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay) + } + divider() + + Column(Modifier.padding(start = 10.dp, top = 12.dp)) { + Text(stringResource(R.string.call_on_lock_screen)) + Row { + SharedPreferenceRadioButton(stringResource(R.string.no_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.DISABLE) + Spacer(Modifier.fillMaxWidth().weight(1f)) + SharedPreferenceRadioButton(stringResource(R.string.show_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.SHOW) + Spacer(Modifier.fillMaxWidth().weight(1f)) + SharedPreferenceRadioButton(stringResource(R.string.accept_call_on_lock_screen), lockCallState, callOnLockScreen, CallOnLockScreen.ACCEPT) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt index 5e6ae30448..0582eaa826 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt @@ -1,6 +1,8 @@ package chat.simplex.app.views.usersettings +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.outlined.* @@ -12,32 +14,36 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SettingsBackgroundLight +import chat.simplex.app.views.helpers.ModalView @Composable fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) Column( Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(8.dp) + horizontalAlignment = Alignment.Start ) { Text( stringResource(R.string.your_privacy), style = MaterialTheme.typography.h1, - modifier = Modifier.padding(start = 8.dp, bottom = 24.dp) + modifier = Modifier.padding(start = 16.dp, bottom = 24.dp) ) - ChatLockSection(chatModel.performLA, setPerformLA) - Spacer(Modifier.height(24.dp)) + SettingsSectionView(stringResource(R.string.settings_section_title_device)) { + ChatLockItem(chatModel.performLA, setPerformLA) + } + Spacer(Modifier.height(30.dp)) - AutoAcceptImagesSection(chatModel.controller.appPrefs.privacyAcceptImages) - divider() - LinkPreviewsSection(chatModel.controller.appPrefs.privacyLinkPreviews) - divider() + SettingsSectionView(stringResource(R.string.settings_section_title_chats)) { + AutoAcceptImagesSection(chatModel.controller.appPrefs.privacyAcceptImages) + divider() + LinkPreviewsSection(chatModel.controller.appPrefs.privacyLinkPreviews) + } } } @Composable private fun AutoAcceptImagesSection(prefAcceptImages: Preference) { - SettingsSectionView() { + SettingsItemView() { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.Image, @@ -51,7 +57,7 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { } @Composable private fun LinkPreviewsSection(prefLinkPreviews: Preference) { - SettingsSectionView() { + SettingsItemView() { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.TravelExplore, 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 0436233d39..f92b6a7715 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 @@ -2,29 +2,26 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.scrollable 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.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler 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.dp +import androidx.compose.ui.unit.* import chat.simplex.app.BuildConfig 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.ui.theme.* import chat.simplex.app.views.TerminalView import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.SimpleXInfo @@ -49,6 +46,12 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { setRunServiceInBackground = ::setRunServiceInBackground, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, + showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close -> + ModalView(close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) { + modalView(chatModel) + } + } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } } // showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } }, @@ -66,70 +69,84 @@ fun SettingsLayout( setRunServiceInBackground: (Boolean) -> Unit, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showTerminal: () -> Unit, // showVideoChatPrototype: () -> Unit ) { val uriHandler = LocalUriHandler.current - Surface( - Modifier - .background(MaterialTheme.colors.background) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { + Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { Column( Modifier .fillMaxSize() - .background(MaterialTheme.colors.background) - .padding(8.dp) + .background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) .padding(top = 16.dp) ) { @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) + @Composable fun spacer() = Spacer(Modifier.height(30.dp)) Text( stringResource(R.string.your_settings), style = MaterialTheme.typography.h1, - modifier = Modifier.padding(start = 8.dp) + modifier = Modifier.padding(start = 16.dp) ) Spacer(Modifier.height(30.dp)) - SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) { - ProfilePreview(profile) + SettingsSectionView(stringResource(R.string.settings_section_title_you)) { + SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) { + ProfilePreview(profile) + } + divider() + UserAddressSection(showModal) } - divider() - UserAddressSection(showModal) - Spacer(Modifier.height(24.dp)) + spacer() - CallSettingsSection(showModal) - divider() - PrivacySettingsSection(showModal, setPerformLA) - divider() - PrivateNotificationsSection(runServiceInBackground, setRunServiceInBackground) - divider() - SMPServersSection(showModal) - Spacer(Modifier.height(24.dp)) + SettingsSectionView(stringResource(R.string.settings_section_title_settings)) { + CallSettingsItem(showSettingsModal) + divider() + PrivacySettingsItem(showSettingsModal, setPerformLA) + divider() + PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground) + divider() + SMPServersItem(showModal) + } + spacer() - HelpViewSection(showModal) - divider() - SimpleXInfoSection(showModal) - divider() - MarkdownHelpSection(showModal) - divider() - ConnectToDevelopersSection(uriHandler) - divider() - SendEmailSection(uriHandler) - Spacer(Modifier.height(24.dp)) + SettingsSectionView(stringResource(R.string.settings_section_title_help)) { + HelpViewItem(showModal) + divider() + SimpleXInfoItem(showModal) + divider() + MarkdownHelpItem(showModal) + divider() + ConnectToDevelopersItem(uriHandler) + divider() + SendEmailItem(uriHandler) + } + spacer() - ChatConsoleSection(showTerminal) - divider() - InstallTerminalAppSection(uriHandler) - divider() - AppVersionSection() + SettingsSectionView(stringResource(R.string.settings_section_title_develop)) { + ChatConsoleItem(showTerminal) + divider() + InstallTerminalAppItem(uriHandler) + divider() + AppVersionItem() + } + } + } +} + +@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) { + Column { + Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, + modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp) + Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) { + Column(Modifier.padding(horizontal = 6.dp)) { content() } } } } @Composable private fun UserAddressSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsSectionView(showModal { UserAddressView(it) }) { + SettingsItemView(showModal { UserAddressView(it) }) { Icon( Icons.Outlined.QrCode, contentDescription = stringResource(R.string.icon_descr_address), @@ -140,8 +157,8 @@ fun SettingsLayout( } } -@Composable private fun CallSettingsSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsSectionView(showModal { CallSettingsView(it) }) { +@Composable private fun CallSettingsItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + SettingsItemView(showSettingsModal { CallSettingsView(it) }) { Icon( Icons.Outlined.Videocam, contentDescription = stringResource(R.string.call_settings), @@ -152,8 +169,8 @@ fun SettingsLayout( } } -@Composable private fun PrivacySettingsSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), setPerformLA: (Boolean) -> Unit) { - SettingsSectionView(showModal { PrivacySettingsView(it, setPerformLA) }) { +@Composable private fun PrivacySettingsItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), setPerformLA: (Boolean) -> Unit) { + SettingsItemView(showSettingsModal { PrivacySettingsView(it, setPerformLA) }) { Icon( Icons.Outlined.Lock, contentDescription = stringResource(R.string.privacy_and_security), @@ -164,8 +181,8 @@ fun SettingsLayout( } } -@Composable private fun HelpViewSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsSectionView(showModal { HelpView(it) }) { +@Composable private fun HelpViewItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + SettingsItemView(showModal { HelpView(it) }) { Icon( Icons.Outlined.HelpOutline, contentDescription = stringResource(R.string.icon_descr_help), @@ -176,8 +193,8 @@ fun SettingsLayout( } } -@Composable private fun SimpleXInfoSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsSectionView(showModal { SimpleXInfo(it, onboarding = false) }) { +@Composable private fun SimpleXInfoItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + SettingsItemView(showModal { SimpleXInfo(it, onboarding = false) }) { Icon( Icons.Outlined.Info, contentDescription = stringResource(R.string.icon_descr_help), @@ -188,8 +205,8 @@ fun SettingsLayout( } } -@Composable private fun MarkdownHelpSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsSectionView(showModal { MarkdownHelpView() }) { +@Composable private fun MarkdownHelpItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + SettingsItemView(showModal { MarkdownHelpView() }) { Icon( Icons.Outlined.TextFormat, contentDescription = stringResource(R.string.markdown_help), @@ -200,8 +217,8 @@ fun SettingsLayout( } } -@Composable private fun ConnectToDevelopersSection(uriHandler: UriHandler) { - SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) { +@Composable private fun ConnectToDevelopersItem(uriHandler: UriHandler) { + SettingsItemView({ uriHandler.openUri(simplexTeamUri) }) { Icon( Icons.Outlined.Tag, contentDescription = stringResource(R.string.icon_descr_simplex_team), @@ -215,8 +232,8 @@ fun SettingsLayout( } } -@Composable private fun SendEmailSection(uriHandler: UriHandler) { - SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) { +@Composable private fun SendEmailItem(uriHandler: UriHandler) { + SettingsItemView({ uriHandler.openUri("mailto:chat@simplex.chat") }) { Icon( Icons.Outlined.Email, contentDescription = stringResource(R.string.icon_descr_email), @@ -230,8 +247,8 @@ fun SettingsLayout( } } -@Composable private fun SMPServersSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsSectionView(showModal { SMPServersView(it) }) { +@Composable private fun SMPServersItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + SettingsItemView(showModal { SMPServersView(it) }) { Icon( Icons.Outlined.Dns, contentDescription = stringResource(R.string.smp_servers), @@ -242,11 +259,11 @@ fun SettingsLayout( } } -@Composable private fun PrivateNotificationsSection( +@Composable private fun PrivateNotificationsItem( runServiceInBackground: MutableState, setRunServiceInBackground: (Boolean) -> Unit ) { - SettingsSectionView() { + SettingsItemView() { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.Bolt, @@ -274,8 +291,8 @@ fun SettingsLayout( } } -@Composable fun ChatLockSection(performLA: MutableState, setPerformLA: (Boolean) -> Unit) { - SettingsSectionView() { +@Composable fun ChatLockItem(performLA: MutableState, setPerformLA: (Boolean) -> Unit) { + SettingsItemView() { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.Lock, @@ -302,8 +319,8 @@ fun SettingsLayout( } } -@Composable private fun ChatConsoleSection(showTerminal: () -> Unit) { - SettingsSectionView(showTerminal) { +@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) { + SettingsItemView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), contentDescription = stringResource(R.string.chat_console), @@ -314,8 +331,8 @@ fun SettingsLayout( } } -@Composable private fun InstallTerminalAppSection(uriHandler: UriHandler) { - SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { +@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) { + SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { Icon( painter = painterResource(id = R.drawable.ic_github), contentDescription = "GitHub", @@ -326,8 +343,8 @@ fun SettingsLayout( } } -@Composable private fun AppVersionSection() { - SettingsSectionView() { +@Composable private fun AppVersionItem() { + SettingsItemView() { Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") } } @@ -346,7 +363,7 @@ fun SettingsLayout( } @Composable -fun SettingsSectionView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) { +fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) { val modifier = Modifier .padding(start = 8.dp) .fillMaxWidth() @@ -374,6 +391,7 @@ fun PreviewSettingsLayout() { setRunServiceInBackground = {}, setPerformLA = {}, showModal = { {} }, + showSettingsModal = { {} }, showCustomModal = { {} }, showTerminal = {}, // showVideoChatPrototype = {} 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 458c0e8c19..7d546a2713 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -428,4 +428,12 @@ Конфиденциальность Автоприем изображений Отправлять картинки ссылок + + + ВЫ + НАСТРОЙКИ + ПОМОЩЬ + ДЛЯ РАЗРАБОТЧИКОВ + УСТРОЙСТВО + ЧАТЫ diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index fe61acfaa1..8586c4d92e 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -430,4 +430,12 @@ Your privacy Auto-accept images Send link previews + + + YOU + SETTINGS + HELP + DEVELOP + DEVICE + CHATS From 23b75f11fe68ef1230e1affdcd0f44615448b26a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 30 May 2022 09:03:56 +0100 Subject: [PATCH 16/23] ios: paste image (#710) --- apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 68024cd5e0..0de0ea5555 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -176,6 +176,11 @@ struct ComposeView: View { Button("Choose from library") { showImagePicker = true } + if UIPasteboard.general.hasImages { + Button("Paste image") { + chosenImage = UIPasteboard.general.image + } + } Button("Choose file") { showFileImporter = true } From e5909d4e124309afbc289e5fedd9d84559b71ab1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 30 May 2022 09:05:02 +0100 Subject: [PATCH 17/23] ios: SMP servers settings page layout (#711) --- .../Views/UserSettings/SMPServers.swift | 117 +++++++++--------- .../Views/UserSettings/SettingsView.swift | 2 +- .../en.xcloc/Localized Contents/en.xliff | 10 +- .../ru.xcloc/Localized Contents/ru.xliff | 10 +- 4 files changed, 68 insertions(+), 71 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/SMPServers.swift b/apps/ios/Shared/Views/UserSettings/SMPServers.swift index a687f613c3..35909320d6 100644 --- a/apps/ios/Shared/Views/UserSettings/SMPServers.swift +++ b/apps/ios/Shared/Views/UserSettings/SMPServers.swift @@ -23,58 +23,72 @@ struct SMPServers: View { @FocusState private var keyboardVisible: Bool var body: some View { - return VStack(alignment: .leading) { - Toggle("Configure SMP servers", isOn: $isUserSMPServersToggle) - .onChange(of: isUserSMPServersToggle) { _ in - if (isUserSMPServersToggle) { - isUserSMPServers = true - } else { - let servers = chatModel.userSMPServers ?? [] - if (!servers.isEmpty) { - showResetServersAlert = true + List { + Section { + Toggle("Configure SMP servers", isOn: $isUserSMPServersToggle) + .onChange(of: isUserSMPServersToggle) { _ in + if (isUserSMPServersToggle) { + isUserSMPServers = true } else { - isUserSMPServers = false - userSMPServersStr = "" - } - } - } - .padding(.bottom) - .alert(isPresented: $showResetServersAlert) { - Alert( - title: Text("Use SimpleX Chat servers?"), - message: Text("Saved SMP servers will be removed"), - primaryButton: .destructive(Text("Confirm")) { - saveSMPServers(smpServers: []) - isUserSMPServers = false - userSMPServersStr = "" - }, secondaryButton: .cancel() { - withAnimation() { - isUserSMPServersToggle = true + let servers = chatModel.userSMPServers ?? [] + if (!servers.isEmpty) { + showResetServersAlert = true + } else { + isUserSMPServers = false + userSMPServersStr = "" } } - ) + } + .alert(isPresented: $showResetServersAlert) { + Alert( + title: Text("Use SimpleX Chat servers?"), + message: Text("Saved SMP servers will be removed"), + primaryButton: .destructive(Text("Confirm")) { + saveSMPServers(smpServers: []) + isUserSMPServers = false + userSMPServersStr = "" + }, secondaryButton: .cancel() { + withAnimation() { + isUserSMPServersToggle = true + } + } + ) + } + } header: { + Text("") + } footer: { + if !isUserSMPServers { + Text("Using SimpleX Chat servers.") } + } - if !isUserSMPServers { - Text("Using SimpleX Chat servers.") - .frame(maxWidth: .infinity, alignment: .leading) - } else { - VStack(alignment: .leading) { - Text("Enter one SMP server per line:") + if isUserSMPServers { + Section { if editSMPServers { TextEditor(text: $userSMPServersStr) .focused($keyboardVisible) .font(serversFont) .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) { + .padding(.horizontal, -5) + .padding(.top, -8) + .frame(height: 160, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ScrollView { + Text(userSMPServersStr) + .font(serversFont) + .frame(minHeight: 0, alignment: .topLeading) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 160) + } + } header: { + Text("SMP servers (one per line)") + } footer: { + HStack(spacing: 20) { + if editSMPServers { Button("Cancel") { initialize() } @@ -86,22 +100,7 @@ struct SMPServers: View { } Spacer() howToButton() - } - } else { - ScrollView { - Text(userSMPServersStr) - .font(serversFont) - .padding(10) - .frame(minHeight: 0, alignment: .topLeading) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(height: 160) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - ) - HStack { + } else { Button("Edit") { editSMPServers = true } @@ -109,12 +108,10 @@ struct SMPServers: View { howToButton() } } + .font(.body) } - .frame(maxWidth: .infinity) } } - .padding() - .frame(maxHeight: .infinity, alignment: .top) .onAppear { initialize() } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 4ef9730c69..ca1ec83e40 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -23,7 +23,7 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" -let appDefaults: [String:Any] = [ +let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_LA_NOTICE_SHOWN: false, DEFAULT_PERFORM_LA: false, 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 ad1421a492..a752ee6555 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -506,11 +506,6 @@ 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 @@ -846,6 +841,11 @@ We will be adding server redundancy to prevent lost messages. SMP servers No comment provided by engineer. + + SMP servers (one per line) + SMP servers (one per line) + No comment provided by engineer. + Save Save 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 665b324bb8..716579010d 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -506,11 +506,6 @@ Включить уведомления? (БЕТА) No comment provided by engineer. - - Enter one SMP server per line: - Введите SMP серверы, каждый на отдельной строке: - No comment provided by engineer. - Error deleting token Ошибка удаления токена @@ -846,6 +841,11 @@ We will be adding server redundancy to prevent lost messages. SMP серверы No comment provided by engineer. + + SMP servers (one per line) + SMP серверы (один на строке) + No comment provided by engineer. + Save Сохранить From d6262bc2a4243917122b46fa7d222822981d3fbf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 30 May 2022 09:12:57 +0100 Subject: [PATCH 18/23] ios: move files --- .../{Helpers => Chat/ChatItem}/CIFileView.swift | 0 .../{Helpers => Chat/ChatItem}/CIImageView.swift | 0 .../{Helpers => Chat/ChatItem}/CILinkView.swift | 0 .../ComposeMessage}/ComposeFileView.swift | 0 .../ComposeMessage}/ComposeImageView.swift | 0 .../ComposeMessage}/ComposeLinkView.swift | 0 apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ 7 files changed, 6 insertions(+), 6 deletions(-) rename apps/ios/Shared/Views/{Helpers => Chat/ChatItem}/CIFileView.swift (100%) rename apps/ios/Shared/Views/{Helpers => Chat/ChatItem}/CIImageView.swift (100%) rename apps/ios/Shared/Views/{Helpers => Chat/ChatItem}/CILinkView.swift (100%) rename apps/ios/Shared/Views/{Helpers => Chat/ComposeMessage}/ComposeFileView.swift (100%) rename apps/ios/Shared/Views/{Helpers => Chat/ComposeMessage}/ComposeImageView.swift (100%) rename apps/ios/Shared/Views/{Helpers => Chat/ComposeMessage}/ComposeLinkView.swift (100%) diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/CIFileView.swift rename to apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/CIImageView.swift rename to apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift diff --git a/apps/ios/Shared/Views/Helpers/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/CILinkView.swift rename to apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift diff --git a/apps/ios/Shared/Views/Helpers/ComposeFileView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/ComposeFileView.swift rename to apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift diff --git a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/ComposeImageView.swift rename to apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/ComposeLinkView.swift rename to apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0fa41d3d1b..76f9cd852a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -374,12 +374,6 @@ 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */, 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */, 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */, - 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */, - 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, - 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, - 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, - 648010AA281ADD15009009B9 /* CIFileView.swift */, - 6454036E2822A9750090DDFF /* ComposeFileView.swift */, 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */, 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */, ); @@ -527,6 +521,9 @@ 5CE4407827ADB701007B033A /* EmojiItemView.swift */, 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */, 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */, + 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, + 648010AA281ADD15009009B9 /* CIFileView.swift */, + 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, 5C029EA72837DBB3004A9677 /* CICallItemView.swift */, 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */, @@ -540,6 +537,9 @@ 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5CEACCE227DE9246000BD591 /* ComposeView.swift */, 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */, + 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, + 6454036E2822A9750090DDFF /* ComposeFileView.swift */, + 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */, ); path = ComposeMessage; sourceTree = ""; From 866d84e7acf1748f202fb3c42c50a164e1c51984 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 30 May 2022 12:32:11 +0100 Subject: [PATCH 19/23] mobile: move calls to experimental features, refactor (#712) --- .../java/chat/simplex/app/MainActivity.kt | 2 +- .../main/java/chat/simplex/app/SimplexApp.kt | 3 +- .../java/chat/simplex/app/model/NtfManager.kt | 1 - .../java/chat/simplex/app/model/SimpleXAPI.kt | 3 + .../simplex/app/views/call/CallManager.kt | 1 + .../chat/simplex/app/views/call/CallView.kt | 3 +- .../app/views/call/IncomingCallActivity.kt | 3 +- .../app/views/call/IncomingCallAlertView.kt | 1 - .../chat/simplex/app/views/chat/ChatView.kt | 9 +- .../simplex/app/views/chat/ComposeView.kt | 5 +- .../views/chat/item/ImageFullScreenView.kt | 1 - .../views/chat/item/IntegrityErrorItemView.kt | 2 - .../app/views/chatlist/ChatListNavLinkView.kt | 2 +- .../app/views/chatlist/ChatListView.kt | 4 +- .../views/chatlist/ContactConnectionView.kt | 1 - .../simplex/app/views/helpers/GetImageView.kt | 3 +- .../simplex/app/views/helpers/LinkPreviews.kt | 1 - .../app/views/usersettings/CallSettings.kt | 12 +- .../usersettings/ExperimentalFeaturesView.kt | 32 ++++ .../app/views/usersettings/PrivacySettings.kt | 44 +---- .../app/views/usersettings/SettingsView.kt | 165 +++++------------- .../app/src/main/res/values-ru/strings.xml | 3 +- .../app/src/main/res/values/strings.xml | 3 +- .../Shared/Views/Call/CallController.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 3 +- .../ExperimentalFeaturesView.swift | 29 +++ .../Views/UserSettings/SettingsView.swift | 23 ++- .../en.xcloc/Localized Contents/en.xliff | 10 ++ .../ru.xcloc/Localized Contents/ru.xliff | 10 ++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + 30 files changed, 184 insertions(+), 200 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt create mode 100644 apps/ios/Shared/Views/UserSettings/ExperimentalFeaturesView.swift 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 000b037224..829839ec78 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 @@ -1,7 +1,7 @@ package chat.simplex.app import android.app.Application -import android.content.* +import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.SystemClock.elapsedRealtime 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 a200f99cc3..73ecd073db 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 @@ -5,7 +5,8 @@ import android.net.LocalServerSocket import android.util.Log import androidx.lifecycle.* import chat.simplex.app.model.* -import chat.simplex.app.views.helpers.* +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 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 404839d9db..dc9ebe5127 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 @@ -1,7 +1,6 @@ package chat.simplex.app.model import android.app.* -import android.app.Notification.VISIBILITY_PUBLIC import android.content.* import android.graphics.BitmapFactory import android.media.AudioAttributes 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 cb2ed9d2ca..f1f66fbeba 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 @@ -80,6 +80,7 @@ class AppPreferences(val context: Context) { val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false) val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) + val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) private fun mkIntPreference(prefName: String, default: Int) = Preference( @@ -111,6 +112,8 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown" private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" + private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" + } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt index b0c8a442be..78f60ab1e8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt @@ -13,6 +13,7 @@ class CallManager(val chatModel: ChatModel) { Log.d(TAG, "CallManager.reportNewIncomingCall") with (chatModel) { callInvitations[invitation.contact.id] = invitation + if (!chatModel.controller.appPrefs.experimentalCalls.get()) return if (Clock.System.now() - invitation.callTs <= 3.minutes) { activeCallInvitation.value = invitation controller.ntfManager.notifyCallInvitation(invitation) 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 index 340acd96c0..036228cb6a 100644 --- 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 @@ -31,7 +31,8 @@ import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.ProfileImage +import chat.simplex.app.views.helpers.withApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.coroutines.delay import kotlinx.serialization.decodeFromString diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt index 1a185daca6..ff1b424f34 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt @@ -1,7 +1,8 @@ package chat.simplex.app.views.call import android.app.KeyguardManager -import android.content.* +import android.content.Context +import android.content.Intent import android.content.res.Configuration import android.os.Build import android.os.Bundle diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt index 703fea35bd..c0960220cf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.call -import androidx.annotation.StringRes import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape 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 6d94fd8c74..b0d59c9030 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 @@ -45,6 +45,7 @@ fun ChatView(chatModel: ChatModel) { val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() + val enableCalls = chatModel.controller.appPrefs.experimentalCalls.get() val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) } val attachmentOption = remember { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) @@ -85,6 +86,7 @@ fun ChatView(chatModel: ChatModel) { attachmentBottomSheetState, chatModel.chatItems, useLinkPreviews = useLinkPreviews, + enableCalls = enableCalls, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -139,6 +141,7 @@ fun ChatLayout( attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, useLinkPreviews: Boolean, + enableCalls: Boolean = false, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, @@ -166,7 +169,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall) }, + topBar = { ChatInfoToolbar(chat, enableCalls, back, info, startCall) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> @@ -180,7 +183,7 @@ fun ChatLayout( } @Composable -fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) { +fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) { @Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) { IconButton(onClick, modifier = modifier) { Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary) @@ -197,7 +200,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: ( ) { val cInfo = chat.chatInfo toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back) - if (cInfo is ChatInfo.Direct) { + if (cInfo is ChatInfo.Direct && enableCalls) { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { 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 c3852b38c1..6c254935a9 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 @@ -24,7 +24,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.outlined.* +import androidx.compose.material.icons.outlined.Reply import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,7 +40,8 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* -import kotlinx.coroutines.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import java.io.File sealed class ComposePreview { 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 29becc9f0f..46051fed52 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 @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.* import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt index 2e6fb52c95..1f8e40c25b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt @@ -10,7 +10,6 @@ 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.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview @@ -18,7 +17,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R 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.helpers.AlertManager import chat.simplex.app.views.helpers.generalGetString 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 10f0fa17e9..0fc8193047 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 @@ -11,7 +11,7 @@ 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.* +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* 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 c7252a5dae..a5ff2008aa 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 @@ -7,7 +7,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.* +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,7 +20,6 @@ import chat.simplex.app.R 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.newchat.NewChatSheet import chat.simplex.app.views.onboarding.MakeConnection import chat.simplex.app.views.usersettings.SettingsView 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 index 64f1107843..318f1a0093 100644 --- 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 @@ -13,7 +13,6 @@ 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 androidx.compose.ui.unit.sp import chat.simplex.app.model.PendingContactConnection import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.* 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 45b52ce276..6b1a50240a 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,7 +18,8 @@ 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.* +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material.icons.outlined.PhotoCamera import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier 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 b9ae1c1140..f101b7c916 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 @@ -11,7 +11,6 @@ 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.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index 1b37f151bf..fac78c976c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -5,10 +5,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* @@ -62,17 +59,18 @@ fun CallSettingsLayout( @Composable fun SharedPreferenceToggle( text: String, - preference: Preference + preference: Preference, + preferenceState: MutableState? = null ) { - var preferenceState by remember { mutableStateOf(preference.get()) } + val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) } Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text(text, Modifier.padding(end = 24.dp)) Spacer(Modifier.fillMaxWidth().weight(1f)) Switch( - checked = preferenceState, + checked = prefState.value, onCheckedChange = { preference.set(it) - preferenceState = it + prefState.value = it }, colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colors.primary, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt new file mode 100644 index 0000000000..81b753a6db --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt @@ -0,0 +1,32 @@ +package chat.simplex.app.views.usersettings + +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.Videocam +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.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel + +@Composable +fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + Text( + stringResource(R.string.settings_experimental_features), + style = MaterialTheme.typography.h1, + modifier = Modifier.padding(start = 16.dp, bottom = 24.dp) + ) + SettingsSectionView("") { + SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls) + } + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt index 0582eaa826..e2e2d1a56e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt @@ -1,21 +1,17 @@ package chat.simplex.app.views.usersettings -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.outlined.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.TravelExplore +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.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.HighOrLowlight -import chat.simplex.app.ui.theme.SettingsBackgroundLight -import chat.simplex.app.views.helpers.ModalView +import chat.simplex.app.model.ChatModel @Composable fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { @@ -35,37 +31,9 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { Spacer(Modifier.height(30.dp)) SettingsSectionView(stringResource(R.string.settings_section_title_chats)) { - AutoAcceptImagesSection(chatModel.controller.appPrefs.privacyAcceptImages) + SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) divider() - LinkPreviewsSection(chatModel.controller.appPrefs.privacyLinkPreviews) + SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) } } } - -@Composable private fun AutoAcceptImagesSection(prefAcceptImages: Preference) { - SettingsItemView() { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Outlined.Image, - contentDescription = stringResource(R.string.auto_accept_images), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - SharedPreferenceToggle(stringResource(R.string.auto_accept_images), prefAcceptImages) - } - } -} - -@Composable private fun LinkPreviewsSection(prefLinkPreviews: Preference) { - SettingsItemView() { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Outlined.TravelExplore, - contentDescription = stringResource(R.string.send_link_previews), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - SharedPreferenceToggle(stringResource(R.string.send_link_previews), prefLinkPreviews) - } - } -} \ No newline at end of file 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 f92b6a7715..505ada39ae 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 @@ -3,14 +3,14 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration 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.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.painterResource @@ -45,6 +45,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { runServiceInBackground = chatModel.runServiceInBackground, setRunServiceInBackground = ::setRunServiceInBackground, setPerformLA = setPerformLA, + enableCalls = remember { mutableStateOf(chatModel.controller.appPrefs.experimentalCalls.get()) }, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close -> ModalView(close = close, modifier = Modifier, @@ -68,6 +69,7 @@ fun SettingsLayout( runServiceInBackground: MutableState, setRunServiceInBackground: (Boolean) -> Unit, setPerformLA: (Boolean) -> Unit, + enableCalls: MutableState, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), @@ -96,31 +98,33 @@ fun SettingsLayout( ProfilePreview(profile) } divider() - UserAddressSection(showModal) + SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_settings)) { - CallSettingsItem(showSettingsModal) - divider() - PrivacySettingsItem(showSettingsModal, setPerformLA) + if (enableCalls.value) { + SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }) + divider() + } + SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }) divider() PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground) divider() - SMPServersItem(showModal) + SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_help)) { - HelpViewItem(showModal) + SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }) divider() - SimpleXInfoItem(showModal) + SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) divider() - MarkdownHelpItem(showModal) + SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() }) divider() - ConnectToDevelopersItem(uriHandler) + SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary) divider() - SendEmailItem(uriHandler) + SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) } spacer() @@ -129,6 +133,8 @@ fun SettingsLayout( divider() InstallTerminalAppItem(uriHandler) divider() + SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) }) + divider() AppVersionItem() } } @@ -145,120 +151,6 @@ fun SettingsLayout( } } -@Composable private fun UserAddressSection(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsItemView(showModal { UserAddressView(it) }) { - Icon( - Icons.Outlined.QrCode, - contentDescription = stringResource(R.string.icon_descr_address), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.your_simplex_contact_address)) - } -} - -@Composable private fun CallSettingsItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsItemView(showSettingsModal { CallSettingsView(it) }) { - Icon( - Icons.Outlined.Videocam, - contentDescription = stringResource(R.string.call_settings), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.call_settings)) - } -} - -@Composable private fun PrivacySettingsItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), setPerformLA: (Boolean) -> Unit) { - SettingsItemView(showSettingsModal { PrivacySettingsView(it, setPerformLA) }) { - Icon( - Icons.Outlined.Lock, - contentDescription = stringResource(R.string.privacy_and_security), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.privacy_and_security)) - } -} - -@Composable private fun HelpViewItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsItemView(showModal { HelpView(it) }) { - Icon( - Icons.Outlined.HelpOutline, - contentDescription = stringResource(R.string.icon_descr_help), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.how_to_use_simplex_chat)) - } -} - -@Composable private fun SimpleXInfoItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsItemView(showModal { SimpleXInfo(it, onboarding = false) }) { - Icon( - Icons.Outlined.Info, - contentDescription = stringResource(R.string.icon_descr_help), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.about_simplex_chat)) - } -} - -@Composable private fun MarkdownHelpItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsItemView(showModal { MarkdownHelpView() }) { - Icon( - Icons.Outlined.TextFormat, - contentDescription = stringResource(R.string.markdown_help), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.markdown_in_messages)) - } -} - -@Composable private fun ConnectToDevelopersItem(uriHandler: UriHandler) { - SettingsItemView({ uriHandler.openUri(simplexTeamUri) }) { - Icon( - Icons.Outlined.Tag, - contentDescription = stringResource(R.string.icon_descr_simplex_team), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - stringResource(R.string.chat_with_the_founder), - color = MaterialTheme.colors.primary - ) - } -} - -@Composable private fun SendEmailItem(uriHandler: UriHandler) { - SettingsItemView({ uriHandler.openUri("mailto:chat@simplex.chat") }) { - Icon( - Icons.Outlined.Email, - contentDescription = stringResource(R.string.icon_descr_email), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - stringResource(R.string.send_us_an_email), - color = MaterialTheme.colors.primary - ) - } -} - -@Composable private fun SMPServersItem(showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - SettingsItemView(showModal { SMPServersView(it) }) { - Icon( - Icons.Outlined.Dns, - contentDescription = stringResource(R.string.smp_servers), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.smp_servers)) - } -} - @Composable private fun PrivateNotificationsItem( runServiceInBackground: MutableState, setRunServiceInBackground: (Boolean) -> Unit @@ -376,6 +268,26 @@ fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: ( } } +@Composable +fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified) { + SettingsItemView(click) { + Icon(icon, text, tint = HighOrLowlight) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(text, color = textColor) + } +} + +@Composable +fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference, prefState: MutableState? = null) { + SettingsItemView() { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, text, tint = HighOrLowlight) + Spacer(Modifier.padding(horizontal = 4.dp)) + SharedPreferenceToggle(text, pref, prefState) + } + } +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -390,6 +302,7 @@ fun PreviewSettingsLayout() { runServiceInBackground = remember { mutableStateOf(true) }, setRunServiceInBackground = {}, setPerformLA = {}, + enableCalls = remember { mutableStateOf(true) }, showModal = { {} }, showSettingsModal = { {} }, showCustomModal = { {} }, 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 7d546a2713..16269f27a7 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -379,7 +379,7 @@ аудиозвонок - Аудио- и видеозвонки + Аудио- и видеозвонки Ваши звонки Соединяться через сервер (relay) Звонки на экране блокировки: @@ -436,4 +436,5 @@ ДЛЯ РАЗРАБОТЧИКОВ УСТРОЙСТВО ЧАТЫ + Экспериментальные функции diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 8586c4d92e..284fd2ca86 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -381,7 +381,7 @@ audio call - Audio & video calls + Audio & video calls Your calls Connect via relay Calls on lock screen: @@ -438,4 +438,5 @@ DEVELOP DEVICE CHATS + Experimental features diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 008db03e9f..ff8339b9b2 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -120,6 +120,7 @@ class CallController: NSObject, CXProviderDelegate, ObservableObject { func reportNewIncomingCall(invitation: CallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall") + if !UserDefaults.standard.bool(forKey: DEFAULT_EXPERIMENTAL_CALLS) { return } if CallController.useCallKit, let uuid = invitation.callkitUUID { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 579069435a..48d581d2c8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -13,6 +13,7 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme + @AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false @ObservedObject var chat: Chat @State private var composeState = ComposeState() @State private var deletingItem: ChatItem? = nil @@ -106,7 +107,7 @@ struct ChatView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - if case let .direct(contact) = cInfo { + if enableCalls, case let .direct(contact) = cInfo { HStack { callButton(contact, .audio, imageName: "phone") callButton(contact, .video, imageName: "video") diff --git a/apps/ios/Shared/Views/UserSettings/ExperimentalFeaturesView.swift b/apps/ios/Shared/Views/UserSettings/ExperimentalFeaturesView.swift new file mode 100644 index 0000000000..0fa754ec20 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/ExperimentalFeaturesView.swift @@ -0,0 +1,29 @@ +// +// ExperimentalFeaturesView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 30/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ExperimentalFeaturesView: View { + @AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false + + var body: some View { + List { + Section("") { + settingsRow("video") { + Toggle("Audio & video calls", isOn: $enableCalls) + } + } + } + } +} + +struct ExperimentalFeaturesView_Previews: PreviewProvider { + static var previews: some View { + ExperimentalFeaturesView() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index ca1ec83e40..f531634c26 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -22,6 +22,7 @@ let DEFAULT_PENDING_CONNECTIONS = "pendingConnections" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" +let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -31,7 +32,8 @@ let appDefaults: [String: Any] = [ DEFAULT_PENDING_CONNECTIONS: true, DEFAULT_WEBRTC_POLICY_RELAY: true, DEFAULT_PRIVACY_ACCEPT_IMAGES: true, - DEFAULT_PRIVACY_LINK_PREVIEWS: true + DEFAULT_PRIVACY_LINK_PREVIEWS: true, + DEFAULT_EXPERIMENTAL_CALLS: false ] private var indent: CGFloat = 36 @@ -42,6 +44,7 @@ struct SettingsView: View { @Binding var showSettings: Bool @AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true + @AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false @State var showNotificationsAlert: Bool = false @State var whichNotificationsAlert = NotificationAlert.enable @@ -67,11 +70,13 @@ struct SettingsView: View { } Section("Settings") { - NavigationLink { - CallSettings() - .navigationTitle("Your calls") - } label: { - settingsRow("video") { Text("Audio & video calls") } + if enableCalls { + NavigationLink { + CallSettings() + .navigationTitle("Your calls") + } label: { + settingsRow("video") { Text("Audio & video calls") } + } } NavigationLink { PrivacySettings() @@ -139,6 +144,12 @@ struct SettingsView: View { Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") .padding(.leading, indent) } + NavigationLink { + ExperimentalFeaturesView() + .navigationTitle("Experimental features") + } label: { + settingsRow("gauge") { Text("Experimental features") } + } // if let token = chatModel.deviceToken { // HStack { // notificationsIcon() 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 a752ee6555..5f1e5c140e 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -531,6 +531,11 @@ Error: URL is invalid No comment provided by engineer. + + Experimental features + Experimental features + 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! @@ -746,6 +751,11 @@ We will be adding server redundancy to prevent lost messages. Paste No comment provided by engineer. + + Paste image + Paste image + No comment provided by engineer. + Paste received link Paste received link 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 716579010d..4dbb579d1c 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -531,6 +531,11 @@ Ошибка: неверная ссылка No comment provided by engineer. + + Experimental features + Экспериментальные функции + No comment provided by engineer. + File will be received when your contact is online, please wait or check later! Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже! @@ -746,6 +751,11 @@ We will be adding server redundancy to prevent lost messages. Вставить No comment provided by engineer. + + Paste image + Вставить изображение + No comment provided by engineer. + Paste received link Вставить полученную ссылку diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 76f9cd852a..6fbfc9e0d6 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; + 5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -158,6 +159,7 @@ 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.swift; sourceTree = ""; }; 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; + 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeaturesView.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 = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; @@ -469,6 +471,7 @@ 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, 640F50E227CF991C001E05C2 /* SMPServers.swift */, + 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */, ); path = UserSettings; sourceTree = ""; @@ -762,6 +765,7 @@ 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, + 5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, ); From 0bb5774ff18e7e9fe51ffd217e5e468958b5296a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 30 May 2022 12:58:09 +0100 Subject: [PATCH 20/23] mobile: update version 2.2 (ios - 49, android - 35) --- apps/android/app/build.gradle | 4 ++-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index d6487da9c8..410b5d966d 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 34 - versionName "2.1.1" + versionCode 35 + versionName "2.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6fbfc9e0d6..1ec19cdf6f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -966,7 +966,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -989,7 +989,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1009,7 +1009,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1032,7 +1032,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1091,7 +1091,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1112,7 +1112,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1131,7 +1131,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1152,7 +1152,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 15ddefe86b0577e18aff2082280bb2201e32464d Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 31 May 2022 20:55:19 +0400 Subject: [PATCH 21/23] mobile: close modal views (#715) --- .../java/chat/simplex/app/MainActivity.kt | 5 +++- apps/ios/Shared/ContentView.swift | 27 ++++++++++--------- apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/Views/Chat/ChatInfoView.swift | 7 +++-- apps/ios/Shared/Views/Chat/ChatView.swift | 7 +++-- 5 files changed, 26 insertions(+), 21 deletions(-) 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 829839ec78..a724d0fdaf 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 @@ -100,6 +100,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { userAuthorized.value = true } else { userAuthorized.value = false + ModalManager.shared.closeModals() authenticate( generalGetString(R.string.auth_unlock), generalGetString(R.string.auth_log_in_using_credential), @@ -241,7 +242,9 @@ fun MainPage( // this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication var chatsAccessAuthorized by remember { mutableStateOf(false) } LaunchedEffect(userAuthorized.value) { - delay(500L) + if (chatModel.controller.appPrefs.performLA.get()) { + delay(500L) + } chatsAccessAuthorized = userAuthorized.value == true } var showAdvertiseLAAlert by remember { mutableStateOf(false) } diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index f9ba1bbaaf..bb5ab76aaa 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -70,18 +70,21 @@ struct ContentView: View { if !prefPerformLA { userAuthorized = true } else { - userAuthorized = false - authenticate(reason: NSLocalizedString("Unlock", comment: "authentication reason")) { laResult in - switch (laResult) { - case .success: - userAuthorized = true - case .failed: - laFailed = true - AlertManager.shared.showAlert(laFailedAlert()) - case .unavailable: - userAuthorized = true - prefPerformLA = false - AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) + chatModel.showChatInfo = false + DispatchQueue.main.async() { + userAuthorized = false + authenticate(reason: NSLocalizedString("Unlock", comment: "authentication reason")) { laResult in + switch (laResult) { + case .success: + userAuthorized = true + case .failed: + laFailed = true + AlertManager.shared.showAlert(laFailedAlert()) + case .unavailable: + userAuthorized = true + prefPerformLA = false + AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) + } } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4d4ae6ad91..ebb3f6731d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -14,6 +14,7 @@ import WebKit final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var currentUser: User? + @Published var showChatInfo: Bool = false // TODO comprehensively close modal views on authentication // list of chat "previews" @Published var chats: [Chat] = [] // current chat diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index b7bd7ef587..18e253b89f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -12,7 +12,6 @@ struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var chat: Chat - @Binding var showChatInfo: Bool @State var alert: ChatInfoViewAlert? = nil @State var deletingContact: Contact? @@ -99,7 +98,7 @@ struct ChatInfoView: View { try await apiDeleteChat(type: .direct, id: contact.apiId) DispatchQueue.main.async { chatModel.removeChat(contact.id) - showChatInfo = false + chatModel.showChatInfo = false } } catch let error { logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") @@ -118,7 +117,7 @@ struct ChatInfoView: View { Task { await clearChat(chat) DispatchQueue.main.async { - showChatInfo = false + chatModel.showChatInfo = false } } }, @@ -130,6 +129,6 @@ struct ChatInfoView: View { struct ChatInfoView_Previews: PreviewProvider { static var previews: some View { @State var showChatInfo = true - return ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showChatInfo: $showChatInfo) + return ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 48d581d2c8..b93252b538 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -18,7 +18,6 @@ struct ChatView: View { @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 var body: some View { @@ -98,12 +97,12 @@ struct ChatView: View { } ToolbarItem(placement: .principal) { Button { - showChatInfo = true + chatModel.showChatInfo = true } label: { ChatInfoToolbar(chat: chat) } - .sheet(isPresented: $showChatInfo) { - ChatInfoView(chat: chat, showChatInfo: $showChatInfo) + .sheet(isPresented: $chatModel.showChatInfo) { + ChatInfoView(chat: chat) } } ToolbarItem(placement: .navigationBarTrailing) { From 82a4a8c6f8b4e053b335260fafa4275799a9897a Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 31 May 2022 21:30:20 +0400 Subject: [PATCH 22/23] mobile: update version 2.2 (ios - 50, android - 36) --- apps/android/app/build.gradle | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 410b5d966d..d0b5c8a8d8 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,7 +11,7 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 35 + versionCode 36 versionName "2.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1ec19cdf6f..b591ceff75 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -966,7 +966,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1009,7 +1009,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1091,7 +1091,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1131,7 +1131,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; From 4db7e88ed8f9c677a718ef4e33ae5f2446814d38 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 1 Jun 2022 18:58:06 +0400 Subject: [PATCH 23/23] terminal: version 2.2.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 709e8fa765..9e097678fa 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 2.1.0 +version: 2.2.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index fd4b1c0a24..588beb8ca0 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: 2.1.0 +version: 2.2.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat