Skip to content

Commit

Permalink
Fix out-of-time notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon Petersson committed Sep 17, 2024
1 parent aa61b9a commit fbb3e81
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 91 deletions.
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 @@ -38,24 +38,18 @@ 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."
))
)
}
Expand All @@ -75,7 +69,7 @@ final class AccountExpiryInAppNotificationProvider: NotificationProvider, InAppN
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 +99,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 accountHasRecentlyExpired = 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)
},
didUpdateDeviceState: { [weak self] _, deviceState, _ in
self?.invalidate(deviceState: deviceState)
didUpdateDeviceState: { [weak self] tunnelManager, deviceState, previousDeviceState in
guard let self else { return }

checkAccountExpiry(
tunnelStatus: tunnelManager.tunnelStatus,
deviceState: deviceState,
previousDeviceState: previousDeviceState
)

invalidate(deviceState: tunnelManager.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 = accountHasRecentlyExpired ? 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,131 @@ 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
}

// Adding extra security here to make sure we don't accidentally spam users with notifications.
//
// Basically making sure that:
// 1. Blocked state is because of account expiry, so that we don't trigger for any kind of blocked state.
// 2. Current state is blocked state.
// 3. Previous expiry state is not the same as current expiry state. This last check can't decide alone if
// we went from expired -> not expired, or the other way around. That's why we combine 2) and 3).
private func checkAccountExpiry(
tunnelStatus: TunnelStatus,
deviceState: DeviceState,
previousDeviceState: DeviceState
) {
var blockedStateByExpiredAccount = false
if case .accountExpired = tunnelStatus.observedState.blockedState?.reason {
blockedStateByExpiredAccount = true
}

let accountHasExpired = deviceState.accountData?.isExpired == true
let accountHasRecentlyExpired = accountHasExpired != previousDeviceState.accountData?.isExpired

self.accountHasRecentlyExpired = blockedStateByExpiredAccount && accountHasExpired && accountHasRecentlyExpired
}

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

extension AccountExpirySystemNotificationProvider {
private var formattedRemainingDurationTitle: String {
accountHasRecentlyExpired
? 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? {
if accountHasRecentlyExpired {
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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ import Foundation

enum NotificationConfiguration {
/**
Duration measured in days, before the account expiry, when notification is scheduled to remind user to add more
time on account.
Duration measured in days, before the account expiry, when a system notification is scheduled to remind user
to add more time on account.
*/
static let closeToExpiryTriggerInterval = 3
static let closeToExpirySystemTriggerIntervals = [3, 1]

/**
Duration measured in days, before the account expiry, when an in-app notification is scheduled to remind user
to add more time on account.
*/
static let closeToExpiryInAppTriggerIntervals: [Int] = [3, 2, 1, 0]

/**
Time interval measured in seconds at which to refresh account expiry in-app notification, which reformats
Expand Down
Loading

0 comments on commit fbb3e81

Please sign in to comment.