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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum TypeName: String {
|
||||
case mailbox = "Mailbox"
|
||||
case email = "Email"
|
||||
case EmailDelivery = "EmailDelivery"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user