Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix out-of-time notifications #6679

Merged
merged 1 commit into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,86 @@
//

import Foundation
import MullvadTypes

struct AccountExpiry {
enum Trigger {
case system, inApp

var dateIntervals: [Int] {
switch self {
case .system:
NotificationConfiguration.closeToExpirySystemTriggerIntervals
case .inApp:
NotificationConfiguration.closeToExpiryInAppTriggerIntervals
}
}
}

private let calendar = Calendar.current

var expiryDate: Date?

var triggerDate: Date? {
guard let expiryDate else { return nil }
func nextTriggerDate(for trigger: Trigger) -> Date? {
let now = Date().secondsPrecision
let triggerDates = triggerDates(for: trigger)

return Calendar.current.date(
byAdding: .day,
value: -NotificationConfiguration.closeToExpiryTriggerInterval,
to: expiryDate
// Get earliest trigger date and remove one day. Since we want to count whole days, If first
// notification should trigger 3 days before account expiry, we need to start checking when
// there's (less than) 4 days left.
guard
let expiryDate,
let earliestDate = triggerDates.min(),
let earliestTriggerDate = calendar.date(byAdding: .day, value: -1, to: earliestDate),
now <= expiryDate.secondsPrecision,
now > earliestTriggerDate.secondsPrecision
else { return nil }

let datesByTimeToTrigger = triggerDates.filter { date in
now.secondsPrecision <= date.secondsPrecision // Ignore dates that have passed.
}.sorted { date1, date2 in
abs(date1.timeIntervalSince(now)) < abs(date2.timeIntervalSince(now))
}

return datesByTimeToTrigger.first
}

func daysRemaining(for trigger: Trigger) -> DateComponents? {
let nextTriggerDate = nextTriggerDate(for: trigger)
guard let expiryDate, let nextTriggerDate else { return nil }

let dateComponents = calendar.dateComponents(
[.day],
from: Date().secondsPrecision,
to: max(nextTriggerDate, expiryDate).secondsPrecision
)

return dateComponents
}

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

guard
let expiryDate,
let triggerDate,
let duration = CustomDateComponentsFormatting.localizedString(
from: now,
to: expiryDate,
unitsStyle: .full
),
now >= triggerDate,
now < expiryDate
else {
return nil
let dates = trigger.dateIntervals.compactMap {
calendar.date(
byAdding: .day,
value: -$0,
to: expiryDate
)
}

return duration
return dates
}
}

private extension Date {
// Used to compare dates with a precision of a minimum of seconds.
var secondsPrecision: Date {
let dateComponents = Calendar.current.dateComponents(
[.second, .minute, .hour, .day, .month, .year, .calendar],
from: self
)

return dateComponents.date ?? self
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN
didLoadConfiguration: { [weak self] tunnelManager in
self?.invalidate(deviceState: tunnelManager.deviceState)
},
didUpdateTunnelStatus: { [weak self] tunnelManager, _ in
self?.invalidate(deviceState: tunnelManager.deviceState)
},
didUpdateDeviceState: { [weak self] _, deviceState, _ in
self?.invalidate(deviceState: deviceState)
}
Expand All @@ -38,44 +41,34 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN
// MARK: - InAppNotificationProvider

var notificationDescriptor: InAppNotificationDescriptor? {
guard let duration = accountExpiry.formattedDuration else {
guard let durationText = remainingDaysText else {
return nil
}

return InAppNotificationDescriptor(
identifier: identifier,
style: .warning,
title: NSLocalizedString(
"ACCOUNT_EXPIRY_INAPP_NOTIFICATION_TITLE",
value: "ACCOUNT CREDIT EXPIRES SOON",
comment: "Title for in-app notification, displayed within the last 3 days until account expiry."
),
body: .init(string: String(
format: NSLocalizedString(
"ACCOUNT_EXPIRY_INAPP_NOTIFICATION_BODY",
value: "%@ left. Buy more credit.",
comment: "Message for in-app notification, displayed within the last 3 days until account expiry."
), duration
title: durationText,
body: NSAttributedString(string: NSLocalizedString(
"ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_BODY",
value: "You can add more time via the account view or website to continue using the VPN.",
comment: "Title for in-app notification, displayed within the last X days until account expiry."
))
)
}

// MARK: - Private

private func invalidate(deviceState: DeviceState) {
updateExpiry(deviceState: deviceState)
accountExpiry.expiryDate = deviceState.accountData?.expiry
updateTimer()
invalidate()
}

private func updateExpiry(deviceState: DeviceState) {
accountExpiry.expiryDate = deviceState.accountData?.expiry
}

private func updateTimer() {
timer?.cancel()

guard let triggerDate = accountExpiry.triggerDate else {
guard let triggerDate = accountExpiry.nextTriggerDate(for: .inApp) else {
return
}

Expand Down Expand Up @@ -105,3 +98,24 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN
invalidate()
}
}

extension AccountExpiryInAppNotificationProvider {
private var remainingDaysText: String? {
guard
let expiryDate = accountExpiry.expiryDate,
let nextTriggerDate = accountExpiry.nextTriggerDate(for: .inApp),
let duration = CustomDateComponentsFormatting.localizedString(
from: nextTriggerDate,
to: expiryDate,
unitsStyle: .full
)
else { return nil }

return String(format: NSLocalizedString(
"ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_TITLE",
tableName: "AccountExpiry",
value: "%@ left on this account",
comment: "Message for in-app notification, displayed within the last X days until account expiry."
), duration).uppercased()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import MullvadSettings
import UserNotifications

final class AccountExpirySystemNotificationProvider: NotificationProvider, SystemNotificationProvider {
private var accountExpiry: Date?
private var accountExpiry = AccountExpiry()
private var tunnelObserver: TunnelBlockObserver?
private var accountHasExpired = false

init(tunnelManager: TunnelManager) {
super.init()
Expand All @@ -21,8 +22,16 @@ final class AccountExpirySystemNotificationProvider: NotificationProvider, Syste
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
self?.invalidate(deviceState: deviceState)
if self?.accountHasExpired == false {
self?.invalidate(deviceState: deviceState)
}
}
)

Expand All @@ -38,22 +47,16 @@ final class AccountExpirySystemNotificationProvider: NotificationProvider, Syste
// MARK: - SystemNotificationProvider

var notificationRequest: UNNotificationRequest? {
guard let trigger else { return nil }
let trigger = accountHasExpired ? triggerExpiry : triggerCloseToExpiry

guard let trigger, let formattedRemainingDurationBody else {
return nil
}

let content = UNMutableNotificationContent()
content.title = NSLocalizedString(
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_TITLE",
tableName: "AccountExpiry",
value: "Account credit expires soon",
comment: "Title for system account expiry notification, fired 3 days prior to account expiry."
)
content.body = NSLocalizedString(
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
tableName: "AccountExpiry",
value: "Account credit expires in 3 days. Buy more credit.",
comment: "Message for system account expiry notification, fired 3 days prior to account expiry."
)
content.sound = UNNotificationSound.default
content.title = formattedRemainingDurationTitle
content.body = formattedRemainingDurationBody
content.sound = .default

return UNNotificationRequest(
identifier: identifier.domainIdentifier,
Expand All @@ -74,33 +77,118 @@ final class AccountExpirySystemNotificationProvider: NotificationProvider, Syste

// MARK: - Private

private var trigger: UNNotificationTrigger? {
guard let accountExpiry else { return nil }
private var triggerCloseToExpiry: UNNotificationTrigger? {
guard let triggerDate = accountExpiry.nextTriggerDate(for: .system) else { return nil }

guard let triggerDate = Calendar.current.date(
byAdding: .day,
value: -NotificationConfiguration.closeToExpiryTriggerInterval,
to: accountExpiry
) else { return nil }
let dateComponents = Calendar.current.dateComponents(
[.second, .minute, .hour, .day, .month, .year],
from: triggerDate
)

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

// Create date components for calendar trigger
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: triggerDate
from: Date().addingTimeInterval(1)
)

return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
}

private var shouldRemovePendingOrDeliveredRequests: Bool {
accountExpiry == nil
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 = deviceState.accountData?.expiry
accountExpiry.expiryDate = deviceState.accountData?.expiry
invalidate()
}
}

extension AccountExpirySystemNotificationProvider {
private var formattedRemainingDurationTitle: String {
accountHasExpired
? NSLocalizedString(
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_TITLE",
tableName: "AccountExpiry",
value: "Account credit has expired",
comment: "Title for system account expiry notification, fired on account expiry."
)
: NSLocalizedString(
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_TITLE",
tableName: "AccountExpiry",
value: "Account credit expires soon",
comment: "Title for system account expiry notification, fired X days prior to account expiry."
)
}

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(
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
tableName: "AccountExpiry",
value: """
Blocking internet: Your time on this account has expired. To continue using the internet, \
please add more time or disconnect the VPN.
""",
comment: "Message for in-app notification, displayed on account expiry while connected to VPN."
)
}

private var singleDayText: String {
NSLocalizedString(
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
tableName: "AccountExpiry",
value: "You have one day left on this account. Please add more time to continue using the VPN.",
comment: "Message for in-app notification, displayed within the last 1 day until account expiry."
)
}

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(
"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY",
tableName: "AccountExpiry",
value: "You have %@ left on this account.",
comment: "Message for in-app notification, displayed within the last X days until account expiry."
), duration.lowercased())
}
}
Loading
Loading