ET-638 Share extension

This commit is contained in:
Jacek Krasiukianis
2025-04-15 13:58:12 +02:00
committed by MargeBot
parent a07a2c568c
commit ac062e361c
33 changed files with 1480 additions and 44 deletions
@@ -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
@@ -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)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+10
View File
@@ -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>
+24 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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: