iOS/Sources/Extensions/NotificationContent/CameraStreamHLSViewController.swift
Bruno Pantaleão Gonçalves e823624661
Do not default to internal URL when external URL is not available (#2767)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

To avoid using internal URL out of local network, this PR makes
"activeURL" optional, and it will not use internalURL when out of local
network.

Tested the internal/external logic on:
- iPhone
- iPad
- Mac
- Watch
- Shortcuts
- Notifications
- Widgets

For VPN users, they have to set external URL the same as internal URL to
have their setup working.

Pending:
- [x] Verify all edge cases where "guard let" were added
- [ ] Enforce SSID to use internal URL in the App

Next possible iteration:
- Add noise protocol between app and server to make http connections
secure

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->
<img width="1154" alt="Screenshot 2024-05-13 at 11 46 47"
src="https://github.com/home-assistant/iOS/assets/5808343/7ea634cc-382b-49c2-ab64-a218f996452e">

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
2024-11-08 17:07:02 +01:00

205 lines
7.4 KiB
Swift

import AVKit
import Foundation
import PromiseKit
import Shared
import UIKit
class CameraStreamHLSViewController: UIViewController, CameraStreamHandler {
let api: HomeAssistantAPI
let url: URL
let playerViewController: AVPlayerViewController
let promise: Promise<Void>
var didUpdateState: (CameraStreamHandlerState) -> Void = { _ in }
private let seal: Resolver<Void>
private var observationTokens: [NSKeyValueObservation] = []
enum HLSError: LocalizedError {
case noPath
case avPlayer(Error?)
var errorDescription: String? {
switch self {
case .noPath:
return L10n.Extensions.NotificationContent.Error.Request.hlsUnavailable
case let .avPlayer(error):
return error?.localizedDescription ?? L10n.Extensions.NotificationContent.Error.Request.other(-1)
}
}
}
required convenience init(api: HomeAssistantAPI, response: StreamCameraResponse) throws {
guard let path = response.hlsPath else {
throw HLSError.noPath
}
guard let url = api.server.info.connection.activeURL()?.appendingPathComponent(path) else {
throw ServerConnectionError.noActiveURL
}
self.init(api: api, url: url)
}
init(api: HomeAssistantAPI, url: URL) {
self.api = api
self.url = url
self.playerViewController = AVPlayerViewController()
(self.promise, self.seal) = Promise<Void>.pending()
super.init(nibName: nil, bundle: nil)
addChild(playerViewController)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
observationTokens.forEach { $0.invalidate() }
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(playerViewController.view)
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playerViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
playerViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
playerViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
playerViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
playerViewController.didMove(toParent: self)
setupVideo()
}
func pause() {
playerViewController.player?.pause()
}
func play() {
setupVideo()
}
private var aspectRatioConstraint: NSLayoutConstraint? {
willSet {
aspectRatioConstraint?.isActive = false
}
didSet {
aspectRatioConstraint?.isActive = true
}
}
private var lastSize: CGSize? {
didSet {
if oldValue != lastSize, let size = lastSize {
aspectRatioConstraint = NSLayoutConstraint.aspectRatioConstraint(
on: playerViewController.view,
size: size
)
}
}
}
private func setupVideo() {
try? AVAudioSession.sharedInstance().setCategory(.playback)
let asset: AVURLAsset
if !url.isFileURL, api.server.info.connection.securityExceptions.hasExceptions {
asset = .init(url: url, options: [
// from WebKit, which has the same behavioral requirements we have
// see
// https://cs.github.com/WebKit/WebKit/blob/f822d46cdb31d1d3df1915a99c0413acbcb06fd1/Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateAVFoundationObjC.mm?q=resourceloaderdelegate#L894
// without this, we can't load the video content (non-playlists) of the hls stream, which means
// we cannot support security exceptions, because auth challenges do not occur
"AVURLAssetUseClientURLLoadingExclusively": true,
"AVURLAssetRequiresCustomURLLoadingKey": true,
])
} else {
asset = .init(url: url)
}
asset.resourceLoader.setDelegate(self, queue: .main)
let playerItem = AVPlayerItem(asset: asset)
let videoPlayer = AVPlayer(playerItem: playerItem)
playerViewController.player = videoPlayer
// assume 16:9
lastSize = CGSize(width: 16, height: 9)
videoPlayer.play()
observationTokens.append(videoPlayer.observe(\.status) { [weak self] player, _ in
Current.Log.error("player status: \(player.status.rawValue) error: \(String(describing: player.error))")
switch player.status {
case .readyToPlay:
// we won't get a rate update on initial play, but it's _happening_!
// the system UI for loading/spinning will take over from here.
self?.didUpdateState(.playing)
self?.seal.fulfill(())
case .failed:
self?.seal.reject(HLSError.avPlayer(player.error))
case .unknown:
break
@unknown default:
break
}
})
observationTokens.append(videoPlayer.observe(\.rate) { [weak self] player, _ in
// these still fire if the user manually pauses/plays in the video player itself
self?.didUpdateState(player.rate > 0 ? .playing : .paused)
})
observationTokens.append(videoPlayer.observe(\AVPlayer.currentItem?.tracks) { [weak self] item, _ in
let sizes = item.currentItem?
.tracks
.compactMap({ $0.assetTrack?.naturalSize })
.filter {
// hls streams occasionally bounce between (0, 0); (1, 1); and the real size
$0.width > 1 && $0.height > 1
}
self?.lastSize = sizes?.first
})
}
}
extension CameraStreamHLSViewController: AVAssetResourceLoaderDelegate {
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
// this is only invoked when we force custom url handling above, for use with auth challenges, because
// auth challenges do not work in AVFoundation (for as many years as i can find dev forums posts)
// not happening here: taking the loadingRequest.dataRequest.requestedOffset and handling it + requestedLength
api.manager.streamRequest(loadingRequest.request).validate().responseStream(stream: { stream in
switch stream.event {
case let .complete(completion):
// not happening here: contentInformationRequest handling
if let error = completion.error {
loadingRequest.finishLoading(with: error)
} else {
loadingRequest.finishLoading()
}
case let .stream(.success(data)):
loadingRequest.dataRequest?.respond(with: data)
}
})
return true
}
@objc public func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForResponseTo authenticationChallenge: URLAuthenticationChallenge
) -> Bool {
// this method is not invoked in any situation. though it probably should be.
// if this starts working, we can stop doing custom resource loading above
false
}
}