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
@@ -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")
}
}
@@ -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
View 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)")
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -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
View 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
View 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
View 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