1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
|
//
// AccountExpirySystemNotificationProvider.swift
// MullvadVPN
//
// Created by pronebird on 03/06/2021.
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
import Foundation
import MullvadSettings
import UserNotifications
final class AccountExpirySystemNotificationProvider: NotificationProvider, SystemNotificationProvider,
@unchecked Sendable
{
private var accountExpiry = AccountExpiry()
private var tunnelObserver: TunnelBlockObserver?
private var accountHasExpired = false
init(tunnelManager: TunnelManager) {
super.init()
let tunnelObserver = TunnelBlockObserver(
didLoadConfiguration: { [weak self] tunnelManager in
self?.invalidate(deviceState: tunnelManager.deviceState)
},
didUpdateTunnelStatus: { [weak self] tunnelManager, _ in
self?.checkAccountExpiry(
tunnelStatus: tunnelManager.tunnelStatus,
deviceState: tunnelManager.deviceState
)
},
didUpdateDeviceState: { [weak self] _, deviceState, _ in
if self?.accountHasExpired == false {
self?.invalidate(deviceState: deviceState)
}
}
)
tunnelManager.addObserver(tunnelObserver)
self.tunnelObserver = tunnelObserver
}
override var identifier: NotificationProviderIdentifier {
.accountExpirySystemNotification
}
override var priority: NotificationPriority {
.high
}
// MARK: - SystemNotificationProvider
var notificationRequest: UNNotificationRequest? {
let trigger = accountHasExpired ? triggerExpiry : triggerCloseToExpiry
guard let trigger, let formattedRemainingDurationBody else {
return nil
}
let content = UNMutableNotificationContent()
content.title = formattedRemainingDurationTitle
content.body = formattedRemainingDurationBody
content.sound = .default
return UNNotificationRequest(
identifier: identifier.domainIdentifier,
content: content,
trigger: trigger
)
}
var shouldRemovePendingRequests: Bool {
// Remove pending notifications when account expiry is not set (user logged out)
shouldRemovePendingOrDeliveredRequests
}
var shouldRemoveDeliveredRequests: Bool {
// Remove delivered notifications when account expiry is not set (user logged out)
shouldRemovePendingOrDeliveredRequests
}
// MARK: - Private
private var triggerCloseToExpiry: UNNotificationTrigger? {
guard let triggerDate = accountExpiry.nextTriggerDate(for: .system) else { return nil }
let dateComponents = Calendar.current.dateComponents(
[.second, .minute, .hour, .day, .month, .year],
from: triggerDate
)
return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
}
private var triggerExpiry: UNNotificationTrigger {
// When scheduling a user notification we need to make sure that the date has not passed
// when it's actually added to the system. Giving it a one second leeway lets us be sure
// that this is the case.
let dateComponents = Calendar.current.dateComponents(
[.second, .minute, .hour, .day, .month, .year],
from: Date().addingTimeInterval(1)
)
return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
}
private var shouldRemovePendingOrDeliveredRequests: Bool {
return accountExpiry.expiryDate == nil
}
private func checkAccountExpiry(tunnelStatus: TunnelStatus, deviceState: DeviceState) {
if !accountHasExpired {
if case .accountExpired = tunnelStatus.observedState.blockedState?.reason {
accountHasExpired = true
}
if accountHasExpired {
invalidate(deviceState: deviceState)
}
}
}
private func invalidate(deviceState: DeviceState) {
accountExpiry.expiryDate = deviceState.accountData?.expiry
invalidate()
}
}
extension AccountExpirySystemNotificationProvider {
private var formattedRemainingDurationTitle: String {
accountHasExpired
? NSLocalizedString("Account credit has expired", comment: "")
: NSLocalizedString("Account credit expires soon", comment: "")
}
private var formattedRemainingDurationBody: String? {
guard !accountHasExpired else { return expiredText }
switch accountExpiry.daysRemaining(for: .system)?.day {
case .none:
return nil
case 1:
return singleDayText
default:
return multipleDaysText
}
}
private var expiredText: String {
NSLocalizedString(
"Blocking internet: Your time on this account has expired. "
+ "To continue using the internet, please add more time or disconnect the VPN.",
comment: ""
)
}
private var singleDayText: String {
NSLocalizedString(
"You have one day left on this account. Please add more time to continue using the VPN.",
comment: ""
)
}
private var multipleDaysText: String? {
guard
let expiryDate = accountExpiry.expiryDate,
let nextTriggerDate = accountExpiry.nextTriggerDate(for: .system),
let duration = CustomDateComponentsFormatting.localizedString(
from: nextTriggerDate,
to: expiryDate,
unitsStyle: .full
)
else { return nil }
return String(
format: NSLocalizedString("You have %@ left on this account.", comment: ""),
duration.lowercased()
)
}
}
|