Skip to content

Commit 5ddd0e9

Browse files
author
Jon Petersson
committed
Fix out-of-time notifications
1 parent 5a663eb commit 5ddd0e9

File tree

7 files changed

+255
-86
lines changed

7 files changed

+255
-86
lines changed

ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"originHash" : "c15149b2d59d9e9c72375f65339c04f41a19943e1117e682df27fc9f943fdc56",
23
"pins" : [
34
{
45
"identity" : "swift-log",
@@ -18,5 +19,5 @@
1819
}
1920
}
2021
],
21-
"version" : 2
22+
"version" : 3
2223
}

ios/MullvadVPN/Notifications/Notification Providers/AccountExpiry.swift

+65-21
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,81 @@
77
//
88

99
import Foundation
10+
import MullvadTypes
1011

1112
struct AccountExpiry {
13+
enum Trigger {
14+
case system, inApp
15+
16+
var dateIntervals: [Int] {
17+
switch self {
18+
case .system:
19+
NotificationConfiguration.closeToExpirySystemTriggerIntervals
20+
case .inApp:
21+
NotificationConfiguration.closeToExpiryInAppTriggerIntervals
22+
}
23+
}
24+
}
25+
26+
private let calendar = Calendar.current
27+
1228
var expiryDate: Date?
1329

14-
var triggerDate: Date? {
15-
guard let expiryDate else { return nil }
30+
func nextTriggerDate(for trigger: Trigger) -> Date? {
31+
let now = Date().secondsPrecision
32+
let triggerDates = triggerDates(for: trigger)
33+
34+
// Get earliest trigger date and remove one day. Since we want to count whole days, If first
35+
// notification should trigger 3 days before account expiry, we need to start checking when
36+
// there's (less than) 4 days left.
37+
guard
38+
let expiryDate,
39+
let earliestDate = triggerDates.min(),
40+
let earliestTriggerDate = calendar.date(byAdding: .day, value: -1, to: earliestDate),
41+
now <= expiryDate.secondsPrecision,
42+
now > earliestTriggerDate.secondsPrecision
43+
else { return nil }
44+
45+
let datesByTimeToTrigger = triggerDates.filter { date in
46+
now.secondsPrecision <= date.secondsPrecision // Ignore dates that have passed.
47+
}.sorted { date1, date2 in
48+
abs(date1.timeIntervalSince(now)) < abs(date2.timeIntervalSince(now))
49+
}
1650

17-
return Calendar.current.date(
18-
byAdding: .day,
19-
value: -NotificationConfiguration.closeToExpiryTriggerInterval,
20-
to: expiryDate
51+
return datesByTimeToTrigger.first
52+
}
53+
54+
func daysRemaining(for trigger: Trigger) -> DateComponents? {
55+
let nextTriggerDate = nextTriggerDate(for: trigger)
56+
guard let expiryDate, let nextTriggerDate else { return nil }
57+
58+
let dateComponents = calendar.dateComponents(
59+
[.day],
60+
from: Date().secondsPrecision,
61+
to: max(nextTriggerDate, expiryDate).secondsPrecision
2162
)
63+
64+
return dateComponents
2265
}
2366

24-
var formattedDuration: String? {
25-
let now = Date()
67+
func triggerDates(for trigger: Trigger) -> [Date] {
68+
guard let expiryDate else { return [] }
2669

27-
guard
28-
let expiryDate,
29-
let triggerDate,
30-
let duration = CustomDateComponentsFormatting.localizedString(
31-
from: now,
32-
to: expiryDate,
33-
unitsStyle: .full
34-
),
35-
now >= triggerDate,
36-
now < expiryDate
37-
else {
38-
return nil
70+
let dates = trigger.dateIntervals.compactMap {
71+
calendar.date(
72+
byAdding: .day,
73+
value: -$0,
74+
to: expiryDate
75+
)
3976
}
4077

41-
return duration
78+
return dates
79+
}
80+
}
81+
82+
private extension Date {
83+
// Used to compare dates with a precision of a minimum of seconds.
84+
var secondsPrecision: Date {
85+
Date(timeIntervalSince1970: TimeInterval(Int(timeIntervalSince1970)))
4286
}
4387
}

ios/MullvadVPN/Notifications/Notification Providers/AccountExpiryInAppNotificationProvider.swift

+28-13
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,18 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN
3838
// MARK: - InAppNotificationProvider
3939

4040
var notificationDescriptor: InAppNotificationDescriptor? {
41-
guard let duration = accountExpiry.formattedDuration else {
41+
guard let durationText = remainingDaysText else {
4242
return nil
4343
}
4444

4545
return InAppNotificationDescriptor(
4646
identifier: identifier,
4747
style: .warning,
48-
title: NSLocalizedString(
49-
"ACCOUNT_EXPIRY_INAPP_NOTIFICATION_TITLE",
50-
value: "ACCOUNT CREDIT EXPIRES SOON",
51-
comment: "Title for in-app notification, displayed within the last 3 days until account expiry."
52-
),
53-
body: .init(string: String(
54-
format: NSLocalizedString(
55-
"ACCOUNT_EXPIRY_INAPP_NOTIFICATION_BODY",
56-
value: "%@ left. Buy more credit.",
57-
comment: "Message for in-app notification, displayed within the last 3 days until account expiry."
58-
), duration
48+
title: durationText,
49+
body: NSAttributedString(string: NSLocalizedString(
50+
"ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_BODY",
51+
value: "You can add more time via the account view or website to continue using the VPN.",
52+
comment: "Title for in-app notification, displayed within the last X days until account expiry."
5953
))
6054
)
6155
}
@@ -75,7 +69,7 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN
7569
private func updateTimer() {
7670
timer?.cancel()
7771

78-
guard let triggerDate = accountExpiry.triggerDate else {
72+
guard let triggerDate = accountExpiry.nextTriggerDate(for: .inApp) else {
7973
return
8074
}
8175

@@ -105,3 +99,24 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN
10599
invalidate()
106100
}
107101
}
102+
103+
extension AccountExpiryInAppNotificationProvider {
104+
private var remainingDaysText: String? {
105+
guard
106+
let expiryDate = accountExpiry.expiryDate,
107+
let nextTriggerDate = accountExpiry.nextTriggerDate(for: .inApp),
108+
let duration = CustomDateComponentsFormatting.localizedString(
109+
from: nextTriggerDate,
110+
to: expiryDate,
111+
unitsStyle: .full
112+
)
113+
else { return nil }
114+
115+
return String(format: NSLocalizedString(
116+
"ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_TITLE",
117+
tableName: "AccountExpiry",
118+
value: "%@ left on this account",
119+
comment: "Message for in-app notification, displayed within the last X days until account expiry."
120+
), duration).uppercased()
121+
}
122+
}

ios/MullvadVPN/Notifications/Notification Providers/AccountExpirySystemNotificationProvider.swift

+105-24
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import MullvadSettings
1111
import UserNotifications
1212

1313
final class AccountExpirySystemNotificationProvider: NotificationProvider, SystemNotificationProvider {
14-
private var accountExpiry: Date?
14+
private var accountExpiry = AccountExpiry()
1515
private var tunnelObserver: TunnelBlockObserver?
16+
private var accountHasRecentlyExpired = false
1617

1718
init(tunnelManager: TunnelManager) {
1819
super.init()
@@ -21,8 +22,16 @@ final class AccountExpirySystemNotificationProvider: NotificationProvider, Syste
2122
didLoadConfiguration: { [weak self] tunnelManager in
2223
self?.invalidate(deviceState: tunnelManager.deviceState)
2324
},
24-
didUpdateDeviceState: { [weak self] _, deviceState, _ in
25-
self?.invalidate(deviceState: deviceState)
25+
didUpdateDeviceState: { [weak self] tunnelManager, deviceState, previousDeviceState in
26+
guard let self else { return }
27+
28+
checkAccountExpiry(
29+
tunnelStatus: tunnelManager.tunnelStatus,
30+
deviceState: deviceState,
31+
previousDeviceState: previousDeviceState
32+
)
33+
34+
invalidate(deviceState: tunnelManager.deviceState)
2635
}
2736
)
2837

@@ -38,21 +47,21 @@ final class AccountExpirySystemNotificationProvider: NotificationProvider, Syste
3847
// MARK: - SystemNotificationProvider
3948

4049
var notificationRequest: UNNotificationRequest? {
41-
guard let trigger else { return nil }
50+
let trigger = accountHasRecentlyExpired ? triggerExpiry : triggerCloseToExpiry
51+
52+
guard let trigger, let durationText = formattedRemainingDuration else {
53+
return nil
54+
}
4255

4356
let content = UNMutableNotificationContent()
4457
content.title = NSLocalizedString(
4558
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_TITLE",
4659
tableName: "AccountExpiry",
4760
value: "Account credit expires soon",
48-
comment: "Title for system account expiry notification, fired 3 days prior to account expiry."
49-
)
50-
content.body = NSLocalizedString(
51-
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
52-
tableName: "AccountExpiry",
53-
value: "Account credit expires in 3 days. Buy more credit.",
54-
comment: "Message for system account expiry notification, fired 3 days prior to account expiry."
61+
comment: "Title for system account expiry notification, fired X days prior to account expiry."
5562
)
63+
64+
content.body = durationText
5665
content.sound = UNNotificationSound.default
5766

5867
return UNNotificationRequest(
@@ -74,33 +83,105 @@ final class AccountExpirySystemNotificationProvider: NotificationProvider, Syste
7483

7584
// MARK: - Private
7685

77-
private var trigger: UNNotificationTrigger? {
78-
guard let accountExpiry else { return nil }
86+
private var triggerCloseToExpiry: UNNotificationTrigger? {
87+
guard let triggerDate = accountExpiry.nextTriggerDate(for: .system) else { return nil }
7988

80-
guard let triggerDate = Calendar.current.date(
81-
byAdding: .day,
82-
value: -NotificationConfiguration.closeToExpiryTriggerInterval,
83-
to: accountExpiry
84-
) else { return nil }
89+
let dateComponents = Calendar.current.dateComponents(
90+
[.second, .minute, .hour, .day, .month, .year],
91+
from: triggerDate
92+
)
8593

86-
// Do not produce notification if less than 3 days left till expiry
87-
guard triggerDate > Date() else { return nil }
94+
return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
95+
}
8896

89-
// Create date components for calendar trigger
97+
private var triggerExpiry: UNNotificationTrigger {
9098
let dateComponents = Calendar.current.dateComponents(
9199
[.second, .minute, .hour, .day, .month, .year],
92-
from: triggerDate
100+
from: Date().addingTimeInterval(1) // Giving some leeway.
93101
)
94102

95103
return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
96104
}
97105

98106
private var shouldRemovePendingOrDeliveredRequests: Bool {
99-
accountExpiry == nil
107+
return accountExpiry.expiryDate == nil
108+
}
109+
110+
private func checkAccountExpiry(
111+
tunnelStatus: TunnelStatus,
112+
deviceState: DeviceState,
113+
previousDeviceState: DeviceState
114+
) {
115+
var blockedStateByExpiredAccount = false
116+
if case .accountExpired = tunnelStatus.observedState.blockedState?.reason {
117+
blockedStateByExpiredAccount = true
118+
}
119+
120+
let accountHasExpired = deviceState.accountData?.isExpired == true
121+
let accountHasRecentlyExpired = deviceState.accountData?.isExpired != previousDeviceState.accountData?.isExpired
122+
123+
self.accountHasRecentlyExpired = blockedStateByExpiredAccount && accountHasExpired && accountHasRecentlyExpired
100124
}
101125

102126
private func invalidate(deviceState: DeviceState) {
103-
accountExpiry = deviceState.accountData?.expiry
127+
accountExpiry.expiryDate = deviceState.accountData?.expiry
104128
invalidate()
105129
}
106130
}
131+
132+
extension AccountExpirySystemNotificationProvider {
133+
private var formattedRemainingDuration: String? {
134+
if accountHasRecentlyExpired {
135+
return expiredText
136+
}
137+
138+
switch accountExpiry.daysRemaining(for: .system)?.day {
139+
case .none:
140+
return nil
141+
case 1:
142+
return singleDayText
143+
default:
144+
return multipleDaysText
145+
}
146+
}
147+
148+
private var expiredText: String {
149+
NSLocalizedString(
150+
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
151+
tableName: "AccountExpiry",
152+
value: """
153+
Blocking internet: Your time on this account has expired. To continue using the internet, \
154+
please add more time or disconnect the VPN.
155+
""",
156+
comment: "Message for in-app notification, displayed on account expiry while connected to VPN."
157+
)
158+
}
159+
160+
private var singleDayText: String {
161+
NSLocalizedString(
162+
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
163+
tableName: "AccountExpiry",
164+
value: "You have one day left on this account. Please add more time to continue using the VPN.",
165+
comment: "Message for in-app notification, displayed within the last 1 day until account expiry."
166+
)
167+
}
168+
169+
private var multipleDaysText: String? {
170+
guard
171+
let expiryDate = accountExpiry.expiryDate,
172+
let nextTriggerDate = accountExpiry.nextTriggerDate(for: .system),
173+
let duration = CustomDateComponentsFormatting.localizedString(
174+
from: nextTriggerDate,
175+
to: expiryDate,
176+
unitsStyle: .full
177+
)
178+
else { return nil }
179+
180+
return String(format: NSLocalizedString(
181+
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
182+
tableName: "AccountExpiry",
183+
value: "You have %@ left on this account.",
184+
comment: "Message for in-app notification, displayed within the last X days until account expiry."
185+
), duration.lowercased())
186+
}
187+
}

ios/MullvadVPN/Notifications/Notification Providers/NotificationConfiguration.swift

+9-3
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ import Foundation
1010

1111
enum NotificationConfiguration {
1212
/**
13-
Duration measured in days, before the account expiry, when notification is scheduled to remind user to add more
14-
time on account.
13+
Duration measured in days, before the account expiry, when a system notification is scheduled to remind user
14+
to add more time on account.
1515
*/
16-
static let closeToExpiryTriggerInterval = 3
16+
static let closeToExpirySystemTriggerIntervals = [3, 1]
17+
18+
/**
19+
Duration measured in days, before the account expiry, when an in-app notification is scheduled to remind user
20+
to add more time on account.
21+
*/
22+
static let closeToExpiryInAppTriggerIntervals: [Int] = [3, 2, 1, 0]
1723

1824
/**
1925
Time interval measured in seconds at which to refresh account expiry in-app notification, which reformats

0 commit comments

Comments
 (0)