Merge branch 'stable'

This commit is contained in:
Evgeny Poberezkin 2024-08-28 18:04:50 +01:00
commit 94c552ca12
No known key found for this signature in database
GPG key ID: 494BDDD9A28B577D
24 changed files with 368 additions and 103 deletions

View file

@ -2199,9 +2199,11 @@ func refreshCallInvitations() async throws {
}
}
func justRefreshCallInvitations() throws {
func justRefreshCallInvitations() async throws {
let callInvitations = try apiGetCallInvitationsSync()
ChatModel.shared.callInvitations = callsByChat(callInvitations)
await MainActor.run {
ChatModel.shared.callInvitations = callsByChat(callInvitations)
}
}
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
@ -2211,12 +2213,13 @@ 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]?.callkitUUID = nil
m.callInvitations[callInvitation.contact.id]?.callUUID = nil
}
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {

View file

@ -185,7 +185,7 @@ struct ActiveCallView: View {
case .ended:
closeCallView(client)
call.callState = .ended
if let uuid = call.callkitUUID {
if let uuid = call.callUUID {
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.callkitUUID {
if let uuid = call.callUUID {
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, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
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)))
.background(.black)
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
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)))
.background(.black)
}
}

View file

@ -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) {
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else {
@ -61,12 +61,30 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
logger.debug("CallController.provider CXAnswerCallAction")
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()
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()
}
}
}
}
@ -75,7 +93,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) { ok in
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
if ok {
action.fulfill()
} else {
@ -86,7 +104,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) {
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
action.fulfill()
} else {
action.fail()
@ -156,6 +174,19 @@ 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)")
@ -171,32 +202,19 @@ 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 invitation = m.callInvitations[contactId] {
let update = self.cxCallUpdate(invitation: invitation)
if let uuid = invitation.callkitUUID {
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 {
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)
@ -205,11 +223,31 @@ 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,
@ -239,8 +277,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
@ -261,6 +299,14 @@ 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() {
@ -272,14 +318,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 uuid = call.callkitUUID {
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
}
}
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} else if invitation.contact.id == activeCallInvitation?.contact.id {
activeCallInvitation = nil
@ -288,14 +334,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportCallRemoteEnded(call: Call) {
logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let uuid = call.callkitUUID {
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
}
}
func startCall(_ contact: Contact, _ media: CallMediaType) {
logger.debug("CallController.startCall")
let uuid = callManager.newOutgoingCall(contact, media)
let callUUID = callManager.newOutgoingCall(contact, media)
guard let uuid = UUID(uuidString: callUUID) else {
return
}
if CallController.useCallKit() {
let handle = CXHandle(type: .generic, value: contact.id)
let action = CXStartCallAction(call: uuid, handle: handle)
@ -307,8 +356,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
update.localizedCallerName = contact.displayName
self.provider.reportCall(with: uuid, updated: update)
}
} else if callManager.startOutgoingCall(callUUID: uuid) {
if callManager.startOutgoingCall(callUUID: uuid) {
} else if callManager.startOutgoingCall(callUUID: callUUID) {
if callManager.startOutgoingCall(callUUID: callUUID) {
logger.debug("CallController.startCall: call started")
} else {
logger.error("CallController.startCall: no active call")
@ -318,8 +367,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func answerCall(invitation: RcvCallInvitation) {
logger.debug("CallController: answering a call")
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
requestTransaction(with: CXAnswerCallAction(call: callUUID))
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
requestTransaction(with: CXAnswerCallAction(call: uuid))
} else {
callManager.answerIncomingCall(invitation: invitation)
}
@ -328,10 +377,13 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
func endCall(callUUID: UUID) {
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
func endCall(callUUID: String) {
let uuid = UUID(uuidString: callUUID)
logger.debug("CallController: ending the call with UUID \(callUUID)")
if CallController.useCallKit() {
requestTransaction(with: CXEndCallAction(call: callUUID))
if let uuid {
requestTransaction(with: CXEndCallAction(call: uuid))
}
} else {
callManager.endCall(callUUID: callUUID) { ok in
if ok {

View file

@ -10,17 +10,17 @@ import Foundation
import SimpleXChat
class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
let uuid = UUID()
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
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)
call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call
return uuid
}
func startOutgoingCall(callUUID: UUID) -> Bool {
func startOutgoingCall(callUUID: String) -> Bool {
let m = ChatModel.shared
if let call = m.activeCall, call.callkitUUID == callUUID {
if let call = m.activeCall, call.callUUID == 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: UUID) -> Bool {
func answerIncomingCall(callUUID: String) -> 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,
callkitUUID: invitation.callkitUUID,
callUUID: invitation.callUUID,
callState: .invitationAccepted,
localMedia: invitation.callType.media,
sharedKey: invitation.sharedKey
@ -68,8 +68,8 @@ class CallManager {
}
}
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
func enableMedia(media: CallMediaType, enable: Bool, callUUID: String) -> Bool {
if let call = ChatModel.shared.activeCall, call.callUUID == 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: UUID, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callUUID == 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: UUID) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
return invitation
}
return nil

View file

@ -18,7 +18,7 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection
var contact: Contact
var callkitUUID: UUID?
var callUUID: String?
var localMedia: CallMediaType
@Published var callState: CallState
@Published var localCapabilities: CallCapabilities?
@ -33,14 +33,14 @@ class Call: ObservableObject, Equatable {
init(
direction: CallDirection,
contact: Contact,
callkitUUID: UUID?,
callUUID: String?,
callState: CallState,
localMedia: CallMediaType,
sharedKey: String? = nil
) {
self.direction = direction
self.contact = contact
self.callkitUUID = callkitUUID
self.callUUID = callUUID
self.callState = callState
self.localMedia = localMedia
self.sharedKey = sharedKey

View file

@ -568,8 +568,8 @@ struct ChatView: View {
private func endCallButton(_ call: Call) -> some View {
Button {
if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid)
if CallController.useCallKit(), let callUUID = call.callUUID {
CallController.shared.endCall(callUUID: callUUID)
} else {
CallController.shared.endCall(call: call) {}
}

View file

@ -339,7 +339,9 @@ class NotificationService: UNNotificationServiceExtension {
CXProvider.reportNewIncomingVoIPPushPayload([
"displayName": invitation.contact.displayName,
"contactId": invitation.contact.id,
"media": invitation.callType.media.rawValue
"callUUID": invitation.callUUID ?? "",
"media": invitation.callType.media.rawValue,
"callTs": invitation.callTs.timeIntervalSince1970
]) { error in
logger.debug("reportNewIncomingVoIPPushPayload result: \(error)")
deliver(error == nil ? nil : createCallInvitationNtf(invitation))

View file

@ -42,6 +42,7 @@ 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 {
@ -52,10 +53,8 @@ public struct RcvCallInvitation: Decodable {
}
}
public var callkitUUID: UUID? = UUID()
private enum CodingKeys: String, CodingKey {
case user, contact, callType, sharedKey, callTs
case user, contact, callType, sharedKey, callUUID, callTs
}
public static let sampleData = RcvCallInvitation(

View file

@ -424,6 +424,7 @@ fun PreviewIncomingCallLockScreenAlert() {
) {
IncomingCallLockScreenAlertLayout(
invitation = RcvCallInvitation(
callUUID = "",
remoteHostId = null,
user = User.sampleData,
contact = Contact.sampleData,

View file

@ -47,6 +47,7 @@ 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,

View file

@ -115,6 +115,7 @@ fun PreviewIncomingCallAlertLayout() {
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callUUID = "",
callTs = Clock.System.now()
),
chatModel = ChatModel,

View file

@ -13,6 +13,7 @@ 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,
@ -105,6 +106,7 @@ 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) {

View file

@ -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, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callUUID = null, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}

View file

@ -19,12 +19,76 @@ 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(
@ -34,12 +98,18 @@ data class GitHubRelease(
val htmlUrl: String,
val name: String,
val draft: Boolean,
val prerelease: Boolean,
@SerialName("prerelease")
private 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(
@ -105,25 +175,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).filterNot { it.draft }
val releases = json.decodeFromString<List<GitHubRelease>>(body)
val release = when (appPrefs.appUpdateChannel.get()) {
AppUpdatesChannel.STABLE -> releases.firstOrNull { !it.prerelease }
AppUpdatesChannel.BETA -> releases.firstOrNull()
AppUpdatesChannel.STABLE -> releases.firstOrNull { r -> !r.draft && !r.isConsideredBeta && currentSemVer < r.semVer }
AppUpdatesChannel.BETA -> releases.firstOrNull { r -> !r.draft && currentSemVer < r.semVer }
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.tagName == appPrefs.appSkippedUpdate.get() || release.tagName == currentVersionName || release.tagName == redactedCurrentVersionName) {
if (release == null || release.tagName == appPrefs.appSkippedUpdate.get()) {
Log.d(TAG, "Skipping update because of the same version or skipped version")
return
}
@ -298,13 +368,15 @@ 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 (Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) {
} else if (!isRunningFromAppImage() && 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 {

View file

@ -0,0 +1,63 @@
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"))
}
}

View file

@ -48,6 +48,7 @@ dependencies:
- tls >= 1.9.0 && < 1.10
- unliftio == 0.2.*
- unliftio-core == 0.2.*
- uuid == 1.3.*
- zip == 2.0.*
flags:

View file

@ -38,6 +38,38 @@
</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>

View file

@ -146,6 +146,7 @@ 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
@ -229,6 +230,7 @@ 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)
@ -292,6 +294,7 @@ 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)
@ -355,6 +358,7 @@ 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)
@ -421,6 +425,7 @@ 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)
@ -485,6 +490,7 @@ 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
@ -555,6 +561,7 @@ 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)
@ -655,6 +662,7 @@ 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)

View file

@ -56,6 +56,8 @@ 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
@ -1291,12 +1293,13 @@ 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, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
let call' = Call {contactId, callId, callUUID, 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]
@ -1366,13 +1369,13 @@ processChatCommand' vr = \case
rcvCallInvitations <- rights <$> mapM rcvCallInvitation invs
pure $ CRCallInvitations rcvCallInvitations
where
callInvitation Call {contactId, callState, callTs} = case callState of
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey)
callInvitation Call {contactId, callUUID, callState, callTs} = case callState of
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callUUID, callTs, peerCallType, sharedKey)
_ -> Nothing
rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withFastStore $ \db -> do
rcvCallInvitation (contactId, callUUID, callTs, peerCallType, sharedKey) = runExceptT . withFastStore $ \db -> do
user <- getUserByContactId db contactId
contact <- getContact db vr user contactId
pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs}
pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callUUID, callTs}
APIGetNetworkStatuses -> withUser $ \_ ->
CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses
APICallStatus contactId receivedStatus ->
@ -6093,9 +6096,10 @@ 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, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
call' = Call {contactId, callId, callUUID, 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)
@ -6103,7 +6107,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, callTs = chatItemTs' ci}
toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci}
toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci]
else featureRejected CFCalls
where

View file

@ -29,6 +29,7 @@ import Simplex.Messaging.Util (decodeJSON, encodeJSON)
data Call = Call
{ contactId :: ContactId,
callId :: CallId,
callUUID :: Text,
chatItemId :: Int64,
callState :: CallState,
callTs :: UTCTime
@ -111,6 +112,7 @@ data RcvCallInvitation = RcvCallInvitation
contact :: Contact,
callType :: CallType,
sharedKey :: Maybe C.Key,
callUUID :: Text,
callTs :: UTCTime
}
deriving (Show)

View file

@ -0,0 +1,18 @@
{-# 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;
|]

View file

@ -415,6 +415,8 @@ 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

View file

@ -110,6 +110,7 @@ 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)]
@ -219,7 +220,8 @@ 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)
("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)
]
-- | The list of migrations in ascending order by date

View file

@ -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, chatItemId, callState} callTs = do
createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do
currentTs <- getCurrentTime
deleteCalls db user contactId
DB.execute
db
[sql|
INSERT INTO calls
(contact_id, shared_call_id, chat_item_id, call_state, call_ts, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
(contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?)
|]
(contactId, callId, chatItemId, callState, callTs, userId, currentTs, currentTs)
(contactId, callId, callUUID, 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, chat_item_id, call_state, call_ts
contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts
FROM calls
ORDER BY call_ts ASC
|]
where
toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call
toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs}
toCall :: (ContactId, CallId, Text, ChatItemId, CallState, UTCTime) -> Call
toCall (contactId, callId, callUUID, chatItemId, callState, callTs) = Call {contactId, callId, callUUID, chatItemId, callState, callTs}
createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId
createCommand db User {userId} connId commandFunction = do