Compare commits
46 Commits
v5.4.0-fdr
...
ep/ios-sha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0336e6c599 | ||
|
|
2f7632a70f | ||
|
|
27c14f32f1 | ||
|
|
13a32f7864 | ||
|
|
b1652b8930 | ||
|
|
a9b36e8e39 | ||
|
|
ee163a6540 | ||
|
|
4fd6405113 | ||
|
|
ccc62274ee | ||
|
|
4c6d52ba75 | ||
|
|
9df63160e5 | ||
|
|
c8e9788c29 | ||
|
|
7099776357 | ||
|
|
3481d379c6 | ||
|
|
85c1e871dc | ||
|
|
fec5ff3f15 | ||
|
|
acaa597c90 | ||
|
|
6a9a67db14 | ||
|
|
f94c0311c1 | ||
|
|
e1ff7c88d7 | ||
|
|
9a1c7f41f7 | ||
|
|
40e69ae713 | ||
|
|
b74e33b958 | ||
|
|
540c8883a0 | ||
|
|
0e18b13bea | ||
|
|
a4b44254bc | ||
|
|
5819e42305 | ||
|
|
9580b4110d | ||
|
|
05a64c99a2 | ||
|
|
6a21d5c7f1 | ||
|
|
950bbe19da | ||
|
|
f31054de4f | ||
|
|
05278e5a06 | ||
|
|
7a54d74517 | ||
|
|
bfcb2ac230 | ||
|
|
3073c4a1d5 | ||
|
|
d4ac1c0cf2 | ||
|
|
d29f1bb0cf | ||
|
|
75c2de8a12 | ||
|
|
f20ac33e67 | ||
|
|
8cc0954430 | ||
|
|
1e6891e222 | ||
|
|
962964a73d | ||
|
|
b9dd2f45c9 | ||
|
|
de1c885501 | ||
|
|
8f6a31ca07 |
14
.github/workflows/build.yml
vendored
@@ -79,10 +79,10 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Haskell
|
||||
uses: haskell/actions/setup@v2
|
||||
uses: haskell-actions/setup@v2
|
||||
with:
|
||||
ghc-version: "8.10.7"
|
||||
cabal-version: "latest"
|
||||
ghc-version: "9.6.3"
|
||||
cabal-version: "3.10.1.0"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
|
||||
run: |
|
||||
scripts/build-desktop-mac.sh
|
||||
scripts/ci/build-desktop-mac.sh
|
||||
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
@@ -259,12 +259,10 @@ jobs:
|
||||
# Unix /
|
||||
|
||||
# / Windows
|
||||
|
||||
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
|
||||
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
|
||||
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
|
||||
|
||||
- name: 'Setup MSYS2'
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: ucrt64
|
||||
|
||||
10
README.md
@@ -232,6 +232,8 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent and important updates:
|
||||
|
||||
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
|
||||
|
||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
||||
|
||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
@@ -366,13 +368,13 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
||||
- ✅ Desktop client.
|
||||
- ✅ Encryption of local files stored in the app.
|
||||
- 🏗 Using mobile profiles from the desktop app.
|
||||
- ✅ Using mobile profiles from the desktop app.
|
||||
- 🏗 Improve experience for the new users.
|
||||
- 🏗 Post-quantum resistant key exchange in double ratchet protocol.
|
||||
- 🏗 Large groups, communities and public channels.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- Post-quantum resistant key exchange in double ratchet protocol.
|
||||
- Large groups, communities and public channels.
|
||||
- Privacy & security slider - a simple way to set all settings at once.
|
||||
- Improve sending videos (including encryption of locally stored videos).
|
||||
- Improve experience for the new users.
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
|
||||
@@ -83,7 +83,7 @@ final class ChatModel: ObservableObject {
|
||||
// current WebRTC call
|
||||
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
|
||||
@Published var activeCall: Call?
|
||||
@Published var callCommand: WCallCommand?
|
||||
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
||||
@Published var showCallView = false
|
||||
// remote desktop
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
@@ -267,7 +267,20 @@ final class ChatModel: ObservableObject {
|
||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update previews
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatItems = [cItem]
|
||||
chats[i].chatItems = switch cInfo {
|
||||
case .group:
|
||||
if let currentPreviewItem = chats[i].chatItems.first {
|
||||
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
|
||||
[cItem]
|
||||
} else {
|
||||
[currentPreviewItem]
|
||||
}
|
||||
} else {
|
||||
[cItem]
|
||||
}
|
||||
default:
|
||||
[cItem]
|
||||
}
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
||||
increaseUnreadCounter(user: currentUser!)
|
||||
|
||||
@@ -605,27 +605,29 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
|
||||
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
|
||||
func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? {
|
||||
let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return nil
|
||||
} else {
|
||||
return connReqType
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) {
|
||||
func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnect: no current user")
|
||||
return (nil, nil)
|
||||
}
|
||||
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
|
||||
let m = ChatModel.shared
|
||||
switch r {
|
||||
case .sentConfirmation: return (.invitation, nil)
|
||||
case .sentInvitation: return (.contact, nil)
|
||||
case let .sentConfirmation(_, connection):
|
||||
return ((.invitation, connection), nil)
|
||||
case let .sentInvitation(_, connection):
|
||||
return ((.contact, connection), nil)
|
||||
case let .contactAlreadyExists(_, contact):
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
}
|
||||
@@ -1362,18 +1364,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
let m = ChatModel.shared
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .newContactConnection(user, connection):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateContactConnection(connection)
|
||||
}
|
||||
}
|
||||
case let .contactConnectionDeleted(user, connection):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.removeChat(connection.id)
|
||||
}
|
||||
}
|
||||
case let .contactDeletedByContact(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
@@ -1666,36 +1656,40 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
activateCall(invitation)
|
||||
case let .callOffer(_, contact, callType, offer, sharedKey, _):
|
||||
await withCall(contact) { call in
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
await MainActor.run {
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
}
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
let iceServers = getIceServers()
|
||||
logger.debug(".callOffer useRelay \(useRelay)")
|
||||
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
|
||||
m.callCommand = .offer(
|
||||
await m.callCommand.processCommand(.offer(
|
||||
offer: offer.rtcSession,
|
||||
iceCandidates: offer.rtcIceCandidates,
|
||||
media: callType.media, aesKey: sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
)
|
||||
))
|
||||
}
|
||||
case let .callAnswer(_, contact, answer):
|
||||
await withCall(contact) { call in
|
||||
call.callState = .answerReceived
|
||||
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
|
||||
await MainActor.run {
|
||||
call.callState = .answerReceived
|
||||
}
|
||||
await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates))
|
||||
}
|
||||
case let .callExtraInfo(_, contact, extraInfo):
|
||||
await withCall(contact) { _ in
|
||||
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
|
||||
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
|
||||
}
|
||||
case let .callEnded(_, contact):
|
||||
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
|
||||
CallController.shared.reportCallRemoteEnded(invitation: invitation)
|
||||
}
|
||||
await withCall(contact) { call in
|
||||
m.callCommand = .end
|
||||
await m.callCommand.processCommand(.end)
|
||||
CallController.shared.reportCallRemoteEnded(call: call)
|
||||
}
|
||||
case .chatSuspended:
|
||||
@@ -1753,9 +1747,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
|
||||
func withCall(_ contact: Contact, _ perform: (Call) -> Void) async {
|
||||
func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async {
|
||||
if let call = m.activeCall, call.contact.apiId == contact.apiId {
|
||||
await MainActor.run { perform(call) }
|
||||
await perform(call)
|
||||
} else {
|
||||
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ struct SimpleXApp: App {
|
||||
@State private var showInitializationView = false
|
||||
|
||||
init() {
|
||||
DispatchQueue.global(qos: .background).sync {
|
||||
// DispatchQueue.global(qos: .background).sync {
|
||||
haskell_init()
|
||||
// hs_init(0, nil)
|
||||
}
|
||||
// }
|
||||
UserDefaults.standard.register(defaults: appDefaults)
|
||||
setGroupDefaults()
|
||||
registerGroupDefaults()
|
||||
|
||||
@@ -49,10 +49,10 @@ struct ActiveCallView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
logger.debug("ActiveCallView: disappear")
|
||||
Task { await m.callCommand.setClient(nil) }
|
||||
AppDelegate.keepScreenOn(false)
|
||||
client?.endCall()
|
||||
}
|
||||
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
|
||||
.background(.black)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@@ -60,19 +60,8 @@ struct ActiveCallView: View {
|
||||
private func createWebRTCClient() {
|
||||
if client == nil && canConnectCall {
|
||||
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
||||
sendCommandToClient()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommandToClient() {
|
||||
if call == m.activeCall,
|
||||
m.activeCall != nil,
|
||||
let client = client,
|
||||
let cmd = m.callCommand {
|
||||
m.callCommand = nil
|
||||
logger.debug("sendCallCommand: \(cmd.cmdType)")
|
||||
Task {
|
||||
await client.sendCallCommand(command: cmd)
|
||||
await m.callCommand.setClient(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,8 +157,10 @@ struct ActiveCallView: View {
|
||||
}
|
||||
case let .error(message):
|
||||
logger.debug("ActiveCallView: command error: \(message)")
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
|
||||
case let .invalid(type):
|
||||
logger.debug("ActiveCallView: invalid response: \(type)")
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,7 +246,6 @@ struct ActiveCallOverlay: View {
|
||||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo {
|
||||
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
|
||||
Text("(") + Text(connInfo.text) + Text(")")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class CallManager {
|
||||
let m = ChatModel.shared
|
||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||
m.showCallView = true
|
||||
m.callCommand = .capabilities(media: call.localMedia)
|
||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -57,19 +57,21 @@ class CallManager {
|
||||
m.activeCall = call
|
||||
m.showCallView = true
|
||||
|
||||
m.callCommand = .start(
|
||||
Task {
|
||||
await m.callCommand.processCommand(.start(
|
||||
media: invitation.callType.media,
|
||||
aesKey: invitation.sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
let m = ChatModel.shared
|
||||
m.callCommand = .media(media: media, enable: enable)
|
||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -94,11 +96,13 @@ class CallManager {
|
||||
completed()
|
||||
} else {
|
||||
logger.debug("CallManager.endCall: ending call...")
|
||||
m.callCommand = .end
|
||||
m.activeCall = nil
|
||||
m.showCallView = false
|
||||
completed()
|
||||
Task {
|
||||
await m.callCommand.processCommand(.end)
|
||||
await MainActor.run {
|
||||
m.activeCall = nil
|
||||
m.showCallView = false
|
||||
completed()
|
||||
}
|
||||
do {
|
||||
try await apiEndCall(call.contact)
|
||||
} catch {
|
||||
|
||||
@@ -335,6 +335,50 @@ extension WCallResponse: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor WebRTCCommandProcessor {
|
||||
private var client: WebRTCClient? = nil
|
||||
private var commands: [WCallCommand] = []
|
||||
private var running: Bool = false
|
||||
|
||||
func setClient(_ client: WebRTCClient?) async {
|
||||
logger.debug("WebRTC: setClient, commands count \(self.commands.count)")
|
||||
self.client = client
|
||||
if client != nil {
|
||||
await processAllCommands()
|
||||
} else {
|
||||
commands.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func processCommand(_ c: WCallCommand) async {
|
||||
// logger.debug("WebRTC: process command \(c.cmdType)")
|
||||
commands.append(c)
|
||||
if !running && client != nil {
|
||||
await processAllCommands()
|
||||
}
|
||||
}
|
||||
|
||||
func processAllCommands() async {
|
||||
logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)")
|
||||
if let client = client {
|
||||
running = true
|
||||
while let c = commands.first, shouldRunCommand(client, c) {
|
||||
commands.remove(at: 0)
|
||||
await client.sendCallCommand(command: c)
|
||||
logger.debug("WebRTC: processed cmd \(c.cmdType)")
|
||||
}
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
|
||||
switch c {
|
||||
case .capabilities, .start, .offer, .end: true
|
||||
default: client.activeCall.wrappedValue != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionState: Codable, Equatable {
|
||||
var connectionState: String
|
||||
var iceConnectionState: String
|
||||
@@ -358,26 +402,12 @@ struct ConnectionInfo: Codable, Equatable {
|
||||
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
|
||||
}
|
||||
}
|
||||
|
||||
var protocolText: String {
|
||||
let unknown = NSLocalizedString("unknown", comment: "connection info")
|
||||
let local = localCandidate?.protocol?.uppercased() ?? unknown
|
||||
let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown
|
||||
let remote = remoteCandidate?.protocol?.uppercased() ?? unknown
|
||||
let localText = localRelay == local || localCandidate?.relayProtocol == nil
|
||||
? local
|
||||
: "\(local) (\(localRelay))"
|
||||
return local == remote
|
||||
? localText
|
||||
: "\(localText) / \(remote)"
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
struct RTCIceCandidate: Codable, Equatable {
|
||||
var candidateType: RTCIceCandidateType?
|
||||
var `protocol`: String?
|
||||
var relayProtocol: String?
|
||||
var sdpMid: String?
|
||||
var sdpMLineIndex: Int?
|
||||
var candidate: String
|
||||
|
||||
@@ -21,7 +21,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
|
||||
struct Call {
|
||||
var connection: RTCPeerConnection
|
||||
var iceCandidates: [RTCIceCandidate]
|
||||
var iceCandidates: IceCandidates
|
||||
var localMedia: CallMediaType
|
||||
var localCamera: RTCVideoCapturer?
|
||||
var localVideoSource: RTCVideoSource?
|
||||
@@ -33,10 +33,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
var frameDecryptor: RTCFrameDecryptor?
|
||||
}
|
||||
|
||||
actor IceCandidates {
|
||||
private var candidates: [RTCIceCandidate] = []
|
||||
|
||||
func getAndClear() async -> [RTCIceCandidate] {
|
||||
let cs = candidates
|
||||
candidates = []
|
||||
return cs
|
||||
}
|
||||
|
||||
func append(_ c: RTCIceCandidate) async {
|
||||
candidates.append(c)
|
||||
}
|
||||
}
|
||||
|
||||
private let rtcAudioSession = RTCAudioSession.sharedInstance()
|
||||
private let audioQueue = DispatchQueue(label: "audio")
|
||||
private var sendCallResponse: (WVAPIMessage) async -> Void
|
||||
private var activeCall: Binding<Call?>
|
||||
var activeCall: Binding<Call?>
|
||||
private var localRendererAspectRatio: Binding<CGFloat?>
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -60,7 +74,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
|
||||
]
|
||||
|
||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
||||
connection.delegate = self
|
||||
createAudioSender(connection)
|
||||
@@ -87,7 +101,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
return Call(
|
||||
connection: connection,
|
||||
iceCandidates: remoteIceCandidates,
|
||||
iceCandidates: IceCandidates(),
|
||||
localMedia: mediaType,
|
||||
localCamera: localCamera,
|
||||
localVideoSource: localVideoSource,
|
||||
@@ -144,26 +158,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
logger.debug("starting incoming call - create webrtc session")
|
||||
if activeCall.wrappedValue != nil { endCall() }
|
||||
let encryption = WebRTCClient.enableEncryption
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), [], media, encryption ? aesKey : nil, relay)
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
|
||||
activeCall.wrappedValue = call
|
||||
call.connection.offer { answer in
|
||||
Task {
|
||||
let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 })
|
||||
if gotCandidates {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .offer(
|
||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])),
|
||||
capabilities: CallCapabilities(encryption: encryption)
|
||||
),
|
||||
command: command)
|
||||
)
|
||||
} else {
|
||||
self.endCall()
|
||||
}
|
||||
}
|
||||
|
||||
let (offer, error) = await call.connection.offer()
|
||||
if let offer = offer {
|
||||
resp = .offer(
|
||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
|
||||
capabilities: CallCapabilities(encryption: encryption)
|
||||
)
|
||||
self.waitForMoreIceCandidates()
|
||||
} else {
|
||||
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
}
|
||||
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
|
||||
if activeCall.wrappedValue != nil {
|
||||
@@ -172,26 +178,21 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
resp = .error(message: "accept: encryption is not supported")
|
||||
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
|
||||
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||
activeCall.wrappedValue = call
|
||||
let pc = call.connection
|
||||
if let type = offer.type, let sdp = offer.sdp {
|
||||
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
||||
pc.answer { answer in
|
||||
let (answer, error) = await pc.answer()
|
||||
if let answer = answer {
|
||||
self.addIceCandidates(pc, remoteIceCandidates)
|
||||
// Task {
|
||||
// try? await Task.sleep(nanoseconds: 32_000 * 1000000)
|
||||
Task {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .answer(
|
||||
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates))
|
||||
),
|
||||
command: command)
|
||||
)
|
||||
}
|
||||
// }
|
||||
resp = .answer(
|
||||
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates()))
|
||||
)
|
||||
self.waitForMoreIceCandidates()
|
||||
} else {
|
||||
resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
}
|
||||
} else {
|
||||
resp = .error(message: "accept: remote description is not set")
|
||||
@@ -234,6 +235,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
resp = .ok
|
||||
}
|
||||
case .end:
|
||||
// TODO possibly, endCall should be called before returning .ok
|
||||
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
|
||||
endCall()
|
||||
}
|
||||
@@ -242,6 +244,33 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
func getInitialIceCandidates() async -> [RTCIceCandidate] {
|
||||
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
|
||||
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
|
||||
return candidates
|
||||
}
|
||||
|
||||
func waitForMoreIceCandidates() {
|
||||
Task {
|
||||
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
|
||||
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||
if candidates.count > 0 {
|
||||
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
|
||||
await self.sendIceCandidates(candidates)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))),
|
||||
command: nil)
|
||||
)
|
||||
}
|
||||
|
||||
func enableMedia(_ media: CallMediaType, _ enable: Bool) {
|
||||
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
|
||||
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
|
||||
@@ -387,12 +416,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
audioSessionToDefaults()
|
||||
}
|
||||
|
||||
func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool {
|
||||
let startedAt = DispatchTime.now()
|
||||
while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds {
|
||||
guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break }
|
||||
}
|
||||
return success()
|
||||
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
|
||||
var t: UInt64 = 0
|
||||
repeat {
|
||||
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||
t += stepMs
|
||||
await action()
|
||||
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,25 +435,33 @@ extension WebRTC.RTCPeerConnection {
|
||||
optionalConstraints: nil)
|
||||
}
|
||||
|
||||
func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
|
||||
offer(for: mediaConstraints()) { (sdp, error) in
|
||||
guard let sdp = sdp else {
|
||||
return
|
||||
func offer() async -> (RTCSessionDescription?, Error?) {
|
||||
await withCheckedContinuation { cont in
|
||||
offer(for: mediaConstraints()) { (sdp, error) in
|
||||
self.processSDP(cont, sdp, error)
|
||||
}
|
||||
self.setLocalDescription(sdp, completionHandler: { (error) in
|
||||
completion(sdp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
|
||||
answer(for: mediaConstraints()) { (sdp, error) in
|
||||
guard let sdp = sdp else {
|
||||
return
|
||||
func answer() async -> (RTCSessionDescription?, Error?) {
|
||||
await withCheckedContinuation { cont in
|
||||
answer(for: mediaConstraints()) { (sdp, error) in
|
||||
self.processSDP(cont, sdp, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) {
|
||||
if let sdp = sdp {
|
||||
self.setLocalDescription(sdp, completionHandler: { (error) in
|
||||
completion(sdp)
|
||||
if let error = error {
|
||||
cont.resume(returning: (nil, error))
|
||||
} else {
|
||||
cont.resume(returning: (sdp, nil))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
cont.resume(returning: (nil, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,6 +517,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
default: enableSpeaker = false
|
||||
}
|
||||
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
||||
case .connected: sendConnectedEvent(connection)
|
||||
case .disconnected, .failed: endCall()
|
||||
default: do {}
|
||||
}
|
||||
@@ -491,7 +530,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
|
||||
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
|
||||
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
|
||||
activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil))
|
||||
Task {
|
||||
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
||||
}
|
||||
}
|
||||
|
||||
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
|
||||
@@ -506,10 +547,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
lastReceivedMs lastDataReceivedMs: Int32,
|
||||
changeReason reason: String) {
|
||||
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)")
|
||||
sendConnectedEvent(connection, local: local, remote: remote)
|
||||
}
|
||||
|
||||
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) {
|
||||
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) {
|
||||
connection.statistics { (stats: RTCStatisticsReport) in
|
||||
stats.statistics.values.forEach { stat in
|
||||
// logger.debug("Stat \(stat.debugDescription)")
|
||||
@@ -517,24 +557,25 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
let localId = stat.values["localCandidateId"] as? String,
|
||||
let remoteId = stat.values["remoteCandidateId"] as? String,
|
||||
let localStats = stats.statistics[localId],
|
||||
let remoteStats = stats.statistics[remoteId],
|
||||
local.sdp.contains("\((localStats.values["ip"] as? String ?? "--")) \((localStats.values["port"] as? String ?? "--"))") &&
|
||||
remote.sdp.contains("\((remoteStats.values["ip"] as? String ?? "--")) \((remoteStats.values["port"] as? String ?? "--"))")
|
||||
let remoteStats = stats.statistics[remoteId]
|
||||
{
|
||||
Task {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .connected(connectionInfo: ConnectionInfo(
|
||||
localCandidate: local.toCandidate(
|
||||
RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
||||
localStats.values["protocol"] as? String,
|
||||
localStats.values["relayProtocol"] as? String
|
||||
localCandidate: RTCIceCandidate(
|
||||
candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
||||
protocol: localStats.values["protocol"] as? String,
|
||||
sdpMid: nil,
|
||||
sdpMLineIndex: nil,
|
||||
candidate: ""
|
||||
),
|
||||
remoteCandidate: remote.toCandidate(
|
||||
RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
||||
remoteStats.values["protocol"] as? String,
|
||||
remoteStats.values["relayProtocol"] as? String
|
||||
))),
|
||||
remoteCandidate: RTCIceCandidate(
|
||||
candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
||||
protocol: remoteStats.values["protocol"] as? String,
|
||||
sdpMid: nil,
|
||||
sdpMLineIndex: nil,
|
||||
candidate: ""))),
|
||||
command: nil)
|
||||
)
|
||||
}
|
||||
@@ -634,11 +675,10 @@ extension RTCIceCandidate {
|
||||
}
|
||||
|
||||
extension WebRTC.RTCIceCandidate {
|
||||
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate {
|
||||
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate {
|
||||
RTCIceCandidate(
|
||||
candidateType: candidateType,
|
||||
protocol: `protocol`,
|
||||
relayProtocol: relayProtocol,
|
||||
sdpMid: sdpMid,
|
||||
sdpMLineIndex: Int(sdpMLineIndex),
|
||||
candidate: sdp
|
||||
|
||||
@@ -73,6 +73,7 @@ struct CreateLinkView: View {
|
||||
Task {
|
||||
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
|
||||
await MainActor.run {
|
||||
m.updateContactConnection(pcc)
|
||||
connReqInvitation = connReq
|
||||
contactConnection = pcc
|
||||
m.connReqInv = connReq
|
||||
|
||||
@@ -52,6 +52,9 @@ struct NewChatButton: View {
|
||||
func addContactAction() {
|
||||
Task {
|
||||
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContactConnection(pcc)
|
||||
}
|
||||
actionSheet = .createLink(link: connReq, connection: pcc)
|
||||
}
|
||||
}
|
||||
@@ -346,7 +349,10 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
|
||||
|
||||
private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
|
||||
Task {
|
||||
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
|
||||
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContactConnection(pcc)
|
||||
}
|
||||
let crt: ConnReqType
|
||||
if let plan = connectionPlan {
|
||||
crt = planToConnReqType(plan)
|
||||
|
||||
@@ -11,20 +11,12 @@ import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct MutableQRCode: View {
|
||||
@Binding var uri: String
|
||||
@State private var image: UIImage?
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let image = image {
|
||||
qrCodeImage(image)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
image = generateImage(uri)
|
||||
}
|
||||
.onChange(of: uri) { _ in
|
||||
image = generateImage(uri)
|
||||
}
|
||||
QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor)
|
||||
.id("simplex-qrcode-view-for-\(uri)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +41,7 @@ struct QRCode: View {
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
@State private var image: UIImage? = nil
|
||||
@State private var makeScreenshotBinding: () -> Void = {}
|
||||
@State private var makeScreenshotFunc: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -70,18 +62,18 @@ struct QRCode: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
makeScreenshotBinding = {
|
||||
makeScreenshotFunc = {
|
||||
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])}
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
.onTapGesture(perform: makeScreenshotBinding)
|
||||
.onTapGesture(perform: makeScreenshotFunc)
|
||||
.onAppear {
|
||||
image = image ?? generateImage(uri)?.replaceColor(UIColor.black, tintColor)
|
||||
image = image ?? generateImage(uri, tintColor: tintColor)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +85,13 @@ private func qrCodeImage(_ image: UIImage) -> some View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func generateImage(_ uri: String) -> UIImage? {
|
||||
private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(uri.utf8)
|
||||
if let outputImage = filter.outputImage,
|
||||
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
|
||||
return UIImage(cgImage: cgImage)
|
||||
return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ struct ConnectDesktopView: View {
|
||||
Section("Found desktop") {
|
||||
Text("Waiting for desktop...").italic()
|
||||
Button {
|
||||
disconnectDesktop(.dismiss)
|
||||
disconnectDesktop()
|
||||
} label: {
|
||||
Label("Scan QR code", systemImage: "qrcode")
|
||||
}
|
||||
|
||||
@@ -190,7 +190,8 @@ struct UserAddressView: View {
|
||||
|
||||
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
|
||||
Section {
|
||||
MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact)))
|
||||
SimpleXLinkQRCode(uri: userAddress.connReqContact)
|
||||
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
|
||||
shareQRCodeButton(userAddress)
|
||||
if MFMailComposeViewController.canSendMail() {
|
||||
shareViaEmailButton(userAddress)
|
||||
|
||||
24
apps/ios/SimpleX Share/Base.lproj/MainInterface.storyboard
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Share View Controller-->
|
||||
<scene sceneID="ceB-am-kn3">
|
||||
<objects>
|
||||
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
27
apps/ios/SimpleX Share/Info.plist
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>10</integer>
|
||||
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||
<integer>10</integer>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
20
apps/ios/SimpleX Share/ShareView.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// ShareView.swift
|
||||
// SimpleX Share
|
||||
//
|
||||
// Created by Evgeny on 10/12/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ShareView: View {
|
||||
var body: some View {
|
||||
Text("Share Extension")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ShareView()
|
||||
}
|
||||
148
apps/ios/SimpleX Share/ShareViewController.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// ShareViewController.swift
|
||||
// SimpleX Share
|
||||
//
|
||||
// Created by Evgeny on 10/12/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import MobileCoreServices
|
||||
import OSLog
|
||||
import Social
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import SimpleXChat
|
||||
|
||||
let logger = Logger()
|
||||
|
||||
let maxTextLength = 15000
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
private var contentIsValid = true
|
||||
private var validated = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// setupShareView()
|
||||
logger.debug("ShareViewController viewDidLoad")
|
||||
if !validated {
|
||||
validated = true
|
||||
validateShareContent()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupShareView() {
|
||||
let swiftUIView = ShareView()
|
||||
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||
|
||||
// Set up the hosting controller's view to fit the available space
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(hostingController.view)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||
])
|
||||
|
||||
addChild(hostingController)
|
||||
hostingController.didMove(toParent: self)
|
||||
}
|
||||
|
||||
private func validateShareContent() {
|
||||
Task {
|
||||
guard let shareItem = extensionContext?.inputItems.first as? NSExtensionItem else {
|
||||
logger.debug("ShareViewController viewDidLoad: no input items")
|
||||
// contentIsValid = false
|
||||
return
|
||||
}
|
||||
logger.debug("ShareViewController viewDidLoad: \(shareItem.attachments?.count ?? 0) attachments")
|
||||
for attachment in shareItem.attachments ?? [] {
|
||||
logger.debug("ShareViewController viewDidLoad: attachment \(attachment.registeredTypeIdentifiers)")
|
||||
let valid = await validateContentItem(attachment)
|
||||
contentIsValid = contentIsValid && valid
|
||||
}
|
||||
await MainActor.run {
|
||||
self.validateContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func validateContentItem(_ p: NSItemProvider) async -> Bool {
|
||||
var valid = false
|
||||
do {
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
logger.debug("ShareViewController validateContentItem: movie")
|
||||
if let url = try await getFileURL(),
|
||||
let fileSize = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
logger.debug("ShareViewController validateContentItem: movie file \(fileSize)")
|
||||
valid = fileSize <= MAX_FILE_SIZE_XFTP
|
||||
}
|
||||
} else if let data = try await loadItem(type: UTType.plainText) {
|
||||
// logger.debug("ShareViewController validateContentItem: text")
|
||||
if let text = data as? String {
|
||||
// logger.debug("ShareViewController validateContentItem: text \(text.count)")
|
||||
valid = text.utf8.count <= maxTextLength
|
||||
}
|
||||
} else if let data = try await loadItem(type: UTType.image) {
|
||||
// logger.debug("ShareViewController validateContentItem: image")
|
||||
if let image = data as? UIImage, let size = image.pngData()?.count {
|
||||
// logger.debug("ShareViewController validateContentItem: image \(size)")
|
||||
valid = size <= MAX_FILE_SIZE_XFTP
|
||||
}
|
||||
} else if let data = try await loadItem(type: UTType.fileURL) {
|
||||
// logger.debug("ShareViewController validateContentItem: file")
|
||||
if let url = data as? URL, let fileSize = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
// logger.debug("ShareViewController validateContentItem: file \(fileSize)")
|
||||
valid = fileSize <= MAX_FILE_SIZE_XFTP
|
||||
}
|
||||
} else if let data = try await loadItem(type: UTType.data) {
|
||||
// logger.debug("ShareViewController validateContentItem: data")
|
||||
if let data = data as? Data {
|
||||
// logger.debug("ShareViewController validateContentItem: data \(data.count)")
|
||||
valid = data.count <= MAX_FILE_SIZE_XFTP
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("ShareViewController validateContentItem: error \(error.localizedDescription)")
|
||||
}
|
||||
return valid
|
||||
|
||||
func getFileURL() async throws -> URL? {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let url = url {
|
||||
cont.resume(returning: url)
|
||||
} else if let error = error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadItem(type: UTType) async throws -> NSSecureCoding? {
|
||||
var item: NSSecureCoding?
|
||||
if p.hasItemConformingToTypeIdentifier(type.identifier) {
|
||||
logger.debug("ShareViewController validateContentItem: conforming to \(type.identifier)")
|
||||
item = try await p.loadItem(forTypeIdentifier: type.identifier)
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
contentIsValid
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
logger.debug("ShareViewController didSelectPost")
|
||||
// This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments.
|
||||
|
||||
// Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context.
|
||||
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
14
apps/ios/SimpleX Share/SimpleX Share.entitlements
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.simplex.app</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)chat.simplex.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -116,15 +116,15 @@
|
||||
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; };
|
||||
5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; };
|
||||
5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; };
|
||||
5CCD1A482B263660001A4199 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD1A472B263660001A4199 /* ShareViewController.swift */; };
|
||||
5CCD1A4B2B263660001A4199 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5CCD1A492B263660001A4199 /* MainInterface.storyboard */; };
|
||||
5CCD1A4F2B263660001A4199 /* SimpleX Share.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CCD1A452B263660001A4199 /* SimpleX Share.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
5CCD1A532B2636BC001A4199 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
|
||||
5CCD1A5A2B2646F8001A4199 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD1A592B2646F8001A4199 /* ShareView.swift */; };
|
||||
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
|
||||
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; };
|
||||
5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; };
|
||||
5CD67B962B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */; };
|
||||
5CD67B972B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */; };
|
||||
5CD67B982B11416700C510B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B932B11416600C510B1 /* libffi.a */; };
|
||||
5CD67B992B11416700C510B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B942B11416600C510B1 /* libgmp.a */; };
|
||||
5CD67B9A2B11416700C510B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B952B11416700C510B1 /* libgmpxx.a */; };
|
||||
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; };
|
||||
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
|
||||
5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
@@ -150,6 +150,11 @@
|
||||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
|
||||
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; };
|
||||
5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; };
|
||||
5CF937182B22552700E1D781 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937132B22552700E1D781 /* libffi.a */; };
|
||||
5CF937192B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */; };
|
||||
5CF9371A2B22552700E1D781 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937152B22552700E1D781 /* libgmp.a */; };
|
||||
5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937162B22552700E1D781 /* libgmpxx.a */; };
|
||||
5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */; };
|
||||
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; };
|
||||
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; };
|
||||
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
@@ -165,11 +170,6 @@
|
||||
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; };
|
||||
64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; };
|
||||
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; };
|
||||
6449333A2AF8E51000AC506E /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933352AF8E51000AC506E /* libgmpxx.a */; };
|
||||
6449333B2AF8E51000AC506E /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933362AF8E51000AC506E /* libgmp.a */; };
|
||||
6449333C2AF8E51000AC506E /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933372AF8E51000AC506E /* libffi.a */; };
|
||||
6449333D2AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933382AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */; };
|
||||
6449333E2AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933392AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */; };
|
||||
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; };
|
||||
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; };
|
||||
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; };
|
||||
@@ -206,6 +206,20 @@
|
||||
remoteGlobalIDString = 5CA059C9279559F40002BEB4;
|
||||
remoteInfo = "SimpleX (iOS)";
|
||||
};
|
||||
5CCD1A4D2B263660001A4199 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5CA059BE279559F40002BEB4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5CCD1A442B263660001A4199;
|
||||
remoteInfo = "SimpleX Share";
|
||||
};
|
||||
5CCD1A552B2636BC001A4199 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5CA059BE279559F40002BEB4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5CE2BA672845308900EC33A6;
|
||||
remoteInfo = SimpleXChat;
|
||||
};
|
||||
5CE2BA6E2845308900EC33A6 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5CA059BE279559F40002BEB4 /* Project object */;
|
||||
@@ -247,6 +261,7 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
5CCD1A4F2B263660001A4199 /* SimpleX Share.appex in Embed App Extensions */,
|
||||
5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
@@ -404,15 +419,16 @@
|
||||
5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = "<group>"; };
|
||||
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
|
||||
5CCD1A452B263660001A4199 /* SimpleX Share.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX Share.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5CCD1A472B263660001A4199 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
5CCD1A4A2B263660001A4199 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
|
||||
5CCD1A4C2B263660001A4199 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
5CCD1A582B26372A001A4199 /* SimpleX Share.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX Share.entitlements"; sourceTree = "<group>"; };
|
||||
5CCD1A592B2646F8001A4199 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
|
||||
5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; };
|
||||
5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = "<group>"; };
|
||||
5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = "<group>"; };
|
||||
5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a"; sourceTree = "<group>"; };
|
||||
5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5CD67B932B11416600C510B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CD67B942B11416600C510B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CD67B952B11416700C510B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -439,6 +455,11 @@
|
||||
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
|
||||
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = "<group>"; };
|
||||
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = "<group>"; };
|
||||
5CF937132B22552700E1D781 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5CF937152B22552700E1D781 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CF937162B22552700E1D781 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a"; sourceTree = "<group>"; };
|
||||
5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = "<group>"; };
|
||||
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||
@@ -453,11 +474,6 @@
|
||||
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
|
||||
64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = "<group>"; };
|
||||
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
|
||||
644933352AF8E51000AC506E /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
644933362AF8E51000AC506E /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
644933372AF8E51000AC506E /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
644933382AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
644933392AF8E51000AC506E /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = "<group>"; };
|
||||
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
|
||||
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = "<group>"; };
|
||||
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
|
||||
@@ -509,6 +525,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5CCD1A422B263660001A4199 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CCD1A532B2636BC001A4199 /* SimpleXChat.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5CDCAD422818589900503DA2 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -521,13 +545,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CD67B972B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a in Frameworks */,
|
||||
5CF9371C2B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a in Frameworks */,
|
||||
5CF9371B2B22552700E1D781 /* libgmpxx.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CF9371A2B22552700E1D781 /* libgmp.a in Frameworks */,
|
||||
5CF937182B22552700E1D781 /* libffi.a in Frameworks */,
|
||||
5CF937192B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5CD67B982B11416700C510B1 /* libffi.a in Frameworks */,
|
||||
5CD67B992B11416700C510B1 /* libgmp.a in Frameworks */,
|
||||
5CD67B962B11416700C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a in Frameworks */,
|
||||
5CD67B9A2B11416700C510B1 /* libgmpxx.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -589,11 +613,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CD67B932B11416600C510B1 /* libffi.a */,
|
||||
5CD67B942B11416600C510B1 /* libgmp.a */,
|
||||
5CD67B952B11416700C510B1 /* libgmpxx.a */,
|
||||
5CD67B922B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9-ghc9.6.3.a */,
|
||||
5CD67B912B11416600C510B1 /* libHSsimplex-chat-5.4.0.6-95eerlCBwIgI8jyla1GCr9.a */,
|
||||
5CF937132B22552700E1D781 /* libffi.a */,
|
||||
5CF937152B22552700E1D781 /* libgmp.a */,
|
||||
5CF937162B22552700E1D781 /* libgmpxx.a */,
|
||||
5CF937142B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4-ghc8.10.7.a */,
|
||||
5CF937172B22552700E1D781 /* libHSsimplex-chat-5.4.0.7-EoJ0xKOyE47DlSpHXf0V4.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -659,6 +683,7 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */,
|
||||
5CA059C2279559F40002BEB4 /* Shared */,
|
||||
5CDCAD462818589900503DA2 /* SimpleX NSE */,
|
||||
5CCD1A462B263660001A4199 /* SimpleX Share */,
|
||||
5CA059DA279559F40002BEB4 /* Tests iOS */,
|
||||
5CE2BA692845308900EC33A6 /* SimpleXChat */,
|
||||
5CA059CB279559F40002BEB4 /* Products */,
|
||||
@@ -688,6 +713,7 @@
|
||||
5CA059D7279559F40002BEB4 /* Tests iOS.xctest */,
|
||||
5CDCAD452818589900503DA2 /* SimpleX NSE.appex */,
|
||||
5CE2BA682845308900EC33A6 /* SimpleXChat.framework */,
|
||||
5CCD1A452B263660001A4199 /* SimpleX Share.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -794,6 +820,18 @@
|
||||
path = ChatList;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CCD1A462B263660001A4199 /* SimpleX Share */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CCD1A582B26372A001A4199 /* SimpleX Share.entitlements */,
|
||||
5CCD1A472B263660001A4199 /* ShareViewController.swift */,
|
||||
5CCD1A592B2646F8001A4199 /* ShareView.swift */,
|
||||
5CCD1A492B263660001A4199 /* MainInterface.storyboard */,
|
||||
5CCD1A4C2B263660001A4199 /* Info.plist */,
|
||||
);
|
||||
path = "SimpleX Share";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CDCAD462818589900503DA2 /* SimpleX NSE */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -931,6 +969,7 @@
|
||||
dependencies = (
|
||||
5CE2BA6F2845308900EC33A6 /* PBXTargetDependency */,
|
||||
5CE2BA9F284555F500EC33A6 /* PBXTargetDependency */,
|
||||
5CCD1A4E2B263660001A4199 /* PBXTargetDependency */,
|
||||
);
|
||||
name = "SimpleX (iOS)";
|
||||
packageProductDependencies = (
|
||||
@@ -961,6 +1000,24 @@
|
||||
productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
5CCD1A442B263660001A4199 /* SimpleX Share */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5CCD1A522B263660001A4199 /* Build configuration list for PBXNativeTarget "SimpleX Share" */;
|
||||
buildPhases = (
|
||||
5CCD1A412B263660001A4199 /* Sources */,
|
||||
5CCD1A422B263660001A4199 /* Frameworks */,
|
||||
5CCD1A432B263660001A4199 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5CCD1A562B2636BC001A4199 /* PBXTargetDependency */,
|
||||
);
|
||||
name = "SimpleX Share";
|
||||
productName = "SimpleX Share";
|
||||
productReference = 5CCD1A452B263660001A4199 /* SimpleX Share.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
5CDCAD442818589900503DA2 /* SimpleX NSE */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */;
|
||||
@@ -1006,7 +1063,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1330;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1340;
|
||||
ORGANIZATIONNAME = "SimpleX Chat";
|
||||
TargetAttributes = {
|
||||
@@ -1018,6 +1075,9 @@
|
||||
CreatedOnToolsVersion = 13.2.1;
|
||||
TestTargetID = 5CA059C9279559F40002BEB4;
|
||||
};
|
||||
5CCD1A442B263660001A4199 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
5CDCAD442818589900503DA2 = {
|
||||
CreatedOnToolsVersion = 13.3;
|
||||
LastSwiftMigration = 1330;
|
||||
@@ -1064,6 +1124,7 @@
|
||||
5CA059C9279559F40002BEB4 /* SimpleX (iOS) */,
|
||||
5CA059D6279559F40002BEB4 /* Tests iOS */,
|
||||
5CDCAD442818589900503DA2 /* SimpleX NSE */,
|
||||
5CCD1A442B263660001A4199 /* SimpleX Share */,
|
||||
5CE2BA672845308900EC33A6 /* SimpleXChat */,
|
||||
);
|
||||
};
|
||||
@@ -1088,6 +1149,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5CCD1A432B263660001A4199 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CCD1A4B2B263660001A4199 /* MainInterface.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5CDCAD432818589900503DA2 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -1263,6 +1332,15 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5CCD1A412B263660001A4199 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CCD1A5A2B2646F8001A4199 /* ShareView.swift in Sources */,
|
||||
5CCD1A482B263660001A4199 /* ShareViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5CDCAD412818589900503DA2 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -1300,6 +1378,16 @@
|
||||
target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */;
|
||||
targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */;
|
||||
};
|
||||
5CCD1A4E2B263660001A4199 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5CCD1A442B263660001A4199 /* SimpleX Share */;
|
||||
targetProxy = 5CCD1A4D2B263660001A4199 /* PBXContainerItemProxy */;
|
||||
};
|
||||
5CCD1A562B2636BC001A4199 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5CE2BA672845308900EC33A6 /* SimpleXChat */;
|
||||
targetProxy = 5CCD1A552B2636BC001A4199 /* PBXContainerItemProxy */;
|
||||
};
|
||||
5CE2BA6F2845308900EC33A6 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5CE2BA672845308900EC33A6 /* SimpleXChat */;
|
||||
@@ -1383,6 +1471,14 @@
|
||||
name = "SimpleX--iOS--InfoPlist.strings";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CCD1A492B263660001A4199 /* MainInterface.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
5CCD1A4A2B263660001A4199 /* Base */,
|
||||
);
|
||||
name = MainInterface.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -1512,7 +1608,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1534,7 +1630,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1555,7 +1651,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1577,7 +1673,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1629,6 +1725,78 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5CCD1A502B263660001A4199 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX Share/SimpleX Share.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "SimpleX Share/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "SimpleX Share";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 SimpleX Chat. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-Share";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5CCD1A512B263660001A4199 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX Share/SimpleX Share.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "SimpleX Share/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "SimpleX Share";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 SimpleX Chat. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-Share";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5CDCAD4E2818589900503DA2 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -1636,7 +1804,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1649,7 +1817,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1668,7 +1836,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1681,7 +1849,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1700,7 +1868,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1724,7 +1892,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1746,7 +1914,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 183;
|
||||
CURRENT_PROJECT_VERSION = 185;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1770,7 +1938,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1817,6 +1985,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
5CCD1A522B263660001A4199 /* Build configuration list for PBXNativeTarget "SimpleX Share" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5CCD1A502B263660001A4199 /* Debug */,
|
||||
5CCD1A512B263660001A4199 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -505,8 +505,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
|
||||
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
|
||||
case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
|
||||
case sentConfirmation(user: UserRef)
|
||||
case sentInvitation(user: UserRef)
|
||||
case sentConfirmation(user: UserRef, connection: PendingContactConnection)
|
||||
case sentInvitation(user: UserRef, connection: PendingContactConnection)
|
||||
case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
|
||||
case contactAlreadyExists(user: UserRef, contact: Contact)
|
||||
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
|
||||
@@ -605,7 +605,6 @@ public enum ChatResponse: Decodable, Error {
|
||||
case ntfTokenStatus(status: NtfTknStatus)
|
||||
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
|
||||
case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
|
||||
case newContactConnection(user: UserRef, connection: PendingContactConnection)
|
||||
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
|
||||
// remote desktop responses/events
|
||||
case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
|
||||
@@ -613,7 +612,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String)
|
||||
case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
|
||||
case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlStopped
|
||||
case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason)
|
||||
// misc
|
||||
case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
|
||||
case cmdOk(user: UserRef?)
|
||||
@@ -752,7 +751,6 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .ntfTokenStatus: return "ntfTokenStatus"
|
||||
case .ntfToken: return "ntfToken"
|
||||
case .ntfMessages: return "ntfMessages"
|
||||
case .newContactConnection: return "newContactConnection"
|
||||
case .contactConnectionDeleted: return "contactConnectionDeleted"
|
||||
case .remoteCtrlList: return "remoteCtrlList"
|
||||
case .remoteCtrlFound: return "remoteCtrlFound"
|
||||
@@ -803,11 +801,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)")
|
||||
case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)")
|
||||
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
|
||||
case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation)
|
||||
case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)")
|
||||
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
|
||||
case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan))
|
||||
case .sentConfirmation: return noDetails
|
||||
case .sentInvitation: return noDetails
|
||||
case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
|
||||
case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
|
||||
case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
|
||||
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
|
||||
@@ -900,7 +898,6 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .ntfTokenStatus(status): return String(describing: status)
|
||||
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
|
||||
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
|
||||
case let .newContactConnection(u, connection): return withUser(u, String(describing: connection))
|
||||
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
|
||||
case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls)
|
||||
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)"
|
||||
@@ -1552,6 +1549,13 @@ public enum RemoteCtrlSessionState: Decodable {
|
||||
case connected(sessionCode: String)
|
||||
}
|
||||
|
||||
public enum RemoteCtrlStopReason: Decodable {
|
||||
case discoveryFailed(chatError: ChatError)
|
||||
case connectionFailed(chatError: ChatError)
|
||||
case setupFailed(chatError: ChatError)
|
||||
case disconnected
|
||||
}
|
||||
|
||||
public struct CtrlAppInfo: Decodable {
|
||||
public var appVersionRange: AppVersionRange
|
||||
public var deviceName: String
|
||||
|
||||
@@ -12,7 +12,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "chat.simplex.app"
|
||||
minSdkVersion(26)
|
||||
minSdkVersion(28)
|
||||
targetSdkVersion(33)
|
||||
// !!!
|
||||
// skip version code after release to F-Droid, as it uses two version codes
|
||||
|
||||
@@ -41,9 +41,7 @@ class MainActivity: FragmentActivity() {
|
||||
)
|
||||
}
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
AppScreen()
|
||||
}
|
||||
AppScreen()
|
||||
}
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
SimplexApp.context.schedulePeriodicWakeUp()
|
||||
|
||||
@@ -32,7 +32,9 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (ProcessPhoenix.isPhoenixProcess(this)) {
|
||||
return;
|
||||
return
|
||||
} else {
|
||||
registerGlobalErrorHandler()
|
||||
}
|
||||
context = this
|
||||
initHaskell()
|
||||
@@ -75,7 +77,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
isAppOnForeground = true
|
||||
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
|
||||
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) {
|
||||
SimplexService.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -110,7 +110,7 @@ android {
|
||||
compileSdkVersion(34)
|
||||
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
defaultConfig {
|
||||
minSdkVersion(26)
|
||||
minSdkVersion(28)
|
||||
targetSdkVersion(33)
|
||||
}
|
||||
compileOptions {
|
||||
|
||||
@@ -8,10 +8,14 @@ import android.os.Build
|
||||
import android.view.*
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import chat.simplex.common.views.helpers.KeyboardState
|
||||
import chat.simplex.common.AppScreen
|
||||
import chat.simplex.common.ui.theme.SimpleXTheme
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import androidx.compose.ui.platform.LocalContext as LocalContext1
|
||||
import chat.simplex.res.MR
|
||||
|
||||
actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show()
|
||||
|
||||
@@ -71,3 +75,37 @@ actual fun hideKeyboard(view: Any?) {
|
||||
}
|
||||
|
||||
actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true)
|
||||
|
||||
actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
|
||||
actual override fun uncaughtException(thread: Thread, e: Throwable) {
|
||||
Log.e(TAG, "App crashed, thread name: " + thread.name + ", exception: " + e.stackTraceToString())
|
||||
if (ModalManager.start.hasModalsOpen()) {
|
||||
ModalManager.start.closeModal()
|
||||
} else if (chatModel.chatId.value != null) {
|
||||
// Since no modals are open, the problem is probably in ChatView
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clear()
|
||||
} else {
|
||||
// ChatList, nothing to do. Maybe to show other view except ChatList
|
||||
}
|
||||
chatModel.activeCall.value?.let {
|
||||
withBGApi {
|
||||
chatModel.callManager.endCall(it)
|
||||
}
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.app_was_crashed),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
//mainActivity.get()?.recreate()
|
||||
mainActivity.get()?.apply {
|
||||
window
|
||||
?.decorView
|
||||
?.findViewById<ViewGroup>(android.R.id.content)
|
||||
?.removeViewAt(0)
|
||||
setContent {
|
||||
AppScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +370,6 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
InfoText(call.callState.text)
|
||||
|
||||
val connInfo = call.connectionInfo
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfoText)
|
||||
}
|
||||
@@ -585,8 +584,8 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
localMedia = CallMediaType.Video,
|
||||
peerMedia = CallMediaType.Video,
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp")
|
||||
)
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
@@ -611,8 +610,8 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
localMedia = CallMediaType.Audio,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp")
|
||||
)
|
||||
),
|
||||
speakerCanBeEnabled = true,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package chat.simplex.common.views.onboarding
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.common.model.SharedPreference
|
||||
import chat.simplex.common.model.User
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)?) {
|
||||
if (user == null) {
|
||||
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick = onclick)
|
||||
} else {
|
||||
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ if(NOT APPLE)
|
||||
else()
|
||||
# Without direct linking it can't find hs_init in linking step
|
||||
add_library( rts SHARED IMPORTED )
|
||||
FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/deps/libHSrts*_thr-*.${OS_LIB_EXT})
|
||||
FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSrts*_thr-*.${OS_LIB_EXT})
|
||||
set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB})
|
||||
|
||||
target_link_libraries(app-lib rts simplex)
|
||||
|
||||
@@ -37,15 +37,16 @@ import kotlinx.coroutines.flow.*
|
||||
|
||||
data class SettingsViewState(
|
||||
val userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
val scaffoldState: ScaffoldState,
|
||||
val switchingUsersAndHosts: MutableState<Boolean>
|
||||
val scaffoldState: ScaffoldState
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppScreen() {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
MainScreen()
|
||||
SimpleXTheme {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,11 +103,8 @@ fun MainScreen() {
|
||||
}
|
||||
|
||||
Box {
|
||||
var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it }
|
||||
}
|
||||
val userCreated = chatModel.userCreated.value
|
||||
val onboarding by remember { chatModel.controller.appPrefs.onboardingStage.state }
|
||||
val localUserCreated = chatModel.localUserCreated.value
|
||||
var showInitializationView by remember { mutableStateOf(false) }
|
||||
when {
|
||||
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
|
||||
@@ -115,14 +113,18 @@ fun MainScreen() {
|
||||
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
|
||||
}
|
||||
}
|
||||
remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView()
|
||||
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
|
||||
remember { chatModel.chatDbEncrypted }.value == null || localUserCreated == null -> SplashView()
|
||||
onboarding == OnboardingStage.OnboardingComplete -> {
|
||||
Box {
|
||||
showAdvertiseLAAlert = true
|
||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(if (chatModel.desktopNoUserNoRemote()) AnimatedViewState.VISIBLE else AnimatedViewState.GONE)) }
|
||||
KeyChangeEffect(chatModel.desktopNoUserNoRemote) {
|
||||
if (chatModel.desktopNoUserNoRemote() && !ModalManager.start.hasModalsOpen()) {
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) }
|
||||
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) }
|
||||
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
|
||||
if (appPlatform.isAndroid) {
|
||||
AndroidScreen(settingsState)
|
||||
} else {
|
||||
@@ -137,12 +139,14 @@ fun MainScreen() {
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
|
||||
onboarding == OnboardingStage.LinkAMobile -> LinkAMobile()
|
||||
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
|
||||
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null)
|
||||
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
ModalManager.fullscreen.showInView()
|
||||
SwitchingUsersView()
|
||||
}
|
||||
|
||||
val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } }
|
||||
@@ -262,7 +266,7 @@ fun CenterPartOfScreen() {
|
||||
.background(MaterialTheme.colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(MR.strings.no_selected_chat))
|
||||
Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat))
|
||||
}
|
||||
} else {
|
||||
ModalManager.center.showInView()
|
||||
@@ -286,6 +290,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
|
||||
}
|
||||
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
|
||||
ModalManager.start.showInView()
|
||||
SwitchingUsersView()
|
||||
}
|
||||
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
|
||||
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
|
||||
@@ -298,7 +303,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
|
||||
EndPartOfScreen()
|
||||
}
|
||||
}
|
||||
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
|
||||
val (userPickerState, scaffoldState ) = settingsState
|
||||
val scope = rememberCoroutineScope()
|
||||
if (scaffoldState.drawerState.isOpen) {
|
||||
Box(
|
||||
@@ -312,7 +317,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
|
||||
)
|
||||
}
|
||||
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
|
||||
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
|
||||
UserPicker(chatModel, userPickerState) {
|
||||
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
@@ -335,3 +340,26 @@ fun InitializationView() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchingUsersView() {
|
||||
if (remember { chatModel.switchingUsersAndHosts }.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package chat.simplex.common.model
|
||||
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -43,7 +42,7 @@ object ChatModel {
|
||||
val setDeliveryReceipts = mutableStateOf(false)
|
||||
val currentUser = mutableStateOf<User?>(null)
|
||||
val users = mutableStateListOf<UserInfo>()
|
||||
val userCreated = mutableStateOf<Boolean?>(null)
|
||||
val localUserCreated = mutableStateOf<Boolean?>(null)
|
||||
val chatRunning = mutableStateOf<Boolean?>(null)
|
||||
val chatDbChanged = mutableStateOf<Boolean>(false)
|
||||
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
|
||||
@@ -51,6 +50,7 @@ object ChatModel {
|
||||
val chats = mutableStateListOf<Chat>()
|
||||
// map of connections network statuses, key is agent connection id
|
||||
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
|
||||
val switchingUsersAndHosts = mutableStateOf(false)
|
||||
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
@@ -67,6 +67,9 @@ object ChatModel {
|
||||
// set when app opened from external intent
|
||||
val clearOverlays = mutableStateOf<Boolean>(false)
|
||||
|
||||
// Only needed during onboarding when user skipped password setup (left as random password)
|
||||
val desktopOnboardingRandomPassword = mutableStateOf(false)
|
||||
|
||||
// set when app is opened via contact or invitation URI
|
||||
val appOpenUrl = mutableStateOf<URI?>(null)
|
||||
|
||||
@@ -108,6 +111,9 @@ object ChatModel {
|
||||
|
||||
var updatingChatsMutex: Mutex = Mutex()
|
||||
|
||||
val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
|
||||
fun desktopNoUserNoRemote(): Boolean = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
|
||||
|
||||
// remote controller
|
||||
val remoteHosts = mutableStateListOf<RemoteHostInfo>()
|
||||
val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
|
||||
@@ -222,8 +228,23 @@ object ChatModel {
|
||||
val chat: Chat
|
||||
if (i >= 0) {
|
||||
chat = chats[i]
|
||||
val newPreviewItem = when (cInfo) {
|
||||
is ChatInfo.Group -> {
|
||||
val currentPreviewItem = chat.chatItems.firstOrNull()
|
||||
if (currentPreviewItem != null) {
|
||||
if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
|
||||
cItem
|
||||
} else {
|
||||
currentPreviewItem
|
||||
}
|
||||
} else {
|
||||
cItem
|
||||
}
|
||||
}
|
||||
else -> cItem
|
||||
}
|
||||
chats[i] = chat.copy(
|
||||
chatItems = arrayListOf(cItem),
|
||||
chatItems = arrayListOf(newPreviewItem),
|
||||
chatStats =
|
||||
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
|
||||
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
|
||||
@@ -605,6 +626,7 @@ object ChatModel {
|
||||
terminalItems.add(item)
|
||||
}
|
||||
|
||||
val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
|
||||
fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
|
||||
}
|
||||
|
||||
@@ -2945,6 +2967,14 @@ sealed class RemoteCtrlSessionState {
|
||||
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class RemoteCtrlStopReason {
|
||||
@Serializable @SerialName("discoveryFailed") class DiscoveryFailed(val chatError: ChatError): RemoteCtrlStopReason()
|
||||
@Serializable @SerialName("connectionFailed") class ConnectionFailed(val chatError: ChatError): RemoteCtrlStopReason()
|
||||
@Serializable @SerialName("setupFailed") class SetupFailed(val chatError: ChatError): RemoteCtrlStopReason()
|
||||
@Serializable @SerialName("disconnected") object Disconnected: RemoteCtrlStopReason()
|
||||
}
|
||||
|
||||
sealed class UIRemoteCtrlSessionState {
|
||||
object Starting: UIRemoteCtrlSessionState()
|
||||
object Searching: UIRemoteCtrlSessionState()
|
||||
|
||||
@@ -173,6 +173,8 @@ class AppPreferences {
|
||||
val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true)
|
||||
val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true)
|
||||
|
||||
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
SharedPreference(
|
||||
get = fun() = settings.getInt(prefName, default),
|
||||
@@ -317,6 +319,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast"
|
||||
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
|
||||
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
|
||||
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +362,7 @@ object ChatController {
|
||||
chatModel.users.addAll(users)
|
||||
if (justStarted) {
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.userCreated.value = true
|
||||
chatModel.localUserCreated.value = true
|
||||
getUserChatData(null)
|
||||
appPrefs.chatLastStart.set(Clock.System.now())
|
||||
chatModel.chatRunning.value = true
|
||||
@@ -379,6 +382,31 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startChatWithoutUser() {
|
||||
Log.d(TAG, "user: null")
|
||||
try {
|
||||
if (chatModel.chatRunning.value == true) return
|
||||
apiSetTempFolder(coreTmpDir.absolutePath)
|
||||
apiSetFilesFolder(appFilesDir.absolutePath)
|
||||
if (appPlatform.isDesktop) {
|
||||
apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
|
||||
}
|
||||
apiSetXFTPConfig(getXFTPCfg())
|
||||
apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
|
||||
chatModel.users.clear()
|
||||
chatModel.currentUser.value = null
|
||||
chatModel.localUserCreated.value = false
|
||||
appPrefs.chatLastStart.set(Clock.System.now())
|
||||
chatModel.chatRunning.value = true
|
||||
startReceiver()
|
||||
setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!)
|
||||
Log.d(TAG, "startChat: started without user")
|
||||
} catch (e: Error) {
|
||||
Log.e(TAG, "failed starting chat without user $e")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) {
|
||||
try {
|
||||
changeActiveUser_(rhId, toUserId, viewPwd)
|
||||
@@ -402,8 +430,9 @@ object ChatController {
|
||||
}
|
||||
|
||||
suspend fun getUserChatData(rhId: Long?) {
|
||||
chatModel.userAddress.value = apiGetUserAddress(rhId)
|
||||
chatModel.chatItemTTL.value = getChatItemTTL(rhId)
|
||||
val hasUser = chatModel.currentUser.value != null
|
||||
chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null
|
||||
chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None
|
||||
updatingChatsMutex.withLock {
|
||||
val chats = apiGetChats(rhId)
|
||||
chatModel.updateChats(chats)
|
||||
@@ -472,7 +501,9 @@ object ChatController {
|
||||
val r = sendCmd(rh, CC.ShowActiveUser())
|
||||
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
|
||||
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
|
||||
chatModel.userCreated.value = false
|
||||
if (rh == null) {
|
||||
chatModel.localUserCreated.value = false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -891,20 +922,21 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean {
|
||||
suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiConnect: no current user")
|
||||
return false
|
||||
return null
|
||||
}
|
||||
val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq))
|
||||
when {
|
||||
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
|
||||
r is CR.SentConfirmation -> return r.connection
|
||||
r is CR.SentInvitation -> return r.connection
|
||||
r is CR.ContactAlreadyExists -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.contact_already_exists),
|
||||
String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName)
|
||||
)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
|
||||
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
|
||||
@@ -912,7 +944,7 @@ object ChatController {
|
||||
generalGetString(MR.strings.invalid_connection_link),
|
||||
generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one)
|
||||
)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
|
||||
&& r.chatError.agentError is AgentErrorType.SMP
|
||||
@@ -921,13 +953,13 @@ object ChatController {
|
||||
generalGetString(MR.strings.connection_error_auth),
|
||||
generalGetString(MR.strings.connection_error_auth_desc)
|
||||
)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
else -> {
|
||||
if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r)
|
||||
}
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1394,9 +1426,9 @@ object ChatController {
|
||||
chatModel.remoteHosts.addAll(hosts)
|
||||
}
|
||||
|
||||
suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true): Triple<RemoteHostInfo?, String, String>? {
|
||||
val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast))
|
||||
if (r is CR.RemoteHostStarted) return Triple(r.remoteHost_, r.invitation, r.ctrlPort)
|
||||
suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true, address: RemoteCtrlAddress?, port: Int?): CR.RemoteHostStarted? {
|
||||
val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast, address, port))
|
||||
if (r is CR.RemoteHostStarted) return r
|
||||
apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r)
|
||||
return null
|
||||
}
|
||||
@@ -1526,16 +1558,6 @@ object ChatController {
|
||||
fun active(user: UserLike): Boolean = activeUser(rhId, user)
|
||||
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
|
||||
when (r) {
|
||||
is CR.NewContactConnection -> {
|
||||
if (active(r.user)) {
|
||||
chatModel.updateContactConnection(rhId, r.connection)
|
||||
}
|
||||
}
|
||||
is CR.ContactConnectionDeleted -> {
|
||||
if (active(r.user)) {
|
||||
chatModel.removeChat(rhId, r.connection.id)
|
||||
}
|
||||
}
|
||||
is CR.ContactDeletedByContact -> {
|
||||
if (active(r.user) && r.contact.directOrUsed) {
|
||||
chatModel.updateContact(rhId, r.contact)
|
||||
@@ -1996,7 +2018,7 @@ object ChatController {
|
||||
chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err))
|
||||
}
|
||||
|
||||
suspend fun switchUIRemoteHost(rhId: Long?) {
|
||||
suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded {
|
||||
// TODO lock the switch so that two switches can't run concurrently?
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.center.closeModals()
|
||||
@@ -2009,7 +2031,10 @@ object ChatController {
|
||||
chatModel.users.clear()
|
||||
chatModel.users.addAll(users)
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.userCreated.value = true
|
||||
if (user == null) {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chats.clear()
|
||||
}
|
||||
val statuses = apiGetNetworkStatuses(rhId)
|
||||
if (statuses != null) {
|
||||
chatModel.networkStatuses.clear()
|
||||
@@ -2019,6 +2044,23 @@ object ChatController {
|
||||
getUserChatData(rhId)
|
||||
}
|
||||
|
||||
suspend fun showProgressIfNeeded(block: suspend () -> Unit) {
|
||||
val job = withBGApi {
|
||||
try {
|
||||
delay(500)
|
||||
chatModel.switchingUsersAndHosts.value = true
|
||||
} catch (e: Throwable) {
|
||||
chatModel.switchingUsersAndHosts.value = false
|
||||
}
|
||||
}
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
job.cancel()
|
||||
chatModel.switchingUsersAndHosts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun getXFTPCfg(): XFTPFileConfig {
|
||||
return XFTPFileConfig(minFileSize = 0)
|
||||
}
|
||||
@@ -2206,7 +2248,7 @@ sealed class CC {
|
||||
// Remote control
|
||||
class SetLocalDeviceName(val displayName: String): CC()
|
||||
class ListRemoteHosts(): CC()
|
||||
class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean): CC()
|
||||
class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean, val address: RemoteCtrlAddress?, val port: Int?): CC()
|
||||
class SwitchRemoteHost (val remoteHostId: Long?): CC()
|
||||
class StopRemoteHost(val remoteHostKey: Long?): CC()
|
||||
class DeleteRemoteHost(val remoteHostId: Long): CC()
|
||||
@@ -2342,7 +2384,7 @@ sealed class CC {
|
||||
is CancelFile -> "/fcancel $fileId"
|
||||
is SetLocalDeviceName -> "/set device name $displayName"
|
||||
is ListRemoteHosts -> "/list remote hosts"
|
||||
is StartRemoteHost -> "/start remote host " + if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}"
|
||||
is StartRemoteHost -> "/start remote host " + (if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}") + (if (address != null) " addr=${address.address} iface=${address.`interface`}" else "") + (if (port != null) " port=$port" else "")
|
||||
is SwitchRemoteHost -> "/switch remote host " + if (remoteHostId == null) "local" else "$remoteHostId"
|
||||
is StopRemoteHost -> "/stop remote host " + if (remoteHostKey == null) "new" else "$remoteHostKey"
|
||||
is DeleteRemoteHost -> "/delete remote host $remoteHostId"
|
||||
@@ -3564,6 +3606,8 @@ data class RemoteHostInfo(
|
||||
val remoteHostId: Long,
|
||||
val hostDeviceName: String,
|
||||
val storePath: String,
|
||||
val bindAddress_: RemoteCtrlAddress?,
|
||||
val bindPort_: Int?,
|
||||
val sessionState: RemoteHostSessionState?
|
||||
) {
|
||||
val activeHost: Boolean
|
||||
@@ -3572,6 +3616,12 @@ data class RemoteHostInfo(
|
||||
fun activeHost(): Boolean = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RemoteCtrlAddress(
|
||||
val address: String,
|
||||
val `interface`: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class RemoteHostSessionState {
|
||||
@Serializable @SerialName("starting") object Starting: RemoteHostSessionState()
|
||||
@@ -3581,6 +3631,13 @@ sealed class RemoteHostSessionState {
|
||||
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class RemoteHostStopReason {
|
||||
@Serializable @SerialName("connectionFailed") data class ConnectionFailed(val chatError: ChatError): RemoteHostStopReason()
|
||||
@Serializable @SerialName("crashed") data class Crashed(val chatError: ChatError): RemoteHostStopReason()
|
||||
@Serializable @SerialName("disconnected") object Disconnected: RemoteHostStopReason()
|
||||
}
|
||||
|
||||
val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
@@ -3700,8 +3757,8 @@ sealed class CR {
|
||||
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
|
||||
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR()
|
||||
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR()
|
||||
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR()
|
||||
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
|
||||
@@ -3795,16 +3852,15 @@ sealed class CR {
|
||||
@Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): CR()
|
||||
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
|
||||
@Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR()
|
||||
// remote events (desktop)
|
||||
@Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR()
|
||||
@Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR()
|
||||
@Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String, val ctrlPort: String): CR()
|
||||
@Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String, val localAddrs: List<RemoteCtrlAddress>, val ctrlPort: String): CR()
|
||||
@Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR()
|
||||
@Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR()
|
||||
@Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR()
|
||||
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?): CR()
|
||||
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?, val rhsState: RemoteHostSessionState, val rhStopReason: RemoteHostStopReason): CR()
|
||||
@Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR()
|
||||
// remote events (mobile)
|
||||
@Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR()
|
||||
@@ -3812,7 +3868,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR()
|
||||
@Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR()
|
||||
@Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR()
|
||||
@Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR()
|
||||
@Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(val rcsState: RemoteCtrlSessionState, val rcStopReason: RemoteCtrlStopReason): CR()
|
||||
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
|
||||
@Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR()
|
||||
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR()
|
||||
@@ -3944,7 +4000,6 @@ sealed class CR {
|
||||
is CallAnswer -> "callAnswer"
|
||||
is CallExtraInfo -> "callExtraInfo"
|
||||
is CallEnded -> "callEnded"
|
||||
is NewContactConnection -> "newContactConnection"
|
||||
is ContactConnectionDeleted -> "contactConnectionDeleted"
|
||||
is RemoteHostList -> "remoteHostList"
|
||||
is CurrentRemoteHost -> "currentRemoteHost"
|
||||
@@ -3999,11 +4054,11 @@ sealed class CR {
|
||||
is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode")
|
||||
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
|
||||
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
|
||||
is Invitation -> withUser(user, connReqInvitation)
|
||||
is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
|
||||
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
|
||||
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
|
||||
is SentConfirmation -> withUser(user, noDetails())
|
||||
is SentInvitation -> withUser(user, noDetails())
|
||||
is SentConfirmation -> withUser(user, json.encodeToString(connection))
|
||||
is SentInvitation -> withUser(user, json.encodeToString(connection))
|
||||
is SentInvitationToContact -> withUser(user, json.encodeToString(contact))
|
||||
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
|
||||
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
|
||||
@@ -4091,7 +4146,6 @@ sealed class CR {
|
||||
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
|
||||
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
|
||||
is CallEnded -> withUser(user, "contact: ${contact.id}")
|
||||
is NewContactConnection -> withUser(user, json.encodeToString(connection))
|
||||
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
|
||||
// remote events (mobile)
|
||||
is RemoteHostList -> json.encodeToString(remoteHosts)
|
||||
|
||||
@@ -55,10 +55,22 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
||||
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||
val user = chatController.apiGetActiveUser(null)
|
||||
if (user == null) {
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
|
||||
chatModel.currentUser.value = null
|
||||
chatModel.users.clear()
|
||||
if (appPlatform.isDesktop) {
|
||||
/**
|
||||
* Setting it here to null because otherwise the screen will flash in [MainScreen] after the first start
|
||||
* because of default value of [OnboardingStage.OnboardingComplete]
|
||||
* */
|
||||
chatModel.localUserCreated.value = null
|
||||
if (chatController.listRemoteHosts()?.isEmpty() == true) {
|
||||
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
}
|
||||
chatController.startChatWithoutUser()
|
||||
} else {
|
||||
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
}
|
||||
} else {
|
||||
val savedOnboardingStage = appPreferences.onboardingStage.get()
|
||||
appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
|
||||
|
||||
@@ -59,7 +59,9 @@ abstract class NtfManager {
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
// TODO include remote host ID in desktop notifications?
|
||||
chatModel.controller.changeActiveUser(null, userId, null)
|
||||
chatModel.controller.showProgressIfNeeded {
|
||||
chatModel.controller.changeActiveUser(null, userId, null)
|
||||
}
|
||||
}
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
@@ -72,7 +74,9 @@ abstract class NtfManager {
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
// TODO include remote host ID in desktop notifications?
|
||||
chatModel.controller.changeActiveUser(null, userId, null)
|
||||
chatModel.controller.showProgressIfNeeded {
|
||||
chatModel.controller.changeActiveUser(null, userId, null)
|
||||
}
|
||||
}
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
|
||||
@@ -16,3 +16,11 @@ expect fun getKeyboardState(): State<KeyboardState>
|
||||
expect fun hideKeyboard(view: Any?)
|
||||
|
||||
expect fun androidIsFinishingMainActivity(): Boolean
|
||||
|
||||
fun registerGlobalErrorHandler() {
|
||||
Thread.setDefaultUncaughtExceptionHandler(GlobalExceptionsHandler())
|
||||
}
|
||||
|
||||
expect class GlobalExceptionsHandler(): Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(thread: Thread, e: Throwable)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.Profile
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -76,7 +76,13 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
disabled = !canCreateProfile(displayName.value),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
click = { createProfileInProfiles(chatModel, displayName.value, close) },
|
||||
click = {
|
||||
if (chatModel.localUserCreated.value == true) {
|
||||
createProfileInProfiles(chatModel, displayName.value, close)
|
||||
} else {
|
||||
createProfileInNoProfileSetup(displayName.value, close)
|
||||
}
|
||||
},
|
||||
)
|
||||
SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device))
|
||||
SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts))
|
||||
@@ -168,6 +174,17 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
|
||||
withApi {
|
||||
val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null)) ?: return@withApi
|
||||
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
|
||||
chatModel.chatRunning.value = false
|
||||
controller.startChat(user)
|
||||
controller.switchUIRemoteHost(null)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) {
|
||||
withApi {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
@@ -190,12 +207,12 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: ()
|
||||
|
||||
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) {
|
||||
withApi {
|
||||
chatModel.controller.apiCreateActiveUser(
|
||||
chatModel.currentUser.value = chatModel.controller.apiCreateActiveUser(
|
||||
null, Profile(displayName.trim(), "", null)
|
||||
) ?: return@withApi
|
||||
val onboardingStage = chatModel.controller.appPrefs.onboardingStage
|
||||
if (chatModel.users.isEmpty()) {
|
||||
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) {
|
||||
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) {
|
||||
OnboardingStage.Step2_5_SetupDatabasePassphrase
|
||||
} else {
|
||||
OnboardingStage.Step3_CreateSimpleXAddress
|
||||
|
||||
@@ -127,18 +127,10 @@ sealed class WCallResponse {
|
||||
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
||||
}
|
||||
}
|
||||
|
||||
val protocolText: String get() {
|
||||
val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||
val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||
val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||
val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)"
|
||||
return if (local == remote) localText else "$localText / $remote"
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?)
|
||||
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
||||
|
||||
|
||||
@@ -164,6 +164,12 @@ fun CIImageView(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
KeyChangeEffect(file) {
|
||||
if (res.value == null) {
|
||||
res.value = imageAndFilePath(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
val loaded = res.value
|
||||
if (loaded != null) {
|
||||
|
||||
@@ -68,14 +68,14 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
|
||||
val (userPickerState, scaffoldState ) = settingsState
|
||||
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
|
||||
scaffoldState = scaffoldState,
|
||||
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
|
||||
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
|
||||
drawerGesturesEnabled = appPlatform.isAndroid,
|
||||
floatingActionButton = {
|
||||
if (searchInList.isEmpty()) {
|
||||
if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (!stopped) {
|
||||
@@ -104,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
||||
) {
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel, search = searchInList)
|
||||
} else if (!switchingUsersAndHosts.value) {
|
||||
} else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
OnboardingButtons(showNewChatSheet)
|
||||
@@ -121,19 +121,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
|
||||
UserPicker(chatModel, userPickerState) {
|
||||
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
if (switchingUsersAndHosts.value) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -209,7 +201,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
||||
navigationButton = {
|
||||
if (showSearch) {
|
||||
NavigationButtonBack(hideSearchOnBack)
|
||||
} else if (chatModel.users.isEmpty()) {
|
||||
} else if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
|
||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||
} else {
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
@@ -304,17 +296,6 @@ private fun ToggleFilterButton() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@Composable
|
||||
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState
|
||||
val (userPickerState, scaffoldState) = settingsState
|
||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
||||
Scaffold(
|
||||
Modifier.padding(end = endPadding),
|
||||
@@ -47,7 +47,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
|
||||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = {
|
||||
UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = {
|
||||
chatModel.sharedContent.value = null
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
})
|
||||
|
||||
@@ -26,7 +26,9 @@ import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
import chat.simplex.common.views.remote.*
|
||||
import chat.simplex.common.views.usersettings.doWithAuth
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -38,7 +40,6 @@ import kotlin.math.roundToInt
|
||||
fun UserPicker(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
switchingUsersAndHosts: MutableState<Boolean>,
|
||||
showSettings: Boolean = true,
|
||||
showCancel: Boolean = false,
|
||||
cancelClicked: () -> Unit = {},
|
||||
@@ -123,14 +124,10 @@ fun UserPicker(
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
if (!u.user.activeUser) {
|
||||
scope.launch {
|
||||
val job = launch {
|
||||
delay(500)
|
||||
switchingUsersAndHosts.value = true
|
||||
controller.showProgressIfNeeded {
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
|
||||
}
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
|
||||
job.cancel()
|
||||
switchingUsersAndHosts.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,13 +159,13 @@ fun UserPicker(
|
||||
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
if (remoteHosts.isNotEmpty()) {
|
||||
if (currentRemoteHost == null) {
|
||||
if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
|
||||
LocalDevicePickerItem(true) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToLocalDevice()
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
} else {
|
||||
} else if (currentRemoteHost != null) {
|
||||
val connecting = rememberSaveable { mutableStateOf(false) }
|
||||
RemoteHostPickerItem(currentRemoteHost,
|
||||
actionButtonClick = {
|
||||
@@ -176,7 +173,7 @@ fun UserPicker(
|
||||
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
|
||||
}) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting)
|
||||
switchToRemoteHost(currentRemoteHost, connecting)
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
@@ -184,7 +181,7 @@ fun UserPicker(
|
||||
|
||||
UsersView()
|
||||
|
||||
if (remoteHosts.isNotEmpty() && currentRemoteHost != null) {
|
||||
if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
|
||||
LocalDevicePickerItem(false) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToLocalDevice()
|
||||
@@ -199,7 +196,7 @@ fun UserPicker(
|
||||
stopRemoteHostAndReloadHosts(h, false)
|
||||
}) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToRemoteHost(h, switchingUsersAndHosts, connecting)
|
||||
switchToRemoteHost(h, connecting)
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
@@ -220,6 +217,18 @@ fun UserPicker(
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
} else if (chatModel.desktopNoUserNoRemote) {
|
||||
CreateInitialProfile {
|
||||
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
|
||||
ModalManager.center.showModalCloseable { close ->
|
||||
LaunchedEffect(Unit) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
}
|
||||
CreateProfile(chat.simplex.common.platform.chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
if (showSettings) {
|
||||
SettingsPickerItem(settingsClicked)
|
||||
@@ -401,6 +410,16 @@ private fun LinkAMobilePickerItem(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateInitialProfile(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.create_chat_profile)
|
||||
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
@@ -441,21 +460,15 @@ private fun switchToLocalDevice() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState<Boolean>, connecting: MutableState<Boolean>) {
|
||||
private fun switchToRemoteHost(h: RemoteHostInfo, connecting: MutableState<Boolean>) {
|
||||
if (!h.activeHost()) {
|
||||
withBGApi {
|
||||
val job = launch {
|
||||
delay(500)
|
||||
switchingUsersAndHosts.value = true
|
||||
}
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
if (h.sessionState != null) {
|
||||
chatModel.controller.switchUIRemoteHost(h.remoteHostId)
|
||||
} else {
|
||||
connectMobileDevice(h, connecting)
|
||||
}
|
||||
job.cancel()
|
||||
switchingUsersAndHosts.value = false
|
||||
}
|
||||
} else {
|
||||
connectMobileDevice(h, connecting)
|
||||
|
||||
@@ -264,7 +264,8 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
|
||||
text,
|
||||
generalGetString(MR.strings.enter_passphrase),
|
||||
isValid = ::validKey,
|
||||
keyboardActions = KeyboardActions(onDone = if (enabled) {
|
||||
// Don't enable this on desktop since it interfere with key event listener
|
||||
keyboardActions = KeyboardActions(onDone = if (enabled && appPlatform.isAndroid) {
|
||||
{ onClick?.invoke() }
|
||||
} else null
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionTextFooter
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -20,6 +21,7 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.updatingChatsMutex
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -59,7 +61,9 @@ fun DatabaseView(
|
||||
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
|
||||
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
|
||||
if (to != null) {
|
||||
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator)
|
||||
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) {
|
||||
startChat(m, chatLastStart, m.chatDbChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
|
||||
@@ -77,7 +81,6 @@ fun DatabaseView(
|
||||
m.chatDbEncrypted.value,
|
||||
m.controller.appPrefs.storeDBPassphrase.state.value,
|
||||
m.controller.appPrefs.initialRandomDBPassphrase,
|
||||
m.controller.appPrefs.developerTools.state.value,
|
||||
importArchiveLauncher,
|
||||
chatArchiveName,
|
||||
chatArchiveTime,
|
||||
@@ -100,7 +103,13 @@ fun DatabaseView(
|
||||
setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize)
|
||||
}
|
||||
},
|
||||
showSettingsModal
|
||||
showSettingsModal,
|
||||
disconnectAllHosts = {
|
||||
val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected }
|
||||
connected.forEachIndexed { index, h ->
|
||||
controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote())
|
||||
}
|
||||
}
|
||||
)
|
||||
if (progressIndicator.value) {
|
||||
Box(
|
||||
@@ -129,7 +138,6 @@ fun DatabaseLayout(
|
||||
chatDbEncrypted: Boolean?,
|
||||
passphraseSaved: Boolean,
|
||||
initialRandomDBPassphrase: SharedPreference<Boolean>,
|
||||
developerTools: Boolean,
|
||||
importArchiveLauncher: FileChooserLauncher,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
@@ -144,36 +152,43 @@ fun DatabaseLayout(
|
||||
deleteChatAlert: () -> Unit,
|
||||
deleteAppFilesAndMedia: () -> Unit,
|
||||
onChatItemTTLSelected: (ChatItemTTL) -> Unit,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
disconnectAllHosts: () -> Unit,
|
||||
) {
|
||||
val stopped = !runChat
|
||||
val operationsDisabled = !stopped || progressIndicator
|
||||
val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.your_chat_database))
|
||||
|
||||
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) {
|
||||
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
|
||||
}
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.messages_section_description) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) {
|
||||
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
|
||||
}
|
||||
)
|
||||
|
||||
if (currentRemoteHost == null) {
|
||||
SectionTextFooter(
|
||||
remember(currentUser?.displayName) {
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.messages_section_description) + " ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(currentUser?.displayName ?: "")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
}
|
||||
)
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
|
||||
}
|
||||
val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected }
|
||||
if (chatModel.localUserCreated.value == true) {
|
||||
SectionView(stringResource(MR.strings.run_chat_section)) {
|
||||
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
|
||||
if (!toggleEnabled) {
|
||||
SectionItemView(disconnectAllHosts) {
|
||||
Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange)
|
||||
}
|
||||
}
|
||||
RunChatSetting(runChat, stopped, toggleEnabled, startChat, stopChatAlert)
|
||||
}
|
||||
SectionTextFooter(
|
||||
if (stopped) {
|
||||
@@ -183,92 +198,96 @@ fun DatabaseLayout(
|
||||
}
|
||||
)
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.chat_database_section)) {
|
||||
val unencrypted = chatDbEncrypted == false
|
||||
SettingsActionItem(
|
||||
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
|
||||
else painterResource(MR.images.ic_lock),
|
||||
stringResource(MR.strings.database_passphrase),
|
||||
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
||||
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
if (appPlatform.isDesktop && developerTools) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_folder_open),
|
||||
stringResource(MR.strings.open_database_folder),
|
||||
::desktopOpenDatabaseDir,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_ios_share),
|
||||
stringResource(MR.strings.export_database),
|
||||
click = {
|
||||
if (initialRandomDBPassphrase.get()) {
|
||||
exportProhibitedAlert()
|
||||
} else {
|
||||
exportArchive()
|
||||
}
|
||||
},
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_download),
|
||||
stringResource(MR.strings.import_database),
|
||||
{ withApi { importArchiveLauncher.launch("application/zip") } },
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
val chatArchiveNameVal = chatArchiveName.value
|
||||
val chatArchiveTimeVal = chatArchiveTime.value
|
||||
val chatLastStartVal = chatLastStart.value
|
||||
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
||||
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_inventory_2),
|
||||
title,
|
||||
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_delete_forever),
|
||||
stringResource(MR.strings.delete_database),
|
||||
deleteChatAlert,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
|
||||
SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) {
|
||||
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
||||
SectionItemView(
|
||||
deleteAppFilesAndMedia,
|
||||
disabled = deleteFilesDisabled
|
||||
) {
|
||||
Text(
|
||||
stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all),
|
||||
color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
val (count, size) = appFilesCountAndSize.value
|
||||
SectionTextFooter(
|
||||
if (count == 0) {
|
||||
stringResource(MR.strings.no_received_app_files)
|
||||
} else {
|
||||
String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SectionView(stringResource(MR.strings.chat_database_section)) {
|
||||
if (chatModel.localUserCreated.value != true && !toggleEnabled) {
|
||||
SectionItemView(disconnectAllHosts) {
|
||||
Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange)
|
||||
}
|
||||
}
|
||||
val unencrypted = chatDbEncrypted == false
|
||||
SettingsActionItem(
|
||||
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
|
||||
else painterResource(MR.images.ic_lock),
|
||||
stringResource(MR.strings.database_passphrase),
|
||||
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
||||
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
if (appPlatform.isDesktop) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_folder_open),
|
||||
stringResource(MR.strings.open_database_folder),
|
||||
::desktopOpenDatabaseDir,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_ios_share),
|
||||
stringResource(MR.strings.export_database),
|
||||
click = {
|
||||
if (initialRandomDBPassphrase.get()) {
|
||||
exportProhibitedAlert()
|
||||
} else {
|
||||
exportArchive()
|
||||
}
|
||||
},
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_download),
|
||||
stringResource(MR.strings.import_database),
|
||||
{ withApi { importArchiveLauncher.launch("application/zip") } },
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
val chatArchiveNameVal = chatArchiveName.value
|
||||
val chatArchiveTimeVal = chatArchiveTime.value
|
||||
val chatLastStartVal = chatLastStart.value
|
||||
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
||||
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_inventory_2),
|
||||
title,
|
||||
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_delete_forever),
|
||||
stringResource(MR.strings.delete_database),
|
||||
deleteChatAlert,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
|
||||
SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) {
|
||||
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
||||
SectionItemView(
|
||||
deleteAppFilesAndMedia,
|
||||
disabled = deleteFilesDisabled
|
||||
) {
|
||||
Text(
|
||||
stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all),
|
||||
color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
val (count, size) = appFilesCountAndSize.value
|
||||
SectionTextFooter(
|
||||
if (count == 0) {
|
||||
stringResource(MR.strings.no_received_app_files)
|
||||
} else {
|
||||
String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size))
|
||||
}
|
||||
)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
@@ -319,6 +338,7 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
|
||||
fun RunChatSetting(
|
||||
runChat: Boolean,
|
||||
stopped: Boolean,
|
||||
enabled: Boolean,
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit
|
||||
) {
|
||||
@@ -337,6 +357,7 @@ fun RunChatSetting(
|
||||
stopChatAlert()
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -501,13 +522,14 @@ private fun importArchiveAlert(
|
||||
m: ChatModel,
|
||||
importedArchiveURI: URI,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
startChat: () -> Unit,
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.import_database_question),
|
||||
text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
||||
confirmText = generalGetString(MR.strings.import_database_confirmation),
|
||||
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) },
|
||||
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator, startChat) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
@@ -516,7 +538,8 @@ private fun importArchive(
|
||||
m: ChatModel,
|
||||
importedArchiveURI: URI,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
startChat: () -> Unit,
|
||||
) {
|
||||
progressIndicator.value = true
|
||||
val archivePath = saveArchiveFromURI(importedArchiveURI)
|
||||
@@ -533,6 +556,10 @@ private fun importArchive(
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database))
|
||||
}
|
||||
if (chatModel.localUserCreated.value == false) {
|
||||
chatModel.chatRunning.value = false
|
||||
startChat()
|
||||
}
|
||||
} else {
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import))
|
||||
@@ -681,7 +708,6 @@ fun PreviewDatabaseLayout() {
|
||||
chatDbEncrypted = false,
|
||||
passphraseSaved = false,
|
||||
initialRandomDBPassphrase = SharedPreference({ true }, {}),
|
||||
developerTools = true,
|
||||
importArchiveLauncher = rememberFileChooserLauncher(true) {},
|
||||
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
||||
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
||||
@@ -697,6 +723,7 @@ fun PreviewDatabaseLayout() {
|
||||
deleteAppFilesAndMedia = {},
|
||||
showSettingsModal = { {} },
|
||||
onChatItemTTLSelected = {},
|
||||
disconnectAllHosts = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -233,53 +236,71 @@ private fun alertTitle(title: String): (@Composable () -> Unit)? {
|
||||
|
||||
@Composable
|
||||
private fun AlertContent(text: String?, hostDevice: Pair<Long?, String>?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
if (appPlatform.isDesktop) {
|
||||
HostDeviceTitle(hostDevice, extraPadding = extraPadding)
|
||||
} else {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
if (text != null) {
|
||||
Text(
|
||||
escapedHtmlToAnnotatedString(text, LocalDensity.current),
|
||||
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
BoxWithConstraints {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
if (appPlatform.isDesktop) {
|
||||
HostDeviceTitle(hostDevice, extraPadding = extraPadding)
|
||||
} else {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
if (text != null) {
|
||||
Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
escapedHtmlToAnnotatedString(text, LocalDensity.current),
|
||||
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertContent(text: AnnotatedString?, hostDevice: Pair<Long?, String>?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
if (appPlatform.isDesktop) {
|
||||
HostDeviceTitle(hostDevice, extraPadding = extraPadding)
|
||||
} else {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
if (text != null) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
BoxWithConstraints {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
if (appPlatform.isDesktop) {
|
||||
HostDeviceTitle(hostDevice, extraPadding = extraPadding)
|
||||
} else {
|
||||
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
if (text != null) {
|
||||
Column(
|
||||
Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text,
|
||||
Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f),
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.views.database.PassphraseStrength
|
||||
import chat.simplex.common.views.database.validKey
|
||||
import chat.simplex.res.MR
|
||||
@@ -123,6 +123,7 @@ fun DefaultConfigurableTextField(
|
||||
isValid: (String) -> Boolean,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
fontSize: TextUnit = 16.sp,
|
||||
dependsOn: State<Any?>? = null,
|
||||
) {
|
||||
var valid by remember { mutableStateOf(isValid(state.value.text)) }
|
||||
@@ -152,7 +153,6 @@ fun DefaultConfigurableTextField(
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.defaultMinSize(
|
||||
@@ -176,14 +176,14 @@ fun DefaultConfigurableTextField(
|
||||
textStyle = TextStyle.Default.copy(
|
||||
color = color,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
fontSize = fontSize
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = state.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) },
|
||||
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary, fontSize = fontSize, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
isError = !valid,
|
||||
|
||||
@@ -10,72 +10,90 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
|
||||
|
||||
@Composable
|
||||
fun <T> ExposedDropDownSetting(
|
||||
values: List<Pair<T, String>>,
|
||||
selection: State<T>,
|
||||
textColor: Color = MaterialTheme.colors.secondary,
|
||||
fontSize: TextUnit = 16.sp,
|
||||
label: String? = null,
|
||||
enabled: State<Boolean> = mutableStateOf(true),
|
||||
minWidth: Dp = 200.dp,
|
||||
maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() },
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = {
|
||||
expanded.value = !expanded.value && enabled.value
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||
Modifier.widthIn(max = maxWidth),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = textColor,
|
||||
fontSize = fontSize,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Icon(
|
||||
if (!expanded.value) painterResource(MR.images.ic_expand_more) else painterResource(MR.images.ic_expand_less),
|
||||
generalGetString(MR.strings.icon_descr_more_button),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
DefaultExposedDropdownMenu(
|
||||
modifier = Modifier.widthIn(min = minWidth),
|
||||
expanded = expanded,
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onSelected(selectionOption.first)
|
||||
expanded.value = false
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)
|
||||
) {
|
||||
Text(
|
||||
selectionOption.second + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||
fontSize = fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ExposedDropDownSettingRow(
|
||||
title: String,
|
||||
values: List<Pair<T, String>>,
|
||||
selection: State<T>,
|
||||
textColor: Color = MaterialTheme.colors.secondary,
|
||||
label: String? = null,
|
||||
icon: Painter? = null,
|
||||
iconTint: Color = MaterialTheme.colors.secondary,
|
||||
enabled: State<Boolean> = mutableStateOf(true),
|
||||
minWidth: Dp = 200.dp,
|
||||
maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() },
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = {
|
||||
expanded.value = !expanded.value && enabled.value
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
val maxWidth = with(LocalDensity.current) { 180.sp.toDp() }
|
||||
Text(
|
||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||
Modifier.widthIn(max = maxWidth),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Icon(
|
||||
if (!expanded.value) painterResource(MR.images.ic_expand_more) else painterResource(MR.images.ic_expand_less),
|
||||
generalGetString(MR.strings.icon_descr_more_button),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
DefaultExposedDropdownMenu(
|
||||
modifier = Modifier.widthIn(min = 200.dp),
|
||||
expanded = expanded,
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onSelected(selectionOption.first)
|
||||
expanded.value = false
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)
|
||||
) {
|
||||
Text(
|
||||
selectionOption.second + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropDownSetting(values, selection ,textColor, label = label, enabled = enabled, minWidth = minWidth, maxWidth = maxWidth, onSelected = onSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.platform.onRightClick
|
||||
@@ -202,13 +203,14 @@ fun SectionTextFooter(text: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTextFooter(text: AnnotatedString) {
|
||||
fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
lineHeight = 18.sp,
|
||||
fontSize = 14.sp
|
||||
fontSize = 14.sp,
|
||||
textAlign = textAlign
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ fun annotatedStringResource(id: StringResource): AnnotatedString {
|
||||
@Composable
|
||||
fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString {
|
||||
val density = LocalDensity.current
|
||||
return remember(id) {
|
||||
return remember(id, args) {
|
||||
escapedHtmlToAnnotatedString(id.localized().format(args = args), density)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ private fun createInvitation(
|
||||
withApi {
|
||||
val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get())
|
||||
if (r != null) {
|
||||
m.updateContactConnection(rhId, r.second)
|
||||
connReqInvitation.value = r.first
|
||||
contactConnection.value = r.second
|
||||
} else {
|
||||
|
||||
@@ -283,10 +283,11 @@ suspend fun connectViaUri(
|
||||
incognito: Boolean,
|
||||
connectionPlan: ConnectionPlan?,
|
||||
close: (() -> Unit)?
|
||||
): Boolean {
|
||||
val r = chatModel.controller.apiConnect(rhId, incognito, uri.toString())
|
||||
) {
|
||||
val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString())
|
||||
val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION
|
||||
if (r) {
|
||||
if (pcc != null) {
|
||||
chatModel.updateContactConnection(rhId, pcc)
|
||||
close?.invoke()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.connection_request_sent),
|
||||
@@ -299,7 +300,6 @@ suspend fun connectViaUri(
|
||||
hostDevice = hostDevice(rhId),
|
||||
)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType {
|
||||
|
||||
@@ -182,6 +182,10 @@ private fun prepareChatBeforeAddressCreation(rhId: Long?) {
|
||||
val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi
|
||||
chatModel.currentUser.value = user
|
||||
if (chatModel.users.isEmpty()) {
|
||||
if (appPlatform.isDesktop) {
|
||||
// Make possible to use chat after going to remote device linking and returning back to local profile creation
|
||||
chatModel.chatRunning.value = false
|
||||
}
|
||||
chatModel.controller.startChat(user)
|
||||
} else {
|
||||
val users = chatModel.controller.listUsers(rhId)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package chat.simplex.common.views.onboarding
|
||||
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.remote.AddingMobileDevice
|
||||
import chat.simplex.common.views.remote.DeviceNameField
|
||||
import chat.simplex.common.views.usersettings.PreferenceToggle
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
fun LinkAMobile() {
|
||||
val connecting = rememberSaveable { mutableStateOf(false) }
|
||||
val deviceName = chatModel.controller.appPrefs.deviceNameForRemoteAccess
|
||||
var deviceNameInQrCode by remember { mutableStateOf(chatModel.controller.appPrefs.deviceNameForRemoteAccess.get()) }
|
||||
val staleQrCode = remember { mutableStateOf(false) }
|
||||
|
||||
LinkAMobileLayout(
|
||||
deviceName = remember { deviceName.state },
|
||||
connecting,
|
||||
staleQrCode,
|
||||
updateDeviceName = {
|
||||
withBGApi {
|
||||
if (it != "" && it != deviceName.get()) {
|
||||
chatModel.controller.setLocalDeviceName(it)
|
||||
deviceName.set(it)
|
||||
staleQrCode.value = deviceName.get() != deviceNameInQrCode
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
KeyChangeEffect(staleQrCode.value) {
|
||||
if (!staleQrCode.value) {
|
||||
deviceNameInQrCode = deviceName.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkAMobileLayout(
|
||||
deviceName: State<String?>,
|
||||
connecting: MutableState<Boolean>,
|
||||
staleQrCode: MutableState<Boolean>,
|
||||
updateDeviceName: (String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.padding(top = 20.dp)) {
|
||||
AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles))
|
||||
Row(Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING * 2), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(
|
||||
Modifier.weight(0.3f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
SectionView(generalGetString(MR.strings.this_device_name).uppercase()) {
|
||||
DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) }
|
||||
SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile))
|
||||
PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) {
|
||||
ChatModel.controller.appPrefs.offerRemoteMulticast.set(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(0.7f)) {
|
||||
AddingMobileDevice(false, staleQrCode, connecting) {
|
||||
// currentRemoteHost will be set instantly but remoteHosts may be delayed
|
||||
if (chatModel.remoteHosts.isEmpty() && chatModel.currentRemoteHost.value == null) {
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
} else {
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SimpleButtonDecorated(
|
||||
text = stringResource(MR.strings.about_simplex),
|
||||
icon = painterResource(MR.images.ic_arrow_back_ios_new),
|
||||
textDecoration = TextDecoration.None,
|
||||
fontWeight = FontWeight.Medium
|
||||
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.common.views.onboarding
|
||||
enum class OnboardingStage {
|
||||
Step1_SimpleXInfo,
|
||||
Step2_CreateProfile,
|
||||
LinkAMobile,
|
||||
Step2_5_SetupDatabasePassphrase,
|
||||
Step3_CreateSimpleXAddress,
|
||||
Step4_SetNotificationsMode,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package chat.simplex.common.views.onboarding
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -15,14 +12,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
@@ -43,7 +38,11 @@ fun SetupDatabasePassphrase(m: ChatModel) {
|
||||
val newKey = rememberSaveable { mutableStateOf("") }
|
||||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||
fun nextStep() {
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
|
||||
if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
|
||||
} else {
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
|
||||
}
|
||||
}
|
||||
SetupDatabasePassphraseLayout(
|
||||
currentKey,
|
||||
@@ -159,10 +158,7 @@ private fun SetupDatabasePassphraseLayout(
|
||||
}
|
||||
},
|
||||
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
if (!disabled) onClickUpdate()
|
||||
defaultKeyboardAction(ImeAction.Done)
|
||||
}),
|
||||
keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }),
|
||||
)
|
||||
|
||||
Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) {
|
||||
@@ -176,7 +172,10 @@ private fun SetupDatabasePassphraseLayout(
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
SkipButton(progressIndicator.value, nextStep)
|
||||
SkipButton(progressIndicator.value) {
|
||||
chatModel.desktopOnboardingRandomPassword.value = true
|
||||
nextStep()
|
||||
}
|
||||
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.graphics.painter.Painter
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -99,26 +100,22 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null) {
|
||||
if (user == null) {
|
||||
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick)
|
||||
} else {
|
||||
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick)
|
||||
}
|
||||
}
|
||||
expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null)
|
||||
|
||||
@Composable
|
||||
fun OnboardingActionButton(
|
||||
labelId: StringResource,
|
||||
onboarding: OnboardingStage?,
|
||||
border: Boolean,
|
||||
icon: Painter? = null,
|
||||
iconColor: Color = MaterialTheme.colors.primary,
|
||||
onclick: (() -> Unit)?
|
||||
) {
|
||||
val modifier = if (border) {
|
||||
Modifier
|
||||
.border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50))
|
||||
.padding(
|
||||
horizontal = DEFAULT_PADDING * 2,
|
||||
horizontal = if (icon == null) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF,
|
||||
vertical = 4.dp
|
||||
)
|
||||
} else {
|
||||
@@ -131,6 +128,9 @@ fun OnboardingActionButton(
|
||||
ChatController.appPrefs.onboardingStage.set(onboarding)
|
||||
}
|
||||
}, modifier) {
|
||||
if (icon != null) {
|
||||
Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor)
|
||||
}
|
||||
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary,
|
||||
|
||||
@@ -9,16 +9,20 @@ import SectionView
|
||||
import TextIconSpaced
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
@@ -30,11 +34,11 @@ import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.common.views.usersettings.PreferenceToggle
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ConnectMobileView() {
|
||||
@@ -97,9 +101,11 @@ fun ConnectMobileLayout(
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
}
|
||||
SectionView(stringResource(MR.strings.devices).uppercase()) {
|
||||
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
|
||||
if (connectedHost.value == null) {
|
||||
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
if (chatModel.localUserCreated.value == true) {
|
||||
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
|
||||
if (connectedHost.value == null) {
|
||||
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +158,7 @@ fun DeviceNameField(
|
||||
DefaultConfigurableTextField(
|
||||
state = state,
|
||||
placeholder = generalGetString(MR.strings.enter_this_device_name),
|
||||
modifier = Modifier.padding(start = DEFAULT_PADDING),
|
||||
modifier = Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING),
|
||||
isValid = { true },
|
||||
)
|
||||
KeyChangeEffect(state.value) {
|
||||
@@ -162,26 +168,40 @@ fun DeviceNameField(
|
||||
|
||||
@Composable
|
||||
private fun ConnectMobileViewLayout(
|
||||
title: String,
|
||||
title: String?,
|
||||
invitation: String?,
|
||||
deviceName: String?,
|
||||
sessionCode: String?,
|
||||
port: String?
|
||||
port: String?,
|
||||
staleQrCode: Boolean = false,
|
||||
refreshQrCode: () -> Unit = {},
|
||||
UnderQrLayout: @Composable () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
AppBarTitle(title)
|
||||
if (title != null) {
|
||||
AppBarTitle(title)
|
||||
}
|
||||
SectionView {
|
||||
if (invitation != null && sessionCode == null && port != null) {
|
||||
QRCode(
|
||||
invitation, Modifier
|
||||
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF)
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code))
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port))
|
||||
Box {
|
||||
QRCode(
|
||||
invitation, Modifier
|
||||
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF)
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
if (staleQrCode) {
|
||||
Box(Modifier.matchParentSize().background(MaterialTheme.colors.background.copy(alpha = 0.9f)), contentAlignment = Alignment.Center) {
|
||||
SimpleButtonDecorated(stringResource(MR.strings.refresh_qr_code), painterResource(MR.images.ic_refresh), click = refreshQrCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code), textAlign = TextAlign.Center)
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect), textAlign = TextAlign.Center)
|
||||
|
||||
UnderQrLayout()
|
||||
|
||||
if (remember { controller.appPrefs.developerTools.state }.value) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
@@ -218,6 +238,9 @@ private fun ConnectMobileViewLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (invitation != null) {
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,64 +260,105 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState<Boolean>) {
|
||||
|
||||
private fun showAddingMobileDevice(connecting: MutableState<Boolean>) {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val port = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val pairing = remember { chatModel.remoteHostPairing }
|
||||
val sessionCode = when (val state = pairing.value?.second) {
|
||||
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
|
||||
else -> null
|
||||
}
|
||||
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
|
||||
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
|
||||
if (cachedSessionCode == null && sessionCode != null) {
|
||||
cachedSessionCode = sessionCode
|
||||
}
|
||||
val remoteDeviceName = pairing.value?.first?.hostDeviceName
|
||||
ConnectMobileViewLayout(
|
||||
title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
|
||||
invitation = invitation.value,
|
||||
deviceName = remoteDeviceName,
|
||||
sessionCode = cachedSessionCode,
|
||||
port = port.value
|
||||
AddingMobileDevice(true, remember { mutableStateOf(false) }, connecting, close)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddingMobileDevice(showTitle: Boolean, staleQrCode: MutableState<Boolean>, connecting: MutableState<Boolean>, close: () -> Unit) {
|
||||
var cachedR by remember { mutableStateOf<CR.RemoteHostStarted?>(null) }
|
||||
val customAddress = rememberSaveable { mutableStateOf<RemoteCtrlAddress?>(null) }
|
||||
val customPort = rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
val startRemoteHost = suspend {
|
||||
val r = chatModel.controller.startRemoteHost(
|
||||
rhId = null,
|
||||
multicast = controller.appPrefs.offerRemoteMulticast.get(),
|
||||
address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_,
|
||||
port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_
|
||||
)
|
||||
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
|
||||
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
|
||||
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) {
|
||||
close()
|
||||
}
|
||||
if (r != null) {
|
||||
cachedR = r
|
||||
connecting.value = true
|
||||
customAddress.value = cachedR.addresses.firstOrNull()
|
||||
customPort.value = cachedR.port
|
||||
chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting
|
||||
}
|
||||
KeyChangeEffect(pairing.value) {
|
||||
if (pairing.value == null) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
}
|
||||
val pairing = remember { chatModel.remoteHostPairing }
|
||||
val sessionCode = when (val state = pairing.value?.second) {
|
||||
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
|
||||
else -> null
|
||||
}
|
||||
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
|
||||
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
|
||||
if (cachedSessionCode == null && sessionCode != null) {
|
||||
cachedSessionCode = sessionCode
|
||||
}
|
||||
val remoteDeviceName = pairing.value?.first?.hostDeviceName
|
||||
ConnectMobileViewLayout(
|
||||
title = if (!showTitle) null else if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
|
||||
invitation = cachedR?.invitation,
|
||||
deviceName = remoteDeviceName,
|
||||
sessionCode = cachedSessionCode,
|
||||
port = cachedR?.ctrlPort,
|
||||
staleQrCode = staleQrCode.value || (cachedR.address != customAddress.value && customAddress.value != null) || cachedR.port != customPort.value,
|
||||
refreshQrCode = {
|
||||
withBGApi {
|
||||
val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get())
|
||||
if (r != null) {
|
||||
connecting.value = true
|
||||
invitation.value = r.second
|
||||
port.value = r.third
|
||||
chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting
|
||||
if (chatController.stopRemoteHost(null)) {
|
||||
startRemoteHost()
|
||||
staleQrCode.value = false
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) {
|
||||
withBGApi {
|
||||
chatController.stopRemoteHost(null)
|
||||
}
|
||||
},
|
||||
UnderQrLayout = { UnderQrLayout(cachedR, customAddress, customPort) }
|
||||
)
|
||||
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
|
||||
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
|
||||
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
KeyChangeEffect(pairing.value) {
|
||||
if (pairing.value == null) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
withBGApi {
|
||||
startRemoteHost()
|
||||
}
|
||||
onDispose {
|
||||
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) {
|
||||
withBGApi {
|
||||
chatController.stopRemoteHost(null)
|
||||
}
|
||||
chatModel.remoteHostPairing.value = null
|
||||
}
|
||||
chatModel.remoteHostPairing.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState<Boolean>) {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
var cachedR by remember { mutableStateOf<CR.RemoteHostStarted?>(null) }
|
||||
val customAddress = rememberSaveable { mutableStateOf<RemoteCtrlAddress?>(null) }
|
||||
val customPort = rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
val startRemoteHost = suspend {
|
||||
val r = chatModel.controller.startRemoteHost(
|
||||
rhId = rh.remoteHostId,
|
||||
multicast = controller.appPrefs.offerRemoteMulticast.get(),
|
||||
address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_ ?: rh.bindAddress_,
|
||||
port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_ ?: rh.bindPort_
|
||||
)
|
||||
if (r != null) {
|
||||
cachedR = r
|
||||
connecting.value = true
|
||||
customAddress.value = cachedR.addresses.firstOrNull()
|
||||
customPort.value = cachedR.port
|
||||
chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting
|
||||
}
|
||||
}
|
||||
val pairing = remember { chatModel.remoteHostPairing }
|
||||
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val port = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val sessionCode = when (val state = pairing.value?.second) {
|
||||
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
|
||||
else -> null
|
||||
@@ -306,25 +370,22 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState
|
||||
}
|
||||
ConnectMobileViewLayout(
|
||||
title = if (cachedSessionCode == null) stringResource(MR.strings.scan_from_mobile) else stringResource(MR.strings.verify_connection),
|
||||
invitation = invitation.value,
|
||||
invitation = cachedR?.invitation,
|
||||
deviceName = pairing.value?.first?.hostDeviceName ?: rh.hostDeviceName,
|
||||
sessionCode = cachedSessionCode,
|
||||
port = port.value
|
||||
port = cachedR?.ctrlPort,
|
||||
staleQrCode = (cachedR.address != customAddress.value && customAddress.value != null) || cachedR.port != customPort.value,
|
||||
refreshQrCode = {
|
||||
withBGApi {
|
||||
if (chatController.stopRemoteHost(rh.remoteHostId)) {
|
||||
startRemoteHost()
|
||||
}
|
||||
}
|
||||
},
|
||||
UnderQrLayout = { UnderQrLayout(cachedR, customAddress, customPort) }
|
||||
)
|
||||
var remoteHostId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
val r = chatModel.controller.startRemoteHost(rh.remoteHostId, controller.appPrefs.offerRemoteMulticast.get())
|
||||
if (r != null) {
|
||||
val (rh_, inv) = r
|
||||
connecting.value = true
|
||||
remoteHostId = rh_?.remoteHostId
|
||||
invitation.value = inv
|
||||
port.value = r.third
|
||||
chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting
|
||||
}
|
||||
}
|
||||
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
|
||||
if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId) {
|
||||
if (cachedR.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId == cachedR.remoteHostId) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
@@ -334,10 +395,13 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
withBGApi {
|
||||
startRemoteHost()
|
||||
}
|
||||
onDispose {
|
||||
if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != remoteHostId) {
|
||||
if (cachedR.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != cachedR.remoteHostId) {
|
||||
withBGApi {
|
||||
chatController.stopRemoteHost(remoteHostId)
|
||||
chatController.stopRemoteHost(cachedR.remoteHostId)
|
||||
}
|
||||
}
|
||||
chatModel.remoteHostPairing.value = null
|
||||
@@ -370,3 +434,77 @@ private fun showConnectedMobileDevice(rh: RemoteHostInfo, disconnectHost: () ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnderQrLayout(cachedR: CR.RemoteHostStarted?, customAddress: MutableState<RemoteCtrlAddress?>, customPort: MutableState<Int?>) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
||||
if (cachedR.addresses.size > 1) {
|
||||
ExposedDropDownSetting(
|
||||
cachedR.addresses.map { it to it.address + " (${it.`interface`})" },
|
||||
customAddress,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
fontSize = 14.sp,
|
||||
minWidth = 250.dp,
|
||||
maxWidth = with(LocalDensity.current) { 250.sp.toDp() },
|
||||
enabled = remember { mutableStateOf(cachedR.addresses.size > 1) },
|
||||
onSelected = {
|
||||
customAddress.value = it
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(customAddress.value?.address + " (${customAddress.value?.`interface`})", fontSize = 14.sp, color = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue((customPort.value ?: cachedR.port!!).toString()))
|
||||
}
|
||||
Spacer(Modifier.width(DEFAULT_PADDING))
|
||||
Box {
|
||||
DefaultConfigurableTextField(
|
||||
portUnsaved,
|
||||
stringResource(MR.strings.random_port),
|
||||
modifier = Modifier.widthIn(max = 132.dp),
|
||||
isValid = { (validPort(it) && it.toInt() > 1023) || it.isBlank() },
|
||||
keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }),
|
||||
keyboardType = KeyboardType.Number,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
if (validPort(portUnsaved.value.text) && portUnsaved.value.text.toInt() > 1023) {
|
||||
Icon(painterResource(MR.images.ic_edit), stringResource(MR.strings.edit_verb), Modifier.padding(end = 56.dp).size(16.dp).align(Alignment.CenterEnd), tint = MaterialTheme.colors.secondary)
|
||||
IconButton(::showOpenPortAlert, Modifier.align(Alignment.TopEnd).padding(top = 2.dp)) {
|
||||
Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { portUnsaved.value.text }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (validPort(it) && it.toInt() > 1023) {
|
||||
customPort.value = it.toInt()
|
||||
} else {
|
||||
customPort.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyChangeEffect(customPort.value) {
|
||||
if (customPort.value != null) {
|
||||
portUnsaved.value = portUnsaved.value.copy(text = customPort.value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOpenPortAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.open_port_in_firewall_title),
|
||||
text = generalGetString(MR.strings.open_port_in_firewall_desc),
|
||||
)
|
||||
}
|
||||
|
||||
private val CR.RemoteHostStarted?.rh: RemoteHostInfo? get() = this?.remoteHost_
|
||||
private val CR.RemoteHostStarted?.remoteHostId: Long? get() = this?.remoteHost_?.remoteHostId
|
||||
private val CR.RemoteHostStarted?.address: RemoteCtrlAddress? get() = this?.localAddrs?.firstOrNull()
|
||||
private val CR.RemoteHostStarted?.addresses: List<RemoteCtrlAddress> get() =
|
||||
(if (controller.appPrefs.developerTools.get() || this?.localAddrs?.indexOfFirst { it.address == "127.0.0.1" } == 0) this?.localAddrs else this?.localAddrs?.filterNot { it.address == "127.0.0.1" }) ?: emptyList()
|
||||
private val CR.RemoteHostStarted?.port: Int? get() = this?.ctrlPort?.toIntOrNull()
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.item.ClickableText
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -169,18 +170,20 @@ fun NetworkAndServersView(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.network_and_servers))
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
|
||||
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
|
||||
|
||||
if (currentRemoteHost == null) {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
|
||||
if (developerTools) {
|
||||
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
|
||||
if (currentRemoteHost == null) {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
|
||||
if (developerTools) {
|
||||
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
}
|
||||
if (currentRemoteHost == null && networkUseSocksProxy.value) {
|
||||
@@ -192,7 +195,7 @@ fun NetworkAndServersView(
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
|
||||
} else {
|
||||
} else if (!chatModel.desktopNoUserNoRemote) {
|
||||
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
|
||||
}
|
||||
|
||||
@@ -302,7 +305,7 @@ fun SockProxySettings(m: ChatModel) {
|
||||
DefaultConfigurableTextField(
|
||||
hostUnsaved,
|
||||
stringResource(MR.strings.host_verb),
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isValid = ::validHost,
|
||||
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
|
||||
keyboardType = KeyboardType.Text,
|
||||
@@ -312,7 +315,7 @@ fun SockProxySettings(m: ChatModel) {
|
||||
DefaultConfigurableTextField(
|
||||
portUnsaved,
|
||||
stringResource(MR.strings.port_verb),
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isValid = ::validPort,
|
||||
keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done); save() }),
|
||||
keyboardType = KeyboardType.Number,
|
||||
@@ -425,7 +428,7 @@ private fun validHost(s: String): Boolean {
|
||||
}
|
||||
|
||||
// https://ihateregex.io/expr/port/
|
||||
private fun validPort(s: String): Boolean {
|
||||
fun validPort(s: String): Boolean {
|
||||
val validPort = Regex("^(6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4})$")
|
||||
return s.isNotBlank() && s.matches(validPort)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ fun PrivacySettingsView(
|
||||
chatModel.simplexLinkMode.value = it
|
||||
})
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
val currentUser = chatModel.currentUser.value
|
||||
if (currentUser != null) {
|
||||
@@ -142,39 +141,42 @@ fun PrivacySettingsView(
|
||||
}
|
||||
}
|
||||
|
||||
DeliveryReceiptsSection(
|
||||
currentUser = currentUser,
|
||||
setOrAskSendReceiptsContacts = { enable ->
|
||||
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
|
||||
if (chat.chatInfo is ChatInfo.Direct) {
|
||||
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts
|
||||
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SectionDividerSpaced()
|
||||
DeliveryReceiptsSection(
|
||||
currentUser = currentUser,
|
||||
setOrAskSendReceiptsContacts = { enable ->
|
||||
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
|
||||
if (chat.chatInfo is ChatInfo.Direct) {
|
||||
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts
|
||||
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
|
||||
} else {
|
||||
count
|
||||
}
|
||||
}
|
||||
if (contactReceiptsOverrides == 0) {
|
||||
setSendReceiptsContacts(enable, clearOverrides = false)
|
||||
} else {
|
||||
count
|
||||
showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts)
|
||||
}
|
||||
},
|
||||
setOrAskSendReceiptsGroups = { enable ->
|
||||
val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts
|
||||
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
|
||||
} else {
|
||||
count
|
||||
}
|
||||
}
|
||||
if (groupReceiptsOverrides == 0) {
|
||||
setSendReceiptsGroups(enable, clearOverrides = false)
|
||||
} else {
|
||||
showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups)
|
||||
}
|
||||
}
|
||||
if (contactReceiptsOverrides == 0) {
|
||||
setSendReceiptsContacts(enable, clearOverrides = false)
|
||||
} else {
|
||||
showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts)
|
||||
}
|
||||
},
|
||||
setOrAskSendReceiptsGroups = { enable ->
|
||||
val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts
|
||||
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
|
||||
} else {
|
||||
count
|
||||
}
|
||||
}
|
||||
if (groupReceiptsOverrides == 0) {
|
||||
setSendReceiptsGroups(enable, clearOverrides = false)
|
||||
} else {
|
||||
showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
import chat.simplex.common.views.database.DatabaseView
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.SimpleXInfo
|
||||
@@ -38,76 +39,39 @@ import kotlinx.coroutines.launch
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
|
||||
val user = chatModel.currentUser.value
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
|
||||
if (user != null) {
|
||||
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state }
|
||||
SettingsLayout(
|
||||
profile = user.profile,
|
||||
stopped,
|
||||
chatModel.chatDbEncrypted.value == true,
|
||||
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
|
||||
remember { chatModel.controller.appPrefs.notificationsMode.state },
|
||||
user.displayName,
|
||||
setPerformLA = setPerformLA,
|
||||
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
|
||||
showSettingsModalWithSearch = { modalView ->
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
ModalView(
|
||||
{ close() },
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = { modalView(chatModel, search) })
|
||||
SettingsLayout(
|
||||
profile = user?.profile,
|
||||
stopped,
|
||||
chatModel.chatDbEncrypted.value == true,
|
||||
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
|
||||
remember { chatModel.controller.appPrefs.notificationsMode.state },
|
||||
user?.displayName,
|
||||
setPerformLA = setPerformLA,
|
||||
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
|
||||
showSettingsModalWithSearch = { modalView ->
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
ModalView(
|
||||
{ close() },
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = { modalView(chatModel, search) })
|
||||
}
|
||||
},
|
||||
showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showVersion = {
|
||||
withApi {
|
||||
val info = chatModel.controller.apiGetVersion()
|
||||
if (info != null) {
|
||||
ModalManager.start.showModal { VersionInfoView(info) }
|
||||
}
|
||||
},
|
||||
showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showVersion = {
|
||||
withApi {
|
||||
val info = chatModel.controller.apiGetVersion()
|
||||
if (info != null) {
|
||||
ModalManager.start.showModal { VersionInfoView(info) }
|
||||
}
|
||||
}
|
||||
},
|
||||
withAuth = { title, desc, block ->
|
||||
if (!requireAuth.value) {
|
||||
block()
|
||||
} else {
|
||||
var autoShow = true
|
||||
ModalManager.fullscreen.showModalCloseable { close ->
|
||||
val onFinishAuth = { success: Boolean ->
|
||||
if (success) {
|
||||
close()
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoShow) {
|
||||
autoShow = false
|
||||
runAuth(title, desc, onFinishAuth)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(MR.strings.auth_unlock),
|
||||
icon = painterResource(MR.images.ic_lock),
|
||||
click = {
|
||||
runAuth(title, desc, onFinishAuth)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = drawerState,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
withAuth = ::doWithAuth,
|
||||
drawerState = drawerState,
|
||||
)
|
||||
}
|
||||
|
||||
val simplexTeamUri =
|
||||
@@ -115,12 +79,12 @@ val simplexTeamUri =
|
||||
|
||||
@Composable
|
||||
fun SettingsLayout(
|
||||
profile: LocalProfile,
|
||||
profile: LocalProfile?,
|
||||
stopped: Boolean,
|
||||
encrypted: Boolean,
|
||||
passphraseSaved: Boolean,
|
||||
notificationsMode: State<NotificationsMode>,
|
||||
userDisplayName: String,
|
||||
userDisplayName: String?,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
@@ -150,13 +114,22 @@ fun SettingsLayout(
|
||||
AppBarTitle(stringResource(MR.strings.your_settings))
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_you)) {
|
||||
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
|
||||
ChatPreferencesItem(showCustomModal, stopped = stopped)
|
||||
if (profile != null) {
|
||||
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
|
||||
ChatPreferencesItem(showCustomModal, stopped = stopped)
|
||||
} else if (chatModel.localUserCreated.value == false) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.center.showModalCloseable { close ->
|
||||
LaunchedEffect(Unit) {
|
||||
closeSettings()
|
||||
}
|
||||
CreateProfile(chatModel, close)
|
||||
} } }, disabled = stopped, extraPadding = true)
|
||||
}
|
||||
if (appPlatform.isDesktop) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true)
|
||||
} else {
|
||||
@@ -176,10 +149,12 @@ fun SettingsLayout(
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_help)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true)
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
@@ -469,6 +444,42 @@ fun PreferenceToggleWithIcon(
|
||||
}
|
||||
}
|
||||
|
||||
fun doWithAuth(title: String, desc: String, block: () -> Unit) {
|
||||
val requireAuth = chatModel.controller.appPrefs.performLA.get()
|
||||
if (!requireAuth) {
|
||||
block()
|
||||
} else {
|
||||
var autoShow = true
|
||||
ModalManager.fullscreen.showModalCloseable { close ->
|
||||
val onFinishAuth = { success: Boolean ->
|
||||
if (success) {
|
||||
close()
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoShow) {
|
||||
autoShow = false
|
||||
runAuth(title, desc, onFinishAuth)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(MR.strings.auth_unlock),
|
||||
icon = painterResource(MR.images.ic_lock),
|
||||
click = {
|
||||
runAuth(title, desc, onFinishAuth)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) {
|
||||
authenticate(
|
||||
title,
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
@@ -56,7 +57,9 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
withBGApi {
|
||||
m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
|
||||
controller.showProgressIfNeeded {
|
||||
m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
|
||||
}
|
||||
}
|
||||
},
|
||||
removeUser = { user ->
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<string name="opening_database">Opening database…</string>
|
||||
<string name="non_content_uri_alert_title">Invalid file path</string>
|
||||
<string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string>
|
||||
<string name="app_was_crashed">View crashed</string>
|
||||
|
||||
<!-- Server info - ChatModel.kt -->
|
||||
<string name="server_connected">connected</string>
|
||||
@@ -565,6 +566,7 @@
|
||||
<string name="your_settings">Your settings</string>
|
||||
<string name="your_simplex_contact_address">Your SimpleX address</string>
|
||||
<string name="your_chat_profiles">Your chat profiles</string>
|
||||
<string name="create_chat_profile">Create chat profile</string>
|
||||
<string name="database_passphrase_and_export">Database passphrase & export</string>
|
||||
<string name="about_simplex_chat">About SimpleX Chat</string>
|
||||
<string name="how_to_use_simplex_chat">How to use it</string>
|
||||
@@ -1659,11 +1661,12 @@
|
||||
<string name="unlink_desktop_question">Unlink desktop?</string>
|
||||
<string name="unlink_desktop">Unlink</string>
|
||||
<string name="disconnect_remote_host">Disconnect</string>
|
||||
<string name="disconnect_remote_hosts">Disconnect mobiles</string>
|
||||
<string name="remote_host_was_disconnected_toast"><![CDATA[Mobile <b>%s</b> was disconnected]]></string>
|
||||
<string name="disconnect_desktop_question">Disconnect desktop?</string>
|
||||
<string name="only_one_device_can_work_at_the_same_time">Only one device can work at the same time</string>
|
||||
<string name="open_on_mobile_and_scan_qr_code"><![CDATA[Open <i>Use from desktop</i> in mobile app and scan QR code.]]></string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[Waiting for mobile to connect on port <i>%s</i>]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">Waiting for mobile to connect:</string>
|
||||
<string name="bad_desktop_address">Bad desktop address</string>
|
||||
<string name="desktop_incompatible_version">Incompatible version</string>
|
||||
<string name="desktop_app_version_is_incompatible">Desktop app version %s is not compatible with this app.</string>
|
||||
@@ -1689,6 +1692,11 @@
|
||||
<string name="paste_desktop_address">Paste desktop address</string>
|
||||
<string name="desktop_device">Desktop</string>
|
||||
<string name="not_compatible">Not compatible!</string>
|
||||
<string name="refresh_qr_code">Refresh</string>
|
||||
<string name="no_connected_mobile">No connected mobile</string>
|
||||
<string name="random_port">Random</string>
|
||||
<string name="open_port_in_firewall_title">Open port in firewall</string>
|
||||
<string name="open_port_in_firewall_desc">To allow a mobile app to connect to the desktop, open this port in your firewall, if you have it enabled</string>
|
||||
|
||||
<!-- Under development -->
|
||||
<string name="in_developing_title">Coming soon!</string>
|
||||
|
||||
@@ -1581,7 +1581,7 @@
|
||||
<string name="v5_4_better_groups_descr">Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung.</string>
|
||||
<string name="linked_mobiles">Verknüpfte Mobiltelefone</string>
|
||||
<string name="this_device_name">Dieser Gerätename</string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[Auf die Mobiltelefonverbindung über Port <i>%s</i> warten]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">Auf die Mobiltelefonverbindung warten:</string>
|
||||
<string name="loading_remote_file_title">Laden der Datei</string>
|
||||
<string name="link_a_mobile">Zu einem Mobiltelefon verbinden</string>
|
||||
<string name="settings_section_title_use_from_desktop">Vom Desktop aus nutzen</string>
|
||||
|
||||
@@ -1486,7 +1486,7 @@
|
||||
<string name="desktop_device">Bureau</string>
|
||||
<string name="connected_to_desktop">Connecté au bureau</string>
|
||||
<string name="this_device_name">Ce nom d\'appareil</string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[En attente d\'une connexion mobile sur le port <i>%s</i>]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">En attente d\'une connexion mobile:</string>
|
||||
<string name="loading_remote_file_title">Chargement du fichier</string>
|
||||
<string name="connecting_to_desktop">Connexion au bureau</string>
|
||||
<string name="desktop_devices">Appareils de bureau</string>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480.433-164.5q-131.583 0-223.758-92.216Q164.5-348.932 164.5-480.082q0-131.149 92.175-223.284Q348.85-795.5 480.5-795.5q84 0 147.75 34.25T738.5-666.5v-129H796V-547H547.5v-57.5H713q-38.032-60.033-96.537-96.767Q557.959-738 480.539-738 372-738 297-663.015q-75 74.986-75 183.25 0 108.265 74.875 183.015Q371.75-222 480.331-222q82.298 0 150.734-47 68.435-47 95.623-124.5H786q-28.5 103-113.49 166-84.991 63-192.077 63Z"/></svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -1517,6 +1517,6 @@
|
||||
<string name="v5_4_more_things_descr">- avvisa facoltativamente i contatti eliminati.
|
||||
\n- nomi del profilo con spazi.
|
||||
\n- e molto altro!</string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[In attesa che il cellulare si connetta alla porta <i>%s</i>]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">In attesa che il cellulare si connette:</string>
|
||||
<string name="group_member_role_author">autore</string>
|
||||
</resources>
|
||||
@@ -1516,7 +1516,7 @@
|
||||
\n- en meer!</string>
|
||||
<string name="remote_host_was_disconnected_toast"><![CDATA[Mobiele verbinding <b>%s</b> is verbroken]]></string>
|
||||
<string name="group_member_role_author">auteur</string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[Wachten tot mobiel verbinding maakt op poort <i>%s</i>]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">Wachten tot mobiel verbinding maakt:</string>
|
||||
<string name="multicast_connect_automatically">Automatisch verbinden</string>
|
||||
<string name="waiting_for_desktop">Wachten op desktop…</string>
|
||||
<string name="found_desktop">Desktop gevonden</string>
|
||||
|
||||
@@ -1496,7 +1496,7 @@
|
||||
<string name="v5_4_better_groups_descr">Szybsze dołączenie i bardziej niezawodne wiadomości.</string>
|
||||
<string name="linked_mobiles">Połączone telefony</string>
|
||||
<string name="this_device_name">Nazwa tego urządzenia</string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[Oczekiwanie na połączenie telefonu na port <i>%s</i>]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">Oczekiwanie na połączenie telefonu:</string>
|
||||
<string name="loading_remote_file_title">Ładowanie pliku</string>
|
||||
<string name="found_desktop">Znaleziono komputer</string>
|
||||
<string name="desktop_devices">Urządzenia komputerowe</string>
|
||||
|
||||
@@ -1601,7 +1601,7 @@
|
||||
<string name="verify_connection">Проверить соединение</string>
|
||||
<string name="multicast_connect_automatically">Соединяться автоматически</string>
|
||||
<string name="waiting_for_desktop">Ожидается подключение…</string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[Ожидается подключение мобильного через порт <i>%s</i>]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">Ожидается подключение мобильного:</string>
|
||||
<string name="found_desktop">Компьютер найден</string>
|
||||
<string name="not_compatible">Несовместимая версия!</string>
|
||||
<string name="group_member_role_author">автор</string>
|
||||
|
||||
@@ -1517,7 +1517,7 @@
|
||||
<string name="v5_4_more_things_descr">- 可选择通知已删除的联系人。
|
||||
\n- 带空格的个人资料名称。
|
||||
\n- 以及更多!</string>
|
||||
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[正等待移动设备在端口 <i>%s</i> 进行连接]]></string>
|
||||
<string name="waiting_for_mobile_to_connect">正等待移动设备 进行连接:</string>
|
||||
<string name="group_member_role_author">作者</string>
|
||||
<string name="multicast_connect_automatically">自动连接</string>
|
||||
<string name="waiting_for_desktop">等待桌面中…</string>
|
||||
|
||||
@@ -9,77 +9,142 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.awt.ComposeWindow
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.desktopPlatform
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
|
||||
import chat.simplex.common.ui.theme.SimpleXTheme
|
||||
import chat.simplex.common.views.TerminalView
|
||||
import chat.simplex.common.views.helpers.FileDialogChooser
|
||||
import chat.simplex.common.views.helpers.escapedHtmlToAnnotatedString
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.event.WindowFocusListener
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
val simplexWindowState = SimplexWindowState()
|
||||
|
||||
fun showApp() = application {
|
||||
// For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366,
|
||||
// it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here
|
||||
val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp
|
||||
val windowState = rememberWindowState(placement = WindowPlacement.Floating, width = width, height = 768.dp)
|
||||
fun showApp() {
|
||||
val closedByError = mutableStateOf(true)
|
||||
while (closedByError.value) {
|
||||
application(exitProcessOnExit = false) {
|
||||
CompositionLocalProvider(
|
||||
LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window ->
|
||||
WindowExceptionHandler { e ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.app_was_crashed),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString())
|
||||
window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
|
||||
closedByError.value = true
|
||||
// If the left side of screen has open modal, it's probably caused the crash
|
||||
if (ModalManager.start.hasModalsOpen()) {
|
||||
ModalManager.start.closeModal()
|
||||
} else if (ModalManager.center.hasModalsOpen() || ModalManager.end.hasModalsOpen()) {
|
||||
ModalManager.center.closeModal()
|
||||
ModalManager.end.closeModal()
|
||||
// Better to not close fullscreen since it can contain passcode
|
||||
} else {
|
||||
// The last possible cause that can be closed
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clear()
|
||||
}
|
||||
chatModel.activeCall.value?.let {
|
||||
withBGApi {
|
||||
chatModel.callManager.endCall(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
AppWindow(closedByError)
|
||||
}
|
||||
}
|
||||
}
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
|
||||
// Creates file if not exists; comes with proper defaults
|
||||
val state = getStoredWindowState()
|
||||
val windowState: WindowState = rememberWindowState(
|
||||
placement = WindowPlacement.Floating,
|
||||
width = state.width.dp,
|
||||
height = state.height.dp,
|
||||
position = WindowPosition(state.x.dp, state.y.dp)
|
||||
)
|
||||
|
||||
LaunchedEffect(
|
||||
windowState.position.x.value,
|
||||
windowState.position.y.value,
|
||||
windowState.size.width.value,
|
||||
windowState.size.height.value
|
||||
) {
|
||||
storeWindowState(
|
||||
WindowPositionSize(
|
||||
x = windowState.position.x.value.toInt(),
|
||||
y = windowState.position.y.value.toInt(),
|
||||
width = windowState.size.width.value.toInt(),
|
||||
height = windowState.size.height.value.toInt()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
simplexWindowState.windowState = windowState
|
||||
// Reload all strings in all @Composable's after language change at runtime
|
||||
if (remember { ChatController.appPrefs.appLanguage.state }.value != "") {
|
||||
Window(state = windowState, onCloseRequest = ::exitApplication, onKeyEvent = {
|
||||
Window(state = windowState, onCloseRequest = { closedByError.value = false; exitApplication() }, onKeyEvent = {
|
||||
if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) {
|
||||
simplexWindowState.backstack.lastOrNull()?.invoke() != null
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}, title = "SimpleX") {
|
||||
SimpleXTheme {
|
||||
AppScreen()
|
||||
if (simplexWindowState.openDialog.isAwaiting) {
|
||||
FileDialogChooser(
|
||||
title = "SimpleX",
|
||||
isLoad = true,
|
||||
params = simplexWindowState.openDialog.params,
|
||||
onResult = {
|
||||
simplexWindowState.openDialog.onResult(it.firstOrNull())
|
||||
}
|
||||
)
|
||||
}
|
||||
simplexWindowState.window = window
|
||||
AppScreen()
|
||||
if (simplexWindowState.openDialog.isAwaiting) {
|
||||
FileDialogChooser(
|
||||
title = "SimpleX",
|
||||
isLoad = true,
|
||||
params = simplexWindowState.openDialog.params,
|
||||
onResult = {
|
||||
simplexWindowState.openDialog.onResult(it.firstOrNull())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (simplexWindowState.openMultipleDialog.isAwaiting) {
|
||||
FileDialogChooser(
|
||||
title = "SimpleX",
|
||||
isLoad = true,
|
||||
params = simplexWindowState.openMultipleDialog.params,
|
||||
onResult = {
|
||||
simplexWindowState.openMultipleDialog.onResult(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (simplexWindowState.openMultipleDialog.isAwaiting) {
|
||||
FileDialogChooser(
|
||||
title = "SimpleX",
|
||||
isLoad = true,
|
||||
params = simplexWindowState.openMultipleDialog.params,
|
||||
onResult = {
|
||||
simplexWindowState.openMultipleDialog.onResult(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (simplexWindowState.saveDialog.isAwaiting) {
|
||||
FileDialogChooser(
|
||||
title = "SimpleX",
|
||||
isLoad = false,
|
||||
params = simplexWindowState.saveDialog.params,
|
||||
onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) }
|
||||
)
|
||||
}
|
||||
val toasts = remember { simplexWindowState.toasts }
|
||||
val toast = toasts.firstOrNull()
|
||||
if (toast != null) {
|
||||
if (simplexWindowState.saveDialog.isAwaiting) {
|
||||
FileDialogChooser(
|
||||
title = "SimpleX",
|
||||
isLoad = false,
|
||||
params = simplexWindowState.saveDialog.params,
|
||||
onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) }
|
||||
)
|
||||
}
|
||||
val toasts = remember { simplexWindowState.toasts }
|
||||
val toast = toasts.firstOrNull()
|
||||
if (toast != null) {
|
||||
SimpleXTheme {
|
||||
Box(Modifier.fillMaxSize().padding(bottom = 20.dp), contentAlignment = Alignment.BottomCenter) {
|
||||
Text(
|
||||
escapedHtmlToAnnotatedString(toast.first, LocalDensity.current),
|
||||
@@ -88,11 +153,11 @@ fun showApp() = application {
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
}
|
||||
// Shows toast in insertion order with preferred delay per toast. New one will be shown once previous one expires
|
||||
LaunchedEffect(toast, toasts.size) {
|
||||
delay(toast.second)
|
||||
simplexWindowState.toasts.removeFirst()
|
||||
}
|
||||
}
|
||||
// Shows toast in insertion order with preferred delay per toast. New one will be shown once previous one expires
|
||||
LaunchedEffect(toast, toasts.size) {
|
||||
delay(toast.second)
|
||||
simplexWindowState.toasts.removeFirst()
|
||||
}
|
||||
}
|
||||
var windowFocused by remember { simplexWindowState.windowFocused }
|
||||
@@ -124,7 +189,7 @@ fun showApp() = application {
|
||||
var hiddenUntilRestart by remember { mutableStateOf(false) }
|
||||
if (!hiddenUntilRestart) {
|
||||
val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp)
|
||||
Window(state = cWindowState, onCloseRequest = ::exitApplication, title = stringResource(MR.strings.chat_console)) {
|
||||
Window(state = cWindowState, onCloseRequest = { hiddenUntilRestart = true }, title = stringResource(MR.strings.chat_console)) {
|
||||
SimpleXTheme {
|
||||
TerminalView(ChatModel) { hiddenUntilRestart = true }
|
||||
}
|
||||
@@ -141,6 +206,7 @@ class SimplexWindowState {
|
||||
val saveDialog = DialogState<File?>()
|
||||
val toasts = mutableStateListOf<Pair<String, Long>>()
|
||||
var windowFocused = mutableStateOf(true)
|
||||
var window: ComposeWindow? = null
|
||||
}
|
||||
|
||||
data class DialogParams(
|
||||
@@ -169,7 +235,5 @@ class DialogState<T> {
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppPreview() {
|
||||
SimpleXTheme {
|
||||
AppScreen()
|
||||
}
|
||||
AppScreen()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package chat.simplex.common
|
||||
|
||||
import chat.simplex.common.model.json
|
||||
import chat.simplex.common.platform.appPreferences
|
||||
import chat.simplex.common.platform.desktopPlatform
|
||||
import kotlinx.serialization.*
|
||||
|
||||
@Serializable
|
||||
data class WindowPositionSize(
|
||||
val width: Int = 1366,
|
||||
val height: Int = 768,
|
||||
val x: Int = 0,
|
||||
val y: Int = 0,
|
||||
)
|
||||
|
||||
fun getStoredWindowState(): WindowPositionSize =
|
||||
try {
|
||||
val str = appPreferences.desktopWindowState.get()
|
||||
var state = if (str == null) {
|
||||
WindowPositionSize()
|
||||
} else {
|
||||
json.decodeFromString(str)
|
||||
}
|
||||
|
||||
// For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366,
|
||||
// it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here
|
||||
if (desktopPlatform.isLinux() && state.width == 1366) {
|
||||
state = state.copy(width = 1376)
|
||||
}
|
||||
state
|
||||
} catch (e: Throwable) {
|
||||
WindowPositionSize()
|
||||
}
|
||||
|
||||
fun storeWindowState(state: WindowPositionSize) =
|
||||
appPreferences.desktopWindowState.set(json.encodeToString(state))
|
||||
@@ -19,3 +19,9 @@ actual fun getKeyboardState(): State<KeyboardState> = remember { mutableStateOf(
|
||||
actual fun hideKeyboard(view: Any?) {}
|
||||
|
||||
actual fun androidIsFinishingMainActivity(): Boolean = false
|
||||
|
||||
actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
|
||||
actual override fun uncaughtException(thread: Thread, e: Throwable) {
|
||||
Log.e(TAG, "App crashed, thread name: " + thread.name + ", exception: " + e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,6 @@ private fun SendStateUpdates() {
|
||||
.collect { call ->
|
||||
val state = call.callState.text
|
||||
val connInfo = call.connectionInfo
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||
val description = call.encryptionStatus + connInfoText
|
||||
chatModel.callCommand.add(WCallCommand.Description(state, description))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package chat.simplex.common.views.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.SharedPreference
|
||||
import chat.simplex.common.model.User
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
||||
@Composable
|
||||
actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)?) {
|
||||
if (user == null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING * 2.5f)) {
|
||||
OnboardingActionButton(MR.strings.link_a_mobile, onboarding = if (controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) OnboardingStage.Step2_5_SetupDatabasePassphrase else OnboardingStage.LinkAMobile, true, icon = painterResource(MR.images.ic_smartphone_300), onclick = onclick)
|
||||
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, icon = painterResource(MR.images.ic_desktop), onclick = onclick)
|
||||
}
|
||||
} else {
|
||||
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,6 @@ package chat.simplex.desktop
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.showApp
|
||||
import java.io.File
|
||||
import java.nio.file.*
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.nio.file.attribute.FileTime
|
||||
import kotlin.io.path.setLastModifiedTime
|
||||
|
||||
fun main() {
|
||||
initHaskell()
|
||||
|
||||
@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
|
||||
android.enableJetifier=true
|
||||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||
|
||||
android.version_name=5.4
|
||||
android.version_code=162
|
||||
android.version_name=5.4.1
|
||||
android.version_code=164
|
||||
|
||||
desktop.version_name=5.4
|
||||
desktop.version_code=18
|
||||
desktop.version_name=5.4.1
|
||||
desktop.version_code=19
|
||||
|
||||
kotlin.version=1.8.20
|
||||
gradle.plugin.version=7.4.2
|
||||
|
||||
@@ -8,7 +8,7 @@ module Main where
|
||||
|
||||
import Control.Concurrent.Async
|
||||
import Control.Concurrent.STM
|
||||
import Control.Monad.Reader
|
||||
import Control.Monad
|
||||
import qualified Data.Text as T
|
||||
import Simplex.Chat.Bot
|
||||
import Simplex.Chat.Controller
|
||||
|
||||
@@ -9,7 +9,7 @@ module Broadcast.Bot where
|
||||
import Control.Concurrent (forkIO)
|
||||
import Control.Concurrent.Async
|
||||
import Control.Concurrent.STM
|
||||
import Control.Monad.Reader
|
||||
import Control.Monad
|
||||
import qualified Data.Text as T
|
||||
import Broadcast.Options
|
||||
import Simplex.Chat.Bot
|
||||
|
||||
@@ -83,5 +83,6 @@ mkChatOpts BroadcastBotOpts {coreOptions} =
|
||||
allowInstantFiles = True,
|
||||
autoAcceptFileSize = 0,
|
||||
muteNotifications = True,
|
||||
markRead = False,
|
||||
maintenance = False
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
module Server where
|
||||
|
||||
import Control.Monad
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.Reader
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
module Directory.Options
|
||||
( DirectoryOpts (..),
|
||||
getDirectoryOpts,
|
||||
mkChatOpts,
|
||||
)
|
||||
( DirectoryOpts (..),
|
||||
getDirectoryOpts,
|
||||
mkChatOpts,
|
||||
)
|
||||
where
|
||||
|
||||
import Options.Applicative
|
||||
@@ -35,8 +35,8 @@ directoryOpts appDir defaultDbFileName = do
|
||||
<> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory"
|
||||
)
|
||||
directoryLog <-
|
||||
Just <$>
|
||||
strOption
|
||||
Just
|
||||
<$> strOption
|
||||
( long "directory-file"
|
||||
<> metavar "DIRECTORY_FILE"
|
||||
<> help "Append only log for directory state"
|
||||
@@ -81,5 +81,6 @@ mkChatOpts DirectoryOpts {coreOptions} =
|
||||
allowInstantFiles = True,
|
||||
autoAcceptFileSize = 0,
|
||||
muteNotifications = True,
|
||||
markRead = False,
|
||||
maintenance = False
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ where
|
||||
import Control.Concurrent (forkIO)
|
||||
import Control.Concurrent.Async
|
||||
import Control.Concurrent.STM
|
||||
import Control.Monad.Reader
|
||||
import Control.Monad
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.List (sortOn)
|
||||
import Data.Maybe (fromMaybe, maybeToList)
|
||||
|
||||
@@ -2,48 +2,128 @@
|
||||
layout: layouts/article.html
|
||||
title: "SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups."
|
||||
date: 2023-11-25
|
||||
preview: SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups.
|
||||
# image: images/20231125-remote-desktop.jpg
|
||||
draft: true
|
||||
imageWide: true
|
||||
permalink: "/blog/20231125-simplex-chat-v5-4-quantum-resistant-mobile-from-desktop-better-groups.html"
|
||||
previewBody: blog_previews/20231125.html
|
||||
image: images/20231125-mobile2.png
|
||||
permalink: "/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"
|
||||
---
|
||||
|
||||
TODO stub for release announcement
|
||||
|
||||
# SimpleX Chat v5.4 - link mobile and desktop apps via quantum resistant protocol, and much better groups.
|
||||
|
||||
**Published:** Nov 25, 2023
|
||||
|
||||
- [Quick start: control SimpleX Chat mobile app from CLI](#⚡️-quick-start-use-profiles-in-SimpleX-Chat-mobile-from-desktop-app)
|
||||
- [What's the problem](#whats-the-problem)
|
||||
- [Why didn't we use some existing solution?](#why-didnt-we-use-some-existing-solution)
|
||||
**What's new in v5.4:**
|
||||
- [Link mobile and desktop apps via secure quantum-resistant protocol](#link-mobile-and-desktop-apps-via-secure-quantum-resistant-protocol).
|
||||
- ⚡️ Quick start - how to use it.
|
||||
- How does it work?
|
||||
- 🤖 Connecting to remote CLI.
|
||||
- [Better groups](#better-groups).
|
||||
- [Faster to join and more reliable](#faster-to-join-with-more-reliable-message-delivery).
|
||||
- [New group features](#new-group-features):
|
||||
- create groups with incognito profile,
|
||||
- block group members to reduce noise,
|
||||
- prohibit files and media in a group.
|
||||
- [Better calls](#better-calls): faster to connect, with screen sharing on desktop.
|
||||
|
||||
## ⚡️ Quick start: use profiles in SimpleX Chat mobile from desktop app
|
||||
There are many [other improvements](#other-improvements) and fixes in this release:
|
||||
- profile names now allow spaces.
|
||||
- when you delete contacts, they are optionally notified.
|
||||
- previously used and your own SimpleX links are recognized by the app.
|
||||
- and more - see the [release notes](https://github.com/simplex-chat/simplex-chat/releases/tag/v5.4.0).
|
||||
|
||||
## What's the problem?
|
||||
## Link mobile and desktop apps via secure quantum-resistant protocol
|
||||
|
||||
Currently you cannot use the same SimpleX Chat profile on mobile and desktop devices. Even though you can use small groups instead of direct conversations as a workaround, it is quite inconvenient – read status and delivery receipts become much less useful.
|
||||
This release allows to use chat profiles you have in mobile app from desktop app.
|
||||
|
||||
So, we need a way to use the same profile on desktop as we use on mobile.
|
||||
This is only possible when both devices are connected to the same local network. To send and receive messages mobile app has to be connected to the Internet.
|
||||
|
||||
If SimpleX Chat profile was stored on the server, the problem would have been simpler - you can just connect to it from another device. But even in this case, accessing the conversation history without compromising the security of double ratchet end-to-end encryption is not really possible.
|
||||
### ⚡️ Quick start - how to use it
|
||||
|
||||
So we decided to implement the solution that is similar to what WhatsApp and WeChat did in early days - allowing a desktop device access profile on mobile via network. Unlike these big apps, we don't use the server to connect to mobile, but instead use the connection over the local network.
|
||||
**On desktop**
|
||||
|
||||
The downside of this approach is that mobile device has to be with you and connected to the same local network (and in case of iOS, the app has to be in the foreground as well). But the upside is that we the connection can be secure, and that you do not have to have a copy of your profiles on the desktop, which usually has lower security.
|
||||
If you don't have desktop app installed yet, [download it](https://simplex.chat/downloads/) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer.
|
||||
|
||||
## Why didn't we use some existing solution?
|
||||
Then in desktop app settings choose *Link a mobile* - it will show a QR code.
|
||||
|
||||
While there are several existing protocols for remote access, all of them are vulnerable to spoofing and man
|
||||
<img src="./images/20231125-desktop1.png" width="170"> <img src="./images/arrow.png" width="24"> <img src="./images/20231125-desktop2.png" width="170"> <img src="./images/arrow.png" width="24"> <img src="./images/20231125-desktop3.png" width="170"> <img src="./images/arrow.png" width="24"> <img src="./images/20231125-desktop4.png" width="510">
|
||||
|
||||
in many cases support of sending files and images is not very good, and sending videos and large files is simply impossible. There are currently these problems:
|
||||
**On mobile**
|
||||
|
||||
- the sender has to be online for file transfer to complete, once it was confirmed by the recipient.
|
||||
- when the file is sent to the group, the sender will have to transfer it separately to each member, creating a lot of traffic.
|
||||
- the file transfer is slow, as it is sent in small chunks - approximately 16kb per message.
|
||||
In mobile app settings choose *Use from desktop*, scan the QR code and verify session code when it appears on both devices - it should be the same. Verifying session code confirms that the devices are connected directly via a secure encrypted connection. There is an option to verify this code on subsequent connections too, but by default it is only required once.
|
||||
|
||||
As a result, we limited the supported size of files in the app to 8mb. Even for supported files, it is quite inefficient for sending any files to large groups.
|
||||
<img src="./images/20231125-mobile1.png" width="170"> <img src="./images/arrow.png" width="24"> <img src="./images/20231125-mobile1a.png" width="170"> <img src="./images/arrow.png" width="24"> <img src="./images/20231125-mobile2.png" width="170"> <img src="./images/arrow.png" width="24"> <img src="./images/20231125-mobile3.png" width="170"> <img src="./images/arrow.png" width="24"> <img src="./images/20231125-mobile4.png" width="170">
|
||||
|
||||
The devices are now paired, and you can continue using all mobile profiles from desktop.
|
||||
|
||||
If it is an Android app, you can move the app to background, but iOS app has to remain open. In both cases, while you are using mobile profiles from desktop, you won't be able to use mobile app.
|
||||
|
||||
The subsequent connections happen much faster - by default, the desktop app broadcasts its session address to the network, in encrypted form, and mobile app connects to it once you choose *Use from desktop* in mobile app settings.
|
||||
|
||||
### How does it work?
|
||||
|
||||
The way we designed this solution avoided any security compromises, and the end-to-end encryption remained as secure as it was - it uses [double-ratchet algorithm](../docs/GLOSSARY.md#double-ratchet-algorithm), with [perfect forward secrecy](../docs/GLOSSARY.md#forward-secrecy), [post-compromise security](../docs/GLOSSARY.md#post-compromise-security) and deniability.
|
||||
|
||||
This solution is similar to WhatsApp and WeChat. But unlike these apps, no server is involved in the connection between mobile and desktop. The connection itself uses a new SimpleX Remote Control Protocol (XRCP) based on secure TLS 1.3 and additional quantum-resistant encryption inside TLS. You can read XRCP protocol specification and threat model in [this document](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2023-10-25-remote-control.md). We will soon be [augmenting double ratchet](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2023-09-30-pq-double-ratchet.md) to be resistant to quantum computers as well.
|
||||
|
||||
The downside of this approach is that mobile device has to be connected to the same local network as desktop. But the upside is that the connection is secure, and you do not need to have a copy of all your data on desktop, which usually has lower security than mobile.
|
||||
|
||||
Please note, that the files you send, save or play from desktop app, and also images you view are automatically saved on your desktop device (encrypted by default except videos). To remove all these files you can unlink the paired mobile device from the desktop app settings – there will be an option soon allowing to remove the files without unlinking the mobile.
|
||||
|
||||
### 🤖 Connecting to remote SimpleX CLI
|
||||
|
||||
*Warning*: this section is for technically advanced users!
|
||||
|
||||
If you run SimpleX CLI on a computer in another network - e.g., in the cloud VM or on a Raspberry Pi at home while you are at work, you can also use if from desktop via SSH tunnel. Below assumes that you have remote machine connected via SSH and CLI running there - you can use `tmux` for it to keep running when you are not connected via ssh.
|
||||
|
||||
Follow these steps to use remote CLI from desktop app:
|
||||
1. On the remote machine add the IP address of your desktop to the firewall rules, so that when CLI tries to connect to this address, it connects to `localhost` instead: `iptables -t nat -A OUTPUT -p all -d 192.168.1.100 -j DNAT --to-destination 127.0.0.1` (replace `192.168.1.100` with the actual address of your desktop, and make sure it is not needed for something else on your remote machine).
|
||||
2. Also on the remote machine, run Simplex CLI with the option `--device-name 'SimpleX CLI'`, or any other name you like. You can also use the command `/set device name <name>` to set it for the CLI.
|
||||
3. Choose *Link a mobile* in desktop app settings, note the port it shows under the QR code, and click "Share link".
|
||||
4. Run ssh port forwarding on desktop computer to let your remote machine connect to desktop app: `ssh -R 12345:127.0.0.1:12345 -N user@example.com` where `12345` is the port on which desktop app is listening for the connections from step 3, `example.com` is the hostname or IP address of your remote machine, and `user` is some username on remote machine. You can run port forwarding in the background by adding `-f` option.
|
||||
5. On the remote machine, run CLI command `/connect remote ctrl <link>`, where `<link>` is the desktop session address copied in step 3. You should run this command within 1 minute from choosing *Link a mobile*.
|
||||
6. If the connection is successful, the CLI will ask you to verify the session code (you need to copy and paste the command) with the one shown in desktop app. Once you use `/verify remote ctrl <code>` command, CLI can be used from desktop app.
|
||||
7. To stop remote session use `/stop remote ctrl` command.
|
||||
|
||||
## Better groups
|
||||
|
||||
### Faster to join, with more reliable message delivery
|
||||
|
||||
We improved the protocols for groups, by making joining groups much faster, and also by adding message forwarding. Previously, the problem was that until a new member connects directly with each existing group member, they did not see each other messages in the group. The problem is explained in detail in [this video](https://www.youtube.com/watch?v=7yjQFmhAftE&t=1104s) at 18:23.
|
||||
|
||||
With v5.4, the admin who added members to the group forwards messages to and from the new members until they connect to the existing members. So you should no longer miss any messages and be surprised with replies to messages you have never seen once you and new group members upgrade.
|
||||
|
||||
### New group features
|
||||
|
||||
<img src="./images/20231125-group1.png" width="220" class="float-to-left"> <img src="./images/20231125-block.png" width="220" class="float-to-left">
|
||||
|
||||
**Create groups with incognito profile**
|
||||
|
||||
Previously, you could only create groups with your main profile. This version allows creating groups with incognito profile directly. You will not be able to add your contacts, they can only join via group link.
|
||||
|
||||
**Block group members to reduce noise**
|
||||
|
||||
You now can block messages from group members that send too many messages, or the messages you don't won't to see. Blocked members won't know that you blocked their messages. When they send messages they will appear in the conversation as one line, showing how many messages were blocked. You can reveal them, or delete all sequential blocked messages at once.
|
||||
|
||||
**Prohibit files and media in a group**
|
||||
|
||||
Group owners now have an option to prohibit sending files and media. This can be useful if you don't won't any images shared, and only want to allow text messages.
|
||||
|
||||
## Better calls
|
||||
|
||||
Calls in SimpleX Chat still require a lot of work to become stable, but this version improved the speed of connecting calls, and they should work for more users.
|
||||
|
||||
We also added screen sharing in video calls to desktop app.
|
||||
|
||||
## Other improvements
|
||||
|
||||
This version also has many small and large improvements to make the app more usable and reliable.
|
||||
|
||||
The new users and group profiles now allow spaces in the names, to make them more readable. To message these contacts in CLI you need to use quotes, for example, `@'John Doe' Hello!`.
|
||||
|
||||
When you delete contacts, you can notify them - to let them know they can't message you.
|
||||
|
||||
When you try to connect to the same contact or join the same group, or connect via your own link, the app will recognize it and warn you, or simply open the correct conversation.
|
||||
|
||||
You can find the full list of fixed bugs and small improvements in the [release notes](https://github.com/simplex-chat/simplex-chat/releases/tag/v5.4.0).
|
||||
|
||||
## SimpleX platform
|
||||
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Blog
|
||||
|
||||
Nov 25, 2023 [SimpleX Chat v5.4 released](./20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md)
|
||||
|
||||
- Link mobile and desktop apps via secure quantum-resistant protocol. 🔗
|
||||
- Better groups:
|
||||
- faster to join and more reliable.
|
||||
- create groups with incognito profile.
|
||||
- block group members to reduce noise.
|
||||
- prohibit files and media in a group.
|
||||
- Better calls: faster to connect, with screen sharing on desktop.
|
||||
- Many other improvements.
|
||||
|
||||
---
|
||||
|
||||
Sep 25, 2023 [SimpleX Chat v5.3 released](./20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md)
|
||||
|
||||
- new desktop app! 💻
|
||||
- directory service and other group improvements.
|
||||
- encrypted local files and media with forward secrecy.
|
||||
- simplified incognito mode.
|
||||
|
||||
---
|
||||
|
||||
July 22, 2023 [SimpleX Chat v5.2 released](./20230722-simplex-chat-v5-2-message-delivery-receipts.md)
|
||||
|
||||
**What's new in v5.2:**
|
||||
|
||||
BIN
blog/images/20231125-block.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
blog/images/20231125-desktop1.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
blog/images/20231125-desktop2.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
blog/images/20231125-desktop3.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
blog/images/20231125-desktop4.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
blog/images/20231125-group1.png
Normal file
|
After Width: | Height: | Size: 780 KiB |
BIN
blog/images/20231125-group2.png
Normal file
|
After Width: | Height: | Size: 535 KiB |
BIN
blog/images/20231125-mobile1.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
blog/images/20231125-mobile1a.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
blog/images/20231125-mobile2.png
Normal file
|
After Width: | Height: | Size: 749 KiB |
BIN
blog/images/20231125-mobile3.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
blog/images/20231125-mobile4.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
blog/images/arrow.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
@@ -2,14 +2,16 @@ packages: .
|
||||
-- packages: . ../simplexmq
|
||||
-- packages: . ../simplexmq ../direct-sqlcipher ../sqlcipher-simple
|
||||
|
||||
with-compiler: ghc-8.10.7
|
||||
with-compiler: ghc-9.6.3
|
||||
|
||||
index-state: 2023-10-20T00:00:00Z
|
||||
|
||||
constraints: zip +disable-bzip2 +disable-zstd
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 281bdebcb82aed4c8c2c08438b9cafc7908183a1
|
||||
tag: a860936072172e261480fa6bdd95203976e366b2
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
@@ -24,12 +26,12 @@ source-repository-package
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/direct-sqlcipher.git
|
||||
tag: 34309410eb2069b029b8fc1872deb1e0db123294
|
||||
tag: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/sqlcipher-simple.git
|
||||
tag: 5e154a2aeccc33ead6c243ec07195ab673137221
|
||||
tag: a46bd361a19376c5211f1058908fc0ae6bf42446
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
@@ -43,5 +45,11 @@ source-repository-package
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/zw3rk/android-support.git
|
||||
tag: 3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb
|
||||
location: https://github.com/simplex-chat/android-support.git
|
||||
tag: 9aa09f148089d6752ce563b14c2df1895718d806
|
||||
|
||||
-- TODO this fork is only needed to compile with GHC 8.10.7 - it allows previous base version
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/zip.git
|
||||
tag: bd421c6b19cc4c465cd7af1f6f26169fb8ee1ebc
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: Download SimpleX apps
|
||||
permalink: /downloads/index.html
|
||||
revision: 01.10.2023
|
||||
revision: 25.11.2023
|
||||
---
|
||||
|
||||
| Updated 01.10.2023 | Languages: EN |
|
||||
| Updated 25.11.2023 | Languages: EN |
|
||||
# Download SimpleX apps
|
||||
|
||||
The latest stable version is v5.3.2.
|
||||
The latest stable version is v5.4.0.
|
||||
|
||||
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
|
||||
|
||||
@@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch
|
||||
|
||||
Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps.
|
||||
|
||||
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-22_04-x86_64.deb).
|
||||
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-22_04-x86_64.deb).
|
||||
|
||||
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
|
||||
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
|
||||
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.3/simplex-desktop-windows-x86-64.msi) (BETA).
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-windows-x86_64.msi).
|
||||
|
||||
## Mobile apps
|
||||
|
||||
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu).
|
||||
|
||||
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-armv7a.apk).
|
||||
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-armv7a.apk).
|
||||
|
||||
## Terminal (console) app
|
||||
|
||||
See [Using terminal app](/docs/CLI.md).
|
||||
|
||||
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-22_04-x86-64).
|
||||
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-22_04-x86-64).
|
||||
|
||||
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#).
|
||||
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#).
|
||||
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-windows-x86-64).
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-windows-x86-64).
|
||||
|
||||
@@ -8,7 +8,7 @@ title: App settings
|
||||
To open app settings:
|
||||
|
||||
- Open the app.
|
||||
- Tap on your user profile image in the upper right-hand of the screen.
|
||||
- Tap on your user profile image in the upper left-hand of the screen.
|
||||
- If you have more than one profile, tap the current profile again or choose Settings.
|
||||
|
||||
## Your profile settings
|
||||
@@ -45,6 +45,8 @@ When people connect to you via this address, you will receive a connection reque
|
||||
|
||||
If you start receiving too many requests via this address it is always safe to remove it – all the connections you created via this address will remain active, as this address is not used to deliver the messages.
|
||||
|
||||
See the comparison with [1-time invitation links](./making-connections.md#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).
|
||||
|
||||
Read more in [this post](../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#auto-accept-contact-requests).
|
||||
|
||||
### Chat preferences
|
||||
|
||||