mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Notifications (#275)
This commit is contained in:
parent
9f3ed6ca7b
commit
d389ce7ad7
File diff suppressed because it is too large
Load Diff
98
ElementX.xcodeproj/xcshareddata/xcschemes/NSE.xcscheme
Normal file
98
ElementX.xcodeproj/xcshareddata/xcschemes/NSE.xcscheme
Normal file
@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1200"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FEB53A5BC378C913769656D8"
|
||||
BuildableName = "NSE.appex"
|
||||
BlueprintName = "NSE"
|
||||
ReferencedContainer = "container:ElementX.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FEB53A5BC378C913769656D8"
|
||||
BuildableName = "NSE.appex"
|
||||
BlueprintName = "NSE"
|
||||
ReferencedContainer = "container:ElementX.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "YES"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FEB53A5BC378C913769656D8"
|
||||
BuildableName = "NSE.appex"
|
||||
BlueprintName = "NSE"
|
||||
ReferencedContainer = "container:ElementX.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FEB53A5BC378C913769656D8"
|
||||
BuildableName = "NSE.appex"
|
||||
BlueprintName = "NSE"
|
||||
ReferencedContainer = "container:ElementX.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -31,3 +31,5 @@
|
||||
|
||||
// Parameter is the application display name (e.g. "ElementX")
|
||||
"default_session_display_name" = "%@ iOS";
|
||||
|
||||
"Notification" = "Notification";
|
||||
|
BIN
ElementX/Resources/Sounds/message.caf
Normal file
BIN
ElementX/Resources/Sounds/message.caf
Normal file
Binary file not shown.
@ -31,7 +31,7 @@ struct ServiceLocator {
|
||||
let userNotificationController: UserNotificationControllerProtocol
|
||||
}
|
||||
|
||||
class AppCoordinator: CoordinatorProtocol {
|
||||
class AppCoordinator: AppCoordinatorProtocol {
|
||||
private let stateMachine: AppCoordinatorStateMachine
|
||||
private let navigationController: NavigationController
|
||||
private let userSessionStore: UserSessionStoreProtocol
|
||||
@ -40,6 +40,7 @@ class AppCoordinator: CoordinatorProtocol {
|
||||
didSet {
|
||||
deobserveUserSessionChanges()
|
||||
if let userSession, !userSession.isSoftLogout {
|
||||
configureNotificationManager()
|
||||
observeUserSessionChanges()
|
||||
}
|
||||
}
|
||||
@ -52,6 +53,7 @@ class AppCoordinator: CoordinatorProtocol {
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private(set) var notificationManager: NotificationManagerProtocol?
|
||||
|
||||
init() {
|
||||
navigationController = NavigationController()
|
||||
@ -63,14 +65,9 @@ class AppCoordinator: CoordinatorProtocol {
|
||||
|
||||
ServiceLocator.serviceLocator = ServiceLocator(userNotificationController: UserNotificationController(rootCoordinator: navigationController))
|
||||
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
fatalError("Should have a valid bundle identifier at this point")
|
||||
}
|
||||
|
||||
backgroundTaskService = UIKitBackgroundTaskService(withApplication: UIApplication.shared)
|
||||
|
||||
userSessionStore = UserSessionStore(bundleIdentifier: bundleIdentifier,
|
||||
backgroundTaskService: backgroundTaskService)
|
||||
userSessionStore = UserSessionStore(backgroundTaskService: backgroundTaskService)
|
||||
|
||||
setupStateMachine()
|
||||
|
||||
@ -97,6 +94,7 @@ class AppCoordinator: CoordinatorProtocol {
|
||||
|
||||
private func setupLogging() {
|
||||
let loggerConfiguration = MXLogConfiguration()
|
||||
loggerConfiguration.maxLogFilesCount = 10
|
||||
|
||||
#if DEBUG
|
||||
// This exposes the full Rust side tracing subscriber filter for more flexibility.
|
||||
@ -252,6 +250,8 @@ class AppCoordinator: CoordinatorProtocol {
|
||||
// regardless of the result, clear user data
|
||||
userSessionStore.logout(userSession: userSession)
|
||||
userSession = nil
|
||||
notificationManager?.delegate = nil
|
||||
notificationManager = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -269,6 +269,38 @@ class AppCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func configureNotificationManager() {
|
||||
guard BuildSettings.enableNotifications else {
|
||||
return
|
||||
}
|
||||
guard notificationManager == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let manager = NotificationManager(clientProxy: userSession.clientProxy)
|
||||
if manager.isAvailable {
|
||||
manager.delegate = self
|
||||
notificationManager = manager
|
||||
manager.start()
|
||||
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
switch callback {
|
||||
case .registeredNotifications(let deviceToken):
|
||||
self?.notificationManager?.register(with: deviceToken)
|
||||
case .failedToRegisteredNotifications(let error):
|
||||
self?.notificationManager?.registrationFailed(with: error)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
} else {
|
||||
MXLog.debug("Couldn't register to AppDelegate callbacks")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observeUserSessionChanges() {
|
||||
userSession.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
@ -321,3 +353,51 @@ extension AppCoordinator: AuthenticationCoordinatorDelegate {
|
||||
stateMachine.processEvent(.succeededSigningIn)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NotificationManagerDelegate
|
||||
|
||||
extension AppCoordinator: NotificationManagerDelegate {
|
||||
func authorizationStatusUpdated(_ service: NotificationManagerProtocol, granted: Bool) {
|
||||
if granted {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
func shouldDisplayInAppNotification(_ service: NotificationManagerProtocol, content: UNNotificationContent) -> Bool {
|
||||
guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
|
||||
return true
|
||||
}
|
||||
guard let userSessionFlowCoordinator else {
|
||||
// there is not a user session yet
|
||||
return false
|
||||
}
|
||||
return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomId: roomId)
|
||||
}
|
||||
|
||||
func notificationTapped(_ service: NotificationManagerProtocol, content: UNNotificationContent) async {
|
||||
MXLog.debug("[AppCoordinator] tappedNotification")
|
||||
|
||||
guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
|
||||
return
|
||||
}
|
||||
|
||||
userSessionFlowCoordinator?.tryDisplayingRoomScreen(roomId: roomId)
|
||||
}
|
||||
|
||||
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
|
||||
MXLog.debug("[AppCoordinator] handle notification reply")
|
||||
|
||||
guard let roomId = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
|
||||
return
|
||||
}
|
||||
let roomProxy = await userSession.clientProxy.roomForIdentifier(roomId)
|
||||
switch await roomProxy?.sendMessage(replyText) {
|
||||
case .success:
|
||||
break
|
||||
default:
|
||||
// error or no room proxy
|
||||
service.showLocalNotification(with: "⚠️ " + ElementL10n.dialogTitleError,
|
||||
subtitle: ElementL10n.a11yErrorSomeMessageNotSent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
21
ElementX/Sources/Application/AppCoordinatorProtocol.swift
Normal file
21
ElementX/Sources/Application/AppCoordinatorProtocol.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol AppCoordinatorProtocol: CoordinatorProtocol {
|
||||
var notificationManager: NotificationManagerProtocol? { get }
|
||||
}
|
@ -14,38 +14,30 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct Application: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var applicationDelegate
|
||||
private let applicationCoordinator: CoordinatorProtocol
|
||||
|
||||
init() {
|
||||
if Tests.isRunningUITests {
|
||||
applicationCoordinator = UITestsAppCoordinator()
|
||||
} else {
|
||||
applicationCoordinator = AppCoordinator()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if Tests.isRunningUnitTests {
|
||||
EmptyView()
|
||||
} else {
|
||||
applicationCoordinator.toPresentable()
|
||||
.tint(.element.accent)
|
||||
.task {
|
||||
applicationCoordinator.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
enum AppDelegateCallback {
|
||||
case registeredNotifications(deviceToken: Data)
|
||||
case failedToRegisteredNotifications(error: Error)
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
private(set) static var shared: AppDelegate!
|
||||
let callbacks = PassthroughSubject<AppDelegateCallback, Never>()
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
// worst singleton ever
|
||||
Self.shared = self
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
callbacks.send(.registeredNotifications(deviceToken: deviceToken))
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
callbacks.send(.failedToRegisteredNotifications(error: error))
|
||||
}
|
||||
}
|
||||
|
45
ElementX/Sources/Application/Application.swift
Normal file
45
ElementX/Sources/Application/Application.swift
Normal file
@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct Application: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var applicationDelegate
|
||||
private let applicationCoordinator: AppCoordinatorProtocol
|
||||
|
||||
init() {
|
||||
if Tests.isRunningUITests {
|
||||
applicationCoordinator = UITestsAppCoordinator()
|
||||
} else {
|
||||
applicationCoordinator = AppCoordinator()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if Tests.isRunningUnitTests {
|
||||
EmptyView()
|
||||
} else {
|
||||
applicationCoordinator.toPresentable()
|
||||
.tint(.element.accent)
|
||||
.task {
|
||||
applicationCoordinator.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -17,11 +17,21 @@
|
||||
import Foundation
|
||||
|
||||
final class BuildSettings {
|
||||
// MARK: - Bundle Settings
|
||||
|
||||
static var pusherAppId: String {
|
||||
#if DEBUG
|
||||
InfoPlistReader.target.baseBundleIdentifier + ".ios.dev"
|
||||
#else
|
||||
InfoPlistReader.target.baseBundleIdentifier + ".ios.prod"
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Servers
|
||||
|
||||
static let defaultHomeserverAddress = "matrix.org"
|
||||
|
||||
static let defaultSlidingSyncProxyBaseURLString = "https://slidingsync.lab.element.dev"
|
||||
static let pushGatewayBaseURL = URL(staticString: "https://matrix.org/_matrix/push/v1/notify")
|
||||
|
||||
// MARK: - Bug report
|
||||
|
||||
@ -38,14 +48,14 @@ final class BuildSettings {
|
||||
#if DEBUG
|
||||
/// The configuration to use for analytics during development. Set `isEnabled` to false to disable analytics in debug builds.
|
||||
/// **Note:** Analytics are disabled by default for forks. If you are maintaining a fork, set custom configurations.
|
||||
static let analyticsConfiguration = AnalyticsConfiguration(isEnabled: ElementInfoPlist.cfBundleIdentifier.starts(with: "io.element.elementx"),
|
||||
static let analyticsConfiguration = AnalyticsConfiguration(isEnabled: InfoPlistReader.target.bundleIdentifier.starts(with: "io.element.elementx"),
|
||||
host: "https://posthog.element.dev",
|
||||
apiKey: "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN",
|
||||
termsURL: URL(staticString: "https://element.io/cookie-policy"))
|
||||
#else
|
||||
/// The configuration to use for analytics. Set `isEnabled` to false to disable analytics.
|
||||
/// **Note:** Analytics are disabled by default for forks. If you are maintaining a fork, set custom configurations.
|
||||
static let analyticsConfiguration = AnalyticsConfiguration(isEnabled: ElementInfoPlist.cfBundleIdentifier.starts(with: "io.element.elementx"),
|
||||
static let analyticsConfiguration = AnalyticsConfiguration(isEnabled: InfoPlistReader.target.bundleIdentifier.starts(with: "io.element.elementx"),
|
||||
host: "https://posthog.hss.element.io",
|
||||
apiKey: "phc_Jzsm6DTm6V2705zeU5dcNvQDlonOR68XvX2sh1sEOHO",
|
||||
termsURL: URL(staticString: "https://element.io/cookie-policy"))
|
||||
@ -63,4 +73,8 @@ final class BuildSettings {
|
||||
// MARK: - Other
|
||||
|
||||
static var permalinkBaseURL = URL(staticString: "https://matrix.to")
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
static let enableNotifications = false
|
||||
}
|
||||
|
@ -1,66 +0,0 @@
|
||||
// swiftlint:disable all
|
||||
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
|
||||
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable superfluous_disable_command
|
||||
// swiftlint:disable file_length
|
||||
|
||||
// MARK: - Plist Files
|
||||
|
||||
// swiftlint:disable identifier_name line_length type_body_length
|
||||
internal enum ElementInfoPlist {
|
||||
private static let _document = PlistDocument(path: "Info.plist")
|
||||
|
||||
internal static let cfBundleDevelopmentRegion: String = _document["CFBundleDevelopmentRegion"]
|
||||
internal static let cfBundleDisplayName: String = _document["CFBundleDisplayName"]
|
||||
internal static let cfBundleExecutable: String = _document["CFBundleExecutable"]
|
||||
internal static let cfBundleIdentifier: String = _document["CFBundleIdentifier"]
|
||||
internal static let cfBundleInfoDictionaryVersion: String = _document["CFBundleInfoDictionaryVersion"]
|
||||
internal static let cfBundleName: String = _document["CFBundleName"]
|
||||
internal static let cfBundlePackageType: String = _document["CFBundlePackageType"]
|
||||
internal static let cfBundleShortVersionString: String = _document["CFBundleShortVersionString"]
|
||||
internal static let cfBundleVersion: String = _document["CFBundleVersion"]
|
||||
internal static let itsAppUsesNonExemptEncryption: Bool = _document["ITSAppUsesNonExemptEncryption"]
|
||||
internal static let uiLaunchStoryboardName: String = _document["UILaunchStoryboardName"]
|
||||
internal static let uiSupportedInterfaceOrientations: [String] = _document["UISupportedInterfaceOrientations"]
|
||||
internal static let appGroupIdentifier: String = _document["appGroupIdentifier"]
|
||||
}
|
||||
// swiftlint:enable identifier_name line_length type_body_length
|
||||
|
||||
// MARK: - Implementation Details
|
||||
|
||||
private func arrayFromPlist<T>(at path: String) -> [T] {
|
||||
guard let url = BundleToken.bundle.url(forResource: path, withExtension: nil),
|
||||
let data = NSArray(contentsOf: url) as? [T] else {
|
||||
fatalError("Unable to load PLIST at path: \(path)")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private struct PlistDocument {
|
||||
let data: [String: Any]
|
||||
|
||||
init(path: String) {
|
||||
self.data = BundleToken.bundle.infoDictionary ?? [:]
|
||||
}
|
||||
|
||||
subscript<T>(key: String) -> T {
|
||||
guard let result = data[key] as? T else {
|
||||
fatalError("Property '\(key)' is not of type \(T.self)")
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable convenience_type
|
||||
private final class BundleToken {
|
||||
static let bundle: Bundle = {
|
||||
#if SWIFT_PACKAGE
|
||||
return Bundle.module
|
||||
#else
|
||||
return Bundle(for: BundleToken.self)
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
// swiftlint:enable convenience_type
|
@ -24,6 +24,8 @@ extension ElementL10n {
|
||||
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
|
||||
/// Tablet
|
||||
public static let loginTabletDevice = ElementL10n.tr("Untranslated", "login_tablet_device")
|
||||
/// Notification
|
||||
public static let notification = ElementL10n.tr("Untranslated", "Notification")
|
||||
/// Editing
|
||||
public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing")
|
||||
/// Failed creating the permalink
|
||||
|
@ -26,13 +26,15 @@ final class ElementSettings: ObservableObject {
|
||||
case enableAnalytics
|
||||
case isIdentifiedForAnalytics
|
||||
case slidingSyncProxyBaseURLString
|
||||
case enableInAppNotifications
|
||||
case pusherProfileTag
|
||||
}
|
||||
|
||||
static let shared = ElementSettings()
|
||||
|
||||
/// UserDefaults to be used on reads and writes.
|
||||
static var store: UserDefaults {
|
||||
guard let userDefaults = UserDefaults(suiteName: ElementInfoPlist.appGroupIdentifier) else {
|
||||
guard let userDefaults = UserDefaults(suiteName: InfoPlistReader.target.appGroupIdentifier) else {
|
||||
fatalError("Fail to load shared UserDefaults")
|
||||
}
|
||||
return userDefaults
|
||||
@ -68,4 +70,13 @@ final class ElementSettings: ObservableObject {
|
||||
|
||||
@AppStorage(UserDefaultsKeys.slidingSyncProxyBaseURLString.rawValue, store: store)
|
||||
var slidingSyncProxyBaseURLString = BuildSettings.defaultSlidingSyncProxyBaseURLString
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
@AppStorage(UserDefaultsKeys.enableInAppNotifications.rawValue, store: store)
|
||||
var enableInAppNotifications = true
|
||||
|
||||
@AppStorage(UserDefaultsKeys.pusherProfileTag.rawValue, store: store)
|
||||
/// Tag describing which set of device specific rules a pusher executes.
|
||||
var pusherProfileTag: String?
|
||||
}
|
||||
|
34
ElementX/Sources/Other/Extensions/FileManager.swift
Normal file
34
ElementX/Sources/Other/Extensions/FileManager.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension FileManager {
|
||||
func directoryExists(at url: URL) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileExists(atPath: url.path(), isDirectory: &isDirectory) else {
|
||||
return false
|
||||
}
|
||||
return isDirectory.boolValue
|
||||
}
|
||||
|
||||
func createDirectoryIfNeeded(at url: URL, withIntermediateDirectories: Bool = true) throws {
|
||||
guard !directoryExists(at: url) else {
|
||||
return
|
||||
}
|
||||
try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories)
|
||||
}
|
||||
}
|
@ -23,4 +23,10 @@ extension ImageCache {
|
||||
result.diskStorage.config.sizeLimit = 1
|
||||
return result
|
||||
}
|
||||
|
||||
static var onlyOnDisk: ImageCache {
|
||||
let result = ImageCache.default
|
||||
result.memoryStorage.config.totalCostLimit = 1
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
@ -23,6 +23,6 @@ extension UIDevice {
|
||||
}
|
||||
|
||||
var initialDisplayName: String {
|
||||
ElementL10n.defaultSessionDisplayName(ElementInfoPlist.cfBundleDisplayName)
|
||||
ElementL10n.defaultSessionDisplayName(InfoPlistReader.target.bundleDisplayName)
|
||||
}
|
||||
}
|
||||
|
@ -24,4 +24,33 @@ extension URL {
|
||||
|
||||
self = url
|
||||
}
|
||||
|
||||
/// The URL of the primary app group container.
|
||||
static var appGroupContainerDirectory: URL {
|
||||
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: InfoPlistReader.target.appGroupIdentifier) else {
|
||||
fatalError("Should always be able to retrieve the container directory")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// The base directory where all session data is stored.
|
||||
static var sessionsBaseDirectory: URL {
|
||||
let url = cacheBaseDirectory
|
||||
.appendingPathComponent("Sessions", isDirectory: true)
|
||||
|
||||
try? FileManager.default.createDirectoryIfNeeded(at: url)
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/// The base directory where all cache is stored.
|
||||
static var cacheBaseDirectory: URL {
|
||||
let url = appGroupContainerDirectory
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Caches", isDirectory: true)
|
||||
|
||||
try? FileManager.default.createDirectoryIfNeeded(at: url)
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
79
ElementX/Sources/Other/InfoPlistReader.swift
Normal file
79
ElementX/Sources/Other/InfoPlistReader.swift
Normal file
@ -0,0 +1,79 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct InfoPlistReader {
|
||||
private enum Keys {
|
||||
static let appGroupIdentifier = "appGroupIdentifier"
|
||||
static let baseBundleIdentifier = "baseBundleIdentifier"
|
||||
static let bundleShortVersion = "CFBundleShortVersionString"
|
||||
static let bundleDisplayName = "CFBundleDisplayName"
|
||||
}
|
||||
|
||||
/// Info.plist reader on the current target
|
||||
static let target = InfoPlistReader(bundle: .main)
|
||||
|
||||
private let bundle: Bundle
|
||||
|
||||
/// Initializer
|
||||
/// - Parameter bundle: bundle to read values from
|
||||
init(bundle: Bundle) {
|
||||
self.bundle = bundle
|
||||
}
|
||||
|
||||
/// App group identifier set in Info.plist of the target
|
||||
var appGroupIdentifier: String {
|
||||
infoPlistStringValue(forKey: Keys.appGroupIdentifier)
|
||||
}
|
||||
|
||||
/// Base bundle identifier set in Info.plist of the target
|
||||
var baseBundleIdentifier: String {
|
||||
infoPlistStringValue(forKey: Keys.baseBundleIdentifier)
|
||||
}
|
||||
|
||||
/// Bundle executable of the target
|
||||
var bundleExecutable: String {
|
||||
infoPlistStringValue(forKey: kCFBundleExecutableKey as String)
|
||||
}
|
||||
|
||||
/// Bundle identifier of the target
|
||||
var bundleIdentifier: String {
|
||||
infoPlistStringValue(forKey: kCFBundleIdentifierKey as String)
|
||||
}
|
||||
|
||||
/// Bundle short version string of the target
|
||||
var bundleShortVersionString: String {
|
||||
infoPlistStringValue(forKey: Keys.bundleShortVersion)
|
||||
}
|
||||
|
||||
/// Bundle version of the target
|
||||
var bundleVersion: String {
|
||||
infoPlistStringValue(forKey: kCFBundleVersionKey as String)
|
||||
}
|
||||
|
||||
/// Bundle display name of the target
|
||||
var bundleDisplayName: String {
|
||||
infoPlistStringValue(forKey: Keys.bundleDisplayName)
|
||||
}
|
||||
|
||||
private func infoPlistStringValue(forKey key: String) -> String {
|
||||
guard let result = bundle.object(forInfoDictionaryKey: key) as? String else {
|
||||
fatalError("Add \(key) into your target's Info.plst")
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
@ -150,9 +150,9 @@ class MXLogger {
|
||||
MXLogger.logCrashes(false)
|
||||
|
||||
// Extract running app information
|
||||
let app = ElementInfoPlist.cfBundleExecutable
|
||||
let appId = ElementInfoPlist.cfBundleIdentifier
|
||||
let appVersion = "\(ElementInfoPlist.cfBundleShortVersionString) (r\(ElementInfoPlist.cfBundleVersion))"
|
||||
let app = InfoPlistReader.target.bundleExecutable
|
||||
let appId = InfoPlistReader.target.bundleIdentifier
|
||||
let appVersion = "\(InfoPlistReader.target.bundleShortVersionString) (r\(InfoPlistReader.target.bundleVersion))"
|
||||
|
||||
// Build the crash log
|
||||
let model = UIDevice.current.model
|
||||
@ -269,7 +269,7 @@ class MXLogger {
|
||||
|
||||
/// The folder where logs are stored
|
||||
private static var logsFolderURL: URL {
|
||||
FileManager.default.appGroupContainerURL ?? URL.documentsDirectory
|
||||
.appGroupContainerDirectory
|
||||
}
|
||||
|
||||
/// If `self.redirectNSLog(toFiles:numberOfFiles:)` is called with a lower numberOfFiles we need to do some cleanup.
|
||||
|
@ -27,8 +27,8 @@ final class UserAgentBuilder {
|
||||
}
|
||||
|
||||
private class func makeUserAgent() -> String? {
|
||||
let clientName = ElementInfoPlist.cfBundleDisplayName
|
||||
let clientVersion = ElementInfoPlist.cfBundleShortVersionString
|
||||
let clientName = InfoPlistReader.target.bundleDisplayName
|
||||
let clientVersion = InfoPlistReader.target.bundleShortVersionString
|
||||
|
||||
#if os(iOS)
|
||||
return String(
|
||||
|
@ -44,10 +44,10 @@ struct AnalyticsPromptStrings {
|
||||
init() {
|
||||
// Create the opt in content with a placeholder.
|
||||
let linkPlaceholder = "{link}"
|
||||
var optInContent = AttributedString(ElementL10n.analyticsOptInContent(ElementInfoPlist.cfBundleDisplayName, linkPlaceholder))
|
||||
var optInContent = AttributedString(ElementL10n.analyticsOptInContent(InfoPlistReader.target.bundleDisplayName, linkPlaceholder))
|
||||
|
||||
guard let range = optInContent.range(of: linkPlaceholder) else {
|
||||
self.optInContent = AttributedString(ElementL10n.analyticsOptInContent(ElementInfoPlist.cfBundleDisplayName,
|
||||
self.optInContent = AttributedString(ElementL10n.analyticsOptInContent(InfoPlistReader.target.bundleDisplayName,
|
||||
ElementL10n.analyticsOptInContentLink))
|
||||
MXLog.failure("Failed to add a link attribute to the opt in content.")
|
||||
return
|
||||
|
@ -67,7 +67,7 @@ struct AnalyticsPrompt: View {
|
||||
Image(uiImage: Asset.Images.analyticsLogo.image)
|
||||
.padding(.bottom, 25)
|
||||
|
||||
Text(ElementL10n.analyticsOptInTitle(ElementInfoPlist.cfBundleDisplayName))
|
||||
Text(ElementL10n.analyticsOptInTitle(InfoPlistReader.target.bundleDisplayName))
|
||||
.font(.element.title2Bold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
|
@ -79,7 +79,7 @@ struct OnboardingViewState: BindableState {
|
||||
message: ElementL10n.ftueAuthCarouselEncryptedBody,
|
||||
image: Asset.Images.onboardingScreenPage3),
|
||||
OnboardingPageContent(title: page4Title.tinting("."),
|
||||
message: ElementL10n.ftueAuthCarouselWorkplaceBody(ElementInfoPlist.cfBundleDisplayName),
|
||||
message: ElementL10n.ftueAuthCarouselWorkplaceBody(InfoPlistReader.target.bundleDisplayName),
|
||||
image: Asset.Images.onboardingScreenPage4)
|
||||
]
|
||||
bindings = OnboardingBindings()
|
||||
|
@ -23,7 +23,7 @@ struct InviteFriendsCoordinator: CoordinatorProtocol {
|
||||
guard let permalink = try? PermalinkBuilder.permalinkTo(userIdentifier: userId).absoluteString else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
let shareText = ElementL10n.inviteFriendsText(ElementInfoPlist.cfBundleDisplayName, permalink)
|
||||
let shareText = ElementL10n.inviteFriendsText(InfoPlistReader.target.bundleDisplayName, permalink)
|
||||
|
||||
return AnyView(UIActivityViewControllerWrapper(activityItems: [shareText])
|
||||
.presentationDetents([.medium])
|
||||
|
@ -36,8 +36,8 @@ struct MessageComposer: View {
|
||||
focused: $focused,
|
||||
maxHeight: 300,
|
||||
onEnterKeyHandler: {
|
||||
sendAction()
|
||||
})
|
||||
sendAction()
|
||||
})
|
||||
|
||||
Button {
|
||||
sendAction()
|
||||
|
@ -64,7 +64,7 @@ struct SettingsScreen: View {
|
||||
}
|
||||
|
||||
private var versionText: some View {
|
||||
Text(ElementL10n.settingsVersion + ": " + ElementInfoPlist.cfBundleShortVersionString + " (" + ElementInfoPlist.cfBundleVersion + ")")
|
||||
Text(ElementL10n.settingsVersion + ": " + InfoPlistReader.target.bundleShortVersionString + " (" + InfoPlistReader.target.bundleVersion + ")")
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
|
@ -92,7 +92,7 @@ class Analytics {
|
||||
|
||||
// Catch and log crashes
|
||||
// MXLogger.logCrashes(true)
|
||||
// MXLogger.setBuildVersion(ElementInfoPlist.cfBundleShortVersionString)
|
||||
// MXLogger.setBuildVersion(Bundle.bundleShortVersionString)
|
||||
}
|
||||
|
||||
/// Use the analytics settings from the supplied user session to configure analytics.
|
||||
|
@ -61,7 +61,7 @@ class BugReportService: BugReportServiceProtocol {
|
||||
// also enable logging crashes, to send them with bug reports
|
||||
MXLogger.logCrashes(true)
|
||||
// set build version for logger
|
||||
MXLogger.buildVersion = ElementInfoPlist.cfBundleShortVersionString
|
||||
MXLogger.buildVersion = InfoPlistReader.target.bundleShortVersionString
|
||||
}
|
||||
|
||||
// MARK: - BugReportServiceProtocol
|
||||
@ -151,8 +151,8 @@ class BugReportService: BugReportServiceProtocol {
|
||||
return [
|
||||
MultipartFormData(key: "user_agent", type: .text(value: "iOS")),
|
||||
MultipartFormData(key: "app", type: .text(value: applicationId)),
|
||||
MultipartFormData(key: "version", type: .text(value: ElementInfoPlist.cfBundleShortVersionString)),
|
||||
MultipartFormData(key: "build", type: .text(value: ElementInfoPlist.cfBundleVersion)),
|
||||
MultipartFormData(key: "version", type: .text(value: InfoPlistReader.target.bundleShortVersionString)),
|
||||
MultipartFormData(key: "build", type: .text(value: InfoPlistReader.target.bundleVersion)),
|
||||
MultipartFormData(key: "os", type: .text(value: os)),
|
||||
MultipartFormData(key: "resolved_language", type: .text(value: Bundle.preferredLanguages[0])),
|
||||
MultipartFormData(key: "user_language", type: .text(value: Bundle.elementLanguage ?? "null")),
|
||||
|
@ -32,11 +32,11 @@ class FileCache {
|
||||
private let fileManager = FileManager.default
|
||||
private let folder: URL
|
||||
|
||||
/// Default instance. Uses `FileCache` as the folder name.
|
||||
static let `default` = FileCache(folderName: "FileCache")
|
||||
/// Default instance. Uses `Files` as the folder name.
|
||||
static let `default` = FileCache(folderName: "Files")
|
||||
|
||||
init(folderName: String) {
|
||||
folder = fileManager.temporaryDirectory.appending(path: folderName, directoryHint: .isDirectory)
|
||||
folder = URL.cacheBaseDirectory.appending(path: folderName, directoryHint: .isDirectory)
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
@ -44,21 +44,6 @@ class FileCache {
|
||||
private func filePath(forKey key: String, fileExtension: String) -> URL {
|
||||
folder.appending(path: key, directoryHint: .notDirectory).appendingPathExtension(fileExtension)
|
||||
}
|
||||
|
||||
private func folderExists() -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileManager.fileExists(atPath: folder.path(), isDirectory: &isDirectory) else {
|
||||
return false
|
||||
}
|
||||
return isDirectory.boolValue
|
||||
}
|
||||
|
||||
private func createFolderIfNeeded() throws {
|
||||
guard !folderExists() else {
|
||||
return
|
||||
}
|
||||
try fileManager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FileCacheProtocol
|
||||
@ -70,7 +55,7 @@ extension FileCache: FileCacheProtocol {
|
||||
}
|
||||
|
||||
func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL {
|
||||
try createFolderIfNeeded()
|
||||
try fileManager.createDirectoryIfNeeded(at: folder)
|
||||
let url = filePath(forKey: key, fileExtension: fileExtension)
|
||||
try data.write(to: url)
|
||||
return url
|
||||
@ -81,7 +66,7 @@ extension FileCache: FileCacheProtocol {
|
||||
}
|
||||
|
||||
func removeAll() throws {
|
||||
guard folderExists() else {
|
||||
guard fileManager.directoryExists(at: folder) else {
|
||||
return
|
||||
}
|
||||
try fileManager.removeItem(at: folder)
|
||||
|
@ -52,6 +52,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
private let client: ClientProtocol
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
private var sessionVerificationControllerProxy: SessionVerificationControllerProxy?
|
||||
private let mediaProxy: MediaProxyProtocol
|
||||
private let clientQueue: DispatchQueue
|
||||
|
||||
private var slidingSyncObserverToken: StoppableSpawn?
|
||||
@ -75,6 +76,8 @@ class ClientProxy: ClientProxyProtocol {
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
clientQueue = .init(label: "ClientProxyQueue",
|
||||
attributes: .concurrent)
|
||||
mediaProxy = MediaProxy(client: client,
|
||||
clientQueue: clientQueue)
|
||||
|
||||
await Task.dispatch(on: clientQueue) {
|
||||
do {
|
||||
@ -205,24 +208,6 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func mediaSourceForURLString(_ urlString: String) -> MatrixRustSDK.MediaSource {
|
||||
MatrixRustSDK.mediaSourceFromUrl(url: urlString)
|
||||
}
|
||||
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data {
|
||||
try await Task.dispatch(on: clientQueue) {
|
||||
let bytes = try self.client.getMediaContent(source: source)
|
||||
return Data(bytes: bytes, count: bytes.count)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data {
|
||||
try await Task.dispatch(on: clientQueue) {
|
||||
let bytes = try self.client.getMediaThumbnail(source: source, width: UInt64(width), height: UInt64(height))
|
||||
return Data(bytes: bytes, count: bytes.count)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError> {
|
||||
await Task.dispatch(on: clientQueue) {
|
||||
do {
|
||||
@ -244,6 +229,32 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func setPusher(pushkey: String,
|
||||
kind: PusherKind?,
|
||||
appId: String,
|
||||
appDisplayName: String,
|
||||
deviceDisplayName: String,
|
||||
profileTag: String?,
|
||||
lang: String,
|
||||
url: String?,
|
||||
format: PushFormat?,
|
||||
defaultPayload: [AnyHashable: Any]?) async throws {
|
||||
// let defaultPayloadString = jsonString(from: defaultPayload)
|
||||
// try await Task.dispatch(on: .global()) {
|
||||
// try self.client.setPusher(pushkey: pushkey,
|
||||
// kind: kind?.rustValue,
|
||||
// appId: appId,
|
||||
// appDisplayName: appDisplayName,
|
||||
// deviceDisplayName: deviceDisplayName,
|
||||
// profileTag: profileTag,
|
||||
// lang: lang,
|
||||
// url: url,
|
||||
// format: format?.rustValue,
|
||||
// defaultPayload: defaultPayloadString)
|
||||
// }
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func roomTupleForIdentifier(_ identifier: String) -> (SlidingSyncRoom?, Room?) {
|
||||
@ -271,4 +282,29 @@ class ClientProxy: ClientProxyProtocol {
|
||||
|
||||
callbacks.send(.receivedSyncUpdate)
|
||||
}
|
||||
|
||||
/// Convenience method to get the json string of an Encodable
|
||||
private func jsonString(from dictionary: [AnyHashable: Any]?) -> String? {
|
||||
guard let dictionary,
|
||||
let data = try? JSONSerialization.data(withJSONObject: dictionary,
|
||||
options: [.fragmentsAllowed]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientProxy: MediaProxyProtocol {
|
||||
func mediaSourceForURLString(_ urlString: String) -> MediaSourceProxy {
|
||||
mediaProxy.mediaSourceForURLString(urlString)
|
||||
}
|
||||
|
||||
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
|
||||
try await mediaProxy.loadMediaContentForSource(source)
|
||||
}
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
|
||||
try await mediaProxy.loadMediaThumbnailForSource(source, width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
@ -33,7 +33,32 @@ enum ClientProxyError: Error {
|
||||
case failedLoadingMedia
|
||||
}
|
||||
|
||||
protocol ClientProxyProtocol {
|
||||
enum PusherKind {
|
||||
case http
|
||||
case email
|
||||
|
||||
// var rustValue: MatrixRustSDK.PusherKind {
|
||||
// switch self {
|
||||
// case .http:
|
||||
// return .http
|
||||
// case .email:
|
||||
// return .email
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
enum PushFormat {
|
||||
case eventIdOnly
|
||||
|
||||
// var rustValue: MatrixRustSDK.PushFormat {
|
||||
// switch self {
|
||||
// case .eventIdOnly:
|
||||
// return .eventIdOnly
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
protocol ClientProxyProtocol: MediaProxyProtocol {
|
||||
var callbacks: PassthroughSubject<ClientProxyCallback, Never> { get }
|
||||
|
||||
var userIdentifier: String { get }
|
||||
@ -62,13 +87,19 @@ protocol ClientProxyProtocol {
|
||||
|
||||
func setAccountData<Content: Encodable>(content: Content, type: String) async -> Result<Void, ClientProxyError>
|
||||
|
||||
func mediaSourceForURLString(_ urlString: String) -> MatrixRustSDK.MediaSource
|
||||
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data
|
||||
|
||||
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError>
|
||||
|
||||
func logout() async
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func setPusher(pushkey: String,
|
||||
kind: PusherKind?,
|
||||
appId: String,
|
||||
appDisplayName: String,
|
||||
deviceDisplayName: String,
|
||||
profileTag: String?,
|
||||
lang: String,
|
||||
url: String?,
|
||||
format: PushFormat?,
|
||||
defaultPayload: [AnyHashable: Any]?) async throws
|
||||
}
|
||||
|
@ -53,15 +53,15 @@ struct MockClientProxy: ClientProxyProtocol {
|
||||
.failure(.failedSettingAccountData)
|
||||
}
|
||||
|
||||
func mediaSourceForURLString(_ urlString: String) -> MatrixRustSDK.MediaSource {
|
||||
MatrixRustSDK.mediaSourceFromUrl(url: urlString)
|
||||
func mediaSourceForURLString(_ urlString: String) -> MediaSourceProxy {
|
||||
.init(urlString: urlString)
|
||||
}
|
||||
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data {
|
||||
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
|
||||
throw ClientProxyError.failedLoadingMedia
|
||||
}
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data {
|
||||
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
|
||||
throw ClientProxyError.failedLoadingMedia
|
||||
}
|
||||
|
||||
@ -72,4 +72,18 @@ struct MockClientProxy: ClientProxyProtocol {
|
||||
func logout() async {
|
||||
// no-op
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func setPusher(pushkey: String,
|
||||
kind: PusherKind?,
|
||||
appId: String,
|
||||
appDisplayName: String,
|
||||
deviceDisplayName: String,
|
||||
profileTag: String?,
|
||||
lang: String,
|
||||
url: String?,
|
||||
format: PushFormat?,
|
||||
defaultPayload: [AnyHashable: Any]?) async throws {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
@ -17,11 +17,22 @@
|
||||
import Foundation
|
||||
import KeychainAccess
|
||||
|
||||
enum KeychainControllerService: String {
|
||||
case sessions
|
||||
case tests
|
||||
|
||||
var identifier: String {
|
||||
InfoPlistReader.target.baseBundleIdentifier + "." + rawValue
|
||||
}
|
||||
}
|
||||
|
||||
class KeychainController: KeychainControllerProtocol {
|
||||
private let keychain: Keychain
|
||||
|
||||
init(identifier: String) {
|
||||
keychain = Keychain(service: identifier)
|
||||
init(service: KeychainControllerService,
|
||||
accessGroup: String) {
|
||||
keychain = Keychain(service: service.identifier,
|
||||
accessGroup: accessGroup)
|
||||
}
|
||||
|
||||
func setRestorationToken(_ restorationToken: RestorationToken, forUsername username: String) {
|
@ -18,26 +18,26 @@ import Kingfisher
|
||||
import UIKit
|
||||
|
||||
struct MediaProvider: MediaProviderProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let mediaProxy: MediaProxyProtocol
|
||||
private let imageCache: Kingfisher.ImageCache
|
||||
private let fileCache: FileCache
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol?
|
||||
|
||||
init(clientProxy: ClientProxyProtocol,
|
||||
init(mediaProxy: MediaProxyProtocol,
|
||||
imageCache: Kingfisher.ImageCache,
|
||||
fileCache: FileCache,
|
||||
backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
self.clientProxy = clientProxy
|
||||
backgroundTaskService: BackgroundTaskServiceProtocol?) {
|
||||
self.mediaProxy = mediaProxy
|
||||
self.imageCache = imageCache
|
||||
self.fileCache = fileCache
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
}
|
||||
|
||||
func imageFromSource(_ source: MediaSource?, avatarSize: AvatarSize?) -> UIImage? {
|
||||
func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage? {
|
||||
guard let source else {
|
||||
return nil
|
||||
}
|
||||
let cacheKey = cacheKeyForURLString(source.underlyingSource.url(), avatarSize: avatarSize)
|
||||
let cacheKey = cacheKeyForURLString(source.url, avatarSize: avatarSize)
|
||||
return imageCache.retrieveImageInMemoryCache(forKey: cacheKey, options: nil)
|
||||
}
|
||||
|
||||
@ -46,19 +46,19 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
return nil
|
||||
}
|
||||
|
||||
return imageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), avatarSize: avatarSize)
|
||||
return imageFromSource(.init(urlString: urlString), avatarSize: avatarSize)
|
||||
}
|
||||
|
||||
func loadImageFromURLString(_ urlString: String, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), avatarSize: avatarSize)
|
||||
await loadImageFromSource(.init(urlString: urlString), avatarSize: avatarSize)
|
||||
}
|
||||
|
||||
func loadImageFromSource(_ source: MediaSource, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
if let image = imageFromSource(source, avatarSize: avatarSize) {
|
||||
return .success(image)
|
||||
}
|
||||
|
||||
let loadImageBgTask = await backgroundTaskService.startBackgroundTask(withName: "LoadImage: \(source.url.hashValue)")
|
||||
let loadImageBgTask = await backgroundTaskService?.startBackgroundTask(withName: "LoadImage: \(source.url.hashValue)")
|
||||
defer {
|
||||
loadImageBgTask?.stop()
|
||||
}
|
||||
@ -73,9 +73,9 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
do {
|
||||
let imageData: Data
|
||||
if let avatarSize {
|
||||
imageData = try await clientProxy.loadMediaThumbnailForSource(source.underlyingSource, width: UInt(avatarSize.scaledValue), height: UInt(avatarSize.scaledValue))
|
||||
imageData = try await mediaProxy.loadMediaThumbnailForSource(source, width: UInt(avatarSize.scaledValue), height: UInt(avatarSize.scaledValue))
|
||||
} else {
|
||||
imageData = try await clientProxy.loadMediaContentForSource(source.underlyingSource)
|
||||
imageData = try await mediaProxy.loadMediaContentForSource(source)
|
||||
}
|
||||
|
||||
guard let image = UIImage(data: imageData) else {
|
||||
@ -92,20 +92,20 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL? {
|
||||
func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? {
|
||||
guard let source else {
|
||||
return nil
|
||||
}
|
||||
let cacheKey = fileCacheKeyForURLString(source.underlyingSource.url())
|
||||
let cacheKey = fileCacheKeyForURLString(source.url)
|
||||
return fileCache.file(forKey: cacheKey, fileExtension: fileExtension)
|
||||
}
|
||||
|
||||
@discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result<URL, MediaProviderError> {
|
||||
@discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result<URL, MediaProviderError> {
|
||||
if let url = fileFromSource(source, fileExtension: fileExtension) {
|
||||
return .success(url)
|
||||
}
|
||||
|
||||
let loadFileBgTask = await backgroundTaskService.startBackgroundTask(withName: "LoadFile: \(source.url.hashValue)")
|
||||
let loadFileBgTask = await backgroundTaskService?.startBackgroundTask(withName: "LoadFile: \(source.url.hashValue)")
|
||||
defer {
|
||||
loadFileBgTask?.stop()
|
||||
}
|
||||
@ -113,7 +113,7 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
let cacheKey = fileCacheKeyForURLString(source.url)
|
||||
|
||||
do {
|
||||
let data = try await clientProxy.loadMediaContentForSource(source.underlyingSource)
|
||||
let data = try await mediaProxy.loadMediaContentForSource(source)
|
||||
|
||||
let url = try fileCache.store(data, with: fileExtension, forKey: cacheKey)
|
||||
return .success(url)
|
||||
@ -128,12 +128,12 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fileFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)),
|
||||
return fileFromSource(MediaSourceProxy(urlString: urlString),
|
||||
fileExtension: fileExtension)
|
||||
}
|
||||
|
||||
func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result<URL, MediaProviderError> {
|
||||
await loadFileFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)),
|
||||
await loadFileFromSource(MediaSourceProxy(urlString: urlString),
|
||||
fileExtension: fileExtension)
|
||||
}
|
||||
|
||||
|
@ -24,17 +24,17 @@ enum MediaProviderError: Error {
|
||||
}
|
||||
|
||||
protocol MediaProviderProtocol {
|
||||
func imageFromSource(_ source: MediaSource?, avatarSize: AvatarSize?) -> UIImage?
|
||||
func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage?
|
||||
|
||||
@discardableResult func loadImageFromSource(_ source: MediaSource, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError>
|
||||
@discardableResult func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError>
|
||||
|
||||
func imageFromURLString(_ urlString: String?, avatarSize: AvatarSize?) -> UIImage?
|
||||
|
||||
@discardableResult func loadImageFromURLString(_ urlString: String, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError>
|
||||
|
||||
func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL?
|
||||
func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL?
|
||||
|
||||
@discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result<URL, MediaProviderError>
|
||||
@discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result<URL, MediaProviderError>
|
||||
|
||||
func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL?
|
||||
|
||||
@ -42,11 +42,11 @@ protocol MediaProviderProtocol {
|
||||
}
|
||||
|
||||
extension MediaProviderProtocol {
|
||||
func imageFromSource(_ source: MediaSource?) -> UIImage? {
|
||||
func imageFromSource(_ source: MediaSourceProxy?) -> UIImage? {
|
||||
imageFromSource(source, avatarSize: nil)
|
||||
}
|
||||
|
||||
@discardableResult func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
|
||||
@discardableResult func loadImageFromSource(_ source: MediaSourceProxy) async -> Result<UIImage, MediaProviderError> {
|
||||
await loadImageFromSource(source, avatarSize: nil)
|
||||
}
|
||||
|
||||
|
49
ElementX/Sources/Services/Media/MediaProxy.swift
Normal file
49
ElementX/Sources/Services/Media/MediaProxy.swift
Normal file
@ -0,0 +1,49 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
class MediaProxy: MediaProxyProtocol {
|
||||
private let client: ClientProtocol
|
||||
private let clientQueue: DispatchQueue
|
||||
|
||||
init(client: ClientProtocol,
|
||||
clientQueue: DispatchQueue = .global()) {
|
||||
self.client = client
|
||||
self.clientQueue = clientQueue
|
||||
}
|
||||
|
||||
func mediaSourceForURLString(_ urlString: String) -> MediaSourceProxy {
|
||||
.init(urlString: urlString)
|
||||
}
|
||||
|
||||
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
|
||||
try await Task.dispatch(on: clientQueue) {
|
||||
let bytes = try self.client.getMediaContent(source: source.underlyingSource)
|
||||
return Data(bytes: bytes, count: bytes.count)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
|
||||
try await Task.dispatch(on: clientQueue) {
|
||||
let bytes = try self.client.getMediaThumbnail(source: source.underlyingSource, width: UInt64(width), height: UInt64(height))
|
||||
return Data(bytes: bytes, count: bytes.count)
|
||||
}
|
||||
}
|
||||
}
|
25
ElementX/Sources/Services/Media/MediaProxyProtocol.swift
Normal file
25
ElementX/Sources/Services/Media/MediaProxyProtocol.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol MediaProxyProtocol {
|
||||
func mediaSourceForURLString(_ urlString: String) -> MediaSourceProxy
|
||||
|
||||
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data
|
||||
}
|
@ -17,24 +17,24 @@
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
struct MediaSource: Equatable {
|
||||
let underlyingSource: MatrixRustSDK.MediaSource
|
||||
struct MediaSourceProxy: Equatable {
|
||||
let underlyingSource: MediaSource
|
||||
|
||||
init(source: MediaSource) {
|
||||
underlyingSource = source
|
||||
}
|
||||
|
||||
init(urlString: String) {
|
||||
underlyingSource = mediaSourceFromUrl(url: urlString)
|
||||
}
|
||||
|
||||
var url: String {
|
||||
underlyingSource.url()
|
||||
}
|
||||
|
||||
init(source: MatrixRustSDK.MediaSource) {
|
||||
underlyingSource = source
|
||||
}
|
||||
|
||||
init(urlString: String) {
|
||||
underlyingSource = MatrixRustSDK.mediaSourceFromUrl(url: urlString)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
static func == (lhs: MediaSource, rhs: MediaSource) -> Bool {
|
||||
lhs.underlyingSource.url() == rhs.underlyingSource.url()
|
||||
static func == (lhs: MediaSourceProxy, rhs: MediaSourceProxy) -> Bool {
|
||||
lhs.url == rhs.url
|
||||
}
|
||||
}
|
@ -18,11 +18,11 @@ import Foundation
|
||||
import UIKit
|
||||
|
||||
struct MockMediaProvider: MediaProviderProtocol {
|
||||
func imageFromSource(_ source: MediaSource?, avatarSize: AvatarSize?) -> UIImage? {
|
||||
func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage? {
|
||||
nil
|
||||
}
|
||||
|
||||
func loadImageFromSource(_ source: MediaSource, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
.failure(.failedRetrievingImage)
|
||||
}
|
||||
|
||||
@ -50,11 +50,11 @@ struct MockMediaProvider: MediaProviderProtocol {
|
||||
return .success(image)
|
||||
}
|
||||
|
||||
func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL? {
|
||||
func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? {
|
||||
nil
|
||||
}
|
||||
|
||||
@discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result<URL, MediaProviderError> {
|
||||
@discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result<URL, MediaProviderError> {
|
||||
.failure(.failedRetrievingFile)
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class MockNotificationManager: NotificationManagerProtocol {
|
||||
// MARK: NotificationManagerProtocol
|
||||
|
||||
var isAvailable: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
weak var delegate: NotificationManagerDelegate?
|
||||
|
||||
func start() {
|
||||
delegate?.authorizationStatusUpdated(self, granted: false)
|
||||
}
|
||||
|
||||
func register(with deviceToken: Data) { }
|
||||
|
||||
func registrationFailed(with error: Error) { }
|
||||
|
||||
func showLocalNotification(with title: String, subtitle: String?) { }
|
||||
}
|
@ -0,0 +1,166 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
class NotificationManager: NSObject, NotificationManagerProtocol {
|
||||
private let notificationCenter = UNUserNotificationCenter.current()
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
|
||||
init(clientProxy: ClientProxyProtocol) {
|
||||
self.clientProxy = clientProxy
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: NotificationManagerProtocol
|
||||
|
||||
weak var delegate: NotificationManagerDelegate?
|
||||
|
||||
var isAvailable: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func start() {
|
||||
let replyAction = UNTextInputNotificationAction(identifier: NotificationConstants.Action.inlineReply,
|
||||
title: ElementL10n.actionQuickReply,
|
||||
options: [])
|
||||
let replyCategory = UNNotificationCategory(identifier: NotificationConstants.Category.reply,
|
||||
actions: [replyAction],
|
||||
intentIdentifiers: [],
|
||||
options: [])
|
||||
notificationCenter.setNotificationCategories([replyCategory])
|
||||
notificationCenter.delegate = self
|
||||
Task {
|
||||
do {
|
||||
let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
MXLog.debug("[NotificationManager] permission granted: \(granted)")
|
||||
|
||||
await MainActor.run {
|
||||
delegate?.authorizationStatusUpdated(self, granted: granted)
|
||||
}
|
||||
} catch {
|
||||
MXLog.debug("[NotificationManager] request authorization failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func register(with deviceToken: Data) {
|
||||
setPusher(with: deviceToken, clientProxy: clientProxy)
|
||||
}
|
||||
|
||||
func registrationFailed(with error: Error) { }
|
||||
|
||||
func showLocalNotification(with title: String, subtitle: String?) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
if let subtitle {
|
||||
content.subtitle = subtitle
|
||||
}
|
||||
let request = UNNotificationRequest(identifier: ProcessInfo.processInfo.globallyUniqueString,
|
||||
content: content,
|
||||
trigger: nil)
|
||||
Task {
|
||||
do {
|
||||
try await notificationCenter.add(request)
|
||||
MXLog.debug("[NotificationManager] show local notification succeeded")
|
||||
} catch {
|
||||
MXLog.debug("[NotificationManager] show local notification failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) {
|
||||
Task {
|
||||
do {
|
||||
try await clientProxy.setPusher(pushkey: deviceToken.base64EncodedString(),
|
||||
kind: .http,
|
||||
appId: BuildSettings.pusherAppId,
|
||||
appDisplayName: "\(InfoPlistReader.target.bundleDisplayName) (iOS)",
|
||||
deviceDisplayName: UIDevice.current.name,
|
||||
profileTag: pusherProfileTag(),
|
||||
lang: Bundle.preferredLanguages.first ?? "en",
|
||||
url: BuildSettings.pushGatewayBaseURL.absoluteString,
|
||||
format: .eventIdOnly,
|
||||
defaultPayload: [
|
||||
"aps": [
|
||||
"mutable-content": 1,
|
||||
"alert": [
|
||||
"loc-key": "Notification",
|
||||
"loc-args": []
|
||||
]
|
||||
]
|
||||
])
|
||||
MXLog.debug("[NotificationManager] set pusher succeeded")
|
||||
} catch {
|
||||
MXLog.debug("[NotificationManager] set pusher failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pusherProfileTag() -> String {
|
||||
if let currentTag = ElementSettings.shared.pusherProfileTag {
|
||||
return currentTag
|
||||
}
|
||||
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let newTag = (0..<16).map { _ in
|
||||
let offset = Int.random(in: 0..<chars.count)
|
||||
return String(chars[chars.index(chars.startIndex, offsetBy: offset)])
|
||||
}.joined()
|
||||
|
||||
ElementSettings.shared.pusherProfileTag = newTag
|
||||
return newTag
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
extension NotificationManager: UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
guard ElementSettings.shared.enableInAppNotifications else {
|
||||
return []
|
||||
}
|
||||
guard let delegate else {
|
||||
return [.badge, .sound, .list, .banner]
|
||||
}
|
||||
|
||||
guard delegate.shouldDisplayInAppNotification(self, content: notification.request.content) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return [.badge, .sound, .list, .banner]
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse) async {
|
||||
switch response.actionIdentifier {
|
||||
case NotificationConstants.Action.inlineReply:
|
||||
guard let response = response as? UNTextInputNotificationResponse else {
|
||||
return
|
||||
}
|
||||
await delegate?.handleInlineReply(self,
|
||||
content: response.notification.request.content,
|
||||
replyText: response.userText)
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
await delegate?.notificationTapped(self,
|
||||
content: response.notification.request.content)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
protocol NotificationManagerDelegate: AnyObject {
|
||||
func authorizationStatusUpdated(_ service: NotificationManagerProtocol,
|
||||
granted: Bool)
|
||||
func shouldDisplayInAppNotification(_ service: NotificationManagerProtocol,
|
||||
content: UNNotificationContent) -> Bool
|
||||
func notificationTapped(_ service: NotificationManagerProtocol,
|
||||
content: UNNotificationContent) async
|
||||
func handleInlineReply(_ service: NotificationManagerProtocol,
|
||||
content: UNNotificationContent,
|
||||
replyText: String) async
|
||||
}
|
||||
|
||||
// MARK: - NotificationManagerProtocol
|
||||
|
||||
protocol NotificationManagerProtocol {
|
||||
var isAvailable: Bool { get }
|
||||
var delegate: NotificationManagerDelegate? { get set }
|
||||
|
||||
func start()
|
||||
func register(with deviceToken: Data)
|
||||
func registrationFailed(with error: Error)
|
||||
func showLocalNotification(with title: String, subtitle: String?)
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NotificationConstants {
|
||||
enum UserInfoKey {
|
||||
static let roomIdentifier = "room_id"
|
||||
static let eventIdentifier = "event_id"
|
||||
static let unreadCount = "unread_count"
|
||||
}
|
||||
|
||||
enum Category {
|
||||
static let discard = "discard"
|
||||
static let reply = "reply"
|
||||
}
|
||||
|
||||
enum Action {
|
||||
static let inlineReply = "inline-reply"
|
||||
}
|
||||
}
|
@ -16,9 +16,8 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension FileManager {
|
||||
/// The URL of the primary app group container.
|
||||
@objc var appGroupContainerURL: URL? {
|
||||
containerURL(forSecurityApplicationGroupIdentifier: ElementInfoPlist.appGroupIdentifier)
|
||||
class MockNotificationServiceProxy: NotificationServiceProxyProtocol {
|
||||
func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxy? {
|
||||
nil
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
struct NotificationItemProxy {
|
||||
// let notificationItem: NotificationItem
|
||||
//
|
||||
// init(notificationItem: NotificationItem) {
|
||||
// self.notificationItem = notificationItem
|
||||
// }
|
||||
//
|
||||
// var timelineItemProxy: TimelineItemProxy {
|
||||
// .init(item: notificationItem.item)
|
||||
// }
|
||||
//
|
||||
// var title: String {
|
||||
// notificationItem.title
|
||||
// }
|
||||
//
|
||||
// var subtitle: String? {
|
||||
// notificationItem.subtitle
|
||||
// }
|
||||
//
|
||||
// var isNoisy: Bool {
|
||||
// notificationItem.isNoisy
|
||||
// }
|
||||
//
|
||||
// var avatarUrl: String? {
|
||||
// notificationItem.avatarUrl
|
||||
// }
|
||||
//
|
||||
// var avatarMediaSource: MediaSourceProxy? {
|
||||
// guard let avatarUrl else {
|
||||
// return nil
|
||||
// }
|
||||
// return .init(urlString: avatarUrl)
|
||||
// }
|
||||
|
||||
var title: String {
|
||||
"Title"
|
||||
}
|
||||
|
||||
var subtitle: String? {
|
||||
nil
|
||||
}
|
||||
|
||||
var isNoisy: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var avatarUrl: String? {
|
||||
nil
|
||||
}
|
||||
|
||||
var avatarMediaSource: MediaSourceProxy? {
|
||||
nil
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
class NotificationServiceProxy: NotificationServiceProxyProtocol {
|
||||
// private let service: NotificationServiceProtocol
|
||||
|
||||
init(basePath: String,
|
||||
userId: String) {
|
||||
// service = NotificationService(basePath: basePath, userId: userId)
|
||||
}
|
||||
|
||||
func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxy? {
|
||||
nil
|
||||
// try await Task.dispatch(on: .global()) {
|
||||
// guard let item = try self.service.getNotificationItem(roomId: roomId, eventId: eventId) else {
|
||||
// return nil
|
||||
// }
|
||||
// return .init(notificationItem: item)
|
||||
// }
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol NotificationServiceProxyProtocol {
|
||||
func notificationItem(roomId: String, eventId: String) async throws -> NotificationItemProxy?
|
||||
}
|
@ -113,8 +113,8 @@ extension MatrixRustSDK.ImageMessageContent: MessageContentProtocol { }
|
||||
|
||||
/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.image`.
|
||||
extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent {
|
||||
var source: MediaSource {
|
||||
MediaSource(source: content.source)
|
||||
var source: MediaSourceProxy {
|
||||
.init(source: content.source)
|
||||
}
|
||||
|
||||
var width: CGFloat? {
|
||||
@ -134,15 +134,15 @@ extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { }
|
||||
|
||||
/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.video`.
|
||||
extension MessageTimelineItem where Content == MatrixRustSDK.VideoMessageContent {
|
||||
var source: MediaSource {
|
||||
MediaSource(source: content.source)
|
||||
var source: MediaSourceProxy {
|
||||
.init(source: content.source)
|
||||
}
|
||||
|
||||
var thumbnailSource: MediaSource? {
|
||||
var thumbnailSource: MediaSourceProxy? {
|
||||
guard let src = content.info?.thumbnailSource else {
|
||||
return nil
|
||||
}
|
||||
return MediaSource(source: src)
|
||||
return .init(source: src)
|
||||
}
|
||||
|
||||
var duration: UInt64 {
|
||||
@ -166,14 +166,14 @@ extension MatrixRustSDK.FileMessageContent: MessageContentProtocol { }
|
||||
|
||||
/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.file`.
|
||||
extension MessageTimelineItem where Content == MatrixRustSDK.FileMessageContent {
|
||||
var source: MediaSource {
|
||||
MediaSource(source: content.source)
|
||||
var source: MediaSourceProxy {
|
||||
.init(source: content.source)
|
||||
}
|
||||
|
||||
var thumbnailSource: MediaSource? {
|
||||
var thumbnailSource: MediaSourceProxy? {
|
||||
guard let src = content.info?.thumbnailSource else {
|
||||
return nil
|
||||
}
|
||||
return MediaSource(source: src)
|
||||
return .init(source: src)
|
||||
}
|
||||
}
|
||||
|
@ -29,8 +29,8 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equat
|
||||
var senderDisplayName: String?
|
||||
var senderAvatar: UIImage?
|
||||
|
||||
let source: MediaSource?
|
||||
let thumbnailSource: MediaSource?
|
||||
let source: MediaSourceProxy?
|
||||
let thumbnailSource: MediaSourceProxy?
|
||||
var cachedFileURL: URL?
|
||||
|
||||
var properties = RoomTimelineItemProperties()
|
||||
|
@ -29,7 +29,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
|
||||
var senderDisplayName: String?
|
||||
var senderAvatar: UIImage?
|
||||
|
||||
let source: MediaSource?
|
||||
let source: MediaSourceProxy?
|
||||
var image: UIImage?
|
||||
|
||||
var width: CGFloat?
|
||||
|
@ -30,8 +30,8 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
|
||||
var senderAvatar: UIImage?
|
||||
|
||||
let duration: UInt64
|
||||
let source: MediaSource?
|
||||
let thumbnailSource: MediaSource?
|
||||
let source: MediaSourceProxy?
|
||||
let thumbnailSource: MediaSourceProxy?
|
||||
var image: UIImage?
|
||||
var cachedVideoURL: URL?
|
||||
|
||||
|
@ -43,6 +43,16 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
|
||||
stateMachine.processEvent(.start)
|
||||
}
|
||||
|
||||
func stop() { }
|
||||
|
||||
func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool {
|
||||
stateMachine.isDisplayingRoomScreen(withRoomId: roomId)
|
||||
}
|
||||
|
||||
func tryDisplayingRoomScreen(roomId: String) {
|
||||
stateMachine.processEvent(.showRoomScreen(roomId: roomId))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
|
@ -150,4 +150,14 @@ class UserSessionFlowCoordinatorStateMachine {
|
||||
func addErrorHandler(_ handler: @escaping StateMachine<State, Event>.Handler) {
|
||||
stateMachine.addErrorHandler(handler: handler)
|
||||
}
|
||||
|
||||
/// Flag indicating the machine is displaying room screen with given room identifier
|
||||
func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool {
|
||||
switch stateMachine.state {
|
||||
case .roomScreen(let displayedRoomId):
|
||||
return roomId == displayedRoomId
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,26 +26,14 @@ class UserSessionStore: UserSessionStoreProtocol {
|
||||
var hasSessions: Bool { !keychainController.restorationTokens().isEmpty }
|
||||
|
||||
/// The base directory where all session data is stored.
|
||||
private(set) lazy var baseDirectory: URL = {
|
||||
guard let appGroupContainerURL = FileManager.default.appGroupContainerURL else {
|
||||
fatalError("Should always be able to retrieve the container directory")
|
||||
}
|
||||
let baseDirectory: URL
|
||||
|
||||
let url = appGroupContainerURL
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Caches", isDirectory: true)
|
||||
.appendingPathComponent("Sessions", isDirectory: true)
|
||||
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: nil)
|
||||
|
||||
MXLog.debug("Setup base directory at: \(url)")
|
||||
|
||||
return url
|
||||
}()
|
||||
|
||||
init(bundleIdentifier: String, backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
keychainController = KeychainController(identifier: bundleIdentifier)
|
||||
init(backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
keychainController = KeychainController(service: .sessions,
|
||||
accessGroup: InfoPlistReader.target.appGroupIdentifier)
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
baseDirectory = .sessionsBaseDirectory
|
||||
MXLog.debug("Setup base directory at: \(baseDirectory)")
|
||||
}
|
||||
|
||||
func restoreUserSession() async -> Result<UserSession, UserSessionStoreError> {
|
||||
@ -58,7 +46,7 @@ class UserSessionStore: UserSessionStoreProtocol {
|
||||
switch await restorePreviousLogin(credentials) {
|
||||
case .success(let clientProxy):
|
||||
return .success(UserSession(clientProxy: clientProxy,
|
||||
mediaProvider: MediaProvider(clientProxy: clientProxy,
|
||||
mediaProvider: MediaProvider(mediaProxy: clientProxy,
|
||||
imageCache: .onlyInMemory,
|
||||
fileCache: .default,
|
||||
backgroundTaskService: backgroundTaskService)))
|
||||
@ -77,7 +65,7 @@ class UserSessionStore: UserSessionStoreProtocol {
|
||||
switch await setupProxyForClient(client) {
|
||||
case .success(let clientProxy):
|
||||
return .success(UserSession(clientProxy: clientProxy,
|
||||
mediaProvider: MediaProvider(clientProxy: clientProxy,
|
||||
mediaProvider: MediaProvider(mediaProxy: clientProxy,
|
||||
imageCache: .onlyInMemory,
|
||||
fileCache: .default,
|
||||
backgroundTaskService: backgroundTaskService)))
|
||||
|
@ -17,8 +17,9 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class UITestsAppCoordinator: CoordinatorProtocol {
|
||||
class UITestsAppCoordinator: AppCoordinatorProtocol {
|
||||
private let navigationController: NavigationController
|
||||
let notificationManager: NotificationManagerProtocol? = nil
|
||||
|
||||
init() {
|
||||
navigationController = NavigationController()
|
||||
|
@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.usernotifications.communication</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
|
@ -22,6 +22,10 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
@ -33,5 +37,7 @@
|
||||
</array>
|
||||
<key>appGroupIdentifier</key>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
<key>baseBundleIdentifier</key>
|
||||
<string>$(BASE_BUNDLE_IDENTIFIER)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -46,15 +46,19 @@ targets:
|
||||
UIInterfaceOrientationLandscapeRight
|
||||
]
|
||||
appGroupIdentifier: $(APP_GROUP_IDENTIFIER)
|
||||
baseBundleIdentifier: $(BASE_BUNDLE_IDENTIFIER)
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSUserActivityTypes: [
|
||||
INSendMessageIntent
|
||||
]
|
||||
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_NAME: ElementX
|
||||
PRODUCT_BUNDLE_IDENTIFIER: $(BASE_BUNDLE_IDENTIFIER)
|
||||
MARKETING_VERSION: 1.0.9
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
DEVELOPMENT_TEAM: 7J4U792NQT
|
||||
MARKETING_VERSION: $(MARKETING_VERSION)
|
||||
CURRENT_PROJECT_VERSION: $(CURRENT_PROJECT_VERSION)
|
||||
DEVELOPMENT_TEAM: $(DEVELOPMENT_TEAM)
|
||||
CODE_SIGN_ENTITLEMENTS: ElementX/SupportingFiles/ElementX.entitlements
|
||||
SWIFT_OBJC_BRIDGING_HEADER: ElementX/SupportingFiles/ElementX-Bridging-Header.h
|
||||
SWIFT_OBJC_INTERFACE_HEADER_NAME: GeneratedInterface-Swift.h
|
||||
@ -97,6 +101,7 @@ targets:
|
||||
fi
|
||||
|
||||
dependencies:
|
||||
- target: NSE
|
||||
- package: MatrixRustSDK
|
||||
- package: DesignKit
|
||||
- package: AnalyticsEvents
|
||||
|
165
NSE/Sources/NotificationServiceExtension.swift
Normal file
165
NSE/Sources/NotificationServiceExtension.swift
Normal file
@ -0,0 +1,165 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Intents
|
||||
import MatrixRustSDK
|
||||
import UserNotifications
|
||||
|
||||
class NotificationServiceExtension: UNNotificationServiceExtension {
|
||||
private lazy var keychainController = KeychainController(service: .sessions,
|
||||
accessGroup: InfoPlistReader.target.appGroupIdentifier)
|
||||
var handler: ((UNNotificationContent) -> Void)?
|
||||
var modifiedContent: UNMutableNotificationContent?
|
||||
|
||||
override init() {
|
||||
// Use `en` as fallback language
|
||||
Bundle.elementFallbackLanguage = "en"
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
guard !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory),
|
||||
let roomId = request.roomId,
|
||||
let eventId = request.eventId,
|
||||
let credentials = keychainController.restorationTokens().first else {
|
||||
// We cannot process this notification, it might be due to one of these:
|
||||
// - Device rebooted and locked
|
||||
// - Not a Matrix notification
|
||||
// - User is not signed in
|
||||
return contentHandler(request.content)
|
||||
}
|
||||
|
||||
handler = contentHandler
|
||||
modifiedContent = request.content.mutableCopy() as? UNMutableNotificationContent
|
||||
|
||||
NSELogger.configure()
|
||||
|
||||
NSELogger.logMemory(with: tag)
|
||||
|
||||
MXLog.debug("\(tag) #########################################")
|
||||
MXLog.debug("\(tag) Payload came: \(request.content.userInfo)")
|
||||
|
||||
Task {
|
||||
try await run(with: credentials,
|
||||
roomId: roomId,
|
||||
eventId: eventId)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
MXLog.debug("\(tag) serviceExtensionTimeWillExpire")
|
||||
notify()
|
||||
}
|
||||
|
||||
private func run(with credentials: KeychainCredentials,
|
||||
roomId: String,
|
||||
eventId: String) async throws {
|
||||
MXLog.debug("\(tag) run with roomId: \(roomId), eventId: \(eventId)")
|
||||
|
||||
let service = NotificationServiceProxy(basePath: URL.sessionsBaseDirectory.path,
|
||||
userId: credentials.userID)
|
||||
|
||||
guard let itemProxy = try await service.notificationItem(roomId: roomId,
|
||||
eventId: eventId) else {
|
||||
MXLog.debug("\(tag) got no notification item")
|
||||
|
||||
// Notification should be discarded
|
||||
return discard()
|
||||
}
|
||||
|
||||
// First process without a media proxy.
|
||||
// After this some properties of the notification should be set, like title, subtitle, sound etc.
|
||||
guard let firstContent = try await itemProxy.process(with: roomId,
|
||||
mediaProvider: nil) else {
|
||||
MXLog.debug("\(tag) not even first content")
|
||||
|
||||
// Notification should be discarded
|
||||
return discard()
|
||||
}
|
||||
|
||||
// After the first processing, update the modified content
|
||||
modifiedContent = firstContent
|
||||
|
||||
guard itemProxy.requiresMediaProvider else {
|
||||
MXLog.debug("\(tag) no media needed")
|
||||
|
||||
// We've processed the item and no media operations needed, so no need to go further
|
||||
return notify()
|
||||
}
|
||||
|
||||
MXLog.debug("\(tag) process with media")
|
||||
|
||||
// There is some media to load, process it again
|
||||
if let latestContent = try await itemProxy.process(with: roomId,
|
||||
mediaProvider: try createMediaProvider(with: credentials)) {
|
||||
// Processing finished, hopefully with some media
|
||||
modifiedContent = latestContent
|
||||
return notify()
|
||||
} else {
|
||||
// This is not very likely, as it should discard the notification sooner
|
||||
return discard()
|
||||
}
|
||||
}
|
||||
|
||||
private func createMediaProvider(with credentials: KeychainCredentials) throws -> MediaProviderProtocol {
|
||||
let builder = ClientBuilder()
|
||||
.basePath(path: URL.sessionsBaseDirectory.path)
|
||||
.username(username: credentials.userID)
|
||||
|
||||
let client = try builder.build()
|
||||
try client.restoreSession(session: credentials.restorationToken.session)
|
||||
|
||||
MXLog.debug("\(tag) creating media provider")
|
||||
|
||||
return MediaProvider(mediaProxy: MediaProxy(client: client),
|
||||
imageCache: .onlyOnDisk,
|
||||
fileCache: .default,
|
||||
backgroundTaskService: nil)
|
||||
}
|
||||
|
||||
private func notify() {
|
||||
MXLog.debug("\(tag) notify")
|
||||
|
||||
guard let modifiedContent else {
|
||||
MXLog.debug("\(tag) notify: no modified content")
|
||||
return
|
||||
}
|
||||
handler?(modifiedContent)
|
||||
handler = nil
|
||||
self.modifiedContent = nil
|
||||
}
|
||||
|
||||
private func discard() {
|
||||
MXLog.debug("\(tag) discard")
|
||||
|
||||
handler?(UNMutableNotificationContent())
|
||||
handler = nil
|
||||
modifiedContent = nil
|
||||
}
|
||||
|
||||
private var tag: String {
|
||||
"[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())]"
|
||||
}
|
||||
|
||||
deinit {
|
||||
NSELogger.logMemory(with: tag)
|
||||
MXLog.debug("\(tag) deinit")
|
||||
}
|
||||
}
|
45
NSE/Sources/Other/DataProtectionManager.swift
Normal file
45
NSE/Sources/Other/DataProtectionManager.swift
Normal file
@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class DataProtectionManager {
|
||||
/// Detects after reboot, before unlocked state. Does this by trying to write a file to the filesystem (to the Caches directory) and read it back.
|
||||
/// - Parameter containerURL: Container url to write the file.
|
||||
/// - Returns: true if the state detected
|
||||
static func isDeviceLockedAfterReboot(containerURL: URL) -> Bool {
|
||||
let dummyString = ProcessInfo.processInfo.globallyUniqueString
|
||||
guard let dummyData = dummyString.data(using: .utf8) else {
|
||||
return true
|
||||
}
|
||||
|
||||
do {
|
||||
// add a unique filename
|
||||
let url = containerURL.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString)
|
||||
|
||||
try dummyData.write(to: url, options: .completeFileProtectionUntilFirstUserAuthentication)
|
||||
let readData = try Data(contentsOf: url)
|
||||
let readString = String(data: readData, encoding: .utf8)
|
||||
try FileManager.default.removeItem(at: url)
|
||||
if readString != dummyString {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
110
NSE/Sources/Other/NSELogger.swift
Normal file
110
NSE/Sources/Other/NSELogger.swift
Normal file
@ -0,0 +1,110 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
class NSELogger {
|
||||
private static var isConfigured = false
|
||||
|
||||
/// Memory formatter, uses exact 2 fraction digits and no grouping
|
||||
private static var numberFormatter: NumberFormatter {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.alwaysShowsDecimalSeparator = true
|
||||
formatter.decimalSeparator = "."
|
||||
formatter.groupingSeparator = ""
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.minimumFractionDigits = 2
|
||||
return formatter
|
||||
}
|
||||
|
||||
private static var formattedMemoryAvailable: String {
|
||||
let freeBytes = os_proc_available_memory()
|
||||
let freeMB = Double(freeBytes) / 1024 / 1024
|
||||
guard let formattedStr = numberFormatter.string(from: NSNumber(value: freeMB)) else {
|
||||
return ""
|
||||
}
|
||||
return "\(formattedStr) MB"
|
||||
}
|
||||
|
||||
/// Details: https://developer.apple.com/forums/thread/105088
|
||||
/// - Returns: Current memory footprint
|
||||
private static var memoryFootprint: Float? {
|
||||
// The `TASK_VM_INFO_COUNT` and `TASK_VM_INFO_REV1_COUNT` macros are too
|
||||
// complex for the Swift C importer, so we have to define them ourselves.
|
||||
let TASK_VM_INFO_COUNT = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
|
||||
guard let offset = MemoryLayout.offset(of: \task_vm_info_data_t.min_address) else {
|
||||
return nil
|
||||
}
|
||||
let TASK_VM_INFO_REV1_COUNT = mach_msg_type_number_t(offset / MemoryLayout<integer_t>.size)
|
||||
var info = task_vm_info_data_t()
|
||||
var count = TASK_VM_INFO_COUNT
|
||||
let kr = withUnsafeMutablePointer(to: &info) { infoPtr in
|
||||
infoPtr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
|
||||
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), intPtr, &count)
|
||||
}
|
||||
}
|
||||
guard kr == KERN_SUCCESS, count >= TASK_VM_INFO_REV1_COUNT else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Float(info.phys_footprint)
|
||||
}
|
||||
|
||||
/// Formatted memory footprint for debugging purposes
|
||||
/// - Returns: Memory footprint in MBs as a readable string
|
||||
public static var formattedMemoryFootprint: String {
|
||||
let usedBytes = UInt64(memoryFootprint ?? 0)
|
||||
let usedMB = Double(usedBytes) / 1024 / 1024
|
||||
guard let formattedStr = numberFormatter.string(from: NSNumber(value: usedMB)) else {
|
||||
return ""
|
||||
}
|
||||
return "\(formattedStr) MB"
|
||||
}
|
||||
|
||||
static func configure() {
|
||||
guard !isConfigured else {
|
||||
return
|
||||
}
|
||||
isConfigured = true
|
||||
|
||||
let configuration = MXLogConfiguration()
|
||||
configuration.maxLogFilesCount = 10
|
||||
configuration.subLogName = "nse"
|
||||
|
||||
#if DEBUG
|
||||
// This exposes the full Rust side tracing subscriber filter for more flexibility.
|
||||
// We can filter by level, crate and even file. See more details here:
|
||||
// https://docs.rs/tracing-subscriber/0.2.7/tracing_subscriber/filter/struct.EnvFilter.html#examples
|
||||
setupTracing(filter: "warn,hyper=warn,sled=warn,matrix_sdk_sled=warn")
|
||||
configuration.logLevel = .debug
|
||||
#else
|
||||
setupTracing(filter: "info,hyper=warn,sled=warn,matrix_sdk_sled=warn")
|
||||
configuration.logLevel = .info
|
||||
#endif
|
||||
|
||||
// Avoid redirecting NSLogs to files if we are attached to a debugger.
|
||||
if isatty(STDERR_FILENO) == 0 {
|
||||
configuration.redirectLogsToFiles = true
|
||||
}
|
||||
|
||||
MXLog.configure(configuration)
|
||||
}
|
||||
|
||||
static func logMemory(with tag: String) {
|
||||
MXLog.debug("\(tag) Memory: footprint: \(formattedMemoryFootprint) - available: \(formattedMemoryAvailable)")
|
||||
}
|
||||
}
|
222
NSE/Sources/Other/NotificationItemProxy+NSE.swift
Normal file
222
NSE/Sources/Other/NotificationItemProxy+NSE.swift
Normal file
@ -0,0 +1,222 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UserNotifications
|
||||
|
||||
extension NotificationItemProxy {
|
||||
var requiresMediaProvider: Bool {
|
||||
false
|
||||
// if avatarUrl != nil {
|
||||
// return true
|
||||
// }
|
||||
// switch timelineItemProxy {
|
||||
// case .event(let eventItem):
|
||||
// guard eventItem.isMessage else {
|
||||
// // To be handled in the future
|
||||
// return false
|
||||
// }
|
||||
// guard let message = eventItem.content.asMessage() else {
|
||||
// fatalError("Only handled messages")
|
||||
// }
|
||||
// switch message.msgtype() {
|
||||
// case .image, .video:
|
||||
// return true
|
||||
// default:
|
||||
// return false
|
||||
// }
|
||||
// case .virtual:
|
||||
// return false
|
||||
// case .other:
|
||||
// return false
|
||||
// }
|
||||
}
|
||||
|
||||
/// Process the receiver item proxy
|
||||
/// - Parameters:
|
||||
/// - roomId: Room identifier
|
||||
/// - mediaProvider: Media provider to process also media. May be passed nil to ignore media operations.
|
||||
/// - Returns: A notification content object if the notification should be displayed. Otherwise nil.
|
||||
func process(with roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? {
|
||||
nil
|
||||
// switch timelineItemProxy {
|
||||
// case .event(let eventItem):
|
||||
// guard eventItem.isMessage else {
|
||||
// // To be handled in the future
|
||||
// return nil
|
||||
// }
|
||||
// guard let message = eventItem.content.asMessage() else {
|
||||
// fatalError("Item must be a message")
|
||||
// }
|
||||
//
|
||||
// return try await process(message: message,
|
||||
// senderId: eventItem.sender,
|
||||
// roomId: roomId,
|
||||
// mediaProvider: mediaProvider)
|
||||
// case .virtual:
|
||||
// return nil
|
||||
// case .other:
|
||||
// return nil
|
||||
// }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// MARK: Common
|
||||
|
||||
private func process(message: Message,
|
||||
senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent? {
|
||||
switch message.msgtype() {
|
||||
case .text(content: let content):
|
||||
return try await processText(content: content,
|
||||
senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
case .image(content: let content):
|
||||
return try await processImage(content: content,
|
||||
senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
case .video(content: let content):
|
||||
return try await processVideo(content: content,
|
||||
senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
case .file(content: let content):
|
||||
return try await processFile(content: content,
|
||||
senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
case .notice(content: let content):
|
||||
return try await processNotice(content: content,
|
||||
senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
case .emote(content: let content):
|
||||
return try await processEmote(content: content,
|
||||
senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
case .none:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func processCommon(senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||
var notification = UNMutableNotificationContent()
|
||||
notification.title = title
|
||||
if let subtitle = subtitle {
|
||||
notification.subtitle = subtitle
|
||||
}
|
||||
notification.threadIdentifier = roomId
|
||||
notification.categoryIdentifier = NotificationConstants.Category.reply
|
||||
notification.sound = isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
|
||||
|
||||
notification = try await notification.addSenderIcon(using: mediaProvider,
|
||||
senderId: senderId,
|
||||
senderName: title,
|
||||
mediaSource: avatarMediaSource,
|
||||
roomId: roomId)
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
// MARK: Message Types
|
||||
|
||||
private func processText(content: TextMessageContent,
|
||||
senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||
let notification = try await processCommon(senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
notification.body = content.body
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
private func processImage(content: ImageMessageContent,
|
||||
senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||
var notification = try await processCommon(senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
notification.body = "📷 " + content.body
|
||||
|
||||
notification = try await notification.addMediaAttachment(using: mediaProvider,
|
||||
mediaSource: .init(source: content.source))
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
private func processVideo(content: VideoMessageContent,
|
||||
senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||
var notification = try await processCommon(senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
notification.body = "📹 " + content.body
|
||||
|
||||
notification = try await notification.addMediaAttachment(using: mediaProvider,
|
||||
mediaSource: .init(source: content.source))
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
private func processFile(content: FileMessageContent,
|
||||
senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||
let notification = try await processCommon(senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
notification.body = "📄 " + content.body
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
private func processNotice(content: NoticeMessageContent,
|
||||
senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||
let notification = try await processCommon(senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
notification.body = "❕ " + content.body
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
private func processEmote(content: EmoteMessageContent,
|
||||
senderId: String,
|
||||
roomId: String,
|
||||
mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||
let notification = try await processCommon(senderId: senderId,
|
||||
roomId: roomId,
|
||||
mediaProvider: mediaProvider)
|
||||
notification.body = "🫥 " + content.body
|
||||
|
||||
return notification
|
||||
}
|
||||
}
|
91
NSE/Sources/Other/UNMutableNotificationContent.swift
Normal file
91
NSE/Sources/Other/UNMutableNotificationContent.swift
Normal file
@ -0,0 +1,91 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Intents
|
||||
import UserNotifications
|
||||
|
||||
extension UNMutableNotificationContent {
|
||||
func addMediaAttachment(using mediaProvider: MediaProviderProtocol?,
|
||||
mediaSource: MediaSourceProxy) async throws -> UNMutableNotificationContent {
|
||||
guard let mediaProvider else {
|
||||
return self
|
||||
}
|
||||
|
||||
switch await mediaProvider.loadFileFromSource(mediaSource, fileExtension: "") {
|
||||
case .success(let url):
|
||||
let attachment = try UNNotificationAttachment(identifier: ProcessInfo.processInfo.globallyUniqueString,
|
||||
url: url,
|
||||
options: nil)
|
||||
attachments.append(attachment)
|
||||
case .failure(let error):
|
||||
MXLog.debug("Couldn't add media attachment: \(error)")
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func addSenderIcon(using mediaProvider: MediaProviderProtocol?,
|
||||
senderId: String,
|
||||
senderName: String,
|
||||
mediaSource: MediaSourceProxy?,
|
||||
roomId: String) async throws -> UNMutableNotificationContent {
|
||||
guard let mediaProvider, let mediaSource else {
|
||||
return self
|
||||
}
|
||||
|
||||
switch await mediaProvider.loadFileFromSource(mediaSource, fileExtension: "jpg") {
|
||||
case .success(let url):
|
||||
// Initialize only the sender for a one-to-one message intent.
|
||||
let handle = INPersonHandle(value: senderId, type: .unknown)
|
||||
let sender = INPerson(personHandle: handle,
|
||||
nameComponents: nil,
|
||||
displayName: senderName,
|
||||
image: INImage(imageData: try Data(contentsOf: url)),
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: nil)
|
||||
|
||||
// Because this communication is incoming, you can infer that the current user is
|
||||
// a recipient. Don't include the current user when initializing the intent.
|
||||
let intent = INSendMessageIntent(recipients: nil,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: roomId,
|
||||
serviceName: nil,
|
||||
sender: sender,
|
||||
attachments: nil)
|
||||
|
||||
// Use the intent to initialize the interaction.
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
|
||||
// Interaction direction is incoming because the user is
|
||||
// receiving this message.
|
||||
interaction.direction = .incoming
|
||||
|
||||
// Donate the interaction before updating notification content.
|
||||
try await interaction.donate()
|
||||
// Update notification content before displaying the
|
||||
// communication notification.
|
||||
let updatedContent = try updating(from: intent)
|
||||
|
||||
return updatedContent.mutableCopy() as! UNMutableNotificationContent
|
||||
case .failure(let error):
|
||||
MXLog.debug("Couldn't add sender icon: \(error)")
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
32
NSE/Sources/Other/UNNotificationRequest.swift
Normal file
32
NSE/Sources/Other/UNNotificationRequest.swift
Normal file
@ -0,0 +1,32 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
extension UNNotificationRequest {
|
||||
var roomId: String? {
|
||||
content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String
|
||||
}
|
||||
|
||||
var eventId: String? {
|
||||
content.userInfo[NotificationConstants.UserInfoKey.eventIdentifier] as? String
|
||||
}
|
||||
|
||||
var unreadCount: Int? {
|
||||
content.userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int
|
||||
}
|
||||
}
|
33
NSE/SupportingFiles/Info.plist
Normal file
33
NSE/SupportingFiles/Info.plist
Normal file
@ -0,0 +1,33 @@
|
||||
<?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>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationServiceExtension</string>
|
||||
</dict>
|
||||
<key>appGroupIdentifier</key>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
<key>baseBundleIdentifier</key>
|
||||
<string>$(BASE_BUNDLE_IDENTIFIER)</string>
|
||||
</dict>
|
||||
</plist>
|
12
NSE/SupportingFiles/NSE.entitlements
Normal file
12
NSE/SupportingFiles/NSE.entitlements
Normal file
@ -0,0 +1,12 @@
|
||||
<?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.developer.usernotifications.filtering</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
89
NSE/SupportingFiles/target.yml
Normal file
89
NSE/SupportingFiles/target.yml
Normal file
@ -0,0 +1,89 @@
|
||||
name: NSE
|
||||
|
||||
schemes:
|
||||
NSE:
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
build:
|
||||
targets:
|
||||
NSE:
|
||||
- running
|
||||
- testing
|
||||
- profiling
|
||||
- analyzing
|
||||
- archiving
|
||||
profile:
|
||||
config: Release
|
||||
run:
|
||||
askForAppToLaunch: true
|
||||
config: Debug
|
||||
debugEnabled: false
|
||||
disableMainThreadChecker: false
|
||||
launchAutomaticallySubstyle: 2
|
||||
test:
|
||||
config: Debug
|
||||
disableMainThreadChecker: false
|
||||
|
||||
targets:
|
||||
NSE:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
|
||||
dependencies:
|
||||
- package: MatrixRustSDK
|
||||
- package: SwiftyBeaver
|
||||
- package: KeychainAccess
|
||||
- package: Kingfisher
|
||||
|
||||
info:
|
||||
path: ../SupportingFiles/Info.plist
|
||||
properties:
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
appGroupIdentifier: $(APP_GROUP_IDENTIFIER)
|
||||
baseBundleIdentifier: $(BASE_BUNDLE_IDENTIFIER)
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.usernotifications.service
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).NotificationServiceExtension
|
||||
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_NAME: NSE
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ${BASE_BUNDLE_IDENTIFIER}.nse
|
||||
MARKETING_VERSION: $(MARKETING_VERSION)
|
||||
CURRENT_PROJECT_VERSION: $(CURRENT_PROJECT_VERSION)
|
||||
DEVELOPMENT_TEAM: $(DEVELOPMENT_TEAM)
|
||||
CODE_SIGN_ENTITLEMENTS: NSE/SupportingFiles/NSE.entitlements
|
||||
SWIFT_OBJC_INTERFACE_HEADER_NAME: GeneratedInterface-Swift.h
|
||||
debug:
|
||||
release:
|
||||
|
||||
sources:
|
||||
- path: ../Sources
|
||||
- path: ../SupportingFiles
|
||||
- path: ../../ElementX/Sources/Services/Timeline/TimelineItemProxy.swift
|
||||
- path: ../../ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift
|
||||
- path: ../../ElementX/Sources/Services/Keychain/KeychainController.swift
|
||||
- path: ../../ElementX/Sources/Services/UserSession/RestorationToken.swift
|
||||
- path: ../../ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxyProtocol.swift
|
||||
- path: ../../ElementX/Sources/Services/Notification/Proxy/NotificationServiceProxy.swift
|
||||
- path: ../../ElementX/Sources/Services/Notification/Proxy/NotificationItemProxy.swift
|
||||
- path: ../../ElementX/Sources/Services/Notification/NotificationConstants.swift
|
||||
- path: ../../ElementX/Sources/Services/Media/MediaProxyProtocol.swift
|
||||
- path: ../../ElementX/Sources/Services/Media/MediaProxy.swift
|
||||
- path: ../../ElementX/Sources/Services/Media/MediaProviderProtocol.swift
|
||||
- path: ../../ElementX/Sources/Services/Media/MediaProvider.swift
|
||||
- path: ../../ElementX/Sources/Services/Media/MediaSourceProxy.swift
|
||||
- path: ../../ElementX/Sources/Services/Background/BackgroundTaskServiceProtocol.swift
|
||||
- path: ../../ElementX/Sources/Services/Background/BackgroundTaskProtocol.swift
|
||||
- path: ../../ElementX/Sources/Services/Cache/FileCache.swift
|
||||
- path: ../../ElementX/Sources/Other/Logging
|
||||
- path: ../../ElementX/Sources/Other/Extensions/Task.swift
|
||||
- path: ../../ElementX/Sources/Other/Extensions/FileManager.swift
|
||||
- path: ../../ElementX/Sources/Other/Extensions/URL.swift
|
||||
- path: ../../ElementX/Sources/Other/Extensions/Bundle.swift
|
||||
- path: ../../ElementX/Sources/Other/Extensions/ImageCache.swift
|
||||
- path: ../../ElementX/Sources/Other/AvatarSize.swift
|
||||
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
|
@ -24,10 +24,3 @@ strings:
|
||||
params:
|
||||
enumName: ElementL10n
|
||||
publicAccess: true
|
||||
plist:
|
||||
inputs: SupportingFiles/Info.plist
|
||||
outputs:
|
||||
templatePath: Templates/Plists/runtime-swift5-element-info.stencil
|
||||
output: InfoPlist.swift
|
||||
params:
|
||||
enumName: ElementInfoPlist
|
||||
|
@ -70,8 +70,8 @@ targets:
|
||||
basedOnDependencyAnalysis: false
|
||||
shell: /bin/sh
|
||||
script: |
|
||||
python3 $PROJECT_DIR/Tools/Scripts/bootTestSimulator.py --name 'iPhone 13 Pro Max' --version 'iOS.16.0'
|
||||
python3 $PROJECT_DIR/Tools/Scripts/bootTestSimulator.py --name 'iPad (9th generation)' --version 'iOS.16.0'
|
||||
python3 $PROJECT_DIR/Tools/Scripts/bootTestSimulator.py --name 'iPhone 13 Pro Max' --version 'iOS.16.1'
|
||||
python3 $PROJECT_DIR/Tools/Scripts/bootTestSimulator.py --name 'iPad (9th generation)' --version 'iOS.16.1'
|
||||
|
||||
sources:
|
||||
- path: ../Sources
|
||||
@ -85,7 +85,8 @@ targets:
|
||||
- path: ../../ElementX/Sources/UITests/UITestScreenIdentifier.swift
|
||||
- path: ../../ElementX/Sources/Generated/Strings.swift
|
||||
- path: ../../ElementX/Sources/Generated/Strings+Untranslated.swift
|
||||
- path: ../../ElementX/Sources/Generated/InfoPlist.swift
|
||||
- path: ../../ElementX/Resources
|
||||
- path: ../../ElementX/Sources/Other/Extensions/Bundle.swift
|
||||
- path: ../../ElementX/Sources/Other/Extensions/FileManager.swift
|
||||
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
|
||||
- path: ../../ElementX/Sources/Other/Extensions/URL.swift
|
||||
|
@ -21,7 +21,8 @@ class KeychainControllerTests: XCTestCase {
|
||||
var keychain: KeychainController!
|
||||
|
||||
override func setUp() {
|
||||
keychain = KeychainController(identifier: "\(ElementInfoPlist.cfBundleIdentifier).tests")
|
||||
keychain = KeychainController(service: .tests,
|
||||
accessGroup: InfoPlistReader.target.appGroupIdentifier)
|
||||
keychain.removeAllRestorationTokens()
|
||||
}
|
||||
|
||||
|
@ -25,11 +25,11 @@ class UserAgentBuilderTests: XCTestCase {
|
||||
|
||||
func testContainsClientName() {
|
||||
let userAgent = UserAgentBuilder.makeASCIIUserAgent()
|
||||
XCTAssert(userAgent?.contains(ElementInfoPlist.cfBundleDisplayName) == true, "\(userAgent ?? "nil") does not contain client name")
|
||||
XCTAssert(userAgent?.contains(InfoPlistReader.target.bundleDisplayName) == true, "\(userAgent ?? "nil") does not contain client name")
|
||||
}
|
||||
|
||||
func testContainsClientVersion() {
|
||||
let userAgent = UserAgentBuilder.makeASCIIUserAgent()
|
||||
XCTAssert(userAgent?.contains(ElementInfoPlist.cfBundleShortVersionString) == true, "\(userAgent ?? "nil") does not contain client version")
|
||||
XCTAssert(userAgent?.contains(InfoPlistReader.target.bundleShortVersionString) == true, "\(userAgent ?? "nil") does not contain client version")
|
||||
}
|
||||
}
|
||||
|
@ -49,4 +49,4 @@ targets:
|
||||
- path: ../SupportingFiles
|
||||
- path: ../../Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit
|
||||
- path: ../Resources
|
||||
|
||||
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
|
||||
|
1
changelog.d/243.feature
Normal file
1
changelog.d/243.feature
Normal file
@ -0,0 +1 @@
|
||||
NSE: Configure target with commented code blocks.
|
@ -25,12 +25,16 @@ settings:
|
||||
BASE_APP_GROUP_IDENTIFIER: io.element
|
||||
APP_GROUP_IDENTIFIER: group.$(BASE_APP_GROUP_IDENTIFIER)
|
||||
BASE_BUNDLE_IDENTIFIER: io.element.elementx
|
||||
MARKETING_VERSION: 1.0.9
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
DEVELOPMENT_TEAM: 7J4U792NQT
|
||||
|
||||
include:
|
||||
- path: ElementX/SupportingFiles/target.yml
|
||||
- path: UnitTests/SupportingFiles/target.yml
|
||||
- path: UITests/SupportingFiles/target.yml
|
||||
- path: IntegrationTests/SupportingFiles/target.yml
|
||||
- path: NSE/SupportingFiles/target.yml
|
||||
|
||||
packages:
|
||||
MatrixRustSDK:
|
||||
|
Loading…
x
Reference in New Issue
Block a user