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:)`
|
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(
|
let uniqueURL = uniqueFileNameURL(
|
||||||
in: destinationFolder,
|
in: destinationFolder,
|
||||||
baseName: file.deletingPathExtension().lastPathComponent,
|
baseName: file.deletingPathExtension().lastPathComponent,
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ final class MockDraft: AppDraftProtocol, @unchecked Sendable {
|
|||||||
mockSender
|
mockSender
|
||||||
}
|
}
|
||||||
|
|
||||||
func setBody(body: String) -> VoidDraftSaveResult { .ok }
|
func setBody(body: String) -> VoidDraftSaveResult {
|
||||||
|
mockBody = body
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
func setSubject(subject: String) -> VoidDraftSaveResult {
|
func setSubject(subject: String) -> VoidDraftSaveResult {
|
||||||
mockSubject = subject
|
mockSubject = subject
|
||||||
|
|||||||
@@ -15,6 +15,6 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
||||||
|
|
||||||
enum JPEG {
|
public enum JPEG {
|
||||||
static let compressionQuality = 0.8
|
public static let compressionQuality = 0.8
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ extension AppLogger {
|
|||||||
case rustLibrary
|
case rustLibrary
|
||||||
case search
|
case search
|
||||||
case send
|
case send
|
||||||
|
case shareExtension
|
||||||
case snooze
|
case snooze
|
||||||
case thirtySecondsBackgroundTask
|
case thirtySecondsBackgroundTask
|
||||||
case userSessions
|
case userSessions
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
//
|
||||||
// Copyright (c) 2025 Proton Technologies AG
|
// Copyright (c) 2025 Proton Technologies AG
|
||||||
//
|
//
|
||||||
// This file is part of Proton Mail.
|
// This file is part of Proton Mail.
|
||||||
@@ -20,7 +21,7 @@ import proton_app_uniffi
|
|||||||
|
|
||||||
extension ApiEnvId {
|
extension ApiEnvId {
|
||||||
/// Payments are not available for sandbox users in production environment.
|
/// Payments are not available for sandbox users in production environment.
|
||||||
var arePaymentsEnabled: Bool {
|
public var arePaymentsEnabled: Bool {
|
||||||
return !(isAppInstalledThroughTestFlight && self == .prod)
|
return !(isAppInstalledThroughTestFlight && self == .prod)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@ public final class MailSessionSpy: MailSessionProtocol {
|
|||||||
public var onPrimaryAccountChanged: (@Sendable (String) -> Void)?
|
public var onPrimaryAccountChanged: (@Sendable (String) -> Void)?
|
||||||
public var appProtectionStub: AppProtection = .none
|
public var appProtectionStub: AppProtection = .none
|
||||||
|
|
||||||
|
public var primaryUserSessionStub: MailUserSession?
|
||||||
|
|
||||||
public var storedSessions: [StoredSessionStub] = [] {
|
public var storedSessions: [StoredSessionStub] = [] {
|
||||||
didSet {
|
didSet {
|
||||||
Task {
|
Task {
|
||||||
@@ -242,7 +244,7 @@ public final class MailSessionSpy: MailSessionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func toPrimaryUserSession() async -> MailSessionToPrimaryUserSessionResult {
|
public func toPrimaryUserSession() async -> MailSessionToPrimaryUserSessionResult {
|
||||||
fatalError(#function)
|
.ok(primaryUserSessionStub!)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func toUserSession(ffiFlow: LoginFlow) async -> MailSessionToUserSessionResult {
|
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
|
"testTimeoutsEnabled" : true
|
||||||
},
|
},
|
||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:Modules\/InboxIAP",
|
||||||
|
"identifier" : "InboxIAPTests",
|
||||||
|
"name" : "InboxIAPTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Modules\/InboxCoreUI",
|
"containerPath" : "container:Modules\/InboxCoreUI",
|
||||||
@@ -29,9 +36,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Modules\/InboxComposer",
|
"containerPath" : "container:Modules\/TestableShareExtension",
|
||||||
"identifier" : "InboxComposerTests",
|
"identifier" : "ShareExtensionTests",
|
||||||
"name" : "InboxComposerTests"
|
"name" : "ShareExtensionTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:Modules\/TestableNotificationService",
|
||||||
|
"identifier" : "NotificationServiceTests",
|
||||||
|
"name" : "NotificationServiceTests"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,6 +55,13 @@
|
|||||||
"name" : "InboxRSVPTests"
|
"name" : "InboxRSVPTests"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:Modules\/InboxComposer",
|
||||||
|
"identifier" : "InboxComposerTests",
|
||||||
|
"name" : "InboxComposerTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Modules\/InboxKeychain",
|
"containerPath" : "container:Modules\/InboxKeychain",
|
||||||
@@ -55,20 +76,6 @@
|
|||||||
"name" : "ProtonMailTest"
|
"name" : "ProtonMailTest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"target" : {
|
|
||||||
"containerPath" : "container:Modules\/InboxIAP",
|
|
||||||
"identifier" : "InboxIAPTests",
|
|
||||||
"name" : "InboxIAPTests"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target" : {
|
|
||||||
"containerPath" : "container:Modules\/TestableNotificationService",
|
|
||||||
"identifier" : "NotificationServiceTests",
|
|
||||||
"name" : "NotificationServiceTests"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Modules\/InboxContacts",
|
"containerPath" : "container:Modules\/InboxContacts",
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,7 @@ default_platform(:ios)
|
|||||||
|
|
||||||
APP_IDENTIFIER = "ch.protonmail.protonmail"
|
APP_IDENTIFIER = "ch.protonmail.protonmail"
|
||||||
NOTIFICATION_EXTENSION_IDENTIFIER = "ch.protonmail.protonmail.notifications"
|
NOTIFICATION_EXTENSION_IDENTIFIER = "ch.protonmail.protonmail.notifications"
|
||||||
|
SHARE_EXTENSION_IDENTIFIER = "ch.protonmail.protonmail.Share"
|
||||||
DEVELOPER_KEYCHAIN_NAME = "PROTONMAIL_IOS_CERTIFICATE_KEYCHAIN"
|
DEVELOPER_KEYCHAIN_NAME = "PROTONMAIL_IOS_CERTIFICATE_KEYCHAIN"
|
||||||
DEVELOPER_KEYCHAIN_PASSWORD = "QrniqyS3LWTH3Ji"
|
DEVELOPER_KEYCHAIN_PASSWORD = "QrniqyS3LWTH3Ji"
|
||||||
CERTIFICATE_PATH = "fastlane/Certificates.p12"
|
CERTIFICATE_PATH = "fastlane/Certificates.p12"
|
||||||
@@ -54,7 +55,8 @@ platform :ios do
|
|||||||
def get_xcode_profile
|
def get_xcode_profile
|
||||||
ids = [
|
ids = [
|
||||||
APP_IDENTIFIER,
|
APP_IDENTIFIER,
|
||||||
NOTIFICATION_EXTENSION_IDENTIFIER
|
NOTIFICATION_EXTENSION_IDENTIFIER,
|
||||||
|
SHARE_EXTENSION_IDENTIFIER
|
||||||
]
|
]
|
||||||
|
|
||||||
ids.each do |id|
|
ids.each do |id|
|
||||||
|
|||||||
+60
-20
@@ -74,6 +74,9 @@ packages:
|
|||||||
TestableNotificationService:
|
TestableNotificationService:
|
||||||
path: Modules/TestableNotificationService
|
path: Modules/TestableNotificationService
|
||||||
group: Modules
|
group: Modules
|
||||||
|
TestableShareExtension:
|
||||||
|
path: Modules/TestableShareExtension
|
||||||
|
group: Modules
|
||||||
TryCatch:
|
TryCatch:
|
||||||
path: Modules/TryCatch
|
path: Modules/TryCatch
|
||||||
group: Modules
|
group: Modules
|
||||||
@@ -100,6 +103,33 @@ settings:
|
|||||||
QA:
|
QA:
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "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:
|
targets:
|
||||||
ProtonMail:
|
ProtonMail:
|
||||||
type: application
|
type: application
|
||||||
@@ -179,6 +209,7 @@ targets:
|
|||||||
- AccountManager
|
- AccountManager
|
||||||
- AccountPassword
|
- AccountPassword
|
||||||
- package: Scrypt
|
- package: Scrypt
|
||||||
|
- target: ShareExtension
|
||||||
- package: SwiftUIIntrospect
|
- package: SwiftUIIntrospect
|
||||||
preBuildScripts:
|
preBuildScripts:
|
||||||
- name: swift-format
|
- name: swift-format
|
||||||
@@ -227,31 +258,40 @@ targets:
|
|||||||
scheme: ProtonMailUITest
|
scheme: ProtonMailUITest
|
||||||
|
|
||||||
NotificationService:
|
NotificationService:
|
||||||
type: app-extension
|
templates:
|
||||||
platform: iOS
|
- 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:
|
dependencies:
|
||||||
- package: TestableNotificationService
|
- package: TestableNotificationService
|
||||||
scheme: {}
|
|
||||||
sources:
|
ShareExtension:
|
||||||
- path: Modules/NotificationService/Sources
|
templates:
|
||||||
settings:
|
- Extension
|
||||||
base:
|
templateAttributes:
|
||||||
CODE_SIGN_ENTITLEMENTS: "NotificationService.entitlements"
|
bundle_identifier: ch.protonmail.protonmail.Share
|
||||||
CODE_SIGN_IDENTITY: "iPhone Distribution: Proton AG (2SB5Z68H26)"
|
principal_class: ShareViewController
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: "ch.protonmail.protonmail.notifications"
|
point_identifier: com.apple.share-services
|
||||||
PROVISIONING_PROFILE_SPECIFIER: "Mail - Notification Service Distribution"
|
release_provisioning_profile: "Protonmail share release"
|
||||||
configs:
|
dependencies:
|
||||||
Debug:
|
- package: TestableShareExtension
|
||||||
CODE_SIGN_IDENTITY: "Apple Development"
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
PROVISIONING_PROFILE_SPECIFIER: ""
|
|
||||||
info:
|
info:
|
||||||
path: Modules/NotificationService/Sources/SupportingFiles/Info.plist
|
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: "$(BUNDLE_DISPLAY_NAME)"
|
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionPointIdentifier: 'com.apple.usernotifications.service'
|
NSExtensionAttributes:
|
||||||
NSExtensionPrincipalClass: '$(PRODUCT_MODULE_NAME).NotificationService'
|
NSExtensionActivationRule: >
|
||||||
|
SUBQUERY (
|
||||||
|
extensionItems,
|
||||||
|
$extensionItem,
|
||||||
|
SUBQUERY (
|
||||||
|
$extensionItem.attachments,
|
||||||
|
$attachment,
|
||||||
|
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.data"
|
||||||
|
).@count > 0
|
||||||
|
).@count > 0
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
ProtonMailUITest:
|
ProtonMailUITest:
|
||||||
|
|||||||
Reference in New Issue
Block a user