Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dea7327610 | |||
| e9dd18a98c | |||
| 5406aabdc6 | |||
| cc5bc9c2cf | |||
| 4153ea7ddc | |||
| fbe26ed7ff |
@@ -5,22 +5,12 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- users
|
||||
tags:
|
||||
- "v*"
|
||||
- "!*-fdroid"
|
||||
- "!*-armv7a"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "apps/ios"
|
||||
- "apps/multiplatform"
|
||||
- "blog"
|
||||
- "docs"
|
||||
- "fastlane"
|
||||
- "images"
|
||||
- "packages"
|
||||
- "website"
|
||||
- "README.md"
|
||||
- "PRIVACY.md"
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -1,33 +1,153 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "any.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename" : "40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename" : "60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"filename" : "29.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "57.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "114.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "50.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "100.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "72.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "144.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
|
||||
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,9 +1,153 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "57.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "114.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "50.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "100.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50"
|
||||
},
|
||||
{
|
||||
"filename" : "72.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "144.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "72x72"
|
||||
},
|
||||
{
|
||||
"filename" : "76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -888,6 +888,35 @@ final class ChatModel: ObservableObject {
|
||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
||||
var i = 0
|
||||
var totalBelow = 0
|
||||
var unreadBelow = 0
|
||||
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
|
||||
totalBelow += 1
|
||||
if im.reversedChatItems[i].isRcvNew {
|
||||
unreadBelow += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return UnreadChatItemCounts(
|
||||
// TODO these thresholds account for the fact that items are still "visible" while
|
||||
// covered by compose area, they should be replaced with the actual height in pixels below the screen.
|
||||
isNearBottom: totalBelow < 15,
|
||||
isReallyNearBottom: totalBelow < 2,
|
||||
unreadBelow: unreadBelow
|
||||
)
|
||||
}
|
||||
|
||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
||||
let maxIx = im.reversedChatItems.count - 1
|
||||
var i = 0
|
||||
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
|
||||
while i < maxIx && !inView(i) { i += 1 }
|
||||
while i < maxIx && inView(i) { i += 1 }
|
||||
return im.reversedChatItems[min(i - 1, maxIx)]
|
||||
}
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
@@ -900,6 +929,12 @@ struct NTFContactRequest {
|
||||
var chatId: String
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts: Equatable {
|
||||
var isNearBottom: Bool
|
||||
var isReallyNearBottom: Bool
|
||||
var unreadBelow: Int
|
||||
}
|
||||
|
||||
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
|
||||
@@ -2199,11 +2199,9 @@ func refreshCallInvitations() async throws {
|
||||
}
|
||||
}
|
||||
|
||||
func justRefreshCallInvitations() async throws {
|
||||
func justRefreshCallInvitations() throws {
|
||||
let callInvitations = try apiGetCallInvitationsSync()
|
||||
await MainActor.run {
|
||||
ChatModel.shared.callInvitations = callsByChat(callInvitations)
|
||||
}
|
||||
ChatModel.shared.callInvitations = callsByChat(callInvitations)
|
||||
}
|
||||
|
||||
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
|
||||
@@ -2213,13 +2211,12 @@ private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: Rcv
|
||||
}
|
||||
|
||||
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||
if !callInvitation.user.showNotifications { return }
|
||||
let m = ChatModel.shared
|
||||
logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
|
||||
if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
|
||||
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
m.callInvitations[callInvitation.contact.id]?.callUUID = nil
|
||||
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
|
||||
}
|
||||
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
||||
} else {
|
||||
|
||||
@@ -185,7 +185,7 @@ struct ActiveCallView: View {
|
||||
case .ended:
|
||||
closeCallView(client)
|
||||
call.callState = .ended
|
||||
if let uuid = call.callUUID {
|
||||
if let uuid = call.callkitUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
}
|
||||
case .ok:
|
||||
@@ -382,7 +382,7 @@ struct ActiveCallOverlay: View {
|
||||
private func endCallButton() -> some View {
|
||||
let cc = CallController.shared
|
||||
return callButton("phone.down.fill", width: 60, height: 60) {
|
||||
if let uuid = call.callUUID {
|
||||
if let uuid = call.callkitUUID {
|
||||
cc.endCall(callUUID: uuid)
|
||||
} else {
|
||||
cc.endCall(call: call) {}
|
||||
@@ -462,9 +462,9 @@ struct ActiveCallOverlay: View {
|
||||
struct ActiveCallOverlay_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||
.background(.black)
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||
.background(.black)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
logger.debug("CallController.provider CXStartCallAction")
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID) {
|
||||
action.fulfill()
|
||||
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
||||
} else {
|
||||
@@ -61,30 +61,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
logger.debug("CallController.provider CXAnswerCallAction")
|
||||
Task {
|
||||
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
|
||||
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
|
||||
if !chatIsReady {
|
||||
action.fail()
|
||||
return
|
||||
}
|
||||
if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
|
||||
try? await justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
}
|
||||
await MainActor.run {
|
||||
logger.debug("CallController.provider will answer on call")
|
||||
|
||||
if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
logger.debug("CallController.provider answered on call")
|
||||
// WebRTC call should be in connected state to fulfill.
|
||||
// Otherwise no audio and mic working on lockscreen
|
||||
fulfillOnConnect = action
|
||||
} else {
|
||||
logger.debug("CallController.provider will fail the call")
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
if callManager.answerIncomingCall(callUUID: action.callUUID) {
|
||||
// WebRTC call should be in connected state to fulfill.
|
||||
// Otherwise no audio and mic working on lockscreen
|
||||
fulfillOnConnect = action
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +75,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
// Should be nil here if connection was in connected state
|
||||
fulfillOnConnect?.fail()
|
||||
fulfillOnConnect = nil
|
||||
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
|
||||
callManager.endCall(callUUID: action.callUUID) { ok in
|
||||
if ok {
|
||||
action.fulfill()
|
||||
} else {
|
||||
@@ -104,7 +86,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
@@ -174,19 +156,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
|
||||
logger.debug("CallController waiting until chat started")
|
||||
var t: UInt64 = 0
|
||||
repeat {
|
||||
if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
|
||||
return true
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||
t += stepMs
|
||||
} while t < timeoutMs
|
||||
return false
|
||||
}
|
||||
|
||||
@objc(pushRegistry:didUpdatePushCredentials:forType:)
|
||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
||||
@@ -202,19 +171,32 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
return
|
||||
}
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
logger.debug("CallController: initializing chat")
|
||||
do {
|
||||
try initializeChat(start: true, refreshInvitations: false)
|
||||
} catch let error {
|
||||
logger.error("CallController: initializing chat error: \(error)")
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.debug("CallController: initialized chat")
|
||||
startChatForCall()
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
// There are no invitations in the model, as it was processed by NSE
|
||||
try? justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||
// Extract the call information from the push notification payload
|
||||
let m = ChatModel.shared
|
||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||
let displayName = payload.dictionaryPayload["displayName"] as? String,
|
||||
let callUUID = payload.dictionaryPayload["callUUID"] as? String,
|
||||
let uuid = UUID(uuidString: callUUID),
|
||||
let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
|
||||
let mediaStr = payload.dictionaryPayload["media"] as? String,
|
||||
let media = CallMediaType(rawValue: mediaStr) {
|
||||
let update = self.cxCallUpdate(contactId, displayName, media)
|
||||
let callTs = Date(timeIntervalSince1970: callTsInterval)
|
||||
if callTs.timeIntervalSinceNow >= -180 {
|
||||
let invitation = m.callInvitations[contactId] {
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
if let uuid = invitation.callkitUUID {
|
||||
logger.debug("CallController: report pushkit call via CallKit")
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||
if error != nil {
|
||||
m.callInvitations.removeValue(forKey: contactId)
|
||||
@@ -223,31 +205,11 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
completion()
|
||||
}
|
||||
} else {
|
||||
logger.debug("CallController will expire call 1")
|
||||
self.reportExpiredCall(update: update, completion)
|
||||
}
|
||||
} else {
|
||||
logger.debug("CallController will expire call 2")
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
}
|
||||
|
||||
//DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
logger.debug("CallController: initializing chat")
|
||||
do {
|
||||
try initializeChat(start: true, refreshInvitations: false)
|
||||
} catch let error {
|
||||
logger.error("CallController: initializing chat error: \(error)")
|
||||
if let call = ChatModel.shared.activeCall {
|
||||
self.endCall(call: call, completed: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.debug("CallController: initialized chat")
|
||||
startChatForCall()
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
}
|
||||
|
||||
// This function fulfils the requirement to always report a call when PushKit notification is received,
|
||||
@@ -277,8 +239,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
|
||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
|
||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
@@ -299,14 +261,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
return update
|
||||
}
|
||||
|
||||
private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
|
||||
let update = CXCallUpdate()
|
||||
update.remoteHandle = CXHandle(type: .generic, value: contactId)
|
||||
update.hasVideo = media == .video
|
||||
update.localizedCallerName = displayName
|
||||
return update
|
||||
}
|
||||
|
||||
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting incoming call connected")
|
||||
if CallController.useCallKit() {
|
||||
@@ -318,14 +272,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting outgoing call connected")
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
||||
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||
logger.debug("CallController: reporting remote ended")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
||||
activeCallInvitation = nil
|
||||
@@ -334,17 +288,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func reportCallRemoteEnded(call: Call) {
|
||||
logger.debug("CallController: reporting remote ended")
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
|
||||
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
||||
logger.debug("CallController.startCall")
|
||||
let callUUID = callManager.newOutgoingCall(contact, media)
|
||||
guard let uuid = UUID(uuidString: callUUID) else {
|
||||
return
|
||||
}
|
||||
let uuid = callManager.newOutgoingCall(contact, media)
|
||||
if CallController.useCallKit() {
|
||||
let handle = CXHandle(type: .generic, value: contact.id)
|
||||
let action = CXStartCallAction(call: uuid, handle: handle)
|
||||
@@ -356,8 +307,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
update.localizedCallerName = contact.displayName
|
||||
self.provider.reportCall(with: uuid, updated: update)
|
||||
}
|
||||
} else if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||
if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||
} else if callManager.startOutgoingCall(callUUID: uuid) {
|
||||
if callManager.startOutgoingCall(callUUID: uuid) {
|
||||
logger.debug("CallController.startCall: call started")
|
||||
} else {
|
||||
logger.error("CallController.startCall: no active call")
|
||||
@@ -367,8 +318,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func answerCall(invitation: RcvCallInvitation) {
|
||||
logger.debug("CallController: answering a call")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
requestTransaction(with: CXAnswerCallAction(call: uuid))
|
||||
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
|
||||
requestTransaction(with: CXAnswerCallAction(call: callUUID))
|
||||
} else {
|
||||
callManager.answerIncomingCall(invitation: invitation)
|
||||
}
|
||||
@@ -377,13 +328,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(callUUID: String) {
|
||||
let uuid = UUID(uuidString: callUUID)
|
||||
logger.debug("CallController: ending the call with UUID \(callUUID)")
|
||||
func endCall(callUUID: UUID) {
|
||||
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
|
||||
if CallController.useCallKit() {
|
||||
if let uuid {
|
||||
requestTransaction(with: CXEndCallAction(call: uuid))
|
||||
}
|
||||
requestTransaction(with: CXEndCallAction(call: callUUID))
|
||||
} else {
|
||||
callManager.endCall(callUUID: callUUID) { ok in
|
||||
if ok {
|
||||
|
||||
@@ -10,17 +10,17 @@ import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
class CallManager {
|
||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
|
||||
let uuid = UUID().uuidString.lowercased()
|
||||
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
|
||||
let uuid = UUID()
|
||||
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||
call.speakerEnabled = media == .video
|
||||
ChatModel.shared.activeCall = call
|
||||
return uuid
|
||||
}
|
||||
|
||||
func startOutgoingCall(callUUID: String) -> Bool {
|
||||
func startOutgoingCall(callUUID: UUID) -> Bool {
|
||||
let m = ChatModel.shared
|
||||
if let call = m.activeCall, call.callUUID == callUUID {
|
||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||
m.showCallView = true
|
||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||
return true
|
||||
@@ -28,7 +28,7 @@ class CallManager {
|
||||
return false
|
||||
}
|
||||
|
||||
func answerIncomingCall(callUUID: String) -> Bool {
|
||||
func answerIncomingCall(callUUID: UUID) -> Bool {
|
||||
if let invitation = getCallInvitation(callUUID) {
|
||||
answerIncomingCall(invitation: invitation)
|
||||
return true
|
||||
@@ -42,7 +42,7 @@ class CallManager {
|
||||
let call = Call(
|
||||
direction: .incoming,
|
||||
contact: invitation.contact,
|
||||
callUUID: invitation.callUUID,
|
||||
callkitUUID: invitation.callkitUUID,
|
||||
callState: .invitationAccepted,
|
||||
localMedia: invitation.callType.media,
|
||||
sharedKey: invitation.sharedKey
|
||||
@@ -68,8 +68,8 @@ class CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: String) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
let m = ChatModel.shared
|
||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||
return true
|
||||
@@ -77,8 +77,8 @@ class CallManager {
|
||||
return false
|
||||
}
|
||||
|
||||
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
|
||||
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
endCall(call: call) { completed(true) }
|
||||
} else if let invitation = getCallInvitation(callUUID) {
|
||||
endCall(invitation: invitation) { completed(true) }
|
||||
@@ -126,8 +126,8 @@ class CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
|
||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
|
||||
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
|
||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
|
||||
return invitation
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -18,7 +18,7 @@ class Call: ObservableObject, Equatable {
|
||||
|
||||
var direction: CallDirection
|
||||
var contact: Contact
|
||||
var callUUID: String?
|
||||
var callkitUUID: UUID?
|
||||
var localMedia: CallMediaType
|
||||
@Published var callState: CallState
|
||||
@Published var localCapabilities: CallCapabilities?
|
||||
@@ -33,14 +33,14 @@ class Call: ObservableObject, Equatable {
|
||||
init(
|
||||
direction: CallDirection,
|
||||
contact: Contact,
|
||||
callUUID: String?,
|
||||
callkitUUID: UUID?,
|
||||
callState: CallState,
|
||||
localMedia: CallMediaType,
|
||||
sharedKey: String? = nil
|
||||
) {
|
||||
self.direction = direction
|
||||
self.contact = contact
|
||||
self.callUUID = callUUID
|
||||
self.callkitUUID = callkitUUID
|
||||
self.callState = callState
|
||||
self.localMedia = localMedia
|
||||
self.sharedKey = sharedKey
|
||||
|
||||
@@ -74,10 +74,12 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ZStack {
|
||||
chatItemsList()
|
||||
// TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates
|
||||
floatingButtons(unreadBelow: floatingButtonModel.unreadBelow, isNearBottom: floatingButtonModel.isNearBottom)
|
||||
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
floatingDate
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
connectingText()
|
||||
if selectedChatItems == nil {
|
||||
@@ -427,7 +429,7 @@ struct ChatView: View {
|
||||
.onChange(of: im.itemAdded) { added in
|
||||
if added {
|
||||
im.itemAdded = false
|
||||
if floatingButtonModel.isReallyNearBottom {
|
||||
if floatingButtonModel.unreadChatItemCounts.isReallyNearBottom {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
@@ -451,42 +453,78 @@ struct ChatView: View {
|
||||
|
||||
class FloatingButtonModel: ObservableObject {
|
||||
static let shared = FloatingButtonModel()
|
||||
@Published var unreadBelow: Int = 0
|
||||
@Published var isNearBottom: Bool = true
|
||||
var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 }
|
||||
|
||||
@Published var unreadChatItemCounts: UnreadChatItemCounts
|
||||
@Published var date: Date?
|
||||
@Published var isDateVisible: Bool = false
|
||||
|
||||
let visibleItems = PassthroughSubject<[String], Never>()
|
||||
let scrollOffset = CurrentValueSubject<Double, Never>(0)
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
unreadChatItemCounts = UnreadChatItemCounts(
|
||||
isNearBottom: true,
|
||||
isReallyNearBottom: true,
|
||||
unreadBelow: 0
|
||||
)
|
||||
|
||||
// Unread item counts
|
||||
visibleItems
|
||||
.receive(on: DispatchQueue.global(qos: .background))
|
||||
.map { itemIds in
|
||||
if let viewId = itemIds.first,
|
||||
let index = ItemsModel.shared.reversedChatItems.firstIndex(where: { $0.viewId == viewId }) {
|
||||
ItemsModel.shared.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
|
||||
if chatItem.isRcvNew { unread += 1 }
|
||||
}
|
||||
} else { 0 }
|
||||
}
|
||||
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: Set($0)) }
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.unreadBelow, on: self)
|
||||
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
|
||||
.assign(to: \.unreadChatItemCounts, on: self)
|
||||
.store(in: &bag)
|
||||
|
||||
scrollOffset
|
||||
.map { $0 < 800 }
|
||||
// Floating date
|
||||
visibleItems
|
||||
.receive(on: DispatchQueue.global(qos: .background))
|
||||
.map { visibleItems -> Date? in
|
||||
if let viewId = visibleItems.last {
|
||||
ItemsModel.shared.reversedChatItems
|
||||
.first { $0.viewId == viewId }
|
||||
.map { Calendar.current.startOfDay(for: $0.meta.itemTs) }
|
||||
} else { nil }
|
||||
}
|
||||
.removeDuplicates()
|
||||
// Delay the state change until scroll to bottom animation is finished
|
||||
.delay(for: 0.35, scheduler: DispatchQueue.main)
|
||||
.assign(to: \.isNearBottom, on: self)
|
||||
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
|
||||
.assign(to: \.date, on: self)
|
||||
.store(in: &bag)
|
||||
|
||||
// Floating date visibility
|
||||
visibleItems
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.map { _ in
|
||||
if !self.isDateVisible {
|
||||
withAnimation { self.isDateVisible = true }
|
||||
}
|
||||
}
|
||||
.debounce(for: 1, scheduler: DispatchQueue.main)
|
||||
.sink {
|
||||
if self.isDateVisible {
|
||||
withAnimation { self.isDateVisible = false }
|
||||
}
|
||||
}
|
||||
.store(in: &bag)
|
||||
}
|
||||
}
|
||||
|
||||
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool) -> some View {
|
||||
@ViewBuilder
|
||||
var floatingDate: some View {
|
||||
if let date = floatingButtonModel.date {
|
||||
DateSeparator(date: date)
|
||||
.padding(.vertical, 4).padding(.horizontal, 8)
|
||||
.background(ToolbarMaterial.material(toolbarMaterial))
|
||||
.clipShape(Capsule())
|
||||
.opacity(floatingButtonModel.isDateVisible ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func floatingButtons(counts: UnreadChatItemCounts) -> some View {
|
||||
VStack {
|
||||
let unreadAbove = chat.chatStats.unreadCount - unreadBelow
|
||||
let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
|
||||
if unreadAbove > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadAbove)
|
||||
@@ -507,16 +545,16 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if unreadBelow > 0 {
|
||||
if counts.unreadBelow > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadBelow)
|
||||
unreadCountText(counts.unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
} else if !isNearBottom {
|
||||
} else if !counts.isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
@@ -546,8 +584,8 @@ struct ChatView: View {
|
||||
|
||||
private func endCallButton(_ call: Call) -> some View {
|
||||
Button {
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID {
|
||||
CallController.shared.endCall(callUUID: callUUID)
|
||||
if let uuid = call.callkitUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
} else {
|
||||
CallController.shared.endCall(call: call) {}
|
||||
}
|
||||
@@ -670,6 +708,21 @@ struct ChatView: View {
|
||||
VoiceItemState.chatView = [:]
|
||||
}
|
||||
|
||||
private struct DateSeparator: View {
|
||||
let date: Date
|
||||
|
||||
var body: some View {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
|
||||
date.formatted(.dateTime.weekday(.abbreviated)),
|
||||
date.formatted(.dateTime.day().month(.abbreviated))
|
||||
))
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -688,8 +741,7 @@ struct ChatView: View {
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
@State private var showForwardingSheet: Bool = false
|
||||
@State private var msgWidth: CGFloat = 0
|
||||
|
||||
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
@@ -742,17 +794,7 @@ struct ChatView: View {
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
chatItemView(chatItem, range, prevItem, timeSeparation)
|
||||
if let date = timeSeparation.date {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
|
||||
date.formatted(.dateTime.weekday(.abbreviated)),
|
||||
date.formatted(.dateTime.day().month(.abbreviated))
|
||||
))
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
if let date = timeSeparation.date { DateSeparator(date: date).padding(8) }
|
||||
}
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
|
||||
@@ -803,51 +845,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct MemberLayout: Layout {
|
||||
let spacing: Double
|
||||
let msgWidth: Double
|
||||
|
||||
private func sizes(subviews: Subviews, proposal: ProposedViewSize) -> (CGSize, CGSize) {
|
||||
assert(subviews.count == 2, "member layout must contain exactly two subviews")
|
||||
let roleSize = subviews[1].sizeThatFits(proposal)
|
||||
let memberSize = subviews[0].sizeThatFits(
|
||||
ProposedViewSize(
|
||||
width: (proposal.width ?? msgWidth) - roleSize.width,
|
||||
height: proposal.height
|
||||
)
|
||||
)
|
||||
return (memberSize, roleSize)
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||||
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
|
||||
return CGSize(
|
||||
width: min(
|
||||
proposal.width ?? msgWidth,
|
||||
max(msgWidth, roleSize.width + spacing + memberSize.width)
|
||||
),
|
||||
height: max(memberSize.height, roleSize.height)
|
||||
)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
||||
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
|
||||
subviews[0].place(
|
||||
at: CGPoint(x: bounds.minX, y: bounds.midY - memberSize.height / 2),
|
||||
proposal: ProposedViewSize(memberSize)
|
||||
)
|
||||
subviews[1].place(
|
||||
at: CGPoint(
|
||||
x: bounds.minX + max(memberSize.width + spacing, msgWidth - roleSize.width),
|
||||
y: bounds.midY - roleSize.height / 2
|
||||
),
|
||||
proposal: ProposedViewSize(roleSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View {
|
||||
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
|
||||
@@ -862,40 +859,15 @@ struct ChatView: View {
|
||||
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Group {
|
||||
if memCount == 1 && member.memberRole > .member {
|
||||
Group {
|
||||
if #available(iOS 16.0, *) {
|
||||
MemberLayout(spacing: 16, msgWidth: msgWidth) {
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
Text(member.memberRole.text)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 16) {
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
Text(member.memberRole.text)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
maxWidth: maxWidth,
|
||||
alignment: chatItem.chatDir.sent ? .trailing : .leading
|
||||
)
|
||||
} else {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
.lineLimit(2)
|
||||
}
|
||||
let t = if memCount == 1 && member.memberRole > .member {
|
||||
Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName)
|
||||
} else {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
}
|
||||
t
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
|
||||
.padding(.top, 3) // this is in addition to message sequence gap
|
||||
}
|
||||
@@ -918,7 +890,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||
controller.representer = self
|
||||
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||
controller.view.layer.removeAllAnimations()
|
||||
switch destination {
|
||||
case .nextPage:
|
||||
controller.scrollToNextPage()
|
||||
@@ -48,7 +47,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
var representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||
private var itemCount: Int = 0
|
||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
init(representer: ReverseList) {
|
||||
@@ -107,10 +105,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
updateFloatingButtons
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { self.updateVisibleItems() }
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -196,23 +190,21 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
)
|
||||
}
|
||||
itemCount = items.count
|
||||
updateFloatingButtons.send()
|
||||
updateVisibleItems()
|
||||
}
|
||||
|
||||
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
updateFloatingButtons.send()
|
||||
updateVisibleItems()
|
||||
}
|
||||
|
||||
private func updateVisibleItems() {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
|
||||
fbm.visibleItems.send(
|
||||
ChatView.FloatingButtonModel.shared.visibleItems.send(
|
||||
(tableView.indexPathsForVisibleRows ?? [])
|
||||
.compactMap { indexPath -> String? in
|
||||
guard let relativeFrame = tableView.superview?.convert(
|
||||
let relativeFrame = tableView.superview!.convert(
|
||||
tableView.rectForRow(at: indexPath),
|
||||
from: tableView
|
||||
) else { return nil }
|
||||
)
|
||||
// Checks that the cell is visible accounting for the added insets
|
||||
let isVisible =
|
||||
relativeFrame.maxY > InvertedTableView.inset &&
|
||||
|
||||
@@ -36,6 +36,8 @@ struct ChatItemClipped: ViewModifier {
|
||||
.sndMsgContent,
|
||||
.rcvMsgContent,
|
||||
.rcvDecryptionError,
|
||||
.rcvGroupInvitation,
|
||||
.sndGroupInvitation,
|
||||
.sndDeleted,
|
||||
.rcvDeleted,
|
||||
.rcvIntegrityError,
|
||||
@@ -54,12 +56,10 @@ struct ChatItemClipped: ViewModifier {
|
||||
tailVisible: tail
|
||||
)
|
||||
: .roundRect(radius: msgRectMaxRadius)
|
||||
case .rcvGroupInvitation, .sndGroupInvitation:
|
||||
return .roundRect(radius: msgRectMaxRadius)
|
||||
default: return .roundRect(radius: 8)
|
||||
}
|
||||
} else {
|
||||
return .roundRect(radius: msgRectMaxRadius)
|
||||
return.roundRect(radius: msgRectMaxRadius)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,9 +339,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
CXProvider.reportNewIncomingVoIPPushPayload([
|
||||
"displayName": invitation.contact.displayName,
|
||||
"contactId": invitation.contact.id,
|
||||
"callUUID": invitation.callUUID ?? "",
|
||||
"media": invitation.callType.media.rawValue,
|
||||
"callTs": invitation.callTs.timeIntervalSince1970
|
||||
"media": invitation.callType.media.rawValue
|
||||
]) { error in
|
||||
logger.debug("reportNewIncomingVoIPPushPayload result: \(error)")
|
||||
deliver(error == nil ? nil : createCallInvitationNtf(invitation))
|
||||
|
||||
@@ -42,7 +42,6 @@ public struct RcvCallInvitation: Decodable {
|
||||
public var contact: Contact
|
||||
public var callType: CallType
|
||||
public var sharedKey: String?
|
||||
public var callUUID: String?
|
||||
public var callTs: Date
|
||||
public var callTypeText: LocalizedStringKey {
|
||||
get {
|
||||
@@ -53,8 +52,10 @@ public struct RcvCallInvitation: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public var callkitUUID: UUID? = UUID()
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case user, contact, callType, sharedKey, callUUID, callTs
|
||||
case user, contact, callType, sharedKey, callTs
|
||||
}
|
||||
|
||||
public static let sampleData = RcvCallInvitation(
|
||||
|
||||
@@ -424,7 +424,6 @@ fun PreviewIncomingCallLockScreenAlert() {
|
||||
) {
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation = RcvCallInvitation(
|
||||
callUUID = "",
|
||||
remoteHostId = null,
|
||||
user = User.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
|
||||
@@ -770,7 +770,6 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Video,
|
||||
peerMedia = CallMediaType.Video,
|
||||
callUUID = "",
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp")
|
||||
@@ -800,7 +799,6 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Audio,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
callUUID = "",
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp")
|
||||
|
||||
@@ -14,7 +14,6 @@ actual fun authenticate(
|
||||
promptSubtitle: String,
|
||||
selfDestruct: Boolean,
|
||||
usingLAMode: LAMode,
|
||||
oneTime: Boolean,
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
val activity = mainActivity.get() ?: return completed(LAResult.Error(""))
|
||||
@@ -28,7 +27,7 @@ actual fun authenticate(
|
||||
else -> completed(LAResult.Unavailable())
|
||||
}
|
||||
LAMode.PASSCODE -> {
|
||||
authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, oneTime, completed)
|
||||
authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ fun MainScreen() {
|
||||
LaunchedEffect(showAdvertiseLAAlert) {
|
||||
if (
|
||||
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||
&& !appPrefs.performLA.get()
|
||||
&& showAdvertiseLAAlert
|
||||
&& chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete
|
||||
&& chatModel.chats.size > 3
|
||||
@@ -212,8 +211,10 @@ fun MainScreen() {
|
||||
} else {
|
||||
ActiveCallView()
|
||||
}
|
||||
} else {
|
||||
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
|
||||
ModalManager.fullscreen.showPasscodeInView()
|
||||
}
|
||||
ModalManager.fullscreen.showOneTimePasscodeInView()
|
||||
AlertManager.privacySensitive.showInView()
|
||||
if (onboarding == OnboardingStage.OnboardingComplete) {
|
||||
LaunchedEffect(chatModel.currentUser.value, chatModel.appOpenUrl.value) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.localauth.SetAppPasscodeView
|
||||
@@ -32,7 +31,7 @@ object AppLock {
|
||||
|
||||
fun showLANotice(laNoticeShown: SharedPreference<Boolean>) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
if (!laNoticeShown.get() && !appPrefs.performLA.get()) {
|
||||
if (!laNoticeShown.get()) {
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.la_notice_title_simplex_lock),
|
||||
@@ -58,8 +57,6 @@ object AppLock {
|
||||
|
||||
private fun showChooseLAMode() {
|
||||
Log.d(TAG, "showLANotice")
|
||||
if (appPrefs.performLA.get()) return
|
||||
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.la_lock_mode),
|
||||
text = null,
|
||||
@@ -83,23 +80,21 @@ object AppLock {
|
||||
authenticate(
|
||||
generalGetString(MR.strings.auth_enable_simplex_lock),
|
||||
generalGetString(MR.strings.auth_confirm_credential),
|
||||
oneTime = true,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.showAuthScreen.value = true
|
||||
m.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.showAuthScreen.value = false
|
||||
// Don't drop auth pref in case of state inconsistency (eg, you have set passcode but somehow bypassed toggle and turned it off and then on)
|
||||
// appPrefs.performLA.set(false)
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.showAuthScreen.value = false
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
m.showAdvertiseLAUnavailableAlert.value = true
|
||||
}
|
||||
@@ -109,22 +104,19 @@ object AppLock {
|
||||
}
|
||||
|
||||
private fun setPasscode() {
|
||||
if (appPrefs.performLA.get()) return
|
||||
|
||||
val appPrefs = ChatController.appPrefs
|
||||
ModalManager.fullscreen.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
ChatModel.showAuthScreen.value = true
|
||||
ChatModel.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||
laTurnedOnAlert()
|
||||
},
|
||||
cancel = {
|
||||
ChatModel.showAuthScreen.value = false
|
||||
// Don't drop auth pref in case of state inconsistency (eg, you have set passcode but somehow bypassed toggle and turned it off and then on)
|
||||
// appPrefs.performLA.set(false)
|
||||
ChatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laPasscodeNotSetAlert()
|
||||
},
|
||||
close = close
|
||||
@@ -155,7 +147,6 @@ object AppLock {
|
||||
else
|
||||
generalGetString(MR.strings.auth_unlock),
|
||||
selfDestruct = true,
|
||||
oneTime = false,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success ->
|
||||
@@ -169,7 +160,7 @@ object AppLock {
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.showAuthScreen.value = false
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
@@ -201,23 +192,22 @@ object AppLock {
|
||||
generalGetString(MR.strings.auth_confirm_credential)
|
||||
else
|
||||
"",
|
||||
oneTime = true,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.showAuthScreen.value = true
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.showAuthScreen.value = false
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.showAuthScreen.value = false
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
@@ -237,13 +227,12 @@ object AppLock {
|
||||
generalGetString(MR.strings.auth_confirm_credential)
|
||||
else
|
||||
generalGetString(MR.strings.auth_disable_simplex_lock),
|
||||
oneTime = true,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
val selfDestructPref = m.controller.appPrefs.selfDestruct
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.showAuthScreen.value = false
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
DatabaseUtils.ksAppPassword.remove()
|
||||
selfDestructPref.set(false)
|
||||
@@ -251,12 +240,12 @@ object AppLock {
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.showAuthScreen.value = true
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.showAuthScreen.value = false
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.internal.ChannelFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.*
|
||||
@@ -97,7 +98,7 @@ object ChatModel {
|
||||
}
|
||||
)
|
||||
}
|
||||
val showAuthScreen by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
|
||||
val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
|
||||
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
|
||||
val showChatPreviews by lazy { mutableStateOf(ChatController.appPrefs.privacyShowChatPreviews.get()) }
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ class CallManager(val chatModel: ChatModel) {
|
||||
remoteHostId = invitation.remoteHostId,
|
||||
userProfile = userProfile,
|
||||
contact = invitation.contact,
|
||||
callUUID = invitation.callUUID,
|
||||
callState = CallState.InvitationAccepted,
|
||||
localMedia = invitation.callType.media,
|
||||
sharedKey = invitation.sharedKey,
|
||||
|
||||
@@ -115,7 +115,6 @@ fun PreviewIncomingCallAlertLayout() {
|
||||
contact = Contact.sampleData,
|
||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||
sharedKey = null,
|
||||
callUUID = "",
|
||||
callTs = Clock.System.now()
|
||||
),
|
||||
chatModel = ChatModel,
|
||||
|
||||
@@ -13,7 +13,6 @@ data class Call(
|
||||
val remoteHostId: Long?,
|
||||
val userProfile: Profile,
|
||||
val contact: Contact,
|
||||
val callUUID: String?,
|
||||
val callState: CallState,
|
||||
val localMedia: CallMediaType,
|
||||
val localCapabilities: CallCapabilities? = null,
|
||||
@@ -106,7 +105,6 @@ sealed class WCallResponse {
|
||||
val contact: Contact,
|
||||
val callType: CallType,
|
||||
val sharedKey: String? = null,
|
||||
val callUUID: String,
|
||||
val callTs: Instant
|
||||
) {
|
||||
val callTypeText: String get() = generalGetString(when(callType.media) {
|
||||
|
||||
@@ -544,7 +544,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType)
|
||||
if (chatInfo is ChatInfo.Direct) {
|
||||
val contactInfo = chatModel.controller.apiContactInfo(remoteHostId, chatInfo.contact.contactId)
|
||||
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
|
||||
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callUUID = null, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
|
||||
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
|
||||
chatModel.showCallView.value = true
|
||||
chatModel.callCommand.add(WCallCommand.Capabilities(media))
|
||||
}
|
||||
|
||||
@@ -423,7 +423,6 @@ fun authStopChat(m: ChatModel, progressIndicator: MutableState<Boolean>? = null,
|
||||
authenticate(
|
||||
generalGetString(MR.strings.auth_stop_chat),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential),
|
||||
oneTime = true,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success, is LAResult.Unavailable -> {
|
||||
|
||||
@@ -34,7 +34,6 @@ expect fun authenticate(
|
||||
promptSubtitle: String,
|
||||
selfDestruct: Boolean = false,
|
||||
usingLAMode: LAMode = ChatModel.controller.appPrefs.laMode.get(),
|
||||
oneTime: Boolean,
|
||||
completed: (LAResult) -> Unit
|
||||
)
|
||||
|
||||
@@ -42,11 +41,10 @@ fun authenticateWithPasscode(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
selfDestruct: Boolean,
|
||||
oneTime: Boolean,
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
val password = DatabaseUtils.ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(MR.strings.la_no_app_password)))
|
||||
ModalManager.fullscreen.showPasscodeCustomModal(oneTime) { close ->
|
||||
ModalManager.fullscreen.showPasscodeCustomModal { close ->
|
||||
BackHandler {
|
||||
close()
|
||||
completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled)))
|
||||
|
||||
@@ -69,7 +69,6 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
// Don't use mutableStateOf() here, because it produces this if showing from SimpleXAPI.startChat():
|
||||
// java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
|
||||
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
|
||||
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
|
||||
|
||||
fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
|
||||
val data = ModalData()
|
||||
@@ -106,13 +105,9 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
}
|
||||
}
|
||||
|
||||
fun showPasscodeCustomModal(oneTime: Boolean, modal: @Composable (close: () -> Unit) -> Unit) {
|
||||
Log.d(TAG, "ModalManager.showPasscodeCustomModal, oneTime: $oneTime")
|
||||
if (oneTime) {
|
||||
onTimePasscodeView.value = modal
|
||||
} else {
|
||||
passcodeView.value = modal
|
||||
}
|
||||
fun showPasscodeCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
|
||||
Log.d(TAG, "ModalManager.showPasscodeCustomModal")
|
||||
passcodeView.value = modal
|
||||
}
|
||||
|
||||
fun hasModalsOpen() = modalCount.value > 0
|
||||
@@ -184,11 +179,6 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
passcodeView.collectAsState().value?.invoke { passcodeView.value = null }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun showOneTimePasscodeInView() {
|
||||
onTimePasscodeView.collectAsState().value?.invoke { onTimePasscodeView.value = null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows to modify a list without getting [ConcurrentModificationException]
|
||||
* */
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -32,6 +34,8 @@ import chat.simplex.common.views.onboarding.ReadableText
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.platform.*
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class LAMode {
|
||||
SYSTEM,
|
||||
@@ -370,8 +374,7 @@ fun SimplexLockView(
|
||||
currentLAMode: SharedPreference<LAMode>,
|
||||
setPerformLA: (Boolean) -> Unit
|
||||
) {
|
||||
val showAuthScreen = remember { chatModel.showAuthScreen }
|
||||
val performLA = remember { appPrefs.performLA.state }
|
||||
val performLA = remember { chatModel.performLA }
|
||||
val laMode = remember { chatModel.controller.appPrefs.laMode.state }
|
||||
val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay }
|
||||
val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } }
|
||||
@@ -379,9 +382,13 @@ fun SimplexLockView(
|
||||
val selfDestructDisplayName = remember { mutableStateOf(chatModel.controller.appPrefs.selfDestructDisplayName.get() ?: "") }
|
||||
val selfDestructDisplayNamePref = remember { chatModel.controller.appPrefs.selfDestructDisplayName }
|
||||
|
||||
fun resetLAEnabled(onOff: Boolean) {
|
||||
chatModel.controller.appPrefs.performLA.set(onOff)
|
||||
chatModel.performLA.value = onOff
|
||||
}
|
||||
|
||||
fun disableUnavailableLA() {
|
||||
chatModel.controller.appPrefs.performLA.set(false)
|
||||
chatModel.showAuthScreen.value = false
|
||||
resetLAEnabled(false)
|
||||
currentLAMode.set(LAMode.default)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
@@ -398,8 +405,7 @@ fun SimplexLockView(
|
||||
} else {
|
||||
generalGetString(MR.strings.chat_lock)
|
||||
},
|
||||
generalGetString(MR.strings.change_lock_mode),
|
||||
oneTime = true,
|
||||
generalGetString(MR.strings.change_lock_mode)
|
||||
) { laResult ->
|
||||
when (laResult) {
|
||||
is LAResult.Error -> {
|
||||
@@ -409,7 +415,7 @@ fun SimplexLockView(
|
||||
LAResult.Success -> {
|
||||
when (toLAMode) {
|
||||
LAMode.SYSTEM -> {
|
||||
authenticate(generalGetString(MR.strings.auth_enable_simplex_lock), promptSubtitle = "", usingLAMode = toLAMode, oneTime = true) { laResult ->
|
||||
authenticate(generalGetString(MR.strings.auth_enable_simplex_lock), promptSubtitle = "", usingLAMode = toLAMode) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
currentLAMode.set(toLAMode)
|
||||
@@ -445,7 +451,7 @@ fun SimplexLockView(
|
||||
}
|
||||
|
||||
fun toggleSelfDestruct(selfDestruct: SharedPreference<Boolean>) {
|
||||
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_mode), oneTime = true) { laResult ->
|
||||
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_mode)) { laResult ->
|
||||
when (laResult) {
|
||||
is LAResult.Error -> laFailedAlert()
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
@@ -464,7 +470,7 @@ fun SimplexLockView(
|
||||
}
|
||||
|
||||
fun changeLAPassword() {
|
||||
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode), oneTime = true) { laResult ->
|
||||
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode)) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
ModalManager.fullscreen.showCustomModal { close ->
|
||||
@@ -488,7 +494,7 @@ fun SimplexLockView(
|
||||
}
|
||||
|
||||
fun changeSelfDestructPassword() {
|
||||
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode), oneTime = true) { laResult ->
|
||||
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode)) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
ModalManager.fullscreen.showCustomModal { close ->
|
||||
@@ -519,8 +525,8 @@ fun SimplexLockView(
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.chat_lock))
|
||||
SectionView {
|
||||
EnableLock(remember { appPrefs.performLA.state }) { performLAToggle ->
|
||||
showAuthScreen.value = performLAToggle
|
||||
EnableLock(performLA) { performLAToggle ->
|
||||
performLA.value = performLAToggle
|
||||
chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (performLAToggle) {
|
||||
when (currentLAMode.state.value) {
|
||||
@@ -537,9 +543,7 @@ fun SimplexLockView(
|
||||
passcodeAlert(generalGetString(MR.strings.passcode_set))
|
||||
},
|
||||
cancel = {
|
||||
chatModel.showAuthScreen.value = false
|
||||
// Don't drop auth pref in case of state inconsistency (eg, you have set passcode but somehow bypassed toggle and turned it off and then on)
|
||||
// chatModel.controller.appPrefs.performLA.set(false)
|
||||
resetLAEnabled(false)
|
||||
},
|
||||
close = close
|
||||
)
|
||||
@@ -656,7 +660,7 @@ private fun EnableSelfDestruct(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnableLock(performLA: State<Boolean>, onCheckedChange: (Boolean) -> Unit) {
|
||||
private fun EnableLock(performLA: MutableState<Boolean>, onCheckedChange: (Boolean) -> Unit) {
|
||||
SectionItemView {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
@@ -235,7 +234,7 @@ fun ChatLockItem(
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
setPerformLA: (Boolean) -> Unit
|
||||
) {
|
||||
val performLA = remember { appPrefs.performLA.state }
|
||||
val performLA = remember { ChatModel.performLA }
|
||||
val currentLAMode = remember { ChatModel.controller.appPrefs.laMode }
|
||||
SettingsActionItemWithContent(
|
||||
click = showSettingsModal { SimplexLockView(ChatModel, currentLAMode, setPerformLA) },
|
||||
@@ -506,7 +505,6 @@ private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) ->
|
||||
authenticate(
|
||||
title,
|
||||
desc,
|
||||
oneTime = true,
|
||||
completed = { laResult ->
|
||||
onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
|
||||
import chat.simplex.common.ui.theme.SimpleXTheme
|
||||
@@ -26,6 +27,7 @@ import kotlinx.coroutines.*
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.event.WindowFocusListener
|
||||
import java.io.File
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
val simplexWindowState = SimplexWindowState()
|
||||
@@ -170,7 +172,7 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
|
||||
var windowFocused by remember { simplexWindowState.windowFocused }
|
||||
LaunchedEffect(windowFocused) {
|
||||
val delay = ChatController.appPrefs.laLockDelay.get()
|
||||
if (!windowFocused && ChatModel.showAuthScreen.value && delay > 0) {
|
||||
if (!windowFocused && ChatModel.performLA.value && delay > 0) {
|
||||
delay(delay * 1000L)
|
||||
// Trigger auth state check when delay ends (and if it ends)
|
||||
AppLock.recheckAuthState()
|
||||
|
||||
@@ -19,76 +19,12 @@ import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import kotlin.math.min
|
||||
|
||||
data class SemVer(
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
val patch: Int,
|
||||
val preRelease: String? = null,
|
||||
val buildNumber: Int? = null,
|
||||
): Comparable<SemVer?> {
|
||||
|
||||
val isNotStable: Boolean = preRelease != null
|
||||
|
||||
override fun compareTo(other: SemVer?): Int {
|
||||
if (other == null) return 1
|
||||
return when {
|
||||
major != other.major -> major.compareTo(other.major)
|
||||
minor != other.minor -> minor.compareTo(other.minor)
|
||||
patch != other.patch -> patch.compareTo(other.patch)
|
||||
preRelease != null && other.preRelease != null -> {
|
||||
val pr = preRelease.compareTo(other.preRelease, ignoreCase = true)
|
||||
when {
|
||||
pr != 0 -> pr
|
||||
buildNumber != null && other.buildNumber != null -> buildNumber.compareTo(other.buildNumber)
|
||||
buildNumber != null -> -1
|
||||
other.buildNumber != null -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
preRelease != null -> -1
|
||||
other.preRelease != null -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val regex = Regex("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([A-Za-z]+)\\.(\\d+))?\$")
|
||||
fun from(tagName: String): SemVer? {
|
||||
val trimmed = tagName.trimStart { it == 'v' }
|
||||
val redacted = when {
|
||||
trimmed.contains('-') && trimmed.substringBefore('-').count { it == '.' } == 1 -> "${trimmed.substringBefore('-')}.0-${trimmed.substringAfter('-')}"
|
||||
trimmed.substringBefore('-').count { it == '.' } == 1 -> "${trimmed}.0"
|
||||
else -> trimmed
|
||||
}
|
||||
val group = regex.matchEntire(redacted)?.groups
|
||||
return if (group != null) {
|
||||
SemVer(
|
||||
major = group[1]?.value?.toIntOrNull() ?: return null,
|
||||
minor = group[2]?.value?.toIntOrNull() ?: return null,
|
||||
patch = group[3]?.value?.toIntOrNull() ?: return null,
|
||||
preRelease = group[4]?.value,
|
||||
buildNumber = group[5]?.value?.toIntOrNull(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fromCurrentVersionName(): SemVer? {
|
||||
val currentVersionName = if (appPlatform.isAndroid) BuildConfigCommon.ANDROID_VERSION_NAME else BuildConfigCommon.DESKTOP_VERSION_NAME
|
||||
return from(currentVersionName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GitHubRelease(
|
||||
@@ -98,18 +34,12 @@ data class GitHubRelease(
|
||||
val htmlUrl: String,
|
||||
val name: String,
|
||||
val draft: Boolean,
|
||||
@SerialName("prerelease")
|
||||
private val preRelease: Boolean,
|
||||
val prerelease: Boolean,
|
||||
val body: String,
|
||||
@SerialName("published_at")
|
||||
val publishedAt: String,
|
||||
val assets: List<GitHubAsset>
|
||||
) {
|
||||
@Transient
|
||||
val semVer: SemVer? = SemVer.from(tagName)
|
||||
|
||||
val isConsideredBeta: Boolean = preRelease || semVer == null || semVer.isNotStable
|
||||
}
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitHubAsset(
|
||||
@@ -175,25 +105,25 @@ private fun createUpdateJob() {
|
||||
|
||||
fun checkForUpdate() {
|
||||
Log.d(TAG, "Checking for update")
|
||||
val currentSemVer = SemVer.fromCurrentVersionName()
|
||||
if (currentSemVer == null) {
|
||||
Log.e(TAG, "Current SemVer cannot be parsed")
|
||||
return
|
||||
}
|
||||
val client = setupHttpClient()
|
||||
try {
|
||||
val request = Request.Builder().url("https://api.github.com/repos/simplex-chat/simplex-chat/releases").addHeader("User-agent", "curl").build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
response.body?.use {
|
||||
val body = it.string()
|
||||
val releases = json.decodeFromString<List<GitHubRelease>>(body)
|
||||
val releases = json.decodeFromString<List<GitHubRelease>>(body).filterNot { it.draft }
|
||||
val release = when (appPrefs.appUpdateChannel.get()) {
|
||||
AppUpdatesChannel.STABLE -> releases.firstOrNull { r -> !r.draft && !r.isConsideredBeta && currentSemVer < r.semVer }
|
||||
AppUpdatesChannel.BETA -> releases.firstOrNull { r -> !r.draft && currentSemVer < r.semVer }
|
||||
AppUpdatesChannel.STABLE -> releases.firstOrNull { !it.prerelease }
|
||||
AppUpdatesChannel.BETA -> releases.firstOrNull()
|
||||
AppUpdatesChannel.DISABLED -> return
|
||||
} ?: return
|
||||
val currentVersionName = "v" + (if (appPlatform.isAndroid) BuildConfigCommon.ANDROID_VERSION_NAME else BuildConfigCommon.DESKTOP_VERSION_NAME)
|
||||
val redactedCurrentVersionName = when {
|
||||
currentVersionName.contains('-') && currentVersionName.substringBefore('-').count { it == '.' } == 1 -> "${currentVersionName.substringBefore('-')}.0-${currentVersionName.substringAfter('-')}"
|
||||
currentVersionName.substringBefore('-').count { it == '.' } == 1 -> "${currentVersionName}.0"
|
||||
else -> currentVersionName
|
||||
}
|
||||
|
||||
if (release == null || release.tagName == appPrefs.appSkippedUpdate.get()) {
|
||||
if (release.tagName == appPrefs.appSkippedUpdate.get() || release.tagName == currentVersionName || release.tagName == redactedCurrentVersionName) {
|
||||
Log.d(TAG, "Skipping update because of the same version or skipped version")
|
||||
return
|
||||
}
|
||||
@@ -368,15 +298,13 @@ private suspend fun downloadAsset(asset: GitHubAsset) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRunningFromAppImage(): Boolean = System.getenv("APPIMAGE") != null
|
||||
|
||||
private fun isRunningFromFlatpak(): Boolean = System.getenv("container") == "flatpak"
|
||||
|
||||
private fun chooseGitHubReleaseAssets(release: GitHubRelease): List<GitHubAsset> {
|
||||
val res = if (isRunningFromFlatpak()) {
|
||||
// No need to show download options for Flatpak users
|
||||
emptyList()
|
||||
} else if (!isRunningFromAppImage() && Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) {
|
||||
} else if (Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) {
|
||||
// Show all available .deb packages and user will choose the one that works on his system (for Debian derivatives)
|
||||
release.assets.filter { it.name.lowercase().endsWith(".deb") }
|
||||
} else {
|
||||
|
||||
@@ -7,11 +7,10 @@ actual fun authenticate(
|
||||
promptSubtitle: String,
|
||||
selfDestruct: Boolean,
|
||||
usingLAMode: LAMode,
|
||||
oneTime: Boolean,
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
when (usingLAMode) {
|
||||
LAMode.PASSCODE -> authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, oneTime, completed)
|
||||
LAMode.PASSCODE -> authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import chat.simplex.common.views.helpers.SemVer
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
// use this command for testing:
|
||||
// ./gradlew desktopTest
|
||||
class SemVerTest {
|
||||
@Test
|
||||
fun testValidSemVer() {
|
||||
assertEquals(SemVer.from("1.0.0"), SemVer(1, 0, 0))
|
||||
assertEquals(SemVer.from("1.0"), SemVer(1, 0, 0))
|
||||
assertEquals(SemVer.from("v1.0"), SemVer(1, 0, 0))
|
||||
assertEquals(SemVer.from("v1.0-beta.1"), SemVer(1, 0, 0, "beta", 1))
|
||||
val r = listOf<Pair<String, SemVer>>(
|
||||
"0.0.4" to SemVer(0, 0, 4),
|
||||
"1.2.3" to SemVer(1, 2, 3),
|
||||
"10.20.30" to SemVer(10, 20, 30),
|
||||
"1.0.0-alpha.1" to SemVer(1, 0, 0, "alpha", buildNumber = 1),
|
||||
"1.0.0" to SemVer(1, 0, 0),
|
||||
"2.0.0" to SemVer(2, 0, 0),
|
||||
"1.1.7" to SemVer(1, 1, 7),
|
||||
"2.0.1-alpha.1227" to SemVer(2, 0, 1, "alpha", 1227),
|
||||
)
|
||||
r.forEach { (value, correct) ->
|
||||
assertEquals(SemVer.from(value), correct)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testComparisonSemVer() {
|
||||
assert(SemVer(0, 1, 0) == SemVer.from("0.1.0"))
|
||||
assert(SemVer(1, 1, 0) == SemVer.from("v1.1.0"))
|
||||
assert(SemVer(0, 1, 0) > SemVer(0, 0, 1))
|
||||
assert(SemVer(1, 0, 0) > SemVer(0, 100, 100))
|
||||
assert(SemVer(0, 200, 0) > SemVer(0, 100, 100))
|
||||
assert(SemVer(0, 1, 0, "beta") > SemVer(0, 1, 0, "alpha"))
|
||||
assert(SemVer(0, 1, 0) > SemVer(0, 1, 0, "alpha"))
|
||||
assert(SemVer(0, 1, 0) > SemVer(0, 1, 0, "beta"))
|
||||
assert(SemVer(0, 1, 0) > SemVer(0, 1, 0, "beta.0"))
|
||||
assert(SemVer(0, 1, 0, "beta", 1) > SemVer(0, 1, 0, "beta", 0))
|
||||
assert(SemVer(0, 1, 0, "beta", 11) > SemVer(0, 1, 0, "beta", 10))
|
||||
assert(SemVer(0, 1, 0, "beta", 11) > SemVer(0, 1, 0, "beta", 9))
|
||||
assert(SemVer(0, 1, 0, "beta.1") > SemVer(0, 1, 0, "alpha.2"))
|
||||
assert(SemVer(1, 1, 0, "beta.1") > SemVer(0, 1, 0, "beta.1"))
|
||||
assert(SemVer(1, 0, 0) > SemVer(1, 0, 0, "beta.1"))
|
||||
assert(SemVer(1, 0, 0) > null)
|
||||
assert(SemVer.from("v6.0.0")!! > SemVer.from("v6.0.0-beta.3"))
|
||||
assert(SemVer.from("v6.0.0-beta.3")!! > SemVer.from("v6.0.0-beta.2"))
|
||||
assert(SemVer.from("0.1.0") == SemVer.from("0.1.0"))
|
||||
assert(SemVer.from("0.1.1")!! > SemVer.from("0.1.0"))
|
||||
assert(SemVer.from("0.2.1")!! > SemVer.from("0.1.1"))
|
||||
assert(SemVer.from("2.0.1")!! > SemVer.from("0.1.1"))
|
||||
assert(SemVer.from("0.1.1-beta.0")!! > SemVer.from("0.1.0-beta.0"))
|
||||
assert(SemVer.from("0.1.1-beta.0")!! == SemVer.from("0.1.1-beta.0"))
|
||||
assert(SemVer.from("0.1.1-beta.1")!! > SemVer.from("0.1.1-beta.0"))
|
||||
assert(SemVer.from("10.0.0-beta.12")!! > SemVer.from("1.1.1"))
|
||||
assert(SemVer.from("1.1.1-beta.120")!! > SemVer.from("1.1.1-alpha.9"))
|
||||
assert(SemVer.from("1.1.1-beta.120")!! > SemVer.from("1.1.1-alpha.120"))
|
||||
assert(SemVer.from("2.0.1")!! > SemVer.from("0.1.1"))
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ dependencies:
|
||||
- tls >= 1.9.0 && < 1.10
|
||||
- unliftio == 0.2.*
|
||||
- unliftio-core == 0.2.*
|
||||
- uuid == 1.3.*
|
||||
- zip == 2.0.*
|
||||
|
||||
flags:
|
||||
|
||||
@@ -38,38 +38,6 @@
|
||||
</description>
|
||||
|
||||
<releases>
|
||||
<release version="6.0.3" date="2024-08-24">
|
||||
<url type="details">https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html</url>
|
||||
<description>
|
||||
<p>New in v6.0.1-3:</p>
|
||||
<ul>
|
||||
<li>reduce app memory usage and start time.</li>
|
||||
<li>faster sending files to groups.</li>
|
||||
<li>fix rare delivery bug.</li>
|
||||
</ul>
|
||||
<p>New in v6.0:</p>
|
||||
<p>New chat experience:</p>
|
||||
<ul>
|
||||
<li>connect to your friends faster.</li>
|
||||
<li>archive contacts to chat later.</li>
|
||||
<li>delete up to 20 messages at once.</li>
|
||||
<li>increase font size.</li>
|
||||
</ul>
|
||||
<p>New media options:</p>
|
||||
<ul>
|
||||
<li>play from the chat list.</li>
|
||||
<li>blur for better privacy.</li>
|
||||
</ul>
|
||||
<p>Private routing:</p>
|
||||
<ul>
|
||||
<li>it protects your IP address and connections and is now enabled by default.</li>
|
||||
</ul>
|
||||
<p>Connection and servers information:</p>
|
||||
<ul>
|
||||
<li>to control your network status and usage.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="6.0.0" date="2024-08-12">
|
||||
<url type="details">https://github.com/simplex-chat/simplex-chat/releases/tag/v6.0.0</url>
|
||||
<description>
|
||||
|
||||
@@ -146,7 +146,6 @@ library
|
||||
Simplex.Chat.Migrations.M20240510_chat_items_via_proxy
|
||||
Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays
|
||||
Simplex.Chat.Migrations.M20240528_quota_err_counter
|
||||
Simplex.Chat.Migrations.M20240827_calls_uuid
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.File
|
||||
Simplex.Chat.Mobile.Shared
|
||||
@@ -230,7 +229,6 @@ library
|
||||
, tls >=1.9.0 && <1.10
|
||||
, unliftio ==0.2.*
|
||||
, unliftio-core ==0.2.*
|
||||
, uuid ==1.3.*
|
||||
, zip ==2.0.*
|
||||
default-language: Haskell2010
|
||||
if flag(swift)
|
||||
@@ -294,7 +292,6 @@ executable simplex-bot
|
||||
, tls >=1.9.0 && <1.10
|
||||
, unliftio ==0.2.*
|
||||
, unliftio-core ==0.2.*
|
||||
, uuid ==1.3.*
|
||||
, zip ==2.0.*
|
||||
default-language: Haskell2010
|
||||
if flag(swift)
|
||||
@@ -358,7 +355,6 @@ executable simplex-bot-advanced
|
||||
, tls >=1.9.0 && <1.10
|
||||
, unliftio ==0.2.*
|
||||
, unliftio-core ==0.2.*
|
||||
, uuid ==1.3.*
|
||||
, zip ==2.0.*
|
||||
default-language: Haskell2010
|
||||
if flag(swift)
|
||||
@@ -425,7 +421,6 @@ executable simplex-broadcast-bot
|
||||
, tls >=1.9.0 && <1.10
|
||||
, unliftio ==0.2.*
|
||||
, unliftio-core ==0.2.*
|
||||
, uuid ==1.3.*
|
||||
, zip ==2.0.*
|
||||
default-language: Haskell2010
|
||||
if flag(swift)
|
||||
@@ -490,7 +485,6 @@ executable simplex-chat
|
||||
, tls >=1.9.0 && <1.10
|
||||
, unliftio ==0.2.*
|
||||
, unliftio-core ==0.2.*
|
||||
, uuid ==1.3.*
|
||||
, websockets ==0.12.*
|
||||
, zip ==2.0.*
|
||||
default-language: Haskell2010
|
||||
@@ -561,7 +555,6 @@ executable simplex-directory-service
|
||||
, tls >=1.9.0 && <1.10
|
||||
, unliftio ==0.2.*
|
||||
, unliftio-core ==0.2.*
|
||||
, uuid ==1.3.*
|
||||
, zip ==2.0.*
|
||||
default-language: Haskell2010
|
||||
if flag(swift)
|
||||
@@ -662,7 +655,6 @@ test-suite simplex-chat-test
|
||||
, tls >=1.9.0 && <1.10
|
||||
, unliftio ==0.2.*
|
||||
, unliftio-core ==0.2.*
|
||||
, uuid ==1.3.*
|
||||
, zip ==2.0.*
|
||||
default-language: Haskell2010
|
||||
if flag(swift)
|
||||
|
||||
@@ -56,8 +56,6 @@ import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
|
||||
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
|
||||
import Data.Time.Clock.System (systemToUTCTime)
|
||||
import Data.Word (Word32)
|
||||
import qualified Data.UUID as UUID
|
||||
import qualified Data.UUID.V4 as V4
|
||||
import qualified Database.SQLite.Simple as SQL
|
||||
import Simplex.Chat.Archive
|
||||
import Simplex.Chat.Call
|
||||
@@ -1293,13 +1291,12 @@ processChatCommand' vr = \case
|
||||
withContactLock "sendCallInvitation" contactId $ do
|
||||
g <- asks random
|
||||
callId <- atomically $ CallId <$> C.randomBytes 16 g
|
||||
callUUID <- UUID.toText <$> liftIO V4.nextRandom
|
||||
dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing
|
||||
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
|
||||
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
|
||||
(msg, _) <- sendDirectContactMessage user ct (XCallInv callId invitation)
|
||||
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0)
|
||||
let call' = Call {contactId, callId, callUUID, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
|
||||
let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
|
||||
call_ <- atomically $ TM.lookupInsert contactId call' calls
|
||||
forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing
|
||||
toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci]
|
||||
@@ -1369,13 +1366,13 @@ processChatCommand' vr = \case
|
||||
rcvCallInvitations <- rights <$> mapM rcvCallInvitation invs
|
||||
pure $ CRCallInvitations rcvCallInvitations
|
||||
where
|
||||
callInvitation Call {contactId, callUUID, callState, callTs} = case callState of
|
||||
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callUUID, callTs, peerCallType, sharedKey)
|
||||
callInvitation Call {contactId, callState, callTs} = case callState of
|
||||
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey)
|
||||
_ -> Nothing
|
||||
rcvCallInvitation (contactId, callUUID, callTs, peerCallType, sharedKey) = runExceptT . withFastStore $ \db -> do
|
||||
rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withFastStore $ \db -> do
|
||||
user <- getUserByContactId db contactId
|
||||
contact <- getContact db vr user contactId
|
||||
pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callUUID, callTs}
|
||||
pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs}
|
||||
APIGetNetworkStatuses -> withUser $ \_ ->
|
||||
CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses
|
||||
APICallStatus contactId receivedStatus ->
|
||||
@@ -6096,10 +6093,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
g <- asks random
|
||||
dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing
|
||||
ci <- saveCallItem CISCallPending
|
||||
callUUID <- UUID.toText <$> liftIO V4.nextRandom
|
||||
let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair))
|
||||
callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey}
|
||||
call' = Call {contactId, callId, callUUID, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
|
||||
call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
|
||||
calls <- asks currentCalls
|
||||
-- theoretically, the new call invitation for the current contact can mark the in-progress call as ended
|
||||
-- (and replace it in ChatController)
|
||||
@@ -6107,7 +6103,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
withStore' $ \db -> createCall db user call' $ chatItemTs' ci
|
||||
call_ <- atomically (TM.lookupInsert contactId call' calls)
|
||||
forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing
|
||||
toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci}
|
||||
toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci}
|
||||
toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci]
|
||||
else featureRejected CFCalls
|
||||
where
|
||||
|
||||
@@ -29,7 +29,6 @@ import Simplex.Messaging.Util (decodeJSON, encodeJSON)
|
||||
data Call = Call
|
||||
{ contactId :: ContactId,
|
||||
callId :: CallId,
|
||||
callUUID :: Text,
|
||||
chatItemId :: Int64,
|
||||
callState :: CallState,
|
||||
callTs :: UTCTime
|
||||
@@ -112,7 +111,6 @@ data RcvCallInvitation = RcvCallInvitation
|
||||
contact :: Contact,
|
||||
callType :: CallType,
|
||||
sharedKey :: Maybe C.Key,
|
||||
callUUID :: Text,
|
||||
callTs :: UTCTime
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20240827_calls_uuid where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20240827_calls_uuid :: Query
|
||||
m20240827_calls_uuid =
|
||||
[sql|
|
||||
ALTER TABLE calls ADD COLUMN call_uuid TEXT NOT NULL DEFAULT "";
|
||||
|]
|
||||
|
||||
down_m20240827_calls_uuid :: Query
|
||||
down_m20240827_calls_uuid =
|
||||
[sql|
|
||||
ALTER TABLE calls DROP COLUMN call_uuid;
|
||||
|]
|
||||
@@ -415,8 +415,6 @@ CREATE TABLE calls(
|
||||
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
,
|
||||
call_uuid TEXT NOT NULL DEFAULT ""
|
||||
);
|
||||
CREATE TABLE commands(
|
||||
command_id INTEGER PRIMARY KEY AUTOINCREMENT, -- used as ACorrId
|
||||
|
||||
@@ -110,7 +110,6 @@ import Simplex.Chat.Migrations.M20240501_chat_deleted
|
||||
import Simplex.Chat.Migrations.M20240510_chat_items_via_proxy
|
||||
import Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays
|
||||
import Simplex.Chat.Migrations.M20240528_quota_err_counter
|
||||
import Simplex.Chat.Migrations.M20240827_calls_uuid
|
||||
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -220,8 +219,7 @@ schemaMigrations =
|
||||
("20240501_chat_deleted", m20240501_chat_deleted, Just down_m20240501_chat_deleted),
|
||||
("20240510_chat_items_via_proxy", m20240510_chat_items_via_proxy, Just down_m20240510_chat_items_via_proxy),
|
||||
("20240515_rcv_files_user_approved_relays", m20240515_rcv_files_user_approved_relays, Just down_m20240515_rcv_files_user_approved_relays),
|
||||
("20240528_quota_err_counter", m20240528_quota_err_counter, Just down_m20240528_quota_err_counter),
|
||||
("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid)
|
||||
("20240528_quota_err_counter", m20240528_quota_err_counter, Just down_m20240528_quota_err_counter)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -549,17 +549,17 @@ overwriteProtocolServers db User {userId} servers =
|
||||
protocol = decodeLatin1 $ strEncode $ protocolTypeI @p
|
||||
|
||||
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
|
||||
createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do
|
||||
createCall db user@User {userId} Call {contactId, callId, chatItemId, callState} callTs = do
|
||||
currentTs <- getCurrentTime
|
||||
deleteCalls db user contactId
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO calls
|
||||
(contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
(contact_id, shared_call_id, chat_item_id, call_state, call_ts, user_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(contactId, callId, callUUID, chatItemId, callState, callTs, userId, currentTs, currentTs)
|
||||
(contactId, callId, chatItemId, callState, callTs, userId, currentTs, currentTs)
|
||||
|
||||
deleteCalls :: DB.Connection -> User -> ContactId -> IO ()
|
||||
deleteCalls db User {userId} contactId = do
|
||||
@@ -572,13 +572,13 @@ getCalls db =
|
||||
db
|
||||
[sql|
|
||||
SELECT
|
||||
contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts
|
||||
contact_id, shared_call_id, chat_item_id, call_state, call_ts
|
||||
FROM calls
|
||||
ORDER BY call_ts ASC
|
||||
|]
|
||||
where
|
||||
toCall :: (ContactId, CallId, Text, ChatItemId, CallState, UTCTime) -> Call
|
||||
toCall (contactId, callId, callUUID, chatItemId, callState, callTs) = Call {contactId, callId, callUUID, chatItemId, callState, callTs}
|
||||
toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call
|
||||
toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs}
|
||||
|
||||
createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId
|
||||
createCommand db User {userId} connId commandFunction = do
|
||||
|
||||