From 3e4450755f9d1ee96b3464f643ea35709146a3a2 Mon Sep 17 00:00:00 2001 From: Finn Behrens Date: Fri, 20 Aug 2021 15:09:59 +0200 Subject: [PATCH] Add AppDelegate and AccountStore implementation of Push notifications --- Configs/GlobalConfig.xcconfig | 1 + Nio.xcodeproj/project.pbxproj | 4 + Nio/AppDelegate.swift | 108 +++++++++++++++++++++ Nio/Info.plist | 2 + Nio/Nio.entitlements | 4 + Nio/NioApp.swift | 4 + Nio/Settings/SettingsView.swift | 49 ++++++++++ NioKit/Extensions/MXRestClient+Async.swift | 22 +++++ NioKit/Session/AccountStore.swift | 41 ++++++++ 9 files changed, 235 insertions(+) create mode 100644 Nio/AppDelegate.swift diff --git a/Configs/GlobalConfig.xcconfig b/Configs/GlobalConfig.xcconfig index 83152481..64d8df84 100644 --- a/Configs/GlobalConfig.xcconfig +++ b/Configs/GlobalConfig.xcconfig @@ -10,6 +10,7 @@ NIO_NAMESPACE = com.example.nio DEVELOPMENT_TEAM = Z123456789 +NIO_PUSHER_URL = sentinel.nio.chat APPGROUP = $(NIO_NAMESPACE) PRODUCT_BUNDLE_IDENTIFIER = $(NIO_NAMESPACE).iOS diff --git a/Nio.xcodeproj/project.pbxproj b/Nio.xcodeproj/project.pbxproj index 09909d37..7ac2b638 100644 --- a/Nio.xcodeproj/project.pbxproj +++ b/Nio.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ 4BFEFD86246F414D00CCF4A0 /* NioShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4BFEFD7C246F414D00CCF4A0 /* NioShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4BFEFD8C246F458000CCF4A0 /* GetURL.js in Resources */ = {isa = PBXBuildFile; fileRef = 4BFEFD8B246F458000CCF4A0 /* GetURL.js */; }; 4BFEFD92246F686000CCF4A0 /* ShareContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BFEFD91246F686000CCF4A0 /* ShareContentView.swift */; }; + 9504FC0326CFD560007E89E1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9504FC0226CFD560007E89E1 /* AppDelegate.swift */; }; 955A0D3D26BC1B310027D188 /* MXSession+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955A0D3C26BC1B310027D188 /* MXSession+Async.swift */; }; 955A0D3E26BC1B310027D188 /* MXSession+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955A0D3C26BC1B310027D188 /* MXSession+Async.swift */; }; 955A0D4026BC1BCD0027D188 /* Continuation+MX.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955A0D3F26BC1BCD0027D188 /* Continuation+MX.swift */; }; @@ -374,6 +375,7 @@ 4BFEFD8F246F5EE000CCF4A0 /* NioShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NioShareExtension.entitlements; sourceTree = ""; }; 4BFEFD90246F5EF500CCF4A0 /* Nio.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Nio.entitlements; sourceTree = ""; }; 4BFEFD91246F686000CCF4A0 /* ShareContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContentView.swift; sourceTree = ""; }; + 9504FC0226CFD560007E89E1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 955A0D3C26BC1B310027D188 /* MXSession+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MXSession+Async.swift"; sourceTree = ""; }; 955A0D3F26BC1BCD0027D188 /* Continuation+MX.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Continuation+MX.swift"; sourceTree = ""; }; 955A0D4226BC1E2C0027D188 /* MXAutoDiscovery+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MXAutoDiscovery+Async.swift"; sourceTree = ""; }; @@ -688,6 +690,7 @@ 390D63BA246F4BEE00B8F640 /* Resources */, 39C931F3238449C2004449E1 /* Supporting Files */, 39C931E42384328B004449E1 /* Preview Content */, + 9504FC0226CFD560007E89E1 /* AppDelegate.swift */, ); path = Nio; sourceTree = ""; @@ -1318,6 +1321,7 @@ 3902B8A52395A77800698B87 /* LoadingView.swift in Sources */, CADF662424614A3300F5063F /* ReactionGroupView.swift in Sources */, 392221B6243F88FD004D8794 /* RoomNameEventView.swift in Sources */, + 9504FC0326CFD560007E89E1 /* AppDelegate.swift in Sources */, 4B0A2E47245E2EF800A79443 /* MultilineTextField.swift in Sources */, A51F762C25D6E9950061B4A4 /* MessageTextViewWrapper.swift in Sources */, A51BF8CE254C2FE5000FB0A4 /* NioApp.swift in Sources */, diff --git a/Nio/AppDelegate.swift b/Nio/AppDelegate.swift new file mode 100644 index 00000000..fe239f3f --- /dev/null +++ b/Nio/AppDelegate.swift @@ -0,0 +1,108 @@ +// +// AppDelegate.swift +// AppDelegate +// +// Created by Finn Behrens on 20.08.21. +// Copyright © 2021 Kilian Koeltzsch. All rights reserved. +// + +import Foundation +import UIKit + +import MatrixSDK + +import NioKit + +@MainActor +class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject { + public static var shared = AppDelegate(); + + var isPushAllowed: Bool = false + + @Published + var deviceToken: String? + + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + Self.shared = self + let notificationCenter = UNUserNotificationCenter.current() + notificationCenter.delegate = self + return true + } + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + let notificationCenter = UNUserNotificationCenter.current() + + self.createMessageActions(notificationCenter: notificationCenter) + + Task.init(priority: .userInitiated) { + do { + let state = try await notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) + self.isPushAllowed = state + application.registerForRemoteNotifications() + } catch { + print("error requesting UNUserNotificationCenter: \(error.localizedDescription)") + } + + // todo: requestSiriAuthorization() + } + + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenString = deviceToken.reduce("", {$0 + String(format: "%02X", $1)}) + print("remote notifications token: \(tokenString)") + self.deviceToken = deviceToken.base64EncodedString() + // FIXME: dispatch a background process to set the token + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + // TODO: show notification to user + print("error registering token: \(error.localizedDescription)") + } +} + +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + // TODO: render app specific banner instead of os banner + // TODO: skip if the notification is for the current shown room + // TODO: special rendering for the preferences notifications + return [.banner, .sound] + } + + // prepare notification actions + func createMessageActions(notificationCenter: UNUserNotificationCenter) { + let likeAction = UNNotificationAction( + identifier: "chat.nio.reaction.emoji.like", + title: "like", + options: [], + icon: UNNotificationActionIcon(systemImageName: "hand.thumbsup") + ) + + // TODO: decide if dislike is a desctructive action, and should get the os tag for desctructive + let dislikeAction = UNNotificationAction( + identifier: "chat.nio.reaction.emoji.dislike", + title: "dislike", + options: [], + icon: UNNotificationActionIcon(systemImageName: "hand.thumbsdown") + ) + + let replyAction = UNTextInputNotificationAction( + identifier: "chat.nio.reaction.msg", + title: "Message", + options: .authenticationRequired, + icon: UNNotificationActionIcon(systemImageName: "text.bubble"), + textInputButtonTitle: "Reply", + textInputPlaceholder: "Message" + ) + + let messageReplyAction = UNNotificationCategory( + identifier: "chat.nio.message.reply", + actions: [likeAction, dislikeAction, replyAction], + intentIdentifiers: [], + options: [.allowInCarPlay, .hiddenPreviewsShowTitle] + ) + + notificationCenter.setNotificationCategories([messageReplyAction]) + } +} diff --git a/Nio/Info.plist b/Nio/Info.plist index b22410e7..ed04cf25 100644 --- a/Nio/Info.plist +++ b/Nio/Info.plist @@ -2,6 +2,8 @@ + NioPusherUrl + $(NIO_PUSHER_URL) AppGroup $(APPGROUP) CFBundleDevelopmentRegion diff --git a/Nio/Nio.entitlements b/Nio/Nio.entitlements index 21d5f383..b8db1809 100644 --- a/Nio/Nio.entitlements +++ b/Nio/Nio.entitlements @@ -2,6 +2,10 @@ + aps-environment + development + com.apple.developer.usernotifications.communication + com.apple.security.app-sandbox com.apple.security.application-groups diff --git a/Nio/NioApp.swift b/Nio/NioApp.swift index 150379c7..9ad5a132 100644 --- a/Nio/NioApp.swift +++ b/Nio/NioApp.swift @@ -3,6 +3,10 @@ import NioKit @main struct NioApp: App { + #if os(iOS) + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + #endif + @StateObject private var accountStore = AccountStore() @AppStorage("accentColor") private var accentColor: Color = .purple diff --git a/Nio/Settings/SettingsView.swift b/Nio/Settings/SettingsView.swift index 537308ca..d7c186fa 100644 --- a/Nio/Settings/SettingsView.swift +++ b/Nio/Settings/SettingsView.swift @@ -55,12 +55,45 @@ private struct MacSettingsView: View { } private struct SettingsView: View { + @EnvironmentObject var store: AccountStore + @AppStorage("accentColor") private var accentColor: Color = .purple + @AppStorage("showDeveloperSettings") private var showDeveloperSettings = false + @StateObject private var appIconTitle = AppIconTitle() let logoutAction: () -> Void + private let bundleVersion = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as! String + private let pusherUrl = Bundle.main.object(forInfoDictionaryKey: "NioPusherUrl") as? String + @Environment(\.presentationMode) private var presentationMode + /// Update the pusher config for the accountStore + private func updatePusher() { + Task(priority: .userInitiated) { + guard let deviceToken = AppDelegate.shared.deviceToken else { + // TODO: show banner informing of missing token + print("missing deviceToken") + return + } + + guard let pusherUrl = pusherUrl else { + // should never happen + print("pusherUrl not set") + return + } + + do { + try await store.setPusher(url: pusherUrl, deviceToken: deviceToken) + } catch { + // TODO: inform of failure + print("failed to update pusher: \(error.localizedDescription)") + } + print("pusher updated") + // TODO: inform of success + } + } + var body: some View { NavigationView { Form { @@ -87,6 +120,22 @@ private struct SettingsView: View { Text(verbatim: L10n.Settings.logOut) } } + + Section("Version") { + Text(bundleVersion) + } + .onTapGesture { + showDeveloperSettings.toggle() + // TODO: show banner informing of activated developer settings + } + + if showDeveloperSettings { + Section("Developer") { + Button(action: updatePusher) { + Text("Refresh pusher config") + }.disabled(pusherUrl == nil || (pusherUrl?.isEmpty ?? true)) + } + } } .navigationBarTitle(L10n.Settings.title, displayMode: .inline) .toolbar { diff --git a/NioKit/Extensions/MXRestClient+Async.swift b/NioKit/Extensions/MXRestClient+Async.swift index ac12419c..eafb1fa8 100644 --- a/NioKit/Extensions/MXRestClient+Async.swift +++ b/NioKit/Extensions/MXRestClient+Async.swift @@ -20,4 +20,26 @@ extension MXRestClient { self.wellKnow({continuation.resume(returning: $0!)}, failure: {continuation.resume(throwing: $0!)}) } } + + func pushers() async throws -> [MXPusher] { + return try await withCheckedThrowingContinuation {continuation in + self.pushers({ continuation.resume(returning: $0 ?? []) }, failure: { continuation.resume(throwing: $0!) }) + } + } + + func setPusher( + pushKey: String, + kind: MXPusherKind, + appId: String, + appDisplayName: String, + deviceDisplayName: String, + profileTag: String, + lang: String, + data: [String: Any], + append: Bool + ) async throws { + return try await withCheckedThrowingContinuation {continuation in + self.setPusher(pushKey: pushKey, kind: kind, appId: appId, appDisplayName: appDisplayName, deviceDisplayName: deviceDisplayName, profileTag: profileTag, lang: lang, data: data, append: append, completion: { continuation.resume(with: $0) }) + } + } } diff --git a/NioKit/Session/AccountStore.swift b/NioKit/Session/AccountStore.swift index c753a9d9..018716bd 100644 --- a/NioKit/Session/AccountStore.swift +++ b/NioKit/Session/AccountStore.swift @@ -156,9 +156,50 @@ public class AccountStore: ObservableObject { self.objectWillChange.send() } } + + public func setPusher(url: String, enable: Bool = true, deviceToken: String) async throws { + guard let session = session else { + throw AccountStoreError.noSession + } + + let appId = Bundle.main.bundleIdentifier ?? "nio.chat" + let lang = NSLocale.preferredLanguages.first ?? "en-US" + + // TODO: generate a pusher profile and use it, instead of a (hopefully) not existing tag + let profileTag = "gloaable" + + let data: [String: Any] = [ + "url": "https://\(url)/_matrix/push/v1/notify", + "format": "event_id_only", + "default_payload": [ + "aps": [ + "mutable-content": 1, + "content-available": 1, + // TODO: add acount info, if we ever enable multi accounting + "alert": [ + "loc-key": "MESSAGE", + "loc-args": [], + ] + ] + ] + ] + + try await session.matrixRestClient.setPusher( + pushKey: deviceToken, + kind: enable ? .http : .none, + appId: appId, + appDisplayName: "Nio", + deviceDisplayName: "Nio iOS", + profileTag: profileTag, + lang: lang, + data: data, + append: false + ) + } } enum AccountStoreError: Error { case noCredentials + case noSession case invalidUrl }