mirror of
https://github.com/ProtonMail/ios-mail.git
synced 2026-05-15 09:50:39 +00:00
ET-638 Share extension
This commit is contained in:
committed by
MargeBot
parent
a07a2c568c
commit
ac062e361c
+1
-1
@@ -37,7 +37,7 @@ extension FileManager {
|
||||
/**
|
||||
Moves the given `file` to `destinationFolder`. If a file with the same name exists, it creates a unique file name using `uniqueFileNameURL(in folder:,baseName:,fileExtension:)`
|
||||
*/
|
||||
func moveToUniqueURL(file: URL, to destinationFolder: URL) throws -> URL {
|
||||
public func moveToUniqueURL(file: URL, to destinationFolder: URL) throws -> URL {
|
||||
let uniqueURL = uniqueFileNameURL(
|
||||
in: destinationFolder,
|
||||
baseName: file.deletingPathExtension().lastPathComponent,
|
||||
|
||||
@@ -178,7 +178,10 @@ final class MockDraft: AppDraftProtocol, @unchecked Sendable {
|
||||
mockSender
|
||||
}
|
||||
|
||||
func setBody(body: String) -> VoidDraftSaveResult { .ok }
|
||||
func setBody(body: String) -> VoidDraftSaveResult {
|
||||
mockBody = body
|
||||
return .ok
|
||||
}
|
||||
|
||||
func setSubject(subject: String) -> VoidDraftSaveResult {
|
||||
mockSubject = subject
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
enum JPEG {
|
||||
static let compressionQuality = 0.8
|
||||
public enum JPEG {
|
||||
public static let compressionQuality = 0.8
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ extension AppLogger {
|
||||
case rustLibrary
|
||||
case search
|
||||
case send
|
||||
case shareExtension
|
||||
case snooze
|
||||
case thirtySecondsBackgroundTask
|
||||
case userSessions
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
@@ -20,7 +21,7 @@ import proton_app_uniffi
|
||||
|
||||
extension ApiEnvId {
|
||||
/// Payments are not available for sandbox users in production environment.
|
||||
var arePaymentsEnabled: Bool {
|
||||
public var arePaymentsEnabled: Bool {
|
||||
return !(isAppInstalledThroughTestFlight && self == .prod)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public final class MailSessionSpy: MailSessionProtocol {
|
||||
public var onPrimaryAccountChanged: (@Sendable (String) -> Void)?
|
||||
public var appProtectionStub: AppProtection = .none
|
||||
|
||||
public var primaryUserSessionStub: MailUserSession?
|
||||
|
||||
public var storedSessions: [StoredSessionStub] = [] {
|
||||
didSet {
|
||||
Task {
|
||||
@@ -242,7 +244,7 @@ public final class MailSessionSpy: MailSessionProtocol {
|
||||
}
|
||||
|
||||
public func toPrimaryUserSession() async -> MailSessionToPrimaryUserSessionResult {
|
||||
fatalError(#function)
|
||||
.ok(primaryUserSessionStub!)
|
||||
}
|
||||
|
||||
public func toUserSession(ffiFlow: LoginFlow) async -> MailSessionToUserSessionResult {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import proton_app_uniffi
|
||||
|
||||
extension ApiEnvId {
|
||||
static let current: Self = {
|
||||
#if QA || DEBUG
|
||||
if let dynamicDomain = UserDefaults.appGroup.string(forKey: "DYNAMIC_DOMAIN") {
|
||||
return .init(dynamicDomain: dynamicDomain)
|
||||
}
|
||||
#endif
|
||||
|
||||
return .prod
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Combine
|
||||
import InboxCore
|
||||
import InboxCoreUI
|
||||
import SwiftUI
|
||||
import TestableShareExtension
|
||||
|
||||
final class ShareViewController: UINavigationController {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
override func beginRequest(with context: NSExtensionContext) {
|
||||
super.beginRequest(with: context)
|
||||
|
||||
let model = ShareScreenModel(apiEnvId: .current, extensionContext: context)
|
||||
showMainScreen(basedOn: model)
|
||||
setUpBindings(observing: model)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
DynamicFontSize.capSupportedSizeCategories()
|
||||
|
||||
super.viewDidLoad()
|
||||
|
||||
isNavigationBarHidden = true
|
||||
}
|
||||
|
||||
private func showMainScreen(basedOn model: ShareScreenModel) {
|
||||
let screen = ShareScreen(model: model)
|
||||
let hostingController = UIHostingController(rootView: screen)
|
||||
setViewControllers([hostingController], animated: false)
|
||||
}
|
||||
|
||||
private func setUpBindings(observing model: ShareScreenModel) {
|
||||
model.$alert.sink { [weak self] message in
|
||||
guard let self else { return }
|
||||
|
||||
if presentedViewController != nil {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
if let message {
|
||||
let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert)
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// swift-tools-version: 5.10
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "TestableShareExtension",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "TestableShareExtension", targets: ["TestableShareExtension"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../InboxComposer"),
|
||||
.package(path: "../InboxKeychain"),
|
||||
.package(path: "../InboxSnapshotTesting"),
|
||||
.package(path: "../InboxTesting"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "TestableShareExtension", dependencies: ["InboxComposer", "InboxKeychain"]),
|
||||
.testTarget(
|
||||
name: "ShareExtensionTests",
|
||||
dependencies: ["InboxSnapshotTesting", "InboxTesting", "TestableShareExtension"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import InboxComposer
|
||||
import proton_app_uniffi
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum DraftPrecomposer {
|
||||
static func populate(draft: AppDraftProtocol, with sharedContent: SharedContent) async throws {
|
||||
if let subject = sharedContent.subject {
|
||||
try draft.setSubject(subject: subject).get()
|
||||
}
|
||||
|
||||
if let inlineImageHTML = try await add(attachments: sharedContent.attachments, to: draft.attachmentList()) {
|
||||
try draft.prependToBody(text: inlineImageHTML.content)
|
||||
}
|
||||
|
||||
if let sharedBody = sharedContent.body {
|
||||
try draft.prependToBody(text: sharedBody)
|
||||
}
|
||||
}
|
||||
|
||||
private static func add(attachments: [NSItemProvider], to attachmentList: AttachmentListProtocol) async throws -> InlineImageHTML? {
|
||||
let uploadFolder: URL = URL(fileURLWithPath: attachmentList.attachmentUploadDirectory())
|
||||
|
||||
var cids: [String] = []
|
||||
|
||||
for attachment in attachments {
|
||||
let url = try await saveFileRepresentation(of: attachment, intoDirectory: uploadFolder)
|
||||
let path = url.path(percentEncoded: false)
|
||||
|
||||
if attachment.hasImageRepresentation {
|
||||
if try url.isScreenshotInPlistFormat() {
|
||||
try await extractImageContent(of: attachment, into: url)
|
||||
}
|
||||
|
||||
let cid = try await attachmentList.addInline(path: path, filenameOverride: nil).get()
|
||||
cids.append(cid)
|
||||
} else {
|
||||
try await attachmentList.add(path: path, filenameOverride: nil).get()
|
||||
}
|
||||
}
|
||||
|
||||
return cids.isEmpty ? nil : InlineImageHTML(cids: cids)
|
||||
}
|
||||
|
||||
private static func saveFileRepresentation(of attachment: NSItemProvider, intoDirectory persistentDirectory: URL) async throws -> URL {
|
||||
try await attachment.performOnFileRepresentation { shortLivedURL in
|
||||
try FileManager.default.moveToUniqueURL(file: shortLivedURL, to: persistentDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractImageContent(of attachment: NSItemProvider, into url: URL) async throws {
|
||||
let image = try await attachment.loadItem(forTypeIdentifier: UTType.image.identifier) as? UIImage
|
||||
try image?.jpegData(compressionQuality: JPEG.compressionQuality)?.write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDraftProtocol {
|
||||
func prependToBody(text: String) throws {
|
||||
let currentBody = body()
|
||||
try setBody(body: text + currentBody).get()
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
func isScreenshotInPlistFormat() throws -> Bool {
|
||||
let plistFileSignature = "bplist00".data(using: .ascii)!
|
||||
let handle = try FileHandle(forReadingFrom: self)
|
||||
|
||||
defer {
|
||||
try? handle.close()
|
||||
}
|
||||
|
||||
let fileSignature = try handle.read(upToCount: plistFileSignature.count)
|
||||
return fileSignature == plistFileSignature
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import InboxCore
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SharedContent {
|
||||
let subject: String?
|
||||
let body: String?
|
||||
let attachments: [NSItemProvider]
|
||||
}
|
||||
|
||||
enum SharedItemsParser {
|
||||
static func parse(extensionItems: [NSExtensionItem]) async throws -> SharedContent {
|
||||
for extensionItem in extensionItems {
|
||||
guard let attachments = extensionItem.attachments else { continue }
|
||||
|
||||
let registeredTypeIdentifiers = attachments.flatMap(\.registeredTypeIdentifiers)
|
||||
let isSharingSafariPage = registeredTypeIdentifiers == [UTType.url.identifier]
|
||||
let isSharingTextFromSelection = registeredTypeIdentifiers == [UTType.plainText.identifier]
|
||||
|
||||
if isSharingSafariPage {
|
||||
let link = try await attachments[0].loadString()
|
||||
let body = "<a href=\"\(link)\">\(link)</a>"
|
||||
return .init(subject: extensionItem.attributedContentText?.string, body: body, attachments: [])
|
||||
} else if isSharingTextFromSelection {
|
||||
return .init(subject: nil, body: extensionItem.attributedContentText?.string, attachments: [])
|
||||
}
|
||||
}
|
||||
|
||||
let allAttachments = extensionItems.compactMap(\.attachments).flatMap(\.self)
|
||||
return .init(subject: nil, body: nil, attachments: allAttachments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import InboxIAP
|
||||
import proton_app_uniffi
|
||||
|
||||
extension UpsellConfiguration {
|
||||
/// The upsell screen should always show this particular plan.
|
||||
static func mail(apiEnvId: ApiEnvId) -> Self {
|
||||
.init(planName: "mail2022", arePaymentsEnabled: apiEnvId.arePaymentsEnabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import InboxCore
|
||||
import InboxCoreUI
|
||||
import InboxDesignSystem
|
||||
import proton_app_uniffi
|
||||
import SwiftUI
|
||||
|
||||
struct ErrorScreen: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
let error: Error
|
||||
let dismissExtension: () -> Void
|
||||
|
||||
init(error: any Error, dismissExtension: @escaping () -> Void) {
|
||||
self.error = error
|
||||
self.dismissExtension = dismissExtension
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: DS.Spacing.extraLarge) {
|
||||
Spacer()
|
||||
|
||||
Text(errorMessage)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
|
||||
if error.shouldPromptToSignIn {
|
||||
Button(L10n.openApp.string) {
|
||||
openURL(URL(string: "\(Bundle.URLScheme.protonmail):")!)
|
||||
dismissExtension()
|
||||
}
|
||||
}
|
||||
|
||||
Button(CommonL10n.cancel.string) {
|
||||
dismissExtension()
|
||||
}
|
||||
}
|
||||
.buttonStyle(BigButtonStyle())
|
||||
.padding(DS.Spacing.huge)
|
||||
.padding(.vertical, DS.Spacing.extraLarge)
|
||||
.background(DS.Color.BackgroundInverted.norm)
|
||||
}
|
||||
|
||||
private var errorMessage: String {
|
||||
if error.shouldPromptToSignIn {
|
||||
L10n.needToSignIn.string
|
||||
} else {
|
||||
error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Error {
|
||||
var shouldPromptToSignIn: Bool {
|
||||
switch self as? UserSessionError {
|
||||
case .reason(.userSessionNotInitialized):
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("with sign-in option") {
|
||||
ErrorScreen(error: UserSessionError.reason(.userSessionNotInitialized), dismissExtension: {})
|
||||
}
|
||||
|
||||
#Preview("without sign-in option") {
|
||||
ErrorScreen(error: NSError(domain: "", code: 0), dismissExtension: {})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import InboxComposer
|
||||
import InboxCoreUI
|
||||
import proton_app_uniffi
|
||||
import SwiftUI
|
||||
|
||||
public struct ShareScreen: View {
|
||||
@ObservedObject private var model: ShareScreenModel
|
||||
@StateObject private var toastStateStore = ToastStateStore(initialState: .initial)
|
||||
|
||||
public init(model: ShareScreenModel) {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
switch model.state {
|
||||
case .preparing:
|
||||
Color.clear
|
||||
.task {
|
||||
await model.prepare()
|
||||
}
|
||||
case .locked(let lockScreenType, let mailSession):
|
||||
LockScreen(
|
||||
state: .init(type: lockScreenType),
|
||||
mailSession: mailSession as! LockScreen.MailSessionType,
|
||||
dismissLock: {
|
||||
Task {
|
||||
await model.onAppUnlocked()
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(.top)
|
||||
case .composing(let draft, let dependencies, let upsellCoordinator):
|
||||
ComposerScreen(
|
||||
draft: draft,
|
||||
draftOrigin: .new,
|
||||
dependencies: dependencies,
|
||||
isAddingAttachmentsEnabled: false,
|
||||
onDismiss: { reason in
|
||||
model.onComposerDismissed(reason: reason)
|
||||
}
|
||||
)
|
||||
.overlay {
|
||||
ToastSceneView()
|
||||
}
|
||||
.environmentObject(toastStateStore)
|
||||
.environmentObject(upsellCoordinator)
|
||||
case .error(let error):
|
||||
ErrorScreen(
|
||||
error: error,
|
||||
dismissExtension: {
|
||||
model.dismissShareExtension(error: error)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import InboxComposer
|
||||
import InboxCore
|
||||
import InboxCoreUI
|
||||
import InboxIAP
|
||||
import proton_app_uniffi
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public final class ShareScreenModel: ObservableObject {
|
||||
typealias MakeNewDraft = (MailUserSession, DraftCreateMode) async throws -> AppDraftProtocol
|
||||
|
||||
enum ViewState {
|
||||
case preparing
|
||||
case composing(AppDraftProtocol, ComposerScreen.Dependencies, UpsellCoordinator)
|
||||
case locked(LockScreenState.LockScreenType, MailSessionProtocol)
|
||||
case error(Error)
|
||||
}
|
||||
|
||||
@Published private(set) var state: ViewState = .preparing
|
||||
@Published private(set) public var alert: String?
|
||||
|
||||
private let extensionContext: NSExtensionContext
|
||||
private let makeNewDraft: MakeNewDraft
|
||||
private let sessionHolder: SessionHolder
|
||||
private let upsellConfiguration: UpsellConfiguration
|
||||
|
||||
public convenience init(apiEnvId: ApiEnvId, extensionContext: NSExtensionContext) {
|
||||
self.init(
|
||||
apiEnvId: apiEnvId,
|
||||
extensionContext: extensionContext,
|
||||
makeMailSession: { try createMailSession(params: $0, keyChain: $1, hvNotifier: $2, deviceInfoProvider: $3).get() },
|
||||
makeNewDraft: { try await newDraft(session: $0, createMode: $1).get() }
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
apiEnvId: ApiEnvId,
|
||||
extensionContext: NSExtensionContext,
|
||||
makeMailSession: @escaping SessionHolder.MakeMailSession,
|
||||
makeNewDraft: @escaping MakeNewDraft
|
||||
) {
|
||||
self.extensionContext = extensionContext
|
||||
self.makeNewDraft = makeNewDraft
|
||||
sessionHolder = .init(apiEnvId: apiEnvId, makeMailSession: makeMailSession)
|
||||
upsellConfiguration = .mail(apiEnvId: apiEnvId)
|
||||
}
|
||||
|
||||
func prepare() async {
|
||||
do {
|
||||
let mailSession = try sessionHolder.mailSession()
|
||||
let appProtection = try await mailSession.appProtection().get()
|
||||
|
||||
if let lockScreenType = appProtection.lockScreenType {
|
||||
state = .locked(lockScreenType, mailSession)
|
||||
} else {
|
||||
await onAppUnlocked()
|
||||
}
|
||||
} catch {
|
||||
AppLogger.log(error: error, category: .shareExtension)
|
||||
state = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func onAppUnlocked() async {
|
||||
do {
|
||||
let userSession = try await sessionHolder.primaryUserSession()
|
||||
let draft = try await prepareDraft(userSession: userSession)
|
||||
|
||||
let dependencies = ComposerScreen.Dependencies(
|
||||
contactProvider: .productionInstance(session: userSession),
|
||||
userSession: userSession
|
||||
)
|
||||
|
||||
let upsellCoordinator = UpsellCoordinator(mailUserSession: userSession, configuration: upsellConfiguration)
|
||||
|
||||
state = .composing(draft, dependencies, upsellCoordinator)
|
||||
} catch {
|
||||
AppLogger.log(error: error, category: .shareExtension)
|
||||
state = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func onComposerDismissed(reason: ComposerDismissReason) {
|
||||
switch reason {
|
||||
case .dismissedManually, .draftDiscarded:
|
||||
dismissShareExtension(error: NSError.userCancelled)
|
||||
case .messageScheduled(let messageID), .messageSent(let messageID):
|
||||
alert = L10n.Sending.sendingInProgress.string
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await waitUntilMessageSendingIsFinished(messageID: messageID)
|
||||
alert = L10n.Sending.messageSent.string
|
||||
} catch {
|
||||
AppLogger.log(error: error, category: .shareExtension)
|
||||
alert = error.localizedDescription
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
|
||||
dismissShareExtension(error: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismissShareExtension(error: Error?) {
|
||||
if let error {
|
||||
AppLogger.log(message: "Sharing cancelled", category: .shareExtension)
|
||||
extensionContext.cancelRequest(withError: error)
|
||||
} else {
|
||||
extensionContext.completeRequest(returningItems: nil) { expired in
|
||||
if expired {
|
||||
AppLogger.log(message: "Sharing interrupted", category: .shareExtension, isError: true)
|
||||
} else {
|
||||
AppLogger.log(message: "Sharing completed", category: .shareExtension)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareDraft(userSession: MailUserSession) async throws -> AppDraftProtocol {
|
||||
let draft = try await makeNewDraft(userSession, .empty)
|
||||
|
||||
let inputItems = extensionContext.inputItems.map { $0 as! NSExtensionItem }
|
||||
let sharedContent = try await SharedItemsParser.parse(extensionItems: inputItems)
|
||||
try await DraftPrecomposer.populate(draft: draft, with: sharedContent)
|
||||
|
||||
return draft
|
||||
}
|
||||
|
||||
private func waitUntilMessageSendingIsFinished(messageID: ID) async throws {
|
||||
let userSession = try await sessionHolder.primaryUserSession()
|
||||
let sendResultPublisher = SendResultPublisher(userSession: userSession)
|
||||
|
||||
for await sendResultInfo in sendResultPublisher.results.values where sendResultInfo.messageId == messageID {
|
||||
switch sendResultInfo.type {
|
||||
case .scheduling, .sending:
|
||||
break
|
||||
case .scheduled, .sent:
|
||||
return
|
||||
case .error(let error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSError {
|
||||
static let userCancelled = NSError(domain: Bundle.main.bundleIdentifier!, code: NSUserCancelledError)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import AccountChallenge
|
||||
import InboxCore
|
||||
import InboxKeychain
|
||||
import proton_app_uniffi
|
||||
|
||||
/**
|
||||
The purpose of this class is to guarantee that MailSession and the primary MailUserSession are:
|
||||
- only created once
|
||||
- retained for the entire lifetime of the Share extension
|
||||
*/
|
||||
final class SessionHolder {
|
||||
typealias MakeMailSession = (MailSessionParams, OsKeyChain, ChallengeNotifier?, DeviceInfoProvider?) throws -> MailSessionProtocol
|
||||
|
||||
private let apiEnvId: ApiEnvId
|
||||
private let makeMailSession: MakeMailSession
|
||||
|
||||
private var cachedMailSession: MailSessionProtocol?
|
||||
private var cachedUserSession: MailUserSession?
|
||||
|
||||
init(apiEnvId: ApiEnvId, makeMailSession: @escaping MakeMailSession) {
|
||||
self.apiEnvId = apiEnvId
|
||||
self.makeMailSession = makeMailSession
|
||||
}
|
||||
|
||||
func mailSession() throws -> MailSessionProtocol {
|
||||
if let cachedMailSession {
|
||||
return cachedMailSession
|
||||
} else {
|
||||
let apiConfig = ApiConfig(envId: apiEnvId)
|
||||
let params = MailSessionParamsFactory.make(origin: .iosShareExt, apiConfig: apiConfig)
|
||||
let newMailSession = try makeMailSession(params, KeychainSDKWrapper(), nil, ChallengePayloadProvider())
|
||||
cachedMailSession = newMailSession
|
||||
return newMailSession
|
||||
}
|
||||
}
|
||||
|
||||
func primaryUserSession() async throws -> MailUserSession {
|
||||
if let cachedUserSession {
|
||||
return cachedUserSession
|
||||
} else {
|
||||
let newUserSession = try await mailSession().toPrimaryUserSession().get()
|
||||
cachedUserSession = newUserSession
|
||||
return newUserSession
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum L10n {
|
||||
enum Sending {
|
||||
static let sendingInProgress = LocalizedStringResource("Sending...", bundle: .module, comment: "Alert shown while the message is being sent")
|
||||
|
||||
static let messageSent = LocalizedStringResource("Message sent!", bundle: .module, comment: "Alert shown after the message has been sent")
|
||||
}
|
||||
|
||||
static let needToSignIn = LocalizedStringResource("You need to sign-in to Proton Mail to share content.", bundle: .module, comment: "Error message when attempting to use the Share extension without being logged in")
|
||||
|
||||
static let openApp = LocalizedStringResource("Open Proton Mail", bundle: .module, comment: "Button to open the main app from the Share extension")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension NSItemProvider {
|
||||
var hasImageRepresentation: Bool {
|
||||
hasItemConformingToTypeIdentifier(UTType.image.identifier)
|
||||
}
|
||||
|
||||
func loadString() async throws -> String {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
_ = loadObject(ofClass: String.self) { value, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: value!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performOnFileRepresentation<T>(block: @escaping (URL) throws -> T) async throws -> T {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
_ = loadFileRepresentation(for: .data) { shortLivedURL, _, error in
|
||||
continuation.resume(
|
||||
with: .init {
|
||||
guard let shortLivedURL else {
|
||||
throw error!
|
||||
}
|
||||
|
||||
return try block(shortLivedURL)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
|
||||
final class ExtensionContextSpy: NSExtensionContext {
|
||||
var stubbedExpirationFlag = false
|
||||
|
||||
private(set) var cancelRequestInvocations: [Error] = []
|
||||
private(set) var completeRequestInvocations: [[Any]?] = []
|
||||
|
||||
override func cancelRequest(withError error: any Error) {
|
||||
cancelRequestInvocations.append(error)
|
||||
}
|
||||
|
||||
override func completeRequest(returningItems items: [Any]?, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
completeRequestInvocations.append(items)
|
||||
completionHandler?(stubbedExpirationFlag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@testable import InboxComposer
|
||||
@testable import TestableShareExtension
|
||||
|
||||
final class DraftPrecomposerTests {
|
||||
private let sut = DraftPrecomposer.self
|
||||
private let draft = MockDraft.emptyMockDraft
|
||||
private let fileManager = FileManager.default
|
||||
private let testDir: URL
|
||||
private let attachmentSourceDir: URL
|
||||
private let attachmentUploadDir: URL
|
||||
|
||||
init() throws {
|
||||
testDir = fileManager.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
attachmentSourceDir = testDir.appending(path: "attachment_source", directoryHint: .isDirectory)
|
||||
attachmentUploadDir = testDir.appending(path: "attachment_upload", directoryHint: .isDirectory)
|
||||
|
||||
for dir in [attachmentSourceDir, attachmentUploadDir] {
|
||||
try fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
draft.mockAttachmentList.attachmentUploadDirectoryURL = attachmentUploadDir
|
||||
}
|
||||
|
||||
deinit {
|
||||
try! fileManager.removeItem(at: testDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
func populatingDraftWithoutAttachments() async throws {
|
||||
let sharedContent = SharedContent(
|
||||
subject: "A subject",
|
||||
body: "Some body",
|
||||
attachments: []
|
||||
)
|
||||
|
||||
try await sut.populate(draft: draft, with: sharedContent)
|
||||
|
||||
#expect(draft.subject() == "A subject")
|
||||
#expect(draft.body() == "Some body")
|
||||
#expect(draft.mockAttachmentList.capturedAddCalls.count == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func movesNonInlineAttachmentsToUploadDirectoryBeforeAdding() async throws {
|
||||
let sourceURLs = (0..<3).map { index in
|
||||
attachmentSourceDir.appending(path: "data-\(index).txt")
|
||||
}
|
||||
|
||||
let sharedContent = SharedContent(
|
||||
subject: nil,
|
||||
body: nil,
|
||||
attachments: try TestDataFactory.stubShortLivedData(in: sourceURLs)
|
||||
)
|
||||
|
||||
try await sut.populate(draft: draft, with: sharedContent)
|
||||
|
||||
let expectedAttachmentPaths = sourceURLs.map {
|
||||
attachmentUploadDir.appending(path: $0.lastPathComponent).path()
|
||||
}
|
||||
|
||||
#expect(Set(draft.mockAttachmentList.capturedAddCalls.map(\.path)) == Set(expectedAttachmentPaths))
|
||||
|
||||
for path in expectedAttachmentPaths {
|
||||
#expect(FileManager.default.fileExists(atPath: path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func insertsInlineAttachmentReferencesInBody() async throws {
|
||||
let sourceURLs = (0..<3).map { index in
|
||||
attachmentSourceDir.appending(path: "image-\(index).png")
|
||||
}
|
||||
|
||||
let sharedContent = SharedContent(
|
||||
subject: nil,
|
||||
body: "Some body",
|
||||
attachments: try TestDataFactory.stubImages(in: sourceURLs)
|
||||
)
|
||||
|
||||
try await sut.populate(draft: draft, with: sharedContent)
|
||||
|
||||
let expectedBody = """
|
||||
Some body<img src="cid:12345" style="max-width: 100%;"><br><img src="cid:12345" style="max-width: 100%;"><br><img src="cid:12345" style="max-width: 100%;"><br>
|
||||
"""
|
||||
#expect(draft.body() == expectedBody)
|
||||
|
||||
let expectedAttachmentPaths = sourceURLs.map {
|
||||
attachmentUploadDir.appending(path: $0.lastPathComponent).path()
|
||||
}
|
||||
|
||||
#expect(Set(draft.mockAttachmentList.capturedAddInlineCalls.map(\.path)) == Set(expectedAttachmentPaths))
|
||||
}
|
||||
|
||||
@Test
|
||||
func extractsImagesFromScreenshotPlists() async throws {
|
||||
let sourceURLs = (0..<3).map { index in
|
||||
attachmentSourceDir.appending(path: "image-\(index).png")
|
||||
}
|
||||
|
||||
let sharedContent = SharedContent(
|
||||
subject: nil,
|
||||
body: "Some body",
|
||||
attachments: try TestDataFactory.stubScreenshots(in: sourceURLs)
|
||||
)
|
||||
|
||||
try await sut.populate(draft: draft, with: sharedContent)
|
||||
|
||||
let expectedBody = """
|
||||
Some body<img src="cid:12345" style="max-width: 100%;"><br><img src="cid:12345" style="max-width: 100%;"><br><img src="cid:12345" style="max-width: 100%;"><br>
|
||||
"""
|
||||
#expect(draft.body() == expectedBody)
|
||||
|
||||
let expectedAttachmentPaths = sourceURLs.map {
|
||||
attachmentUploadDir.appending(path: $0.lastPathComponent)
|
||||
}
|
||||
|
||||
#expect(expectedAttachmentPaths.count == 3)
|
||||
|
||||
for url in expectedAttachmentPaths {
|
||||
let data = try Data(contentsOf: url)
|
||||
#expect(UIImage(data: data) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func handlesAttachmentLoadingFailures() async throws {
|
||||
let sharedContent = SharedContent(
|
||||
subject: nil,
|
||||
body: "Some body",
|
||||
attachments: [TestDataFactory.stubError()]
|
||||
)
|
||||
|
||||
await #expect(throws: NSError.self) {
|
||||
try await self.sut.populate(draft: self.draft, with: sharedContent)
|
||||
}
|
||||
|
||||
#expect(draft.body() == .empty)
|
||||
#expect(draft.mockAttachmentList.capturedAddCalls.count == 0)
|
||||
#expect(draft.mockAttachmentList.capturedAddInlineCalls.count == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@testable import TestableShareExtension
|
||||
|
||||
final class SharedItemsParserTests {
|
||||
private let sut = SharedItemsParser.self
|
||||
|
||||
@Test
|
||||
func testSharingSeveralImagesFromPhotosApp() async throws {
|
||||
let extensionItems = emulateSharingSeveralImagesFromPhotosApp(count: 5)
|
||||
|
||||
let sharedContent = try await sut.parse(extensionItems: extensionItems)
|
||||
|
||||
#expect(sharedContent.subject == nil)
|
||||
#expect(sharedContent.body == nil)
|
||||
#expect(sharedContent.attachments == extensionItems[0].attachments)
|
||||
}
|
||||
|
||||
@Test
|
||||
func testSharingSeveralImagesAndFilesFromFilesApp() async throws {
|
||||
let extensionItems = emulateSharingSeveralImagesAndFilesFromFilesApp(imageCount: 3, fileCount: 2)
|
||||
|
||||
let sharedContent = try await sut.parse(extensionItems: extensionItems)
|
||||
|
||||
#expect(sharedContent.subject == nil)
|
||||
#expect(sharedContent.body == nil)
|
||||
#expect(sharedContent.attachments == extensionItems[0].attachments)
|
||||
}
|
||||
|
||||
@Test
|
||||
func testSharingSafariPage() async throws {
|
||||
let extensionItems = emulateSharingSafariPage(
|
||||
url: URL(string: "https://example.com")!,
|
||||
pageTitle: "An example webpage"
|
||||
)
|
||||
|
||||
let sharedContent = try await sut.parse(extensionItems: extensionItems)
|
||||
|
||||
#expect(sharedContent.subject == "An example webpage")
|
||||
#expect(sharedContent.body == #"<a href="https://example.com">https://example.com</a>"#)
|
||||
#expect(sharedContent.attachments == [])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testSharingSelectedText() async throws {
|
||||
let extensionItems = emulateSharing(selectedText: "Lorem ipsum")
|
||||
|
||||
let sharedContent = try await sut.parse(extensionItems: extensionItems)
|
||||
|
||||
#expect(sharedContent.subject == nil)
|
||||
#expect(sharedContent.body == "Lorem ipsum")
|
||||
#expect(sharedContent.attachments == [])
|
||||
}
|
||||
|
||||
// MARK: realistic scenarios
|
||||
|
||||
private func emulateSharingSeveralImagesFromPhotosApp(count: UInt) -> [NSExtensionItem] {
|
||||
let extensionItem = NSExtensionItem()
|
||||
extensionItem.attachments = TestDataFactory.makeItemProviders(types: [.jpeg, .heic], count: count)
|
||||
return [extensionItem]
|
||||
}
|
||||
|
||||
private func emulateSharingSeveralImagesAndFilesFromFilesApp(imageCount: UInt, fileCount: UInt) -> [NSExtensionItem] {
|
||||
let images = TestDataFactory.makeItemProviders(types: [.heic, .fileURL], count: imageCount)
|
||||
let files = TestDataFactory.makeItemProviders(types: [.plainText, .fileURL], count: fileCount)
|
||||
|
||||
let extensionItem = NSExtensionItem()
|
||||
extensionItem.attachments = images + files
|
||||
return [extensionItem]
|
||||
}
|
||||
|
||||
private func emulateSharingSafariPage(url: URL, pageTitle: String) -> [NSExtensionItem] {
|
||||
let extensionItem = NSExtensionItem()
|
||||
extensionItem.attributedContentText = .init(string: pageTitle)
|
||||
extensionItem.attachments = [
|
||||
.init(item: url as NSSecureCoding, typeIdentifier: UTType.url.identifier)
|
||||
]
|
||||
return [extensionItem]
|
||||
}
|
||||
|
||||
private func emulateSharing(selectedText: String) -> [NSExtensionItem] {
|
||||
let extensionItem = NSExtensionItem()
|
||||
extensionItem.attributedContentText = .init(string: selectedText)
|
||||
extensionItem.attachments = [
|
||||
.init(item: "this is irrelevant" as NSSecureCoding, typeIdentifier: UTType.plainText.identifier)
|
||||
]
|
||||
return [extensionItem]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum TestDataFactory {
|
||||
static func makeItemProviders(types: [UTType], count: UInt) -> [NSItemProvider] {
|
||||
(0..<count).map { index in
|
||||
let itemProvider = NSItemProvider()
|
||||
|
||||
for type in types {
|
||||
let url = URL(fileURLWithPath: "attachments/\(index)-\(type.identifier)")
|
||||
|
||||
itemProvider.registerFileRepresentation(for: type) { completion in
|
||||
completion(url, true, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return itemProvider
|
||||
}
|
||||
}
|
||||
|
||||
static func stubShortLivedData(in urls: [URL]) throws -> [NSItemProvider] {
|
||||
let data = Data("foo".utf8)
|
||||
|
||||
return try urls.map { url in
|
||||
try data.write(to: url)
|
||||
|
||||
let itemProvider = NSItemProvider()
|
||||
|
||||
itemProvider.registerFileRepresentation(for: .data) { completion in
|
||||
completion(url, false, nil)
|
||||
try! FileManager.default.removeItem(at: url)
|
||||
return nil
|
||||
}
|
||||
|
||||
return itemProvider
|
||||
}
|
||||
}
|
||||
|
||||
static func stubImages(in urls: [URL]) throws -> [NSItemProvider] {
|
||||
let image = UIImage(systemName: "checkmark")!
|
||||
|
||||
return try urls.map { url in
|
||||
try image.pngData()!.write(to: url)
|
||||
|
||||
let itemProvider = NSItemProvider()
|
||||
|
||||
itemProvider.registerFileRepresentation(for: .image) { completion in
|
||||
completion(url, true, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
return itemProvider
|
||||
}
|
||||
}
|
||||
|
||||
static func stubScreenshots(in urls: [URL]) throws -> [NSItemProvider] {
|
||||
let image = UIImage(systemName: "checkmark")!
|
||||
|
||||
let plist: [String: Any] = ["foo": "bar"]
|
||||
let plistData = try PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0)
|
||||
|
||||
return try urls.map { url in
|
||||
try plistData.write(to: url)
|
||||
|
||||
let itemProvider = NSItemProvider()
|
||||
|
||||
itemProvider.registerFileRepresentation(for: .data) { completion in
|
||||
completion(url, false, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
itemProvider.registerItem(forTypeIdentifier: UTType.image.identifier) { completion, _, _ in
|
||||
completion!(image, nil)
|
||||
}
|
||||
|
||||
return itemProvider
|
||||
}
|
||||
}
|
||||
|
||||
static func stubError() -> NSItemProvider {
|
||||
let itemProvider = NSItemProvider()
|
||||
|
||||
itemProvider.registerFileRepresentation(for: .data) { completion in
|
||||
completion(nil, false, TestError())
|
||||
return nil
|
||||
}
|
||||
|
||||
return itemProvider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TestError: LocalizedError, Equatable {
|
||||
let errorDescription: String? = "Something failed."
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import InboxSnapshotTesting
|
||||
import proton_app_uniffi
|
||||
import Testing
|
||||
|
||||
@testable import TestableShareExtension
|
||||
|
||||
@MainActor
|
||||
struct ErrorScreenSnapshotTests {
|
||||
@Test
|
||||
func withButtonToOpenApp() {
|
||||
let sut = ErrorScreen(error: UserSessionError.reason(.userSessionNotInitialized), dismissExtension: {})
|
||||
assertSnapshotsOnIPhoneX(of: sut)
|
||||
}
|
||||
|
||||
@Test
|
||||
func withoutButtonToOpenApp() {
|
||||
let sut = ErrorScreen(error: TestError(), dismissExtension: {})
|
||||
assertSnapshotsOnIPhoneX(of: sut)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// Copyright (c) 2025 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Mail.
|
||||
//
|
||||
// Proton Mail is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Proton Mail is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import InboxTesting
|
||||
import proton_app_uniffi
|
||||
import Testing
|
||||
|
||||
@testable import InboxComposer
|
||||
@testable import TestableShareExtension
|
||||
|
||||
@MainActor
|
||||
final class ShareScreenModelTests {
|
||||
private let extensionContext = ExtensionContextSpy()
|
||||
private let mailSession = MailSessionSpy()
|
||||
private var stubbedMailSessionResult: Result<MailSessionProtocol, TestError>
|
||||
private var stubbedNewDraftResult: Result<AppDraftProtocol, TestError>
|
||||
|
||||
private lazy var sut = ShareScreenModel(
|
||||
apiEnvId: .atlas,
|
||||
extensionContext: extensionContext,
|
||||
makeMailSession: { [unowned self] _, _, _, _ in
|
||||
try stubbedMailSessionResult.get()
|
||||
},
|
||||
makeNewDraft: { [unowned self] _, _ in
|
||||
try stubbedNewDraftResult.get()
|
||||
}
|
||||
)
|
||||
|
||||
init() {
|
||||
mailSession.primaryUserSessionStub = MailUserSessionSpy(id: "")
|
||||
stubbedMailSessionResult = .success(mailSession)
|
||||
stubbedNewDraftResult = .success(MockDraft.emptyMockDraft)
|
||||
}
|
||||
|
||||
@Test(arguments: [AppProtection.biometrics, .pin])
|
||||
func showsLockScreenIfAppProtectionIsSet(appProtection: AppProtection) async {
|
||||
mailSession.appProtectionStub = appProtection
|
||||
|
||||
await sut.prepare()
|
||||
|
||||
switch sut.state {
|
||||
case .locked(appProtection.lockScreenType, _):
|
||||
break
|
||||
default:
|
||||
Issue.record("unexpected state: \(sut.state)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func showsComposerIfAppProtectionIsNotSet() async {
|
||||
mailSession.appProtectionStub = .none
|
||||
|
||||
await sut.prepare()
|
||||
|
||||
switch sut.state {
|
||||
case .composing:
|
||||
break
|
||||
default:
|
||||
Issue.record("unexpected state: \(sut.state)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func showsErrorScreenIfInitialSetupFails() async throws {
|
||||
stubbedMailSessionResult = .failure(TestError())
|
||||
|
||||
await sut.prepare()
|
||||
|
||||
switch sut.state {
|
||||
case .error(_ as TestError):
|
||||
break
|
||||
default:
|
||||
Issue.record("unexpected state: \(sut.state)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func showsErrorScreenIfPreparingComposerScreenFails() async throws {
|
||||
stubbedNewDraftResult = .failure(TestError())
|
||||
|
||||
await sut.prepare()
|
||||
|
||||
switch sut.state {
|
||||
case .error(_ as TestError):
|
||||
break
|
||||
default:
|
||||
Issue.record("unexpected state: \(sut.state)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func dismissingWithErrorCancelsTheRequestOfTheContext() {
|
||||
sut.dismissShareExtension(error: TestError())
|
||||
|
||||
#expect(extensionContext.cancelRequestInvocations.count == 1)
|
||||
#expect(extensionContext.cancelRequestInvocations.first is TestError)
|
||||
#expect(extensionContext.completeRequestInvocations.count == 0)
|
||||
}
|
||||
|
||||
@Test(arguments: [true, false])
|
||||
func dismissingWithoutErrorCompletesTheRequestOfTheContext(expirationFlag: Bool) {
|
||||
extensionContext.stubbedExpirationFlag = expirationFlag
|
||||
|
||||
sut.dismissShareExtension(error: nil)
|
||||
|
||||
#expect(extensionContext.completeRequestInvocations.count == 1)
|
||||
#expect(extensionContext.cancelRequestInvocations.count == 0)
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,10 @@
|
||||
<?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.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.me.proton.mail</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -20,6 +20,13 @@
|
||||
"testTimeoutsEnabled" : true
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/InboxIAP",
|
||||
"identifier" : "InboxIAPTests",
|
||||
"name" : "InboxIAPTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/InboxCoreUI",
|
||||
@@ -29,9 +36,16 @@
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/InboxComposer",
|
||||
"identifier" : "InboxComposerTests",
|
||||
"name" : "InboxComposerTests"
|
||||
"containerPath" : "container:Modules\/TestableShareExtension",
|
||||
"identifier" : "ShareExtensionTests",
|
||||
"name" : "ShareExtensionTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/TestableNotificationService",
|
||||
"identifier" : "NotificationServiceTests",
|
||||
"name" : "NotificationServiceTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -41,6 +55,13 @@
|
||||
"name" : "InboxRSVPTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/InboxComposer",
|
||||
"identifier" : "InboxComposerTests",
|
||||
"name" : "InboxComposerTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/InboxKeychain",
|
||||
@@ -55,20 +76,6 @@
|
||||
"name" : "ProtonMailTest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/InboxIAP",
|
||||
"identifier" : "InboxIAPTests",
|
||||
"name" : "InboxIAPTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/TestableNotificationService",
|
||||
"identifier" : "NotificationServiceTests",
|
||||
"name" : "NotificationServiceTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Modules\/InboxContacts",
|
||||
|
||||
+3
-1
@@ -4,6 +4,7 @@ default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "ch.protonmail.protonmail"
|
||||
NOTIFICATION_EXTENSION_IDENTIFIER = "ch.protonmail.protonmail.notifications"
|
||||
SHARE_EXTENSION_IDENTIFIER = "ch.protonmail.protonmail.Share"
|
||||
DEVELOPER_KEYCHAIN_NAME = "PROTONMAIL_IOS_CERTIFICATE_KEYCHAIN"
|
||||
DEVELOPER_KEYCHAIN_PASSWORD = "QrniqyS3LWTH3Ji"
|
||||
CERTIFICATE_PATH = "fastlane/Certificates.p12"
|
||||
@@ -54,7 +55,8 @@ platform :ios do
|
||||
def get_xcode_profile
|
||||
ids = [
|
||||
APP_IDENTIFIER,
|
||||
NOTIFICATION_EXTENSION_IDENTIFIER
|
||||
NOTIFICATION_EXTENSION_IDENTIFIER,
|
||||
SHARE_EXTENSION_IDENTIFIER
|
||||
]
|
||||
|
||||
ids.each do |id|
|
||||
|
||||
+60
-20
@@ -74,6 +74,9 @@ packages:
|
||||
TestableNotificationService:
|
||||
path: Modules/TestableNotificationService
|
||||
group: Modules
|
||||
TestableShareExtension:
|
||||
path: Modules/TestableShareExtension
|
||||
group: Modules
|
||||
TryCatch:
|
||||
path: Modules/TryCatch
|
||||
group: Modules
|
||||
@@ -100,6 +103,33 @@ settings:
|
||||
QA:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "QA"
|
||||
|
||||
targetTemplates:
|
||||
Extension:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
scheme: {}
|
||||
sources:
|
||||
- path: Modules/${target_name}/Sources
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "iPhone Distribution: Proton AG (2SB5Z68H26)"
|
||||
CODE_SIGN_ENTITLEMENTS: ${target_name}.entitlements
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ${bundle_identifier}
|
||||
PROVISIONING_PROFILE_SPECIFIER: ${release_provisioning_profile}
|
||||
configs:
|
||||
Debug:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
PROVISIONING_PROFILE_SPECIFIER: ""
|
||||
info:
|
||||
path: Modules/${target_name}/Sources/SupportingFiles/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: "$(BUNDLE_DISPLAY_NAME)"
|
||||
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: ${point_identifier}
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).${principal_class}
|
||||
|
||||
targets:
|
||||
ProtonMail:
|
||||
type: application
|
||||
@@ -179,6 +209,7 @@ targets:
|
||||
- AccountManager
|
||||
- AccountPassword
|
||||
- package: Scrypt
|
||||
- target: ShareExtension
|
||||
- package: SwiftUIIntrospect
|
||||
preBuildScripts:
|
||||
- name: swift-format
|
||||
@@ -227,31 +258,40 @@ targets:
|
||||
scheme: ProtonMailUITest
|
||||
|
||||
NotificationService:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
templates:
|
||||
- Extension
|
||||
templateAttributes:
|
||||
bundle_identifier: ch.protonmail.protonmail.notifications
|
||||
principal_class: NotificationService
|
||||
point_identifier: com.apple.usernotifications.service
|
||||
release_provisioning_profile: "Mail - Notification Service Distribution"
|
||||
dependencies:
|
||||
- package: TestableNotificationService
|
||||
scheme: {}
|
||||
sources:
|
||||
- path: Modules/NotificationService/Sources
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_ENTITLEMENTS: "NotificationService.entitlements"
|
||||
CODE_SIGN_IDENTITY: "iPhone Distribution: Proton AG (2SB5Z68H26)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "ch.protonmail.protonmail.notifications"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "Mail - Notification Service Distribution"
|
||||
configs:
|
||||
Debug:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
PROVISIONING_PROFILE_SPECIFIER: ""
|
||||
|
||||
ShareExtension:
|
||||
templates:
|
||||
- Extension
|
||||
templateAttributes:
|
||||
bundle_identifier: ch.protonmail.protonmail.Share
|
||||
principal_class: ShareViewController
|
||||
point_identifier: com.apple.share-services
|
||||
release_provisioning_profile: "Protonmail share release"
|
||||
dependencies:
|
||||
- package: TestableShareExtension
|
||||
info:
|
||||
path: Modules/NotificationService/Sources/SupportingFiles/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: "$(BUNDLE_DISPLAY_NAME)"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: 'com.apple.usernotifications.service'
|
||||
NSExtensionPrincipalClass: '$(PRODUCT_MODULE_NAME).NotificationService'
|
||||
NSExtensionAttributes:
|
||||
NSExtensionActivationRule: >
|
||||
SUBQUERY (
|
||||
extensionItems,
|
||||
$extensionItem,
|
||||
SUBQUERY (
|
||||
$extensionItem.attachments,
|
||||
$attachment,
|
||||
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.data"
|
||||
).@count > 0
|
||||
).@count > 0
|
||||
|
||||
schemes:
|
||||
ProtonMailUITest:
|
||||
|
||||
Reference in New Issue
Block a user