iOS/Sources/Extensions/NotificationContent/NotificationViewController.swift
Zac West 5c104f76e9
Multi-server (#1906)
## Summary
Most, but not all, of the changes necessary to support multi-server throughout the app and all its features.

## Screenshots
| Light | Dark |
| ----- | ---- |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 24](https://user-images.githubusercontent.com/74188/143670011-9b9905ac-1b5b-4a82-b9f3-1490465c4ec5.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 26](https://user-images.githubusercontent.com/74188/143670012-0080230a-8f68-4f34-9691-db9f5e825a83.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 30](https://user-images.githubusercontent.com/74188/143670015-ceeac558-e039-4639-a186-b5001ab418b8.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 29](https://user-images.githubusercontent.com/74188/143670016-d72bb69d-83f5-4197-a742-59d208467258.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 47](https://user-images.githubusercontent.com/74188/143670021-6c90c40f-c2f1-4a33-aad9-da6626e99d9d.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 45](https://user-images.githubusercontent.com/74188/143670024-e99de69d-61d8-4e12-be73-a172242806a0.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 05](https://user-images.githubusercontent.com/74188/143670033-1a41ac7e-d4d1-458b-974e-2efdaf8e2288.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 03](https://user-images.githubusercontent.com/74188/143670049-baf4db64-64db-4bfb-88cf-4930f9e5661b.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 21](https://user-images.githubusercontent.com/74188/143670053-7ec794f1-857c-4ef6-a92a-5318e90ac6b6.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 19](https://user-images.githubusercontent.com/74188/143670056-a6a5207c-3bba-49fc-b5c6-fc6fa8141f9c.png) |

## Any other notes
- Encapsulates all connectivity, token & server-specific knowledge in a Server model object which gets passed around.
- Updates various places throughout the app to know about and use Server rather than accessing said information through non-server-specific methods.
- Visually requests/notes server in places where it's ambiguous. For example, the Open Page widget will gain a subtitle if multiple servers are set up.
- Allows switching which server is shown in the WebViews. Note that this doesn't take into account multi-window support on iPad/macOS yet.

Most things will migrate successfully however adding an additional server causes things like Shortcuts to start erroring requiring you specify which to use in the particular Shortcut.

Future work necessary:
- Model objects currently clobber each other if their identifiers match. For example, both servers having a zone named `home` means one of them wins the fight for which is known to the app.
- Being remotely logged out on any account causes the app to require onboarding again, when instead it should only do that if the last known server is logged out.
2021-11-27 12:33:46 -08:00

182 lines
6.7 KiB
Swift

import Alamofire
import KeychainAccess
import MBProgressHUD
import ObjectMapper
import PromiseKit
import Shared
import UIKit
import UserNotifications
import UserNotificationsUI
class NotificationViewController: UIViewController, UNNotificationContentExtension {
var activeViewController: (UIViewController & NotificationCategory)? {
willSet {
activeViewController?.willMove(toParent: nil)
newValue.flatMap { addChild($0) }
}
didSet {
oldValue?.view.removeFromSuperview()
oldValue?.removeFromParent()
if let viewController = activeViewController {
view.addSubview(viewController.view)
viewController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
viewController.didMove(toParent: self)
} else {
// 0 doesn't adjust size, must be a > check
preferredContentSize.height = .leastNonzeroMagnitude
}
}
}
private static var possibleControllers: [(UIViewController & NotificationCategory).Type] { [
CameraViewController.self,
MapViewController.self,
ImageAttachmentViewController.self,
PlayerAttachmentViewController.self,
] }
private func viewController(
for notification: UNNotification,
api: HomeAssistantAPI,
attachmentURL: URL?,
allowDownloads: Bool = true
) -> Guarantee<(UIViewController & NotificationCategory)?> {
// Try based on current info (e.g. entity_id or attached via service extension)
for controllerType in Self.possibleControllers {
do {
let controller = try controllerType.init(
api: api,
notification: notification,
attachmentURL: attachmentURL
)
return .value(controller)
} catch {
// not valid
}
}
// Try to grab the attachments, in case they failed or were lazy
let shouldDownload: Bool
if Current.isCatalyst {
// catalyst doesn't have access to the system container for the builtin attachments
// however, it _also_ shows the system preview image in all cases, so we don't need to for that too
shouldDownload = attachmentURL == nil
} else {
shouldDownload = true
}
if allowDownloads, shouldDownload {
return firstly {
// potential future optimization: feed the url into e.g. the AVPlayer instance.
// not super straightforward because authentication headers may be needed.
Current.notificationAttachmentManager.downloadAttachment(from: notification.request.content, api: api)
}.then { [self] url in
viewController(for: notification, api: api, attachmentURL: url, allowDownloads: false)
}.recover { _ in
.value(nil)
}
} else {
return .value(nil)
}
}
func didReceive(_ notification: UNNotification) {
let catID = notification.request.content.categoryIdentifier.lowercased()
Current.Log.verbose("Received a notif with userInfo \(notification.request.content.userInfo)")
guard let server = Current.servers.server(for: notification.request.content) else {
Current.Log.info("ignoring push when unable to find server")
return
}
let api = Current.api(for: server)
// we only do it for 'dynamic' or unconfigured existing categories, so we don't stomp old configs
if catID == "dynamic" || extensionContext?.notificationActions.isEmpty == true {
extensionContext?.notificationActions = notification.request.content.userInfoActions
}
activeViewController = NotificationLoadingViewController()
var hud: MBProgressHUD?
viewController(
for: notification,
api: api,
attachmentURL: notification.request.content.attachments.first?.url
).then { [weak self] controller -> Promise<Void> in
self?.activeViewController = controller
guard let controller = controller else {
return .value(())
}
if controller.mediaPlayPauseButtonType == .none, let view = self?.view {
// don't show the HUD for a screen that has pause/play because it already acts like a loading indicator
hud = {
let hud = MBProgressHUD.showAdded(to: view, animated: true)
hud.offset = CGPoint(x: 0, y: -MBProgressMaxOffset + 50)
return hud
}()
}
return controller.start()
}.ensure {
hud?.hide(animated: true)
}.catch { [weak self] error in
Current.Log.error("finally failed: \(error)")
self?.activeViewController = NotificationErrorViewController(error: error)
}
}
var mediaPlayPauseButtonType: UNNotificationContentExtensionMediaPlayPauseButtonType {
activeViewController?.mediaPlayPauseButtonType ?? .none
}
var mediaPlayPauseButtonFrame: CGRect {
CGRect(
x: view.bounds.width / 2.0 - 22,
y: view.bounds.height / 2.0 - 22,
width: 44,
height: 44
)
}
public func mediaPlay() {
activeViewController?.mediaPlay()
}
public func mediaPause() {
activeViewController?.mediaPause()
}
}
protocol NotificationCategory: NSObjectProtocol {
init(api: HomeAssistantAPI, notification: UNNotification, attachmentURL: URL?) throws
func start() -> Promise<Void>
// Implementing this method and returning a button type other that "None" will
// make the notification attempt to draw a play/pause button correctly styled
// for that type.
var mediaPlayPauseButtonType: UNNotificationContentExtensionMediaPlayPauseButtonType { get }
// Implementing this method and returning a non-empty frame will make
// the notification draw a button that allows the user to play and pause
// media content embedded in the notification.
var mediaPlayPauseButtonFrame: CGRect? { get }
// Called when the user taps the play or pause button.
func mediaPlay()
func mediaPause()
}