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..543918d62632 --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift @@ -0,0 +1,27 @@ +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.", + comment: + "Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in." + ) + case .siteNotFound: + return LocalizedStringResource( + "ios-appintents.openError.siteNotFound", + defaultValue: "The site could not be found.", + comment: + "Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists." + ) + } + } +} diff --git a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift new file mode 100644 index 000000000000..340d58dcce51 --- /dev/null +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -0,0 +1,60 @@ +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", + comment: "Short title of the New Post shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: OpenNotificationsIntent(), + phrases: [ + "Open my \(.applicationName) notifications", + "Show my \(.applicationName) notifications" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openNotifications.shortTitle", + defaultValue: "Notifications", + comment: "Short title of the Notifications shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "bell" + ) + AppShortcut( + intent: OpenStatsIntent(), + phrases: [ + "Open my \(.applicationName) stats", + "Show my site stats in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openStats.shortTitle", + defaultValue: "Stats", + comment: "Short title of the Stats shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "chart.bar" + ) + AppShortcut( + intent: OpenReaderIntent(), + phrases: [ + "Open the \(.applicationName) Reader", + "Open Reader in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.openReader.shortTitle", + defaultValue: "Reader", + comment: "Short title of the Reader shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "book" + ) + } +} diff --git a/Sources/Jetpack/AppIntents/NewPostIntent.swift b/Sources/Jetpack/AppIntents/NewPostIntent.swift new file mode 100644 index 000000000000..046cd7e78175 --- /dev/null +++ b/Sources/Jetpack/AppIntents/NewPostIntent.swift @@ -0,0 +1,27 @@ +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", + comment: "Title of the App Intent that opens the post editor. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.newPost.description", + defaultValue: "Opens the editor to write a new post.", + comment: "Description of the App Intent that opens the post editor. Shown in the Shortcuts app." + ) + ) + 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..21cb706abf05 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift @@ -0,0 +1,29 @@ +import AppIntents + +/// Opens the Notifications tab. +struct OpenNotificationsIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openNotifications.title", + defaultValue: "Open Notifications", + comment: "Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openNotifications.description", + defaultValue: "Opens your notifications.", + comment: "Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app." + ) + ) + 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..9a5d1a5d853e --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift @@ -0,0 +1,29 @@ +import AppIntents + +/// Opens the Reader tab. +struct OpenReaderIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openReader.title", + defaultValue: "Open Reader", + comment: "Title of the App Intent that opens the Reader tab. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openReader.description", + defaultValue: "Opens the Reader to browse blogs you follow.", + comment: "Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app." + ) + ) + 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..34278ccd6335 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift @@ -0,0 +1,43 @@ +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", + comment: "Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openStats.description", + defaultValue: "Opens the stats for one of your sites.", + comment: "Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app." + ) + ) + static let openAppWhenRun = true + + @Parameter( + title: LocalizedStringResource( + "ios-appintents.openStats.siteParameter", + defaultValue: "Site", + comment: "Label of the site parameter of the Open Stats App Intent. Shown in the Shortcuts app." + ) + ) + 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/docs/localization.md b/docs/localization.md index c34705a36c99..18bb7de8dff3 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -129,3 +129,29 @@ private enum Strings { ## Testing Test with long words and special characters to ensure UI layouts work across languages. + +## App Intents strings + +App Intents display strings (`LocalizedStringResource` titles, descriptions, parameter names, +dialog and error text) cannot be extracted by `genstrings`. Write them with an explicit prefixed +key, an English default, and a translator comment — all in code, which is the single source: + + LocalizedStringResource( + "ios-appintents.newPost.title", + defaultValue: "New Post", + comment: "Title of the App Shortcut that opens the editor" + ) + +Interpolating values in a `defaultValue` is supported for preformatted Strings only — the freeze +upload renders each `\(…)` as a positional `%@` specifier; format numbers and dates into Strings +before interpolating them. + +At code freeze, `generate_app_intents_strings_for_glotpress` extracts these call sites with +`xcstringstool` (dirs listed in `APP_INTENTS_STRINGS_ROOTS`) and merges them into the GlotPress +upload; `bundle exec fastlane generate_app_intents_strings_for_glotpress` runs the extraction +standalone as a smoke check (it prints the count and errors on unprefixed keys). Translations +come back in the app's `Localizable.strings`, which the system resolves against on iOS 18 and +later (the iOS 17 widget-configuration UI only reads the widget extension bundle and therefore +shows the English defaults). Siri invocation phrases are different: they live in +`AppShortcuts.strings` (keyed by the English phrase), a roundtrip file whose per-locale siblings +ship in the app bundle. diff --git a/fastlane/lanes/localization.rb b/fastlane/lanes/localization.rb index 12a30becac68..d5e2f2d37fc0 100644 --- a/fastlane/lanes/localization.rb +++ b/fastlane/lanes/localization.rb @@ -96,7 +96,8 @@ MANUALLY_MAINTAINED_STRINGS_FILES = { 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', 'JetpackDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-jetpack-sharesheet.', # CFBundleDisplayName for the "Save to Jetpack" share action + 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 diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index 0a1647ebf69b..171469b23e03 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -44,11 +44,13 @@ # freeze extracts them from these dirs with xcstringstool and merges them into the GlotPress upload # (see generate_app_intents_strings_for_glotpress). Keys in code are self-qualified with these prefixes. APP_INTENTS_STRINGS_ROOTS = [ - File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources', 'JetpackStatsWidgetsCore', 'AppIntents') + File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources', 'JetpackStatsWidgetsCore', 'AppIntents'), + File.join(PROJECT_ROOT_FOLDER, 'Sources', 'Jetpack', 'AppIntents') ].freeze APP_INTENTS_KEY_PREFIXES = [ - 'ios-widget.' + 'ios-widget.', + 'ios-appintents.' ].freeze platform :ios do