Share extension (#3506)

* Setup simple share extension

* Switch the app url scheme to be the full bundle identifier

* Setup a share extension that show a SwiftUI view, uses rust tracing and redirects to the hosting aplication

* Move media as json through the custom scheme into the main app and deep link into the media upload preview screen

* Fix message forwarding and global search screen room summary provider filtering.

* Tweak the message forwarding and global search screen designs.

* Add a room selection screen to use after receiving a share request from the share extension

* Fix share extension entitlements

* Share the temporary directory between the main app and the extensions; rename the caches one.

* Remove the no longer needed notification avatar flipping fix.

* Extract the placeholder avatar image generator from the NSE

* Nest `AvatarSize` within the new `Avatars` enum

* Donate an `INSendMessageIntent` to the system every time we send a message so they appear as share suggestions

* Support suggestions in the share extension itself

* Improve sharing animations and fix presentation when room already on the stack

* Clear all routes when sharing without a preselected room.

* Fix broken unit tests

* Various initial tweaks following code review.

* Correctly clean up and dismiss the share extension for all paths.

* Move the share extension path to a constants enum

* Rename UserSessionFlowCoordinator specific share extension states and events

* Add UserSession and Room flow coordinator share route tests

* Tweak the share extension logic.
This commit is contained in:
Stefan Ceriu
2024-11-13 14:02:47 +02:00
committed by GitHub
parent 3a600a94d5
commit b122b02bee
69 changed files with 1583 additions and 272 deletions
@@ -6,29 +6,55 @@
//
import Foundation
import UIKit
import SwiftUI
enum AvatarSize {
case user(on: UserAvatarSizeOnScreen)
case room(on: RoomAvatarSizeOnScreen)
// custom
case custom(CGFloat)
enum Avatars {
enum Size {
case user(on: UserAvatarSizeOnScreen)
case room(on: RoomAvatarSizeOnScreen)
// custom
case custom(CGFloat)
/// Value in UIKit points
var value: CGFloat {
switch self {
case .user(let screen):
return screen.value
case .room(let screen):
return screen.value
case .custom(let val):
return val
/// Value in UIKit points
var value: CGFloat {
switch self {
case .user(let screen):
return screen.value
case .room(let screen):
return screen.value
case .custom(let val):
return val
}
}
/// Value in pixels by using the scale of the main screen
var scaledValue: CGFloat {
value * UIScreen.main.scale
}
var scaledSize: CGSize {
CGSize(width: scaledValue, height: scaledValue)
}
}
/// Value in pixels by using the scale of the main screen
var scaledValue: CGFloat {
value * UIScreen.main.scale
@MainActor
static func generatePlaceholderAvatarImageData(name: String, id: String, size: CGSize) -> Data? {
let image = PlaceholderAvatarImage(name: name, contentID: id)
.clipShape(Circle())
.frame(width: size.width, height: size.height)
let renderer = ImageRenderer(content: image)
// Specify the scale so the image is rendered correctly. We don't have access to the screen
// here so a hardcoded 3.0 will have to do
renderer.scale = 3.0
guard let image = renderer.uiImage else {
MXLog.info("Generating notification icon placeholder failed")
return nil
}
return image.pngData()
}
}
@@ -87,6 +113,7 @@ enum RoomAvatarSizeOnScreen {
case home
case messageForwarding
case globalSearch
case roomSelection
case details
case notificationSettings
case roomDirectorySearch
@@ -104,6 +131,8 @@ enum RoomAvatarSizeOnScreen {
return 36
case .globalSearch:
return 36
case .roomSelection:
return 36
case .home:
return 52
case .details:
@@ -113,9 +142,3 @@ enum RoomAvatarSizeOnScreen {
}
}
}
extension AvatarSize {
var scaledSize: CGSize {
CGSize(width: scaledValue, height: scaledValue)
}
}
@@ -38,7 +38,7 @@ extension FileManager {
@discardableResult
func writeDataToTemporaryDirectory(data: Data, fileName: String) throws -> URL {
let newURL = URL.temporaryDirectory.appendingPathComponent(fileName)
let newURL = URL.appGroupTemporaryDirectory.appendingPathComponent(fileName)
try data.write(to: newURL)
@@ -183,37 +183,17 @@ extension UNMutableNotificationContent {
@MainActor
private func getPlaceholderAvatarImageData(name: String, id: String) async -> Data? {
// The version value is used in case the design of the placeholder is updated to force a replacement
let shouldFlipAvatar = shouldFlipAvatar()
let prefix = "notification_placeholder\(shouldFlipAvatar ? "V9F" : "V9")"
let prefix = "notification_placeholderV9"
let fileName = "\(prefix)_\(name)_\(id).png"
if let data = try? Data(contentsOf: URL.temporaryDirectory.appendingPathComponent(fileName)) {
MXLog.info("Found existing notification icon placeholder")
return data
}
MXLog.info("Generating notification icon placeholder")
let image = PlaceholderAvatarImage(name: name,
contentID: id)
.clipShape(Circle())
.frame(width: 50, height: 50)
let renderer = ImageRenderer(content: image)
// Specify the scale so the image is rendered correctly. We don't have access to the screen
// here so a hardcoded 3.0 will have to do
renderer.scale = 3.0
guard let image = renderer.uiImage else {
MXLog.info("Generating notification icon placeholder failed")
return nil
}
let data: Data?
if shouldFlipAvatar {
data = image.flippedVertically().pngData()
} else {
data = image.pngData()
}
let data = Avatars.generatePlaceholderAvatarImageData(name: name, id: id, size: .init(width: 50, height: 50))
if let data {
do {
@@ -224,44 +204,7 @@ extension UNMutableNotificationContent {
return data
}
}
return data
}
/// On simulators and macOS the image is rendered correctly
/// On devices before iOS 17 and iOS 17.2.0 it's rendered upside down and needs to be flipped
/// On all other versions it's rendered correctly and **doesn't** need to be flipped
private func shouldFlipAvatar() -> Bool {
#if targetEnvironment(simulator)
return false
#else
if ProcessInfo.processInfo.isiOSAppOnMac {
return false
}
guard let version = Version(UIDevice.current.systemVersion) else {
return false
}
if version < Version(17, 0, 0) {
return true
}
if version == Version(17, 2, 0) {
return true
}
return false
#endif
}
}
private extension UIImage {
func flippedVertically() -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = scale
return UIGraphicsImageRenderer(size: size, format: format).image { context in
context.cgContext.concatenate(CGAffineTransform(scaleX: 1, y: -1))
self.draw(at: CGPoint(x: 0, y: -size.height))
}
}
}
+15 -2
View File
@@ -60,7 +60,7 @@ extension URL: @retroactive ExpressibleByStringLiteral {
}
/// The base directory where all application support data is stored.
static var cachesBaseDirectory: URL {
static var sessionCachesBaseDirectory: URL {
let url = appGroupContainerDirectory
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Caches", isDirectory: true)
@@ -69,7 +69,20 @@ extension URL: @retroactive ExpressibleByStringLiteral {
try? FileManager.default.createDirectoryIfNeeded(at: url)
// Caches are excluded from backups automatically anyway.
// Caches are excluded from backups automatically.
// https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html
return url
}
/// The app group temporary directory
static var appGroupTemporaryDirectory: URL {
let url = appGroupContainerDirectory
.appendingPathComponent("tmp", isDirectory: true)
try? FileManager.default.createDirectoryIfNeeded(at: url)
// Temporary files are excluded from backups automatically.
// https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html
return url
@@ -0,0 +1,81 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import MatrixRustSDK
enum ExtensionLogger {
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
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(currentTarget: String, logLevel: TracingConfiguration.LogLevel) {
guard !isConfigured else {
return
}
isConfigured = true
MXLog.configure(currentTarget: currentTarget, filePrefix: currentTarget, logLevel: logLevel)
}
static func logMemory(with tag: String) {
MXLog.info("\(tag) Memory: footprint: \(formattedMemoryFootprint) - available: \(formattedMemoryAvailable)")
}
}
@@ -25,13 +25,13 @@ struct AvatarHeaderView<Footer: View>: View {
private let subtitle: String?
private let badges: [Badge]
private let avatarSize: AvatarSize
private let avatarSize: Avatars.Size
private let mediaProvider: MediaProviderProtocol?
private var onAvatarTap: ((URL) -> Void)?
@ViewBuilder private var footer: () -> Footer
init(room: RoomDetails,
avatarSize: AvatarSize,
avatarSize: Avatars.Size,
mediaProvider: MediaProviderProtocol? = nil,
onAvatarTap: ((URL) -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
@@ -72,7 +72,7 @@ struct AvatarHeaderView<Footer: View>: View {
init(member: RoomMemberDetails,
isVerified: Bool = false,
avatarSize: AvatarSize,
avatarSize: Avatars.Size,
mediaProvider: MediaProviderProtocol? = nil,
onAvatarTap: ((URL) -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
@@ -88,7 +88,7 @@ struct AvatarHeaderView<Footer: View>: View {
init(user: UserProfileProxy,
isVerified: Bool,
avatarSize: AvatarSize,
avatarSize: Avatars.Size,
mediaProvider: MediaProviderProtocol? = nil,
onAvatarTap: ((URL) -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
@@ -11,7 +11,7 @@ struct LoadableAvatarImage: View {
private let url: URL?
private let name: String?
private let contentID: String?
private let avatarSize: AvatarSize
private let avatarSize: Avatars.Size
private let mediaProvider: MediaProviderProtocol?
private let onTap: ((URL) -> Void)?
@@ -19,7 +19,7 @@ struct LoadableAvatarImage: View {
init(url: URL?, name: String?,
contentID: String?,
avatarSize: AvatarSize,
avatarSize: Avatars.Size,
mediaProvider: MediaProviderProtocol?,
onTap: ((URL) -> Void)? = nil) {
self.url = url
@@ -12,12 +12,12 @@ struct OverridableAvatarImage: View {
private let url: URL?
private let name: String?
private let contentID: String?
private let avatarSize: AvatarSize
private let avatarSize: Avatars.Size
private let mediaProvider: MediaProviderProtocol?
@ScaledMetric private var frameSize: CGFloat
init(overrideURL: URL?, url: URL?, name: String?, contentID: String?, avatarSize: AvatarSize, mediaProvider: MediaProviderProtocol?) {
init(overrideURL: URL?, url: URL?, name: String?, contentID: String?, avatarSize: Avatars.Size, mediaProvider: MediaProviderProtocol?) {
self.overrideURL = overrideURL
self.url = url
self.name = name
@@ -22,7 +22,7 @@ enum RoomAvatar: Equatable {
struct RoomAvatarImage: View {
let avatar: RoomAvatar
let avatarSize: AvatarSize
let avatarSize: Avatars.Size
let mediaProvider: MediaProviderProtocol?
private(set) var onAvatarTap: ((URL) -> Void)?
@@ -18,7 +18,7 @@ struct StackedAvatarsView: View {
let lineWidth: CGFloat
var shouldStackFromLast = false
let avatars: [StackedAvatarInfo]
let avatarSize: AvatarSize
let avatarSize: Avatars.Size
let mediaProvider: MediaProviderProtocol?
var body: some View {