Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: generate notification incoming/missed call - WPB-11664 #2397

Open
wants to merge 3 commits into
base: refactor/generate-notification-new-messages
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ enum Injector {
// MARK: - Resolve

static func resolve<Service>() -> Service {
typealias FactoryType = () -> Any
typealias FactoryType = (()) -> Any
return _genericResolve(serviceType: Service.self) { (factory: FactoryType) in
factory(())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import WireProtos

struct CallContent: Decodable {
let type: String
let properties: Properties?
let callerUserID: String?
let callerClientID: String
let resp: Bool

enum CodingKeys: String, CodingKey {
case type
case properties = "props"
case callerUserID = "src_userid"
case callerClientID = "src_clientid"
case resp
}

struct Properties: Decodable {
private let videosend: String

var isVideo: Bool {
videosend == "true"
}
}
}

extension CallContent {
static func decode(from calling: Calling) -> Self? {
let decoder = JSONDecoder()

guard let data = calling.content.data(using: .utf8) else {
return nil
}

do {
return try decoder.decode(Self.self, from: data)
} catch {
return nil
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import CallKit
import WireAPI
import WireDataModel
import WireLogging

protocol CallKitReporting {
static func reportIncomingCall(payload: [AnyHashable: Any]) async throws
}

extension CXProvider: CallKitReporting {
static func reportIncomingCall(payload: [AnyHashable: Any]) async throws {
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)
}
}

/// Handles a `CallKit` notification related to an incoming / ending call
struct CallKitNotificationBuilder: NotificationBuilder {

private enum CallKitState: Equatable {
case initiatesRinging
case terminatesRinging
case unhandled

init(callContent: CallContent, wasCallHandleReported: Bool) {
let isStartCall = callContent.type.isOne(of: ["SETUP", "GROUPSTART", "CONFSTART"])
let isIncomingCall = isStartCall && !callContent.resp
let isEndCall = callContent.type.isOne(of: ["CANCEL", "GROUPEND", "CONFEND"])
let isAnsweredElsewhere = isStartCall && callContent.resp
let isRejected = callContent.type == "REJECT"

if isIncomingCall && !wasCallHandleReported {
self = .initiatesRinging
} else if isEndCall || isAnsweredElsewhere || isRejected {
self = .terminatesRinging
} else {
self = .unhandled
}
}
}

private struct Context {
let accountID: String
let conversationID: String
let isGroupConversation: Bool
let callerName: String?
let conversationName: String?
let teamName: String?
let shouldRing: Bool
let isVideo: Bool
}

private struct Validator {
let isConversationMuted: Bool
let conversationNeedsBackendUpdate: Bool
let isConversationForcedReadOnly: Bool
let isAVSReady: Bool
let isCallKitReady: Bool
let isUserSessionLoaded: Bool
let isCallerSelf: Bool
let isCallStateValid: Bool

func validate() -> Bool {
!conversationNeedsBackendUpdate
&& !isConversationMuted
&& !isConversationForcedReadOnly
&& isAVSReady
&& isCallKitReady
&& isUserSessionLoaded
&& isCallStateValid
}
}

private let context: Context
private let validator: Validator
private let callKitReporter: CallKitReporting.Type

init?(
calling: Calling,
conversationID: ConversationID,
senderID: UserID,
accountID: UUID,
userDefaults: UserDefaults = .standard,
callKitReporting: CallKitReporting.Type = CXProvider.self
) async {
guard let callContent: CallContent = .decode(from: calling) else {
return nil
}

let handle = "\(accountID.transportString())+\(conversationID.uuid.transportString())"
let knownCallHandles = userDefaults.object(forKey: "knownCalls") as? [String] ?? []
let wasCallHandleReported = knownCallHandles.contains(handle)

let callKitState = CallKitState(
callContent: callContent,
wasCallHandleReported: wasCallHandleReported
)

let conversationLocalStore: ConversationLocalStoreProtocol = Injector.resolve()
let userLocalStore: UserLocalStoreProtocol = Injector.resolve()

let conversation = await conversationLocalStore.fetchOrCreateConversation(
id: conversationID.uuid,
domain: conversationID.domain
)
let selfUser = await userLocalStore.fetchSelfUser()
let caller = await userLocalStore.fetchOrCreateUser(
id: senderID.uuid,
domain: senderID.domain
)

// Validation criteria

let needsToBeUpdatedFromBackend = await conversationLocalStore.conversationNeedsBackendUpdate(conversation)
let mutedMessagesTypes = await conversationLocalStore
.conversationMutedMessageTypesIncludingAvailability(conversation)
let isConversationMuted = mutedMessagesTypes == .all
let isConversationForcedReadOnly = await conversationLocalStore.isConversationForcedReadOnly(conversation)
let isAVSReady = userDefaults.bool(forKey: "isAVSReady")
let isCallKitReady = userDefaults.bool(forKey: "isCallKitAvailable")
let loadedUserSessions = userDefaults.object(forKey: "loadedUserSessions") as? [String] ?? []
let loaderUserSessionsIDs = loadedUserSessions.compactMap(UUID.init(uuidString:))
let isUserSessionLoaded = loaderUserSessionsIDs.contains(accountID)

self.validator = Validator(
isConversationMuted: isConversationMuted,
conversationNeedsBackendUpdate: needsToBeUpdatedFromBackend,
isConversationForcedReadOnly: isConversationForcedReadOnly,
isAVSReady: isAVSReady,
isCallKitReady: isCallKitReady,
isUserSessionLoaded: isUserSessionLoaded,
isCallerSelf: selfUser == caller,
isCallStateValid: callKitState != .unhandled
)

// Context

let isGroupConversation = await conversationLocalStore.isGroupConversation(conversation)
let conversationName = await conversationLocalStore.name(for: conversation)
let teamName = await userLocalStore.teamName(for: selfUser)
let callerName = await userLocalStore.name(for: caller)

self.context = Context(
accountID: accountID.uuidString,
conversationID: conversationID.uuid.uuidString,
isGroupConversation: isGroupConversation,
callerName: callerName,
conversationName: conversationName,
teamName: teamName,
shouldRing: callKitState == .initiatesRinging,
isVideo: callContent.properties?.isVideo ?? false
)

self.callKitReporter = callKitReporting
}

func shouldBuildNotification() async -> Bool {
validator.validate()
}

func buildContent() async -> UNMutableNotificationContent {
let payload: [String: Any] = [
"accountID": context.accountID,
"conversationID": context.conversationID,
"shouldRing": context.shouldRing,
"callerName": makeTitle() ?? "",
"hasVideo": context.isVideo
]

do {
WireLogger.calling.info("waking up main app to handle call event")
try await callKitReporter.reportIncomingCall(payload: payload)
} catch {
WireLogger.calling.error(
"failed to wake up main app: \(error.localizedDescription)"
)
}

// `CallKit` notification is automatically generated by the system.
// so we simply return an empty notification to comply with the protocol requirement.
return UNMutableNotificationContent()
}

// MARK: - Helpers

private func makeTitle() -> String? {
let isGroupConversation = context.isGroupConversation
let teamName = context.teamName
let conversationName = context.conversationName
let callerName = context.callerName

guard let conversationName, let callerName else {
return nil
}

let format: NotificationTitle.MessageTitleFormat = if isGroupConversation {
if let teamName {
.conversationInTeam(conversation: conversationName, team: teamName)
} else {
.conversation(conversation: conversationName)
}
} else {
if let teamName {
.senderInTeam(sender: callerName, team: teamName)
} else {
.sender(sender: callerName)
}
}

return NotificationTitle
.newMessage(format)
.make()
}

}
Loading
Loading