Proper VPN simulator with notifications, etc.

This commit is contained in:
relikd
2020-07-27 17:50:15 +02:00
parent a2b0f311d5
commit 01523b250f
10 changed files with 257 additions and 188 deletions

View File

@@ -0,0 +1,43 @@
import Foundation
import UserNotifications
struct CachedConnectionAlert {
let enabled: Bool
let invertedMode: Bool
let listBlocked, listCustomA, listCustomB, listElse: Bool
let tone: AnyObject?
init() {
enabled = PrefsShared.ConnectionAlerts.Enabled
guard #available(iOS 10.0, *), enabled else {
invertedMode = false
listBlocked = false
listCustomA = false
listCustomB = false
listElse = false
tone = nil
return
}
invertedMode = PrefsShared.ConnectionAlerts.ExcludeMode
listBlocked = PrefsShared.ConnectionAlerts.Lists.Blocked
listCustomA = PrefsShared.ConnectionAlerts.Lists.CustomA
listCustomB = PrefsShared.ConnectionAlerts.Lists.CustomB
listElse = PrefsShared.ConnectionAlerts.Lists.Else
tone = UNNotificationSound.from(string: PrefsShared.ConnectionAlerts.Sound)
}
/// If notifications are enabled and allowed, schedule new notification. Otherwise NOOP.
/// - Parameters:
/// - domain: Domain will be used as unique identifier for noticiation center and in notification message.
/// - blck: Indicator whether `domain` is part of `blocked` list
/// - custA: Indicator whether `domain` is part of custom list `A`
/// - custB: Indicator whether `domain` is part of custom list `B`
func postOrIgnore(_ domain: String, blck: Bool, custA: Bool, custB: Bool) {
if #available(iOS 10.0, *), enabled {
let onAnyList = listBlocked && blck || listCustomA && custA || listCustomB && custB || listElse
if invertedMode ? !onAnyList : onAnyList {
PushNotification.scheduleConnectionAlert(domain, sound: tone as! UNNotificationSound?)
}
}
}
}

View File

@@ -0,0 +1,153 @@
import UserNotifications
struct PushNotification {
enum Identifier: String {
case YouShallRecordMoreReminder
case CantStopMeNowReminder
case RestInPeaceTombstone
case AllConnectionAlertNotifications
}
static func allowed(_ closure: @escaping (NotificationRequestState) -> Void) {
guard #available(iOS 10, *) else { return }
UNUserNotificationCenter.current().getNotificationSettings { settings in
let state = NotificationRequestState(settings.authorizationStatus)
DispatchQueue.main.async {
closure(state)
}
}
}
/// Available in iOS 12+
static func requestProvisionalOrDoNothing(_ closure: @escaping (Bool) -> Void) {
guard #available(iOS 12, *) else { return closure(false) }
let opt: UNAuthorizationOptions = [.alert, .sound, .badge, .provisional, .providesAppNotificationSettings]
UNUserNotificationCenter.current().requestAuthorization(options: opt) { granted, _ in
DispatchQueue.main.async {
closure(granted)
}
}
}
static func requestAuthorization(_ closure: @escaping (Bool) -> Void) {
guard #available(iOS 10, *) else { return }
var opt: UNAuthorizationOptions = [.alert, .sound, .badge]
if #available(iOS 12, *) {
opt.formUnion(.providesAppNotificationSettings)
}
UNUserNotificationCenter.current().requestAuthorization(options: opt) { granted, _ in
DispatchQueue.main.async {
closure(granted)
}
}
}
static func hasPending(_ ident: Identifier, _ closure: @escaping (Bool) -> Void) {
guard #available(iOS 10, *) else { return }
UNUserNotificationCenter.current().getPendingNotificationRequests {
let hasIt = $0.contains { $0.identifier == ident.rawValue }
DispatchQueue.main.async {
closure(hasIt)
}
}
}
static func cancel(_ ident: Identifier, keepDelivered: Bool = false) {
guard #available(iOS 10, *) else { return }
let center = UNUserNotificationCenter.current()
guard ident != .AllConnectionAlertNotifications else {
// remove all connection alert notifications while
// keeping general purpose reminder notifications
center.getDeliveredNotifications {
var list = $0.map { $0.request.identifier }
list.removeAll { !$0.contains(".") } // each domain (or IP) has a dot
center.removeDeliveredNotifications(withIdentifiers: list)
// no need to do the same for pending since con-alerts are always immediate
}
return
}
center.removePendingNotificationRequests(withIdentifiers: [ident.rawValue])
if !keepDelivered {
center.removeDeliveredNotifications(withIdentifiers: [ident.rawValue])
}
}
@available(iOS 10.0, *)
static func schedule(_ ident: Identifier, content: UNNotificationContent, trigger: UNNotificationTrigger? = nil, waitUntilDone: Bool = false) {
schedule(ident.rawValue, content: content, trigger: trigger, waitUntilDone: waitUntilDone)
}
@available(iOS 10.0, *)
static func schedule(_ ident: String, content: UNNotificationContent, trigger: UNNotificationTrigger? = nil, waitUntilDone: Bool = false) {
let req = UNNotificationRequest(identifier: ident, content: content, trigger: trigger)
waitUntilDone ? req.pushAndWait() : req.push()
}
}
// MARK: - Reminder Alerts
extension PushNotification {
/// Auto-check preferences whether `withText` is set, then schedule notification to 5 min in the future.
static func scheduleRestartReminderBanner() {
guard #available(iOS 10, *), PrefsShared.RestartReminder.WithText else { return }
schedule(.CantStopMeNowReminder,
content: .make("AppCheck disabled",
body: "AppCheck can't monitor network traffic because VPN has stopped.",
sound: .from(string: PrefsShared.RestartReminder.Sound)),
trigger: .make(Date(timeIntervalSinceNow: 5 * 60)),
waitUntilDone: true)
}
/// Auto-check preferences whether `withBadge` is set, then post badge immediatelly.
/// - Parameter on: If `true`, set `1` on app icon. If `false`, remove badge on app icon.
static func scheduleRestartReminderBadge(on: Bool) {
guard #available(iOS 10, *), PrefsShared.RestartReminder.WithBadge else { return }
schedule(.RestInPeaceTombstone, content: .makeBadge(on ? 1 : 0), waitUntilDone: true)
}
}
// MARK: - Connection Alerts
extension PushNotification {
static private let queue = ThrottledBatchQueue<String>(0.5, using: .init(label: "PSINotificationQueue", qos: .default, target: .global()))
/// Post new notification with given domain name. If notification already exists, increase occurrence count.
/// - Parameter domain: Used in the description and as notification identifier.
@available(iOS 10.0, *)
static func scheduleConnectionAlert(_ domain: String, sound: UNNotificationSound?) {
queue.addDelayed(domain) { batch in
let groupSum = batch.reduce(into: [:]) { $0[$1] = ($0[$1] ?? 0) + 1 }
scheduleConnectionAlertMulti(groupSum, sound: sound)
}
}
/// Internal method to post a batch of counted domains.
@available(iOS 10.0, *)
static private func scheduleConnectionAlertMulti(_ group: [String: Int], sound: UNNotificationSound?) {
UNUserNotificationCenter.current().getDeliveredNotifications { delivered in
for (dom, count) in group {
let num: Int
if let prev = delivered.first(where: { $0.request.identifier == dom })?.request.content {
if let p = prev.body.split(separator: "×").first, let i = Int(p) {
num = count + i
} else {
num = count + 1
}
} else {
num = count
}
schedule(dom, content: .make("DNS connection", body: num > 1 ? "\(num)× \(dom)" : dom, sound: sound))
}
}
}
}

View File

@@ -0,0 +1,28 @@
import UserNotifications
extension PushNotification {
static func scheduleRecordingReminder(force: Bool) {
if force {
scheduleRecordingReminder()
} else {
hasPending(.YouShallRecordMoreReminder) {
if !$0 { scheduleRecordingReminder() }
}
}
}
private static func scheduleRecordingReminder() {
guard #available(iOS 10, *) else { return }
let now = Timestamp.now()
var next = RecordingsDB.lastTimestamp() ?? (now - 1)
while next < now {
next += .days(14)
}
schedule(.YouShallRecordMoreReminder,
content: .make("Start new recording",
body: "It's been a while since your last recording …",
sound: .from(string: Prefs.RecordingReminder.Sound)),
trigger: .make(Date(next)))
}
}

View File

@@ -0,0 +1,83 @@
import UserNotifications
enum NotificationRequestState {
case NotDetermined, Denied, Authorized, Provisional
@available(iOS 10.0, *)
init(_ from: UNAuthorizationStatus) {
switch from {
case .denied: self = .Denied
case .authorized: self = .Authorized
case .provisional: self = .Provisional
case .notDetermined: fallthrough
@unknown default: self = .NotDetermined
}
}
}
@available(iOS 10.0, *)
extension UNNotificationRequest {
func push() {
UNUserNotificationCenter.current().add(self) { error in
if let e = error {
NSLog("[ERROR] Can't add push notification: \(e)")
}
}
}
func pushAndWait() {
let semaphore = DispatchSemaphore(value: 0)
UNUserNotificationCenter.current().add(self) { error in
if let e = error {
NSLog("[ERROR] Can't add push notification: \(e)")
}
semaphore.signal()
}
_ = semaphore.wait(wallTimeout: .distantFuture)
}
}
@available(iOS 10.0, *)
extension UNNotificationContent {
/// - Parameter sound: Use `#default` or `nil` to play the default tone. Use `#mute` to play no tone at all. Else use an `UNNotificationSoundName`.
static func make(_ title: String, body: String, sound: UNNotificationSound? = .default) -> UNNotificationContent {
let x = UNMutableNotificationContent()
// use NSString.localizedUserNotificationString(forKey:arguments:)
x.title = title
x.body = body
x.sound = sound
return x
}
/// - Parameter value: `0` will remove the badge
static func makeBadge(_ value: Int) -> UNNotificationContent {
let x = UNMutableNotificationContent()
x.badge = value as NSNumber?
return x
}
}
@available(iOS 10.0, *)
extension UNNotificationTrigger {
/// Calls `(dateMatching: components, repeats: repeats)`
static func make(_ components: DateComponents, repeats: Bool) -> UNCalendarNotificationTrigger {
UNCalendarNotificationTrigger(dateMatching: components, repeats: repeats)
}
/// Calls `(dateMatching: components(second-year), repeats: false)`
static func make(_ date: Date) -> UNCalendarNotificationTrigger {
let components = Calendar.current.dateComponents([.year,.month,.day,.hour,.minute,.second], from: date)
return UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
}
/// Calls `(timeInterval: time, repeats: repeats)`
static func make(_ time: TimeInterval, repeats: Bool) -> UNTimeIntervalNotificationTrigger {
UNTimeIntervalNotificationTrigger(timeInterval: time, repeats: repeats)
}
}
@available(iOS 10.0, *)
extension UNNotificationSound {
static func from(string: String) -> UNNotificationSound? {
switch string {
case "#mute": return nil
case "#default": return .default
case let name: return .init(named: UNNotificationSoundName(name + ".caf"))
}
}
}