diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 423368e006f3..2c0712730427 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,7 @@ * [*] [internal] In-app updates: guard the flexible update notice against double-posting when two update checks race [#25666] * [*] [build tooling] Add a String Catalog localization pipeline (plurals + build-free catalog generation) with a CI coverage gate [#25688] * [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753] +* [*] Add App Shortcuts for creating a post and opening Notifications, Stats, and the Reader from Siri, Spotlight, and the Shortcuts app [#25754] 27.0 diff --git a/Sources/Jetpack/AppIntents/AppIntentOpenError.swift b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift new file mode 100644 index 000000000000..5f29adf4896d --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift @@ -0,0 +1,23 @@ +import AppIntents +import Foundation + +/// Errors surfaced to the system when an open intent cannot proceed. +enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible { + case notLoggedIn + case siteNotFound + + var localizedStringResource: LocalizedStringResource { + switch self { + case .notLoggedIn: + return LocalizedStringResource( + "ios-appintents.openError.notLoggedIn", + defaultValue: "Sign in to the app first to use this action." + ) + case .siteNotFound: + return LocalizedStringResource( + "ios-appintents.openError.siteNotFound", + defaultValue: "The site could not be found." + ) + } + } +} diff --git a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift new file mode 100644 index 000000000000..f95107888648 --- /dev/null +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -0,0 +1,47 @@ +import AppIntents + +/// Surfaces the navigation intents in the Shortcuts app and Siri without any user setup. +struct JetpackAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: NewPostIntent(), + phrases: [ + "Create a new post in \(.applicationName)", + "Write a new post in \(.applicationName)", + "New \(.applicationName) post" + ], + shortTitle: LocalizedStringResource("ios-appintents.newPost.shortTitle", defaultValue: "New Post"), + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: OpenNotificationsIntent(), + phrases: [ + "Open my \(.applicationName) notifications", + "Show my \(.applicationName) notifications" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openNotifications.shortTitle", + defaultValue: "Notifications" + ), + systemImageName: "bell" + ) + AppShortcut( + intent: OpenStatsIntent(), + phrases: [ + "Open my \(.applicationName) stats", + "Show my site stats in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("ios-appintents.openStats.shortTitle", defaultValue: "Stats"), + systemImageName: "chart.bar" + ) + AppShortcut( + intent: OpenReaderIntent(), + phrases: [ + "Open the \(.applicationName) Reader", + "Open Reader in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"), + systemImageName: "book" + ) + } +} diff --git a/Sources/Jetpack/AppIntents/NewPostIntent.swift b/Sources/Jetpack/AppIntents/NewPostIntent.swift new file mode 100644 index 000000000000..33e45416204c --- /dev/null +++ b/Sources/Jetpack/AppIntents/NewPostIntent.swift @@ -0,0 +1,22 @@ +import AppIntents + +/// Opens the post editor for the last used site. +struct NewPostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.newPost.title", defaultValue: "New Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.newPost.description", + defaultValue: "Opens the editor to write a new post." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + RootViewCoordinator.sharedPresenter.showPostEditor(animated: false) + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift new file mode 100644 index 000000000000..75503e971e66 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift @@ -0,0 +1,27 @@ +import AppIntents + +/// Opens the Notifications tab. +struct OpenNotificationsIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openNotifications.title", + defaultValue: "Open Notifications" + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openNotifications.description", + defaultValue: "Opens your notifications." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showNotificationsTab() + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenReaderIntent.swift b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift new file mode 100644 index 000000000000..5d37fd58d44d --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift @@ -0,0 +1,24 @@ +import AppIntents + +/// Opens the Reader tab. +struct OpenReaderIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.openReader.title", defaultValue: "Open Reader") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openReader.description", + defaultValue: "Opens the Reader to browse blogs you follow." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showReader(path: nil) + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenStatsIntent.swift b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift new file mode 100644 index 000000000000..63e780a86162 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift @@ -0,0 +1,32 @@ +import AppIntents +import JetpackStatsWidgetsCore +import WordPressData + +/// Opens Stats for a chosen site, or the last used site when none is picked. +struct OpenStatsIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.openStats.title", defaultValue: "Open Stats") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openStats.description", + defaultValue: "Opens the stats for one of your sites." + ) + ) + static let openAppWhenRun = true + + @Parameter(title: LocalizedStringResource("ios-appintents.openStats.siteParameter", defaultValue: "Site")) + var site: SiteEntity? + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + guard let blog = Blog.forAppIntent(siteIdentifier: site?.id, in: ContextManager.shared.mainContext) else { + throw AppIntentOpenError.siteNotFound + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showStats(for: blog, source: .shortcut) + return .result() + } +} diff --git a/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings new file mode 100644 index 000000000000..34d8ecdd9937 --- /dev/null +++ b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings @@ -0,0 +1,39 @@ +/* Siri invocation phrases for the App Shortcuts declared in + Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift. + + Each key must match a phrase in code character for character, with ${applicationName} in + place of the \(.applicationName) interpolation; the system looks phrases up by the English + text, so a mismatched key silently disables that phrase's localization. Translations must + keep the ${applicationName} placeholder as-is. + + This file ships in the Jetpack app bundle. It is uploaded to GlotPress under the + "ios-appintents-phrases." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in + fastlane/lanes/localization.rb), and the per-locale sibling files are generated by the + release tooling when translations are downloaded. */ + +/* Siri phrase to open the post editor. ${applicationName} is the app's name. */ +"Create a new post in ${applicationName}" = "Create a new post in ${applicationName}"; + +/* Siri phrase to open the post editor. ${applicationName} is the app's name. */ +"Write a new post in ${applicationName}" = "Write a new post in ${applicationName}"; + +/* Siri phrase to open the post editor. ${applicationName} is the app's name. */ +"New ${applicationName} post" = "New ${applicationName} post"; + +/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */ +"Open my ${applicationName} notifications" = "Open my ${applicationName} notifications"; + +/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */ +"Show my ${applicationName} notifications" = "Show my ${applicationName} notifications"; + +/* Siri phrase to open Stats. ${applicationName} is the app's name. */ +"Open my ${applicationName} stats" = "Open my ${applicationName} stats"; + +/* Siri phrase to open Stats. ${applicationName} is the app's name. */ +"Show my site stats in ${applicationName}" = "Show my site stats in ${applicationName}"; + +/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */ +"Open the ${applicationName} Reader" = "Open the ${applicationName} Reader"; + +/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */ +"Open Reader in ${applicationName}" = "Open Reader in ${applicationName}"; diff --git a/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift b/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift new file mode 100644 index 000000000000..29499d2c9bb0 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import WordPress +@testable import WordPressData + +@MainActor +@Suite("Blog app intent resolution") +struct BlogAppIntentResolutionTests { + @Test("a site identifier resolves the matching blog") + func explicitIdentifierResolvesMatchingBlog() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + let target = BlogBuilder(context).with(dotComID: 222).build() + + #expect(Blog.forAppIntent(siteIdentifier: "222", in: context) == target) + } + + @Test("an identifier with no matching blog resolves nothing instead of falling back") + func unknownIdentifierResolvesNil() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: "999", in: context) == nil) + } + + @Test("a non-numeric identifier resolves nothing") + func nonNumericIdentifierResolvesNil() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: "not-a-number", in: context) == nil) + } + + @Test("no identifier falls back to the last used or first blog") + func missingIdentifierFallsBack() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: nil, in: context) == blog) + } +} diff --git a/WordPress/Classes/Models/Blog/Blog+AppIntents.swift b/WordPress/Classes/Models/Blog/Blog+AppIntents.swift new file mode 100644 index 000000000000..b8afb1ebc5a9 --- /dev/null +++ b/WordPress/Classes/Models/Blog/Blog+AppIntents.swift @@ -0,0 +1,20 @@ +import Foundation +import WordPressData + +extension Blog { + /// Resolves the blog an App Intent should act on. + /// + /// An explicit site identifier must resolve to its own blog: falling back to + /// another site would silently act on the wrong one, so unresolvable + /// identifiers return `nil`. Only when no identifier is given does the + /// resolution fall back to the last used or first blog. + static func forAppIntent(siteIdentifier: String?, in context: NSManagedObjectContext) -> Blog? { + guard let siteIdentifier else { + return lastUsedOrFirst(in: context) + } + guard let siteID = Int(siteIdentifier) else { + return nil + } + return try? lookup(withID: siteID, in: context) + } +} diff --git a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings new file mode 100644 index 000000000000..fc09d73c9c87 --- /dev/null +++ b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings @@ -0,0 +1,53 @@ +/* Source strings for the Jetpack App Intents (Siri, Shortcuts, Spotlight), uploaded to GlotPress + under the "ios-appintents." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in + fastlane/lanes/localization.rb). + + This file is not shipped in any bundle. At runtime the prefixed keys are resolved from the + app's Localizable.strings, with the defaultValue in code as the fallback until the keys have + gone through a code freeze. Keep the keys in sync with the LocalizedStringResource keys in + Sources/Jetpack/AppIntents (minus the prefix). */ + +/* Title of the App Intent that opens the post editor. Shown in the Shortcuts app and Spotlight. */ +"newPost.title" = "New Post"; + +/* Description of the App Intent that opens the post editor. Shown in the Shortcuts app. */ +"newPost.description" = "Opens the editor to write a new post."; + +/* Short title of the New Post shortcut tile in Spotlight and the Shortcuts app. */ +"newPost.shortTitle" = "New Post"; + +/* Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight. */ +"openNotifications.title" = "Open Notifications"; + +/* Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app. */ +"openNotifications.description" = "Opens your notifications."; + +/* Short title of the Notifications shortcut tile in Spotlight and the Shortcuts app. */ +"openNotifications.shortTitle" = "Notifications"; + +/* Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight. */ +"openStats.title" = "Open Stats"; + +/* Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app. */ +"openStats.description" = "Opens the stats for one of your sites."; + +/* Label of the site parameter of the Open Stats App Intent. Shown in the Shortcuts app. */ +"openStats.siteParameter" = "Site"; + +/* Short title of the Stats shortcut tile in Spotlight and the Shortcuts app. */ +"openStats.shortTitle" = "Stats"; + +/* Title of the App Intent that opens the Reader tab. Shown in the Shortcuts app and Spotlight. */ +"openReader.title" = "Open Reader"; + +/* Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app. */ +"openReader.description" = "Opens the Reader to browse blogs you follow."; + +/* Short title of the Reader shortcut tile in Spotlight and the Shortcuts app. */ +"openReader.shortTitle" = "Reader"; + +/* Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in. */ +"openError.notLoggedIn" = "Sign in to the app first to use this action."; + +/* Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists. */ +"openError.siteNotFound" = "The site could not be found."; diff --git a/fastlane/lanes/localization.rb b/fastlane/lanes/localization.rb index a6897b7dc1ac..99c3697e0e74 100644 --- a/fastlane/lanes/localization.rb +++ b/fastlane/lanes/localization.rb @@ -97,7 +97,9 @@ File.join('WordPress', 'Resources', 'en.lproj', 'InfoPlist.strings') => 'infoplist.', # For now WordPress and Jetpack share the same InfoPlist.strings File.join('WordPress', 'WordPressDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-sharesheet.', # CFBundleDisplayName for the "Save as Draft" share action File.join('WordPress', 'JetpackDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-jetpack-sharesheet.', # CFBundleDisplayName for the "Save to Jetpack" share action - File.join('WordPress', 'JetpackStatsWidgets', 'en.lproj', 'Localizable.strings') => 'ios-widget.' # Strings for the App Intents UI used when configuring the iOS Widget; resolved at runtime via the prefixed keys in the app's Localizable.strings + File.join('WordPress', 'JetpackStatsWidgets', 'en.lproj', 'Localizable.strings') => 'ios-widget.', # Strings for the App Intents UI used when configuring the iOS Widget; resolved at runtime via the prefixed keys in the app's Localizable.strings + File.join('WordPress', 'JetpackAppIntents', 'en.lproj', 'Localizable.strings') => 'ios-appintents.', # Titles, descriptions, and dialogs of the Jetpack App Intents; resolved at runtime via the prefixed keys in the app's Localizable.strings + File.join('Sources', 'Jetpack', 'Resources', 'en.lproj', 'AppShortcuts.strings') => 'ios-appintents-phrases.' # Siri invocation phrases for the Jetpack App Shortcuts; the per-locale sibling files ship in the Jetpack app bundle }.freeze # Remote Swift Packages whose localizable strings we want to extract (they're checked out under Derived Data