TF-2384 Handle NSE to get plain notification in iOS

Signed-off-by: dab246 <tdvu@linagora.com>
(cherry picked from commit 364f32a14b9888f73a3a103b8b5e1bc9b31e148e)
This commit is contained in:
dab246
2023-12-24 20:09:25 +07:00
committed by Dat H. Pham
parent edcc30808c
commit a1f86d502a
18 changed files with 932 additions and 73 deletions
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychainAccessGroupIdentifier</key>
<string>KUT463DS29.com.linagora.ios.teammail.shared</string>
<key>baseBundleIdentifier</key>
<string>com.linagora.ios.teammail</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,12 @@
import Foundation
struct KeychainCredentials {
let accountId: String
let sharingSession: KeychainSharingSession
}
protocol KeychainControllerDelegate: AnyObject {
func retrieveSharingSessionFromKeychain(accountId: String) -> KeychainSharingSession?
func retrieveSharingSessions() -> [KeychainCredentials]
func updateEmailStateToKeychain(accountId: String, newState: String)
}
@@ -0,0 +1,51 @@
import Foundation
import KeychainAccess
enum KeychainControllerService: String {
case sessions
var identifier: String {
InfoPlistReader.main.baseBundleIdentifier + "." + rawValue
}
}
class KeychainController: KeychainControllerDelegate {
private let keychain: Keychain
init(service: KeychainControllerService,
accessGroup: String) {
keychain = Keychain(service: service.identifier,
accessGroup: accessGroup)
}
func retrieveSharingSessionFromKeychain(accountId: String) -> KeychainSharingSession? {
do {
guard let sessionData = try keychain.getData(accountId) else {
return nil
}
return try JSONDecoder().decode(KeychainSharingSession.self, from: sessionData)
} catch {
return nil
}
}
func retrieveSharingSessions() -> [KeychainCredentials] {
keychain.allKeys().compactMap { accountId in
guard let sharingSession = retrieveSharingSessionFromKeychain(accountId: accountId) else {
return nil
}
return KeychainCredentials(accountId: accountId, sharingSession: sharingSession)
}
}
func updateEmailStateToKeychain(accountId: String, newState: String) {
do {
if let sharingSession = retrieveSharingSessionFromKeychain(accountId: accountId) {
let newSharingSession = sharingSession.updateEmailState(newState: newState)
try keychain.set(newSharingSession.toJson() ?? "", key: accountId)
}
} catch {}
}
}
@@ -0,0 +1,39 @@
import Foundation
struct KeychainSharingSession: Codable {
let accountId: String
let userName: String
let authenticationType: AuthenticationType
let apiUrl: String
let emailState: String?
let tokenOIDC: TokenOidc?
let basicAuth: String?
}
extension KeychainSharingSession {
func updateEmailState(newState: String) -> KeychainSharingSession {
return KeychainSharingSession(
accountId: self.accountId,
userName: self.userName,
authenticationType: self.authenticationType,
apiUrl: self.apiUrl,
emailState: newState,
tokenOIDC: self.tokenOIDC,
basicAuth: self.basicAuth
)
}
func toData() -> Data? {
if let encodedData = try? JSONEncoder().encode(self) {
return encodedData
}
return nil
}
func toJson() -> String? {
if let data = toData(), let jsonString = String(data: data, encoding: .utf8) {
return jsonString
}
return nil
}
}
+7
View File
@@ -0,0 +1,7 @@
import Foundation
enum TypeName: String {
case mailbox = "Mailbox"
case email = "Email"
case EmailDelivery = "EmailDelivery"
}
+100
View File
@@ -0,0 +1,100 @@
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
private var handler: ((UNNotificationContent) -> Void)?
private var modifiedContent: UNMutableNotificationContent?
private lazy var keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard let payloadData = request.content.userInfo as? [String: Any],
!keychainController.retrieveSharingSessions().isEmpty else {
return self.discard()
}
handler = contentHandler
modifiedContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
self.modifiedContent?.title = InfoPlistReader(bundle: .app).bundleDisplayName
Task {
await handleGetNewEmails(payloadData: payloadData)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
notify()
}
private func handleGetNewEmails(payloadData: [String: Any]) async {
let mapStateChanges: [String: [TypeName: String]] = PayloadParser.shared.parsingPayloadNotification(payloadData: payloadData)
if (mapStateChanges.isEmpty) {
return self.discard()
} else {
guard let currentAccountId = mapStateChanges.keys.first,
let keychainSharingSession = keychainController.retrieveSharingSessionFromKeychain(accountId: currentAccountId),
let listStateOfAccount = mapStateChanges[currentAccountId],
let newEmailState = listStateOfAccount[TypeName.EmailDelivery],
let oldEmailState = keychainSharingSession.emailState,
newEmailState != oldEmailState,
keychainSharingSession.tokenOIDC != nil || keychainSharingSession.basicAuth != nil else {
return self.discard()
}
JmapClient.shared.getNewEmails(
apiUrl: keychainSharingSession.apiUrl,
accountId: keychainSharingSession.accountId,
sinceState: oldEmailState,
authenticationType: keychainSharingSession.authenticationType,
tokenOidc: keychainSharingSession.tokenOIDC,
basicAuth: keychainSharingSession.basicAuth,
onSuccess: { emails in
self.keychainController.updateEmailStateToKeychain(accountId: keychainSharingSession.accountId, newState: newEmailState)
self.modifiedContent?.subtitle = emails.first?.subject ?? ""
self.modifiedContent?.body = emails.first?.preview ?? ""
self.modifiedContent?.badge = NSNumber(value: emails.count)
return self.notify()
},
onFailure: { error in
if let errorJmap = error as? JmapExceptions, errorJmap == JmapExceptions.notFoundNewEmails {
return self.discard()
} else {
self.modifiedContent?.body = "You have new emails"
self.modifiedContent?.badge = NSNumber(value: 1)
return self.notify()
}
}
)
}
}
private func notify() {
guard let modifiedContent else {
return discard()
}
handler?(modifiedContent)
cleanUp()
}
private func discard() {
handler?(UNMutableNotificationContent())
cleanUp()
}
private func cleanUp() {
handler = nil
modifiedContent = nil
}
deinit {
cleanUp()
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.linagora.teammail</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.linagora.ios.teammail.shared</string>
</array>
</dict>
</plist>
@@ -0,0 +1,16 @@
import Foundation
public extension Bundle {
/// The top-level bundle that contains the entire app.
static var app: Bundle {
var bundle = Bundle.main
if bundle.bundleURL.pathExtension == "appex" {
// Peel off two directory levels - MY_APP.app/PlugIns/MY_APP_EXTENSION.appex
let url = bundle.bundleURL.deletingLastPathComponent().deletingLastPathComponent()
if let otherBundle = Bundle(url: url) {
bundle = otherBundle
}
}
return bundle
}
}
@@ -0,0 +1,45 @@
import Foundation
struct InfoPlistReader {
private enum Keys {
static let baseBundleIdentifier = "baseBundleIdentifier"
static let keychainAccessGroupIdentifier = "keychainAccessGroupIdentifier"
static let bundleDisplayName = "CFBundleDisplayName"
}
/// Info.plist reader on the bundle object that contains the current executable.
static let main = InfoPlistReader(bundle: .main)
/// Info.plist reader on the bundle object that contains the main app executable.
static let app = InfoPlistReader(bundle: .app)
private let bundle: Bundle
/// Initializer
/// - Parameter bundle: bundle to read values from
init(bundle: Bundle) {
self.bundle = bundle
}
/// Base bundle identifier set in Info.plist of the target
var baseBundleIdentifier: String {
infoPlistValue(forKey: Keys.baseBundleIdentifier)
}
/// Keychain access group identifier set in Info.plist of the target
var keychainAccessGroupIdentifier: String {
infoPlistValue(forKey: Keys.keychainAccessGroupIdentifier)
}
/// Bundle display name of the target
var bundleDisplayName: String {
infoPlistValue(forKey: Keys.bundleDisplayName)
}
private func infoPlistValue<T>(forKey key: String) -> T {
guard let result = bundle.object(forInfoDictionaryKey: key) as? T else {
fatalError("Add \(key) into your target's Info.plst")
}
return result
}
}
@@ -0,0 +1,37 @@
import Foundation
class PayloadParser {
static let shared: PayloadParser = PayloadParser()
private let prefixState: String = ":"
private func validatePushNotificationStateChange(state: String) -> Bool {
return state.contains(prefixState) &&
(state.contains(TypeName.mailbox.rawValue) ||
state.contains(TypeName.email.rawValue) ||
state.contains(TypeName.EmailDelivery.rawValue))
}
func parsingPayloadNotification(payloadData: [String: Any]) -> [String: [TypeName: String]]{
var mapStateChanges = [String: [TypeName: String]]()
payloadData.keys.forEach { key in
if validatePushNotificationStateChange(state: key),
let accountId = key.components(separatedBy: prefixState).first,
let typeName = TypeName(rawValue: key.components(separatedBy: prefixState).last ?? ""),
let stateValue = payloadData[key] as? String {
if (mapStateChanges.keys.contains(accountId)) {
var mapTypes = mapStateChanges[accountId]!
mapTypes[typeName] = stateValue
mapStateChanges[accountId] = mapTypes
} else {
var mapTypes = [TypeName: String]()
mapTypes[typeName] = stateValue
mapStateChanges[accountId] = mapTypes
}
}
}
return mapStateChanges
}
}