Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ enum WebViewExternalBusOutgoingMessage: String, CaseIterable {
case improvDiscoveredDevice = "improv/discovered_device"
case improvDiscoveredDeviceSetupDone = "improv/device_setup_done"
case navigate = "navigate"
case matterCommissionFinish = "matter/commission/finish"
}
Original file line number Diff line number Diff line change
Expand Up @@ -471,16 +471,31 @@ final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessag
Current.Log.error("WebViewController not available while commissioning matter device")
return
}
Current.matter.commission(webViewController.server).done {
Current.Log.info("Commission call completed")
Current.matter.commission(webViewController.server).done { [weak self] deviceName in
Current.Log.info("Commission call completed with device name: \(String(describing: deviceName))")
guard let deviceName else {
Current.Log.error("Matter commission completed without a device name")
return
}
self?.communicateMatterCommissioningFinished(deviceName: deviceName, success: true)
}.catch { [weak self] error in
// we don't show a user-visible error because even a successful operation will return 'cancelled'
// but the errors aren't public, so we can't compare -- the apple ui shows errors visually though
Current.Log.error(error)
self?.webViewController?.refresh()
self?.communicateMatterCommissioningFinished(deviceName: nil, success: false)
}
}

private func communicateMatterCommissioningFinished(deviceName: String?, success: Bool) {
sendExternalBus(message: .init(
command: WebViewExternalBusOutgoingMessage.matterCommissionFinish.rawValue,
payload: [
"name": deviceName,
"success": success,
]
))
}

func showAssist(server: Server, pipeline: String = "", autoStartRecording: Bool = false) {
if AssistSession.shared.inProgress {
AssistSession.shared.requestNewSession(.init(
Expand Down
2 changes: 1 addition & 1 deletion Sources/Extensions/Matter/MatterRequestHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,6 @@ class MatterRequestHandler: MatterAddDeviceExtensionRequestHandler {
}

override func configureDevice(named name: String, in room: MatterAddDeviceRequest.Room?) async {
// Use this function to configure the (now) commissioned device with the given name and room.
Current.settingsStore.matterLastCommissionedDeviceName = name
}
}
15 changes: 10 additions & 5 deletions Sources/Shared/Environment/MatterWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,33 +49,38 @@ public class MatterWrapper {
set { Current.settingsStore.prefs.set(newValue?.rawValue, forKey: "lastCommissionServerID") }
}

public lazy var commission: (_ server: Server) -> Promise<Void> = { [self] server in
public lazy var commission: (_ server: Server) -> Promise<String?> = { [self] server in
#if canImport(MatterSupport)
guard #available(iOS 16.4, *) else {
return .value(())
return .value(nil)
}

lastCommissionServerIdentifier = server.identifier
Current.settingsStore.matterLastCommissionedDeviceName = nil

let request = MatterAddDeviceRequest(
topology: .init(ecosystemName: "Home Assistant", homes: []),
shouldScanNetworks: true
)

return Promise<Void> { seal in
return Promise<String?> { seal in
Task {
do {
try await request.perform()
let deviceName = Current.settingsStore.matterLastCommissionedDeviceName
// Reset device name after reading it, so that if the user tries to pair another device without
// going through the flow again, we won't have a stale name hanging around
Current.settingsStore.matterLastCommissionedDeviceName = nil
Current.Log.info("Matter pairing finished (native flow manually closed or pairing succeeded)")
seal.fulfill(())
seal.fulfill(deviceName)
} catch {
Current.Log.error("Matter pairing failed: \(error)")
seal.reject(error)
}
}
}
#else
return .value(())
return .value(nil)
#endif
}
#endif
Expand Down
9 changes: 9 additions & 0 deletions Sources/Shared/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ public class SettingsStore {
}
}

public var matterLastCommissionedDeviceName: String? {
get {
keychain["matterLastCommissionedDeviceName"]
}
set {
keychain["matterLastCommissionedDeviceName"] = newValue
}
}

public func isLocationEnabled(for state: UIApplication.State) -> Bool {
let authorizationStatus: CLAuthorizationStatus

Expand Down
3 changes: 3 additions & 0 deletions Tests/App/WebView/Mocks/MockWebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
@testable import HomeAssistant
import Shared
import UIKit
import XCTest

final class MockWebViewController: WebViewControllerProtocol {
var webViewExternalMessageHandler: WebViewExternalMessageHandlerProtocol
Expand All @@ -17,6 +18,7 @@ final class MockWebViewController: WebViewControllerProtocol {
var evaluateJavaScriptCalled = false
var lastEvaluatedJavaScriptScript: String?
var lastEvaluatedJavaScriptCompletion: ((Any?, (any Error)?) -> Void)?
var evaluateJavaScriptExpectation: XCTestExpectation?
var dismissControllerAboveOverlayControllerCalled = false
var dismissOverlayControllerCalled = false
var dismissOverlayControllerLastAnimated = false
Expand Down Expand Up @@ -69,6 +71,7 @@ final class MockWebViewController: WebViewControllerProtocol {
evaluateJavaScriptCalled = true
lastEvaluatedJavaScriptScript = script
lastEvaluatedJavaScriptCompletion = completion
evaluateJavaScriptExpectation?.fulfill()
}

func dismissOverlayController(animated: Bool, completion: (() -> Void)?) {
Expand Down
6 changes: 5 additions & 1 deletion Tests/App/WebView/WebViewExternalBusMessageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ final class WebViewExternalBusMessageTests: XCTestCase {
WebViewExternalBusOutgoingMessage.navigate.rawValue,
"navigate"
)
XCTAssertEqual(
WebViewExternalBusOutgoingMessage.matterCommissionFinish.rawValue,
"matter/commission/finish"
)

XCTAssertEqual(WebViewExternalBusOutgoingMessage.allCases.count, 7)
XCTAssertEqual(WebViewExternalBusOutgoingMessage.allCases.count, 8)
}

@MainActor func testConfigResultIncludesAllExpectedKeys() {
Expand Down
46 changes: 46 additions & 0 deletions Tests/App/WebView/WebViewExternalMessageHandlerTests.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
@testable import HomeAssistant
import Improv_iOS
import PromiseKit
@testable import Shared
import SwiftUI
import XCTest

final class WebViewExternalMessageHandlerTests: XCTestCase {
private var sut: WebViewExternalMessageHandler!
private var mockWebViewController: MockWebViewController!
private var originalMatterCommission: ((Server) -> Promise<String?>)!

override func setUp() async throws {
originalMatterCommission = Current.matter.commission
mockWebViewController = MockWebViewController()
sut = WebViewExternalMessageHandler(
improvManager: ImprovManager.shared
)
sut.webViewController = mockWebViewController
}

override func tearDown() async throws {
Current.matter.commission = originalMatterCommission
originalMatterCommission = nil
sut = nil
mockWebViewController = nil
}

@MainActor func testHandleExternalMessageConfigScreenShowShowSettings() {
let dictionary: [String: Any] = [
"id": 1,
Expand Down Expand Up @@ -157,6 +168,31 @@ final class WebViewExternalMessageHandlerTests: XCTestCase {
XCTAssertEqual(mockWebViewController.overlayedController?.view.backgroundColor, .clear)
}

@MainActor func testHandleExternalMessageMatterCommissionSendsFinishMessageWithDeviceName() throws {
let deviceName = "Kitchen Plug"
let expectation = expectation(description: "Matter commission finish message sent")
mockWebViewController.evaluateJavaScriptExpectation = expectation
Current.matter.commission = { _ in .value(deviceName) }

let dictionary: [String: Any] = [
"id": 1,
"message": "",
"command": "",
"type": "matter/commission",
]

sut.handleExternalMessage(dictionary)

wait(for: [expectation], timeout: 1)
let script = try XCTUnwrap(mockWebViewController.lastEvaluatedJavaScriptScript)
let message = try externalBusMessage(from: script)
let payload = try XCTUnwrap(message["payload"] as? [String: Any])

XCTAssertEqual(message["type"] as? String, "command")
XCTAssertEqual(message["command"] as? String, WebViewExternalBusOutgoingMessage.matterCommissionFinish.rawValue)
XCTAssertEqual(payload["name"] as? String, deviceName)
}

@MainActor func testHandleExternalMessageShowAssistShowsAssist() {
let dictionary: [String: Any] = [
"id": 1,
Expand Down Expand Up @@ -184,6 +220,16 @@ final class WebViewExternalMessageHandlerTests: XCTestCase {
XCTAssertEqual(mockWebViewController.overlayedController?.modalPresentationStyle, .overFullScreen)
}

private func externalBusMessage(from script: String) throws -> [String: Any] {
let prefix = "window.externalBus("
XCTAssertTrue(script.hasPrefix(prefix))
XCTAssertTrue(script.hasSuffix(")"))

let jsonString = String(script.dropFirst(prefix.count).dropLast())
let jsonObject = try JSONSerialization.jsonObject(with: Data(jsonString.utf8))
return try XCTUnwrap(jsonObject as? [String: Any])
}

@MainActor func testHandleExternalMessageCameraPlayerShowPresentsCameraPlayer() {
let dictionary: [String: Any] = [
"id": 1,
Expand Down
Loading