diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 5cfcb95defa..ab6bec64614 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + BFA100052C8511E000000001 /* AppIntentsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFA100042C8511E000000001 /* AppIntentsTest.swift */; }; + BFA100022C8511E000000001 /* SignalAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFA100012C8511E000000001 /* SignalAppIntents.swift */; }; 040277FA303DCFA6009C1468 /* LocalFileBackupAttachmentRestoreProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040277F9303DCF9B009C1468 /* LocalFileBackupAttachmentRestoreProgress.swift */; }; 040277FE303DE4F2009C1468 /* ChatListViewController+LocalFileBackupRestoreProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040277FD303DE4F2009C1468 /* ChatListViewController+LocalFileBackupRestoreProgressView.swift */; }; 040506F82F7EEF3B0078B769 /* RemoteReleaseNotesFetchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506F72F7EEF290078B769 /* RemoteReleaseNotesFetchingManager.swift */; }; @@ -4253,6 +4255,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BFA100042C8511E000000001 /* AppIntentsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTest.swift; sourceTree = ""; }; + BFA100012C8511E000000001 /* SignalAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalAppIntents.swift; sourceTree = ""; }; 040277F9303DCF9B009C1468 /* LocalFileBackupAttachmentRestoreProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileBackupAttachmentRestoreProgress.swift; sourceTree = ""; }; 040277FD303DE4F2009C1468 /* ChatListViewController+LocalFileBackupRestoreProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListViewController+LocalFileBackupRestoreProgressView.swift"; sourceTree = ""; }; 040506F72F7EEF290078B769 /* RemoteReleaseNotesFetchingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesFetchingManager.swift; sourceTree = ""; }; @@ -8655,6 +8659,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + BFA100032C8511E000000001 /* AppIntents */ = { + isa = PBXGroup; + children = ( + BFA100012C8511E000000001 /* SignalAppIntents.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; 040507132F80639B0078B769 /* RemoteReleaseNotes */ = { isa = PBXGroup; children = ( @@ -11903,6 +11915,7 @@ 34B3F8951E8DF1B90035BE1A /* ViewControllers */, 34BEDB0F21C41E71007B0EAE /* views */, F9B93CDF28E246D900B3F8A0 /* AppDelegateTest.swift */, + BFA100042C8511E000000001 /* AppIntentsTest.swift */, 4C83AC4123C55D9C00D4F2E6 /* SignalBaseTest.swift */, ); path = test; @@ -12310,6 +12323,7 @@ children = ( F94D12FD28BD0DD900B2C478 /* Accessibility */, B91751C52EC552B100FF7A9C /* AppIcons */, + BFA100032C8511E000000001 /* AppIntents */, 5045F44129E0DAA400058E5F /* AppLaunch */, 661AEE462C2088DF0046B1D8 /* Attachments */, D9EB00702FE0730700F81AD0 /* Autofill */, @@ -19135,6 +19149,7 @@ 886BB3D325BA0CA400079781 /* SetWallpaperViewController.swift in Sources */, F915A77029CB6D4C00EB6F68 /* ShareActivityUtil.swift in Sources */, 880D90302481E617003D2B14 /* SignalApp.swift in Sources */, + BFA100022C8511E000000001 /* SignalAppIntents.swift in Sources */, 50C861E62EC52CA90001A4FA /* SignalAttachmentCloner.swift in Sources */, D9E43C2D2CC194140001536E /* SignalCall.swift in Sources */, 8822558D26B9D1D7001A33C4 /* SignalDotMePhoneNumberLink.swift in Sources */, @@ -19240,6 +19255,7 @@ files = ( 6675F65129261E0C007A311E /* APNSRotationStoreTest.swift in Sources */, F9B93CE028E246D900B3F8A0 /* AppDelegateTest.swift in Sources */, + BFA100052C8511E000000001 /* AppIntentsTest.swift in Sources */, F9B3A92F293554090071EB95 /* ASWebAuthenticationSessionUtilTest.swift in Sources */, 50B6BCB42AEC58250010FB3B /* AuthorMergeHelperBuilderTest.swift in Sources */, D962FC432E149ECF004DB623 /* BackupAttachmentDownloadTrackerTest.swift in Sources */, diff --git a/Signal/AppIntents/SignalAppIntents.swift b/Signal/AppIntents/SignalAppIntents.swift new file mode 100644 index 00000000000..cc96f55d4cf --- /dev/null +++ b/Signal/AppIntents/SignalAppIntents.swift @@ -0,0 +1,434 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import AppIntents +import Foundation +import UIKit + +import SignalServiceKit + +@available(iOS 16.0, *) +enum SignalIntentError: Swift.Error, CustomLocalizedStringResourceConvertible { + case notRegistered + case appNotReady + case conversationNotFound + case conversationCannotReceiveMessages + case emptyMessage + case sendFailed + case sendTimedOut + + var localizedStringResource: LocalizedStringResource { + switch self { + case .notRegistered: + return "Signal is not set up or signed in. Please open Signal and register your account first." + case .appNotReady: + return "Signal must be open and unlocked before you can use this shortcut." + case .conversationNotFound: + return "The selected Signal conversation could not be found." + case .conversationCannotReceiveMessages: + return "The selected Signal conversation cannot receive messages." + case .emptyMessage: + return "The message text cannot be empty." + case .sendFailed: + return "Signal could not send the message." + case .sendTimedOut: + return "Signal has not confirmed the message yet. It may still send; check the chat before trying again." + } + } +} + +@available(iOS 16.0, *) +private func waitForSignalEnvironment(timeoutSeconds: Double = 3.5) async -> Bool { + guard !Task.isCancelled else { return false } + + if AppReadinessObjcBridge.isAppReady, SSKEnvironment.hasShared { + return true + } + + let interval: UInt64 = 150_000_000 + let iterations = Int((timeoutSeconds * 1_000_000_000) / Double(interval)) + for _ in 0.. Bool { + guard + await waitForSignalEnvironment(), + DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered + else { + return false + } + + return await MainActor.run { + UIApplication.shared.isProtectedDataAvailable + && !AppEnvironment.shared.screenLockUI.isLockedForAppIntents + } +} + +@available(iOS 16.0, *) +private func canResolveSavedConversationEntity(timeoutSeconds: Double = 3.5) async -> Bool { + guard + await waitForSignalEnvironment(timeoutSeconds: timeoutSeconds), + DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered + else { + return false + } + + return await MainActor.run { + UIApplication.shared.isProtectedDataAvailable + } +} + +@available(iOS 16.0, *) +struct SignalConversationEntity: AppEntity { + static var defaultQuery = SignalConversationQuery() + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Signal Conversation" + + var id: String + var name: String + var isGroup: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: isGroup + ? LocalizedStringResource("Group Chat", comment: "Subtitle for group chat entity") + : LocalizedStringResource("Contact", comment: "Subtitle for contact entity"), + image: .init(systemName: isGroup ? "person.3.fill" : "person.crop.circle.fill"), + ) + } +} + +@available(iOS 16.0, *) +struct SignalConversationQuery: EntityQuery, EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [SignalConversationEntity] { + guard await canResolveSavedConversationEntity() else { return [] } + + return SSKEnvironment.shared.databaseStorageRef.read { tx in + identifiers.compactMap { uniqueId in + guard let thread = DependenciesBridge.shared.threadStore.fetchThread(uniqueId: uniqueId, tx: tx) else { + return nil + } + guard thread.shouldThreadBeVisible else { return nil } + let name = SSKEnvironment.shared.contactManagerRef.displayName(for: thread, tx: tx)?.resolvedValue() ?? "Signal Contact" + return SignalConversationEntity(id: uniqueId, name: name, isGroup: thread is TSGroupThread) + } + } + } + + func suggestedEntities() async throws -> [SignalConversationEntity] { + guard await canExposeConversationEntities() else { return [] } + + return SSKEnvironment.shared.databaseStorageRef.read { tx in + var list = [SignalConversationEntity]() + DependenciesBridge.shared.threadStore.enumerateNonStoryThreads(tx: tx) { thread in + guard thread.shouldThreadBeVisible else { return true } + let name = SSKEnvironment.shared.contactManagerRef.displayName(for: thread, tx: tx)?.resolvedValue() ?? "Signal Contact" + list.append(SignalConversationEntity(id: thread.uniqueId, name: name, isGroup: thread is TSGroupThread)) + return list.count < 30 + } + return list + } + } + + func entities(matching string: String) async throws -> [SignalConversationEntity] { + guard await canExposeConversationEntities() else { return [] } + + let query = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + return try await suggestedEntities() + } + + return SSKEnvironment.shared.databaseStorageRef.read { tx in + var list = [SignalConversationEntity]() + DependenciesBridge.shared.threadStore.enumerateNonStoryThreads(tx: tx) { thread in + guard thread.shouldThreadBeVisible else { return true } + let name = SSKEnvironment.shared.contactManagerRef.displayName(for: thread, tx: tx)?.resolvedValue() ?? "" + guard name.localizedCaseInsensitiveContains(query) else { return true } + list.append(SignalConversationEntity(id: thread.uniqueId, name: name, isGroup: thread is TSGroupThread)) + return list.count < 25 + } + return list + } + } +} + +@available(iOS 16.0, *) +struct SendSignalMessageIntent: AppIntent { + static var title: LocalizedStringResource = "Send Signal Message" + static var description = IntentDescription("Sends a text message after device authentication.") + static var openAppWhenRun = false + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + + static var parameterSummary: some ParameterSummary { + Summary("Send \(\.$message) to \(\.$recipient)") + } + + @Parameter(title: "Recipient", requestValueDialog: IntentDialog("Who would you like to message on Signal?")) + var recipient: SignalConversationEntity + + @Parameter(title: "Message", requestValueDialog: IntentDialog("What would you like to say?")) + var message: String + + func perform() async throws -> some IntentResult & ProvidesDialog { + let trimmedText = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { + throw SignalIntentError.emptyMessage + } + guard await canResolveSavedConversationEntity(timeoutSeconds: 25) else { + throw SignalIntentError.appNotReady + } + try Task.checkCancellation() + + let messageBody = try await DependenciesBridge.shared.attachmentContentValidator + .prepareOversizeTextIfNeeded(MessageBody(text: trimmedText, ranges: .empty)) + + let backgroundMessageFetcher = DependenciesBridge.shared.backgroundMessageFetcherFactory.buildFetcher() + await backgroundMessageFetcher.start() + defer { + Task { + await backgroundMessageFetcher.stopAndWaitBeforeSuspending() + } + } + + let sendPromise: Promise + do { + sendPromise = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in + guard let thread = DependenciesBridge.shared.threadStore.fetchThread(uniqueId: recipient.id, tx: transaction) else { + throw SignalIntentError.conversationNotFound + } + guard thread.shouldThreadBeVisible, thread.canSendChatMessagesToThread() else { + throw SignalIntentError.conversationCannotReceiveMessages + } + + ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequest( + thread, + setDefaultTimerIfNecessary: true, + tx: transaction, + ) + + let builder: TSOutgoingMessageBuilder = .withDefaultValues(thread: thread) + builder.setMessageBody(messageBody) + let dmConfig = DependenciesBridge.shared.disappearingMessagesConfigurationStore + .fetchOrBuildDefault(for: .thread(thread), tx: transaction) + builder.expiresInSeconds = dmConfig.durationSeconds + builder.expireTimerVersion = NSNumber(value: dmConfig.timerVersion) + + let outgoingMessage = TSOutgoingMessage( + outgoingMessageWith: builder, + additionalRecipients: [], + explicitRecipients: [], + skippedRecipients: [], + transaction: transaction, + ) + let preparedMessage = try UnpreparedOutgoingMessage.forMessage( + outgoingMessage, + body: messageBody, + quotedReplyDraft: nil, + ).prepare(tx: transaction) + return ThreadUtil.enqueueMessagePromise( + message: preparedMessage, + limitToCurrentProcessLifetime: false, + isHighPriority: true, + transaction: transaction, + ) + } + } catch let error as SignalIntentError { + throw error + } catch { + throw SignalIntentError.sendFailed + } + + do { + try await sendPromise + .timeout( + seconds: 10, + description: "Sending Signal message", + timeoutErrorBlock: { SignalIntentError.sendTimedOut }, + ) + .awaitableWithUncooperativeCancellationHandling() + } catch let error as SignalIntentError { + throw error + } catch { + throw SignalIntentError.sendFailed + } + + return .result(dialog: IntentDialog("Message sent.")) + } +} + +@available(iOS 16.0, *) +struct OpenSignalConversationIntent: AppIntent { + static var title: LocalizedStringResource = "Open Chat" + static var description = IntentDescription("Open a specific conversation in Signal.") + static var openAppWhenRun = true + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + + static var parameterSummary: some ParameterSummary { + Summary("Open chat with \(\.$conversation)") + } + + @Parameter(title: "Chat", requestValueDialog: IntentDialog("Which chat would you like to open?")) + var conversation: SignalConversationEntity + + @MainActor + func perform() async throws -> some IntentResult { + try await ensureSignalReadyAndUnlocked() + + let threadExists = SSKEnvironment.shared.databaseStorageRef.read { tx in + DependenciesBridge.shared.threadStore.fetchThread(uniqueId: conversation.id, tx: tx)?.shouldThreadBeVisible == true + } + guard threadExists else { + throw SignalIntentError.conversationNotFound + } + + try await prepareUIForIntentPresentation() + SignalApp.shared.presentConversationForThread(threadUniqueId: conversation.id, action: .compose, animated: true) + return .result() + } +} + +@available(iOS 16.0, *) +struct NewSignalMessageIntent: AppIntent { + static var title: LocalizedStringResource = "New Message" + static var description = IntentDescription("Open the new message composer in Signal.") + static var openAppWhenRun = true + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + + @MainActor + func perform() async throws -> some IntentResult { + try await ensureSignalReadyAndUnlocked() + try await prepareUIForIntentPresentation() + SignalApp.shared.showNewConversationView() + return .result() + } +} + +@available(iOS 16.0, *) +struct OpenSignalCameraIntent: AppIntent { + static var title: LocalizedStringResource = "Open Signal Camera" + static var description = IntentDescription("Open the camera view in Signal.") + static var openAppWhenRun = true + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + + @MainActor + func perform() async throws -> some IntentResult { + try await ensureSignalReadyAndUnlocked() + try await prepareUIForIntentPresentation() + SignalApp.shared.showCameraCaptureView() + return .result() + } +} + +@available(iOS 16.0, *) +struct OpenSignalSettingsIntent: AppIntent { + static var title: LocalizedStringResource = "Open Signal Settings" + static var description = IntentDescription("Open the Settings screen in Signal.") + static var openAppWhenRun = true + static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + + @MainActor + func perform() async throws -> some IntentResult { + try await ensureSignalReadyAndUnlocked() + try await prepareUIForIntentPresentation() + SignalApp.shared.conversationSplitViewController?.showAppSettings() + return .result() + } +} + +@available(iOS 16.0, *) +struct SignalShortcutsProvider: AppShortcutsProvider { + static var shortcutTileColor: ShortcutTileColor = .blue + + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: SendSignalMessageIntent(), + phrases: [ + "Send a message to \(\.$recipient) in \(.applicationName)", + "Message \(\.$recipient) on \(.applicationName)", + ], + shortTitle: "Send Message", + systemImageName: "paperplane.fill", + ) + AppShortcut( + intent: OpenSignalConversationIntent(), + phrases: [ + "Open chat with \(\.$conversation) in \(.applicationName)", + "Open chat in \(.applicationName)", + ], + shortTitle: "Open Chat", + systemImageName: "bubble.left.and.bubble.right.fill", + ) + AppShortcut( + intent: NewSignalMessageIntent(), + phrases: ["New message in \(.applicationName)"], + shortTitle: "New Message", + systemImageName: "square.and.pencil", + ) + AppShortcut( + intent: OpenSignalCameraIntent(), + phrases: ["Open \(.applicationName) camera"], + shortTitle: "Open Camera", + systemImageName: "camera.fill", + ) + AppShortcut( + intent: OpenSignalSettingsIntent(), + phrases: ["Open \(.applicationName) settings"], + shortTitle: "Settings", + systemImageName: "gearshape.fill", + ) + } +} diff --git a/Signal/AppLaunch/AppDelegate.swift b/Signal/AppLaunch/AppDelegate.swift index add29cef7c5..a4f431059c2 100644 --- a/Signal/AppLaunch/AppDelegate.swift +++ b/Signal/AppLaunch/AppDelegate.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import AppIntents import CryptoKit import GRDB import Intents @@ -134,6 +135,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // This should be the first thing we do. let mainAppContext = MainAppContext() SetCurrentAppContext(mainAppContext, isRunningTests: false) + if #available(iOS 16.0, *) { + SignalShortcutsProvider.updateAppShortcutParameters() + } let debugLogger = DebugLogger.shared debugLogger.enableTTYLoggingIfNeeded() diff --git a/Signal/test/AppIntentsTest.swift b/Signal/test/AppIntentsTest.swift new file mode 100644 index 00000000000..e5f49d0ef4b --- /dev/null +++ b/Signal/test/AppIntentsTest.swift @@ -0,0 +1,19 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import XCTest + +@testable import Signal + +@available(iOS 16.0, *) +final class AppIntentsTest: XCTestCase { + func testOnlyQuickSendRunsWithoutForegroundingSignal() { + XCTAssertFalse(SendSignalMessageIntent.openAppWhenRun) + XCTAssertTrue(OpenSignalConversationIntent.openAppWhenRun) + XCTAssertTrue(NewSignalMessageIntent.openAppWhenRun) + XCTAssertTrue(OpenSignalCameraIntent.openAppWhenRun) + XCTAssertTrue(OpenSignalSettingsIntent.openAppWhenRun) + } +} diff --git a/Signal/util/ScreenLockUI.swift b/Signal/util/ScreenLockUI.swift index 19f198e1393..9a9a4ed4edf 100644 --- a/Signal/util/ScreenLockUI.swift +++ b/Signal/util/ScreenLockUI.swift @@ -76,6 +76,33 @@ class ScreenLockUI { } } + @MainActor + var isLockedForAppIntents: Bool { + guard appReadiness.isAppReady else { + return true + } + guard ScreenLock.shared.isScreenLockEnabled() else { + return false + } + guard !isScreenLockLocked else { + return true + } + guard !CurrentAppContext().isMainAppAndActiveIsolated else { + return false + } + guard let screenLockCountdownTimestamp else { + return true + } + + let currentTimestamp = monotonicTimestamp() + guard currentTimestamp >= screenLockCountdownTimestamp, currentTimestamp != 0 else { + return true + } + + let elapsed = TimeInterval(currentTimestamp - screenLockCountdownTimestamp) / TimeInterval(NSEC_PER_SEC) + return elapsed >= ScreenLock.shared.screenLockTimeout() + } + struct ScreenUnlockActionReplacedError: Error {} private var pendingScreenUnlockContinuation: CheckedContinuation?