Skip to content

Commit 714f4af

Browse files
authored
Merge pull request #7323 from woocommerce/feat/7318-local-notifications-feature-flag-and-setup
Login Reminder: schedule a local notification 24 hours after the user encounters an error logging in with site address
2 parents 4342dd1 + 8d6cb77 commit 714f4af

File tree

12 files changed

+218
-11
lines changed

12 files changed

+218
-11
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
4747
return true
4848
case .loginPrologueOnboarding:
4949
return true
50+
case .loginErrorNotifications:
51+
return buildConfig == .localDeveloper || buildConfig == .alpha
5052
default:
5153
return true
5254
}

Experiments/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,8 @@ public enum FeatureFlag: Int {
9797
/// Onboarding experiment on the login prologue screen
9898
///
9999
case loginPrologueOnboarding
100+
101+
/// Local notifications scheduled 24 hours after certain login errors
102+
///
103+
case loginErrorNotifications
100104
}

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public enum WooAnalyticsStat: String {
3838
case loginEmailFormViewed = "login_email_form_viewed"
3939
case loginJetpackRequiredScreenViewed = "login_jetpack_required_screen_viewed"
4040
case loginJetpackRequiredViewInstructionsButtonTapped = "login_jetpack_required_view_instructions_button_tapped"
41+
case loginLocalNotificationTapped = "login_local_notification_tapped"
4142
case loginWhatIsJetpackHelpScreenViewed = "login_what_is_jetpack_help_screen_viewed"
4243
case loginWhatIsJetpackHelpScreenOkButtonTapped = "login_what_is_jetpack_help_screen_ok_button_tapped"
4344
case loginWhatIsJetpackHelpScreenLearnMoreButtonTapped = "login_what_is_jetpack_help_screen_learn_more_button_tapped"

WooCommerce/Classes/AppDelegate.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
6363
setupKeyboardStateProvider()
6464
handleLaunchArguments()
6565
appleIDCredentialChecker.observeLoggedInStateForAppleIDObservations()
66+
setupUserNotificationCenter()
6667

6768
// Components that require prior Auth
6869
setupZendesk()
@@ -288,6 +289,9 @@ private extension AppDelegate {
288289
///
289290
func setupPushNotificationsManagerIfPossible() {
290291
guard ServiceLocator.stores.isAuthenticated, ServiceLocator.stores.needsDefaultStore == false else {
292+
if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.loginErrorNotifications) {
293+
ServiceLocator.pushNotesManager.ensureAuthorizationIsRequested(includesProvisionalAuth: true, onCompletion: nil)
294+
}
291295
return
292296
}
293297

@@ -296,10 +300,17 @@ private extension AppDelegate {
296300
#else
297301
let pushNotesManager = ServiceLocator.pushNotesManager
298302
pushNotesManager.registerForRemoteNotifications()
299-
pushNotesManager.ensureAuthorizationIsRequested(onCompletion: nil)
303+
pushNotesManager.ensureAuthorizationIsRequested(includesProvisionalAuth: false, onCompletion: nil)
300304
#endif
301305
}
302306

307+
func setupUserNotificationCenter() {
308+
guard ServiceLocator.featureFlagService.isFeatureFlagEnabled(.loginErrorNotifications) else {
309+
return
310+
}
311+
UNUserNotificationCenter.current().delegate = self
312+
}
313+
303314
/// Set up app review prompt
304315
///
305316
func setupAppRatingManager() {
@@ -403,3 +414,32 @@ extension AppDelegate {
403414
RequirementsChecker.checkMinimumWooVersionForDefaultStore()
404415
}
405416
}
417+
418+
// MARK: - UNUserNotificationCenterDelegate
419+
420+
extension AppDelegate: UNUserNotificationCenterDelegate {
421+
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
422+
switch response.actionIdentifier {
423+
case LocalNotification.Action.contactSupport.rawValue:
424+
guard let viewController = window?.rootViewController else {
425+
return
426+
}
427+
ZendeskProvider.shared.showNewRequestIfPossible(from: viewController, with: nil)
428+
ServiceLocator.analytics.track(.loginLocalNotificationTapped, withProperties: [
429+
"action": "contact_support",
430+
"type": response.notification.request.identifier
431+
])
432+
default:
433+
// Triggered when the user taps on the notification itself instead of one of the actions.
434+
switch response.notification.request.identifier {
435+
case LocalNotification.Scenario.loginSiteAddressError.rawValue:
436+
ServiceLocator.analytics.track(.loginLocalNotificationTapped, withProperties: [
437+
"action": "default",
438+
"type": response.notification.request.identifier
439+
])
440+
default:
441+
return
442+
}
443+
}
444+
}
445+
}

WooCommerce/Classes/Authentication/AuthenticationManager.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ extension AuthenticationManager: WordPressAuthenticatorDelegate {
209209
}
210210

211211
func handleError(_ error: Error, onCompletion: @escaping (UIViewController) -> Void) {
212+
requestLocalNotificationIfApplicable(error: error)
213+
212214
guard let errorViewModel = viewModel(error) else {
213215
return
214216
}
@@ -264,6 +266,10 @@ extension AuthenticationManager: WordPressAuthenticatorDelegate {
264266
return
265267
}
266268

269+
if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.loginErrorNotifications) {
270+
ServiceLocator.pushNotesManager.cancelLocalNotification(scenarios: [.loginSiteAddressError])
271+
}
272+
267273
let matcher = ULAccountMatcher()
268274
matcher.refreshStoredSites()
269275

@@ -404,6 +410,28 @@ extension AuthenticationManager: WordPressAuthenticatorDelegate {
404410
}
405411
}
406412

413+
// MARK: - Local notifications
414+
415+
private extension AuthenticationManager {
416+
func requestLocalNotificationIfApplicable(error: Error) {
417+
guard ServiceLocator.featureFlagService.isFeatureFlagEnabled(.loginErrorNotifications) else {
418+
return
419+
}
420+
421+
let wooAuthError = AuthenticationError.make(with: error)
422+
switch wooAuthError {
423+
case .notWPSite, .notValidAddress:
424+
let notification = LocalNotification(scenario: .loginSiteAddressError)
425+
ServiceLocator.pushNotesManager.cancelLocalNotification(scenarios: [notification.scenario])
426+
ServiceLocator.pushNotesManager.requestLocalNotification(notification,
427+
// 24 hours from now.
428+
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 86400, repeats: false))
429+
default:
430+
break
431+
}
432+
}
433+
}
434+
407435
// MARK: - Private helpers
408436
private extension AuthenticationManager {
409437
func isJetpackValidForSelfHostedSite(url: String) -> Bool {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Foundation
2+
3+
/// Content for a local notification to be converted to `UNNotificationContent`.
4+
struct LocalNotification {
5+
let title: String
6+
let body: String
7+
let scenario: Scenario
8+
let actions: CategoryActions?
9+
10+
/// A category of actions in a notification.
11+
struct CategoryActions {
12+
let category: Category
13+
let actions: [Action]
14+
}
15+
16+
/// The scenario for the local notification.
17+
/// Its raw value is used for the identifier of a local notification and also the event property for analytics.
18+
enum Scenario: String {
19+
case loginSiteAddressError = "site_address_error"
20+
}
21+
22+
/// The category of actions for a local notification.
23+
enum Category: String {
24+
case loginError
25+
}
26+
27+
/// The action type in a local notification.
28+
enum Action: String {
29+
case contactSupport
30+
31+
/// The title of the action in a local notification.
32+
var title: String {
33+
switch self {
34+
case .contactSupport:
35+
return NSLocalizedString("Contact support", comment: "Local notification action to contact support.")
36+
}
37+
}
38+
}
39+
}
40+
41+
extension LocalNotification {
42+
init(scenario: Scenario) {
43+
switch scenario {
44+
case .loginSiteAddressError:
45+
self.init(title: Localization.errorLoggingInTitle,
46+
body: Localization.errorLoggingInBody,
47+
scenario: .loginSiteAddressError,
48+
actions: .init(category: .loginError, actions: [.contactSupport]))
49+
}
50+
}
51+
}
52+
53+
private extension LocalNotification {
54+
enum Localization {
55+
static let errorLoggingInTitle = NSLocalizedString("Problems with logging in?",
56+
comment: "Local notification title when the user encounters an error logging in " +
57+
"with site address.")
58+
static let errorLoggingInBody = NSLocalizedString("Get some help!",
59+
comment: "Local notification body when the user encounters an error logging in " +
60+
"with site address.")
61+
}
62+
}

WooCommerce/Classes/Notifications/PushNotificationsManager.swift

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,20 @@ final class PushNotificationsManager: PushNotesManager {
9595
//
9696
extension PushNotificationsManager {
9797

98-
/// Requests Authorization to receive Push Notifications, *only* when the current Status is not determined.
98+
/// Requests Authorization to receive Push Notifications, *only* when the current Status is not determined or provisional.
9999
///
100100
/// - Parameter onCompletion: Closure to be executed on completion. Receives a Boolean indicating if we've got Push Permission.
101101
///
102-
func ensureAuthorizationIsRequested(onCompletion: ((Bool) -> Void)? = nil) {
102+
func ensureAuthorizationIsRequested(includesProvisionalAuth: Bool = false, onCompletion: ((Bool) -> Void)? = nil) {
103103
let nc = configuration.userNotificationsCenter
104104

105105
nc.loadAuthorizationStatus(queue: .main) { status in
106-
guard status == .notDetermined else {
106+
guard status == .notDetermined || status == .provisional else {
107107
onCompletion?(status == .authorized)
108108
return
109109
}
110110

111-
nc.requestAuthorization(queue: .main) { allowed in
111+
nc.requestAuthorization(queue: .main, includesProvisionalAuth: includesProvisionalAuth) { allowed in
112112
let stat: WooAnalyticsStat = allowed ? .pushNotificationOSAlertAllowed : .pushNotificationOSAlertDenied
113113
ServiceLocator.analytics.track(stat)
114114

@@ -261,6 +261,54 @@ extension PushNotificationsManager {
261261
}
262262
}
263263
}
264+
265+
func requestLocalNotification(_ notification: LocalNotification, trigger: UNNotificationTrigger?) {
266+
Task {
267+
// TODO: 7318 - tech debt - replace `UNUserNotificationCenter.current()` with
268+
// `configuration.userNotificationsCenter` for unit testing
269+
let center = UNUserNotificationCenter.current()
270+
let settings = await center.notificationSettings()
271+
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
272+
DDLogError("⛔️ Unable to request a local notification due to invalid authorization status: \(settings.authorizationStatus)")
273+
return
274+
}
275+
276+
let content = UNMutableNotificationContent()
277+
content.title = notification.title
278+
content.body = notification.body
279+
280+
if let categoryAndActions = notification.actions {
281+
let categoryIdentifier = categoryAndActions.category.rawValue
282+
let actions = categoryAndActions.actions.map {
283+
UNNotificationAction(identifier: $0.rawValue,
284+
title: $0.title,
285+
options: .foreground)
286+
}
287+
let category = UNNotificationCategory(identifier: categoryIdentifier,
288+
actions: actions,
289+
intentIdentifiers: [],
290+
hiddenPreviewsBodyPlaceholder: nil,
291+
categorySummaryFormat: nil,
292+
options: .allowAnnouncement)
293+
center.setNotificationCategories([category])
294+
content.categoryIdentifier = categoryIdentifier
295+
}
296+
297+
let request = UNNotificationRequest(identifier: notification.scenario.rawValue,
298+
content: content,
299+
trigger: trigger)
300+
do {
301+
try await center.add(request)
302+
} catch {
303+
DDLogError("⛔️ Unable to request a local notification: \(error)")
304+
}
305+
}
306+
}
307+
308+
func cancelLocalNotification(scenarios: [LocalNotification.Scenario]) {
309+
let center = UNUserNotificationCenter.current()
310+
center.removePendingNotificationRequests(withIdentifiers: scenarios.map { $0.rawValue })
311+
}
264312
}
265313

266314
// MARK: - Notification count & app badge number update

WooCommerce/Classes/Notifications/UserNotificationsCenterAdapter.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ protocol UserNotificationsCenterAdapter {
1212

1313
/// Requests Push Notifications Authorization
1414
///
15-
func requestAuthorization(queue: DispatchQueue, completion: @escaping (Bool) -> Void)
15+
func requestAuthorization(queue: DispatchQueue, includesProvisionalAuth: Bool, completion: @escaping (Bool) -> Void)
1616

1717
/// Removes all push notifications that have been delivered or scheduled
1818
func removeAllNotifications()
@@ -35,8 +35,9 @@ extension UNUserNotificationCenter: UserNotificationsCenterAdapter {
3535

3636
/// Requests Push Notifications Authorization
3737
///
38-
func requestAuthorization(queue: DispatchQueue = .main, completion: @escaping (Bool) -> Void) {
39-
requestAuthorization(options: [.badge, .sound, .alert]) { (allowed, _) in
38+
func requestAuthorization(queue: DispatchQueue = .main, includesProvisionalAuth: Bool, completion: @escaping (Bool) -> Void) {
39+
let options: UNAuthorizationOptions = includesProvisionalAuth ? [.badge, .sound, .alert, .provisional]: [.badge, .sound, .alert]
40+
requestAuthorization(options: options) { (allowed, _) in
4041
queue.async {
4142
completion(allowed)
4243
}

WooCommerce/Classes/ServiceLocator/PushNotesManager.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ protocol PushNotesManager {
4242

4343
/// Requests Authorization to receive Push Notifications, *only* when the current Status is not determined.
4444
///
45+
/// - Parameter includesProvisionalAuth: A boolean that indicates whether to request provisional authorization in order to send trial notifications.
4546
/// - Parameter onCompletion: Closure to be executed on completion. Receives a Boolean indicating if we've got Push Permission.
4647
///
47-
func ensureAuthorizationIsRequested(onCompletion: ((Bool) -> Void)?)
48+
func ensureAuthorizationIsRequested(includesProvisionalAuth: Bool, onCompletion: ((Bool) -> Void)?)
4849

4950
/// Handles Push Notifications Registration Errors. This method unregisters the current device from the WordPress.com
5051
/// Push Service.
@@ -66,4 +67,14 @@ protocol PushNotesManager {
6667
func handleNotification(_ userInfo: [AnyHashable: Any],
6768
onBadgeUpdateCompletion: @escaping () -> Void,
6869
completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
70+
71+
/// Requests a local notification to be scheduled under a given trigger.
72+
/// - Parameters:
73+
/// - notification: the notification content.
74+
/// - trigger: if nil, the local notification is delivered immediately.
75+
func requestLocalNotification(_ notification: LocalNotification, trigger: UNNotificationTrigger?)
76+
77+
/// Cancels a local notification that was previously scheduled.
78+
/// - Parameter scenarios: the scenarios of the notification to be cancelled.
79+
func cancelLocalNotification(scenarios: [LocalNotification.Scenario])
6980
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
021E2A1E23AA24C600B1DE07 /* StringInputFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021E2A1D23AA24C600B1DE07 /* StringInputFormatter.swift */; };
9494
021E2A2023AA274700B1DE07 /* ProductBackordersSettingListSelectorCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021E2A1F23AA274700B1DE07 /* ProductBackordersSettingListSelectorCommandTests.swift */; };
9595
021FB44C24A5E3B00090E144 /* ProductListMultiSelectorSearchUICommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021FB44B24A5E3B00090E144 /* ProductListMultiSelectorSearchUICommand.swift */; };
96+
0221121E288973C20028F0AF /* LocalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0221121D288973C20028F0AF /* LocalNotification.swift */; };
9697
0225C42824768A4C00C5B4F0 /* FilterProductListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0225C42724768A4C00C5B4F0 /* FilterProductListViewModelTests.swift */; };
9798
0225C42A24768CE900C5B4F0 /* FilterProductListViewModelProductListFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0225C42924768CE900C5B4F0 /* FilterProductListViewModelProductListFilterTests.swift */; };
9899
0225C42C2477D0D500C5B4F0 /* ProductFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0225C42B2477D0D500C5B4F0 /* ProductFormViewModel.swift */; };
@@ -1901,6 +1902,7 @@
19011902
021E2A1D23AA24C600B1DE07 /* StringInputFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringInputFormatter.swift; sourceTree = "<group>"; };
19021903
021E2A1F23AA274700B1DE07 /* ProductBackordersSettingListSelectorCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductBackordersSettingListSelectorCommandTests.swift; sourceTree = "<group>"; };
19031904
021FB44B24A5E3B00090E144 /* ProductListMultiSelectorSearchUICommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListMultiSelectorSearchUICommand.swift; sourceTree = "<group>"; };
1905+
0221121D288973C20028F0AF /* LocalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNotification.swift; sourceTree = "<group>"; };
19041906
0225C42724768A4C00C5B4F0 /* FilterProductListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterProductListViewModelTests.swift; sourceTree = "<group>"; };
19051907
0225C42924768CE900C5B4F0 /* FilterProductListViewModelProductListFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterProductListViewModelProductListFilterTests.swift; sourceTree = "<group>"; };
19061908
0225C42B2477D0D500C5B4F0 /* ProductFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductFormViewModel.swift; sourceTree = "<group>"; };
@@ -6719,6 +6721,7 @@
67196721
B5BBD6DD21B1703600E3207E /* PushNotificationsManager.swift */,
67206722
B509FED221C05121000076A9 /* SupportManagerAdapter.swift */,
67216723
B555530C21B57DC300449E71 /* UserNotificationsCenterAdapter.swift */,
6724+
0221121D288973C20028F0AF /* LocalNotification.swift */,
67226725
);
67236726
path = Notifications;
67246727
sourceTree = "<group>";
@@ -9327,6 +9330,7 @@
93279330
57A49128250A7EB2000FEF21 /* OrderListViewController.swift in Sources */,
93289331
DE34771327F174C8009CA300 /* StatusView.swift in Sources */,
93299332
45DB704A26121F3C0064A6CF /* TitleAndValueRow.swift in Sources */,
9333+
0221121E288973C20028F0AF /* LocalNotification.swift in Sources */,
93309334
451A04E62386CE8700E368C9 /* ProductImagesHeaderTableViewCell.swift in Sources */,
93319335
02E19B9C284743A40010B254 /* ProductImageUploader.swift in Sources */,
93329336
B59C09D92188CBB100AB41D6 /* Array+Notes.swift in Sources */,

0 commit comments

Comments
 (0)