Notifications (#275)

This commit is contained in:
ismailgulek
2022-11-21 19:37:13 +03:00
committed by GitHub
parent 9f3ed6ca7b
commit d389ce7ad7
74 changed files with 2459 additions and 324 deletions
@@ -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,7 +53,8 @@ class AppCoordinator: CoordinatorProtocol {
private let backgroundTaskService: BackgroundTaskServiceProtocol
private var cancellables = Set<AnyCancellable>()
private(set) var notificationManager: NotificationManagerProtocol?
init() {
navigationController = NavigationController()
stateMachine = AppCoordinatorStateMachine()
@@ -60,17 +62,12 @@ class AppCoordinator: CoordinatorProtocol {
bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL)
navigationController.setRootCoordinator(SplashScreenCoordinator())
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
}
}
@@ -268,6 +268,38 @@ class AppCoordinator: CoordinatorProtocol {
startAuthentication()
}
}
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
@@ -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)
}
}
}
@@ -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 }
}
+21 -29
View File
@@ -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))
}
}
@@ -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,12 +17,22 @@
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
static let bugReportServiceBaseURL = URL(staticString: "https://riot.im/bugreports")
@@ -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
+14 -3
View File
@@ -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
@@ -63,9 +65,18 @@ final class ElementSettings: ObservableObject {
@AppStorage(UserDefaultsKeys.timelineStyle.rawValue, store: store)
var timelineStyle = BuildSettings.defaultRoomTimelineStyle
// MARK: - Client
@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?
}
@@ -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
}
}
@@ -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 {
@@ -204,25 +207,7 @@ class ClientProxy: ClientProxyProtocol {
.failure(.failedSettingAccountData)
}
}
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 {
@@ -243,6 +228,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
@@ -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,13 +17,24 @@
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) {
do {
let tokenData = try JSONEncoder().encode(restorationToken)
@@ -32,7 +43,7 @@ class KeychainController: KeychainControllerProtocol {
MXLog.error("Failed storing user restore token with error: \(error)")
}
}
func restorationTokenForUsername(_ username: String) -> RestorationToken? {
do {
guard let tokenData = try keychain.getData(username) else {
@@ -49,24 +60,24 @@ class KeychainController: KeychainControllerProtocol {
homeserverUrl: legacyRestorationToken.homeURL,
isSoftLogout: legacyRestorationToken.isSoftLogout ?? false))
}
return try JSONDecoder().decode(RestorationToken.self, from: tokenData)
} catch {
MXLog.error("Failed retrieving user restore token")
return nil
}
}
func restorationTokens() -> [KeychainCredentials] {
keychain.allKeys().compactMap { username in
guard let restorationToken = restorationTokenForUsername(username) else {
return nil
}
return KeychainCredentials(userID: username, restorationToken: restorationToken)
}
}
func removeRestorationTokenForUsername(_ username: String) {
do {
try keychain.remove(username)
@@ -74,7 +85,7 @@ class KeychainController: KeychainControllerProtocol {
MXLog.error("Failed removing restore token with error: \(error)")
}
}
func removeAllRestorationTokens() {
do {
try keychain.removeAll()
@@ -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)
}
@@ -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)
}
}
}
@@ -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?
@@ -42,7 +42,17 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
func start() {
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 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
}()
let baseDirectory: 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()