summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN/Notifications/Notification Providers/AccountExpirySystemNotificationProvider.swift
blob: 212255c13bec7bb62e1ea19bf9acfd0d6c5a74a0 (plain)
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()
        )
    }
}