|
| 1 | +// |
| 2 | +// AppExpirationAlerter.swift |
| 3 | +// Loop |
| 4 | +// |
| 5 | +// Created by Pete Schwamb on 8/21/21. |
| 6 | +// Copyright © 2021 LoopKit Authors. All rights reserved. |
| 7 | +// |
| 8 | + |
| 9 | +import Foundation |
| 10 | +import UserNotifications |
| 11 | +import LoopCore |
| 12 | + |
| 13 | + |
| 14 | +class AppExpirationAlerter { |
| 15 | + |
| 16 | + static let expirationAlertWindow: TimeInterval = .days(20) |
| 17 | + static let settingsPageExpirationWarningModeWindow: TimeInterval = .days(3) |
| 18 | + |
| 19 | + static func alertIfNeeded(viewControllerToPresentFrom: UIViewController) { |
| 20 | + |
| 21 | + let now = Date() |
| 22 | + |
| 23 | + guard let profileExpiration = BuildDetails.default.profileExpiration else { |
| 24 | + return |
| 25 | + } |
| 26 | + |
| 27 | + let expirationDate = calculateExpirationDate(profileExpiration: profileExpiration) |
| 28 | + |
| 29 | + let timeUntilExpiration = expirationDate.timeIntervalSince(now) |
| 30 | + |
| 31 | + if timeUntilExpiration > expirationAlertWindow { |
| 32 | + return |
| 33 | + } |
| 34 | + |
| 35 | + let minimumTimeBetweenAlerts: TimeInterval = timeUntilExpiration > .hours(24) ? .days(2) : .hours(1) |
| 36 | + |
| 37 | + if let lastAlertDate = UserDefaults.appGroup?.lastProfileExpirationAlertDate { |
| 38 | + guard now > lastAlertDate + minimumTimeBetweenAlerts else { |
| 39 | + return |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + let formatter = DateComponentsFormatter() |
| 44 | + formatter.allowedUnits = [.day, .hour] |
| 45 | + formatter.unitsStyle = .full |
| 46 | + formatter.zeroFormattingBehavior = .dropLeading |
| 47 | + formatter.maximumUnitCount = 1 |
| 48 | + let timeUntilExpirationStr = formatter.string(from: timeUntilExpiration) |
| 49 | + |
| 50 | + let alertMessage = createVerboseAlertMessage(timeUntilExpirationStr: timeUntilExpirationStr!) |
| 51 | + |
| 52 | + var dialog: UIAlertController |
| 53 | + if isTestFlightBuild() { |
| 54 | + dialog = UIAlertController( |
| 55 | + title: NSLocalizedString("TestFlight Expires Soon", comment: "The title for notification of upcoming TestFlight expiration"), |
| 56 | + message: alertMessage, |
| 57 | + preferredStyle: .alert) |
| 58 | + dialog.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Text for ok action on notification of upcoming TestFlight expiration"), style: .default, handler: nil)) |
| 59 | + dialog.addAction(UIAlertAction(title: NSLocalizedString("More Info", comment: "Text for more info action on notification of upcoming TestFlight expiration"), style: .default, handler: { (_) in |
| 60 | + UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/gh-actions/gh-update/")!) |
| 61 | + })) |
| 62 | + |
| 63 | + } else { |
| 64 | + dialog = UIAlertController( |
| 65 | + title: NSLocalizedString("Profile Expires Soon", comment: "The title for notification of upcoming profile expiration"), |
| 66 | + message: alertMessage, |
| 67 | + preferredStyle: .alert) |
| 68 | + dialog.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Text for ok action on notification of upcoming profile expiration"), style: .default, handler: nil)) |
| 69 | + dialog.addAction(UIAlertAction(title: NSLocalizedString("More Info", comment: "Text for more info action on notification of upcoming profile expiration"), style: .default, handler: { (_) in |
| 70 | + UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/build/updating/")!) |
| 71 | + })) |
| 72 | + } |
| 73 | + viewControllerToPresentFrom.present(dialog, animated: true, completion: nil) |
| 74 | + |
| 75 | + UserDefaults.appGroup?.lastProfileExpirationAlertDate = now |
| 76 | + } |
| 77 | + |
| 78 | + static func createVerboseAlertMessage(timeUntilExpirationStr:String) -> String { |
| 79 | + if isTestFlightBuild() { |
| 80 | + return String(format: NSLocalizedString("%1$@ will stop working in %2$@. You will need to rebuild before that.", comment: "Format string for body for notification of upcoming expiration. (1: app name) (2: amount of time until expiration"), Bundle.main.bundleDisplayName, timeUntilExpirationStr) |
| 81 | + } else { |
| 82 | + return String(format: NSLocalizedString("%1$@ will stop working in %2$@. You will need to update before that, with a new provisioning profile.", comment: "Format string for body for notification of upcoming provisioning profile expiration. (1: app name) (2: amount of time until expiration"), Bundle.main.bundleDisplayName, timeUntilExpirationStr) |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + static func isNearExpiration(expirationDate:Date) -> Bool { |
| 87 | + return expirationDate.timeIntervalSinceNow < settingsPageExpirationWarningModeWindow |
| 88 | + } |
| 89 | + |
| 90 | + static func createProfileExpirationSettingsMessage(expirationDate:Date) -> String { |
| 91 | + let nearExpiration = isNearExpiration(expirationDate: expirationDate) |
| 92 | + let maxUnitCount = nearExpiration ? 2 : 1 // only include hours in the msg if near expiration |
| 93 | + let readableRelativeTime: String? = relativeTimeFormatter(maxUnitCount: maxUnitCount).string(from: expirationDate.timeIntervalSinceNow) |
| 94 | + let relativeTimeRemaining: String = readableRelativeTime ?? NSLocalizedString("Unknown time", comment: "Unknown amount of time in settings' profile expiration section") |
| 95 | + let verboseMessage = createVerboseAlertMessage(timeUntilExpirationStr: relativeTimeRemaining) |
| 96 | + let conciseMessage = relativeTimeRemaining + NSLocalizedString(" remaining", comment: "remaining time in setting's profile expiration section") |
| 97 | + return nearExpiration ? verboseMessage : conciseMessage |
| 98 | + } |
| 99 | + |
| 100 | + private static func relativeTimeFormatter(maxUnitCount:Int) -> DateComponentsFormatter { |
| 101 | + let formatter = DateComponentsFormatter() |
| 102 | + let includeHours = maxUnitCount == 2 |
| 103 | + formatter.allowedUnits = includeHours ? [.day, .hour] : [.day] |
| 104 | + formatter.unitsStyle = .full |
| 105 | + formatter.zeroFormattingBehavior = .dropLeading |
| 106 | + formatter.maximumUnitCount = maxUnitCount |
| 107 | + return formatter |
| 108 | + } |
| 109 | + |
| 110 | + static func buildDate() -> Date? { |
| 111 | + let dateFormatter = DateFormatter() |
| 112 | + dateFormatter.dateFormat = "EEE MMM d HH:mm:ss 'UTC' yyyy" |
| 113 | + dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Set locale to ensure parsing works |
| 114 | + dateFormatter.timeZone = TimeZone(identifier: "UTC") |
| 115 | + |
| 116 | + guard let dateString = BuildDetails.default.buildDateString, |
| 117 | + let date = dateFormatter.date(from: dateString) else { |
| 118 | + return nil |
| 119 | + } |
| 120 | + |
| 121 | + return date |
| 122 | + } |
| 123 | + |
| 124 | + static func isTestFlightBuild() -> Bool { |
| 125 | + // If the target environment is a simulator, then |
| 126 | + // this is not a TestFlight distribution. Return false. |
| 127 | + #if targetEnvironment(simulator) |
| 128 | + return false |
| 129 | + #endif |
| 130 | + |
| 131 | + // If an "embedded.mobileprovision" is present in the main bundle, then |
| 132 | + // this is an Xcode, Ad-Hoc, or Enterprise distribution. Return false. |
| 133 | + if Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") != nil { |
| 134 | + return false |
| 135 | + } |
| 136 | + |
| 137 | + // If an app store receipt is not present in the main bundle, then we cannot |
| 138 | + // say whether this is a TestFlight or App Store distribution. Return false. |
| 139 | + guard let receiptName = Bundle.main.appStoreReceiptURL?.lastPathComponent else { |
| 140 | + return false |
| 141 | + } |
| 142 | + |
| 143 | + // A TestFlight distribution presents a "sandboxReceipt", while an App Store |
| 144 | + // distribution presents a "receipt". Return true if we have a TestFlight receipt. |
| 145 | + return "sandboxReceipt".caseInsensitiveCompare(receiptName) == .orderedSame |
| 146 | + } |
| 147 | + |
| 148 | + static func calculateExpirationDate(profileExpiration: Date) -> Date { |
| 149 | + let isTestFlight = isTestFlightBuild() |
| 150 | + |
| 151 | + if isTestFlight, let buildDate = buildDate() { |
| 152 | + let testflightExpiration = Calendar.current.date(byAdding: .day, value: 90, to: buildDate)! |
| 153 | + |
| 154 | + return profileExpiration < testflightExpiration ? profileExpiration : testflightExpiration |
| 155 | + } else { |
| 156 | + return profileExpiration |
| 157 | + } |
| 158 | + } |
| 159 | +} |
0 commit comments