diff --git a/KiaExtension/GetCarPowerLevelStatusHandler.swift b/KiaExtension/GetCarPowerLevelStatusHandler.swift index b7481cc..0d9d985 100644 --- a/KiaExtension/GetCarPowerLevelStatusHandler.swift +++ b/KiaExtension/GetCarPowerLevelStatusHandler.swift @@ -69,8 +69,8 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan } else { try manager.store(status: status) } - result = status.state.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: status.lastUpdateTime) - logDebug("Loaded car status '\(status.state.vehicle.green.batteryManagement.batteryRemain.ratio)'", category: .vehicle) + result = status.status.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: status.lastUpdateTime) + logDebug("Loaded car status '\(status.status.green.batteryManagement.batteryRemain.ratio)'", category: .vehicle) } catch { if let error = error as? ApiError { switch error { @@ -92,7 +92,7 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan logDebug("Returning cached data for failure", category: .vehicle) manager.restoreOutdatedData() if let cachedData = try? manager.vehicleState { - return cachedData.state.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: cachedData.lastUpdateTime) + return cachedData.status.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: cachedData.lastUpdateTime) } else { logDebug("No cached data, returning failure", category: .vehicle) manager.removeLastUpdateDate() @@ -118,7 +118,11 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan } logDebug("Handler: Returning mocking data", category: .vehicle) - return VehicleStateResponse.lowBatteryPreview.state.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: .now - 1 * 60) + return KiaVehicleStatusMapper.map(response: VehicleStateResponse.lowBatteryPreview).status.toIntentResponse( + carId: carId, + vehicleParameters: vehicleParameters, + lastUpdateDate: .now - 1 * 60 + ) } else if let cachedData = try? manager.vehicleState { // Use data from cache if cachedData.lastUpdateTime + 5 * 60 < Date.now { @@ -133,7 +137,7 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan } logDebug("Handler: Use cached data", category: .vehicle) - return cachedData.state.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: cachedData.lastUpdateTime) + return cachedData.status.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: cachedData.lastUpdateTime) } else { // Get data from server await credentialsHandler.continueOrWaitForCredentials() @@ -160,7 +164,7 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan // Send initial update from cached data immediately if let cachedData = try? manager.vehicleState { logDebug("Updater: Sending initial update from cached data", category: .vehicle) - let response = cachedData.state.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: .now - 1 * 60) + let response = cachedData.status.toIntentResponse(carId: carId, vehicleParameters: vehicleParameters, lastUpdateDate: .now - 1 * 60) observer.didUpdate(getCarPowerLevelStatus: response) } @@ -179,7 +183,8 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan logDebug("Updater: Received MQTT update", category: .mqtt) - let response = mqttStatus.state.toIntentResponse( + let mappedStatus = KiaVehicleStatusMapper.map(state: mqttStatus.state.vehicle) + let response = mappedStatus.toIntentResponse( carId: carId, vehicleParameters: self.vehicleParameters, lastUpdateDate: mqttStatus.lastUpdateTime @@ -187,12 +192,9 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan // Update stored status do { - let status = VehicleStateResponse( - resultCode: "S", - serviceNumber: "0", - returnCode: "0", + let status = VehicleStatusSnapshot( lastUpdateTime: mqttStatus.lastUpdateTime, - state: mqttStatus.state + status: mappedStatus ) try manager.store(status: status) } catch { @@ -302,7 +304,7 @@ class GetCarPowerLevelStatusHandler: NSObject, INGetCarPowerLevelStatusIntentHan } } -extension VehicleStateWrapper { +extension VehicleStatus { /// Converts vehicle status to Apple Maps compatible INGetCarPowerLevelStatusIntentResponse /// - Parameters: /// - carId: Unique identifier for the vehicle @@ -312,10 +314,10 @@ extension VehicleStateWrapper { let result: INGetCarPowerLevelStatusIntentResponse let dateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: lastUpdateDate) - let chargingInformation = vehicle.green.chargingInformation - let batteryManagement = vehicle.green.batteryManagement - let drivetrain = vehicle.drivetrain - let batteryCapacity = Double(batteryManagement.batteryCapacity.value) + let chargingInformation = green.chargingInformation + let batteryManagement = green.batteryManagement + let driveTrain = drivetrain + let batteryCapacity = estimatedBatteryCapacity(from: batteryManagement) let batteryRemain = Float(batteryManagement.batteryRemain.ratio) result = .init(code: .success, userActivity: nil) @@ -325,23 +327,23 @@ extension VehicleStateWrapper { result.chargingFormulaArguments = vehicleParameters.chargingFormulaArguments(maximumBatteryCapacity: batteryCapacity, unit: .kilojoules) result.maximumDistance = .init(value: vehicleParameters.maximumDistance, unit: .kilometers) - result.distanceRemaining = .init(value: Double(drivetrain.fuelSystem.dte.total), unit: drivetrain.fuelSystem.dte.unit.measuremntUnit) + result.distanceRemaining = .init(value: Double(driveTrain.fuelSystem.dte.total), unit: driveTrain.fuelSystem.dte.unit.measuremntUnit) result.maximumDistanceElectric = .init(value: vehicleParameters.maximumDistance, unit: .kilometers) - result.distanceRemainingElectric = .init(value: Double(drivetrain.fuelSystem.dte.total), unit: drivetrain.fuelSystem.dte.unit.measuremntUnit) + result.distanceRemainingElectric = .init(value: Double(driveTrain.fuelSystem.dte.total), unit: driveTrain.fuelSystem.dte.unit.measuremntUnit) result.minimumBatteryCapacity = .init(value: 0, unit: .kilowattHours) result.currentBatteryCapacity = .init(value: batteryCapacity * 0.01 * Double(batteryRemain), unit: .kilojoules) result.maximumBatteryCapacity = .init(value: batteryCapacity, unit: .kilojoules) - result.charging = chargingInformation.electricCurrentLevel.state == 1 + result.charging = isCharging if result.charging == true { let charging = chargingInformation.charging let measurement = Measurement(value: charging.remainTime, unit: charging.remainTimeUnit.unitDuration) result.minutesToFull = Int(measurement.converted(to: .minutes).value) result.activeConnector = .ccs2 } else { - result.minutesToFull = chargingInformation.estimatedTime.quick + result.minutesToFull = nil result.activeConnector = nil } @@ -349,5 +351,10 @@ extension VehicleStateWrapper { return result } -} + private func estimatedBatteryCapacity(from batteryManagement: VehicleStatus.Green.BatteryManagement) -> Double { + let ratio = batteryManagement.batteryRemain.ratio + guard ratio > 0 else { return batteryManagement.batteryRemain.value } + return batteryManagement.batteryRemain.value * 100 / ratio + } +} diff --git a/KiaMaps.xcodeproj/project.pbxproj b/KiaMaps.xcodeproj/project.pbxproj index ca6ee91..e24adee 100644 --- a/KiaMaps.xcodeproj/project.pbxproj +++ b/KiaMaps.xcodeproj/project.pbxproj @@ -91,16 +91,27 @@ Api/ApiResponse.swift, Api/Helpers/BoolPropertyWrapper.swift, Api/Helpers/DatePropertyWrapper.swift, + Api/Kia/HMGAuthClient.swift, + Api/Kia/HMGMQTTClient.swift, + Api/Kia/HMGVehicleClient.swift, + Api/Kia/KiaApiConfiguration.swift, + Api/Kia/KiaApiEndpoint.swift, + Api/Kia/KiaMQTTModels.swift, + Api/Kia/KiaVehicleStatusMapper.swift, Api/Models/AuthenticationModels.swift, Api/Models/ClimateControlModels.swift, Api/Models/ConnectorAuthorizationModels.swift, - Api/Models/MQTTModels.swift, Api/Models/NotificationRegistrationResponse.swift, Api/Models/SignInResponse.swift, Api/Models/UserIntegrationResponse.swift, Api/Models/VehicleResponse.swift, + Api/Models/VehicleStatus.swift, Api/Models/VehicleStatusResponse.swift, Api/Models/VehicleTypes.swift, + Api/Porsche/PorscheApiEndpoint.swift, + Api/Porsche/PorscheAuthClient.swift, + Api/Porsche/PorscheModels.swift, + Api/Porsche/PorscheVehicleMapper.swift, Authorization/Authorization.swift, Authorization/DarwinNotificationHelper.swift, Authorization/Keychain.swift, diff --git a/KiaMaps/App/ApiExtensions.swift b/KiaMaps/App/ApiExtensions.swift index da1075c..468a42e 100644 --- a/KiaMaps/App/ApiExtensions.swift +++ b/KiaMaps/App/ApiExtensions.swift @@ -168,7 +168,7 @@ extension Api { } /// Fetch cached vehicle status with automatic token refresh - func vehicleCachedStatusWithAutoRefresh(_ vehicleId: UUID) async throws -> VehicleStateResponse { + func vehicleCachedStatusWithAutoRefresh(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot { return try await executeWithAutoRefresh { return try await self.vehicleCachedStatus(vehicleId) } diff --git a/KiaMaps/App/MainView.swift b/KiaMaps/App/MainView.swift index 5405f9c..063da6e 100644 --- a/KiaMaps/App/MainView.swift +++ b/KiaMaps/App/MainView.swift @@ -39,7 +39,7 @@ struct MainView: View { @State var state: ViewState @State var vehicles: [Vehicle] = [] @State var selectedVehicle: Vehicle? = nil - @State var selectedVehicleState: VehicleStateResponse? = nil + @State var selectedVehicleState: VehicleStatusSnapshot? = nil @State var isSelectedVahicleExpanded = true @State var lastUpdateDate: Date? @State var showingProfile = false @@ -47,7 +47,7 @@ struct MainView: View { // MQTT Integration State @StateObject private var mqttManager: MQTTManager - @State private var currentVehicleState: VehicleState? + @State private var currentVehicleState: VehicleStatus? @State private var mqttConnectionStatus: MQTTConnectionStatus = .disconnected @State private var receivedMQTTUpdate = false @@ -115,7 +115,7 @@ struct MainView: View { .onAppear { setupMQTTIntegration() } - .onChange(of: selectedVehicleState?.state.vehicle.isCharging) { _, isCharging in + .onChange(of: selectedVehicleState?.status.isCharging) { _, isCharging in handleChargingStateChange(isCharging ?? false) } .onChange(of: mqttManager.connectionStatus) { _, connectionStatus in @@ -133,7 +133,7 @@ struct MainView: View { var contentView: some View { if let selectedVehicle = selectedVehicle, let selectedVehicleState = selectedVehicleState { // Use MQTT-updated status if available, otherwise use API status - let currentStatus = currentVehicleState ?? selectedVehicleState.state.vehicle + let currentStatus = currentVehicleState ?? selectedVehicleState.status OverviewPageView( brandName: api.configuration.brandName, @@ -156,7 +156,7 @@ struct MainView: View { // MARK: - Vehicle Status Icons (for toolbar) - private func VehicleStateIcons(status: VehicleStateResponse) -> some View { + private func VehicleStateIcons(status: VehicleStatusSnapshot) -> some View { HStack(spacing: KiaDesign.Spacing.small) { // Last update indicator VStack(spacing: 2) { @@ -170,7 +170,7 @@ struct MainView: View { } // Battery status - let batteryLevel = status.state.vehicle.green.batteryManagement.batteryRemain.ratio + let batteryLevel = status.status.green.batteryManagement.batteryRemain.ratio VStack(spacing: 2) { if batteryLevel > 80 { Image(systemName: "battery.100percent") @@ -192,7 +192,7 @@ struct MainView: View { } // Charging status (if applicable) - if status.state.vehicle.isCharging { + if status.status.isCharging { VStack(spacing: 2) { Image(systemName: "bolt.circle.fill") .font(.caption) @@ -218,7 +218,7 @@ struct MainView: View { } ) .accessibilityElement(children: .combine) - .accessibilityLabel("Vehicle status: \(Int(status.state.vehicle.green.batteryManagement.batteryRemain.ratio))% battery, \(status.state.vehicle.drivingReady ? "ready" : "not ready"), updated \(timeAgoString(from: status.lastUpdateTime))") + .accessibilityLabel("Vehicle status: \(Int(status.status.green.batteryManagement.batteryRemain.ratio))% battery, \(status.status.drivingReady ? "ready" : "not ready"), updated \(timeAgoString(from: status.lastUpdateTime))") } // MARK: - Helper Views @@ -276,9 +276,9 @@ struct MainView: View { if let cachedVehicle = try? manager.vehicleState { selectedVehicleState = cachedVehicle } else { - let VehicleState = try await api.vehicleCachedStatusWithAutoRefresh(vehicle.vehicleId) - try manager.store(status: VehicleState) - selectedVehicleState = VehicleState + let vehicleStatus = try await api.vehicleCachedStatusWithAutoRefresh(vehicle.vehicleId) + try manager.store(status: vehicleStatus) + selectedVehicleState = vehicleStatus } state = .authorized @@ -360,7 +360,7 @@ struct MainView: View { mqttConnectionStatus = mqttManager.connectionStatus // Start MQTT if car is already charging - if selectedVehicleState?.state.vehicle.isCharging == true { + if selectedVehicleState?.status.isCharging == true { startMQTTCommunication() } } @@ -370,21 +370,16 @@ struct MainView: View { guard let VehicleState = VehicleState else { return } receivedMQTTUpdate = true - if let status = selectedVehicleState { + let status = KiaVehicleStatusMapper.map(state: VehicleState.state.vehicle) + if selectedVehicleState != nil { selectedVehicleState = .init( - resultCode: status.resultCode, - serviceNumber: status.serviceNumber, - returnCode: status.returnCode, lastUpdateTime: VehicleState.lastUpdateTime, - state: .init(vehicle: VehicleState.state.vehicle) + status: status ) } else { selectedVehicleState = .init( - resultCode: "S", - serviceNumber: "0", - returnCode: "0", lastUpdateTime: VehicleState.lastUpdateTime, - state: .init(vehicle: VehicleState.state.vehicle) + status: status ) } } diff --git a/KiaMaps/App/Views/Components/TemperatureDial.swift b/KiaMaps/App/Views/Components/TemperatureDial.swift index f6125d8..bf39d39 100644 --- a/KiaMaps/App/Views/Components/TemperatureDial.swift +++ b/KiaMaps/App/Views/Components/TemperatureDial.swift @@ -314,7 +314,7 @@ struct TemperatureDial: View { /// Complete climate control interface with temperature dial and additional controls struct ClimateControlView: View { - let vehicleState: VehicleState? + let vehicleState: VehicleStatus? let unit: TemperatureUnit @State private var targetTemperature: Double = 22 @@ -322,7 +322,7 @@ struct ClimateControlView: View { @State private var fanSpeed: Double = 3 @State private var isAutoMode: Bool = true - init(vehicleState: VehicleState? = nil, unit: TemperatureUnit = .celsius) { + init(vehicleState: VehicleStatus? = nil, unit: TemperatureUnit = .celsius) { self.vehicleState = vehicleState self.unit = unit } diff --git a/KiaMaps/App/Views/Components/VehicleSilhouetteView.swift b/KiaMaps/App/Views/Components/VehicleSilhouetteView.swift index 9bf455e..7090d92 100644 --- a/KiaMaps/App/Views/Components/VehicleSilhouetteView.swift +++ b/KiaMaps/App/Views/Components/VehicleSilhouetteView.swift @@ -10,7 +10,7 @@ import SwiftUI /// Interactive vehicle silhouette showing doors, windows, and status indicators struct VehicleSilhouetteView: View { - let vehicleState: VehicleState + let vehicleState: VehicleStatus let onDoorTap: ((DoorPosition) -> Void)? let onTireTap: ((TirePosition) -> Void)? let onChargingPortTap: (() -> Void)? @@ -90,7 +90,7 @@ struct VehicleSilhouetteView: View { } init( - vehicleState: VehicleState, + vehicleState: VehicleStatus, onDoorTap: ((DoorPosition) -> Void)? = nil, onTireTap: ((TirePosition) -> Void)? = nil, onChargingPortTap: (() -> Void)? = nil, @@ -566,7 +566,7 @@ struct VehicleSilhouetteView: View { /// Expandable detail view showing comprehensive vehicle status struct VehicleStateDetailView: View { - let vehicleState: VehicleState + let vehicleState: VehicleStatus let selectedElement: VehicleSilhouetteView.InteractiveElement? var body: some View { @@ -993,7 +993,7 @@ struct VehicleStateDetailView: View { /// Container view combining silhouette with expandable details struct InteractiveVehicleSilhouetteView: View { - let vehicleState: VehicleState + let vehicleState: VehicleStatus @State private var selectedElement: VehicleSilhouetteView.InteractiveElement? @State private var showingDetails = false @@ -1093,7 +1093,7 @@ struct InteractiveVehicleSilhouetteView: View { Text("Standard Scenario") .font(KiaDesign.Typography.title2) - VehicleSilhouetteView(vehicleState: MockVehicleData.standard) + VehicleSilhouetteView(vehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.standard)) Divider() @@ -1101,7 +1101,7 @@ struct InteractiveVehicleSilhouetteView: View { Text("Charging Scenario (AC)") .font(KiaDesign.Typography.title2) - InteractiveVehicleSilhouetteView(vehicleState: MockVehicleData.charging) + InteractiveVehicleSilhouetteView(vehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.charging)) Divider() @@ -1109,7 +1109,7 @@ struct InteractiveVehicleSilhouetteView: View { Text("Fast Charging Scenario (DC)") .font(KiaDesign.Typography.title2) - InteractiveVehicleSilhouetteView(vehicleState: MockVehicleData.fastCharging) + InteractiveVehicleSilhouetteView(vehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.fastCharging)) Divider() @@ -1117,16 +1117,16 @@ struct InteractiveVehicleSilhouetteView: View { Text("Low Tire Pressure Demo") .font(KiaDesign.Typography.title2) - InteractiveVehicleSilhouetteView(vehicleState: MockVehicleData.lowTirePressure) + InteractiveVehicleSilhouetteView(vehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.lowTirePressure)) } .padding() } .background(KiaDesign.Colors.background) } -// MARK: - VehicleState Extensions +// MARK: - VehicleStatus Extensions -extension VehicleState { +extension VehicleStatus { func isDoorOpen(_ door: VehicleSilhouetteView.DoorPosition) -> Bool { let doors = cabin.door switch door { diff --git a/KiaMaps/App/Views/Pages/ClimatePageView.swift b/KiaMaps/App/Views/Pages/ClimatePageView.swift index 64131a4..4f9afc6 100644 --- a/KiaMaps/App/Views/Pages/ClimatePageView.swift +++ b/KiaMaps/App/Views/Pages/ClimatePageView.swift @@ -10,7 +10,7 @@ import SwiftUI /// Climate control page with temperature dial and status struct ClimatePageView: View { - let status: VehicleState + let status: VehicleStatus let isActive: Bool var body: some View { @@ -159,7 +159,7 @@ struct ClimatePageView: View { #Preview("Climate Page View") { ClimatePageView( - status: MockVehicleData.standard, + status: KiaVehicleStatusMapper.map(state: MockVehicleData.standard), isActive: true ) } diff --git a/KiaMaps/App/Views/Pages/MapPageView.swift b/KiaMaps/App/Views/Pages/MapPageView.swift index fe96c91..f9e5b7b 100644 --- a/KiaMaps/App/Views/Pages/MapPageView.swift +++ b/KiaMaps/App/Views/Pages/MapPageView.swift @@ -11,8 +11,8 @@ import SwiftUI /// Map page showing vehicle location and navigation struct MapPageView: View { let vehicle: Vehicle - let vehicleState: VehicleState - let vehicleLocation: VehicleLocation + let vehicleState: VehicleStatus + let vehicleLocation: VehicleStatus.Location let isActive: Bool var body: some View { @@ -30,8 +30,8 @@ struct MapPageView: View { #Preview("Map Page View") { MapPageView( vehicle: MockVehicleData.mockVehicle, - vehicleState: MockVehicleData.standard, - vehicleLocation: MockVehicleData.standard.location!, + vehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.standard), + vehicleLocation: KiaVehicleStatusMapper.map(state: MockVehicleData.standard).location!, isActive: true ) } diff --git a/KiaMaps/App/Views/Pages/OverviewPageView.swift b/KiaMaps/App/Views/Pages/OverviewPageView.swift index aa54c19..d332c7a 100644 --- a/KiaMaps/App/Views/Pages/OverviewPageView.swift +++ b/KiaMaps/App/Views/Pages/OverviewPageView.swift @@ -13,7 +13,7 @@ import Combine struct OverviewPageView: View { let brandName: String let vehicle: Vehicle - let status: VehicleState + let status: VehicleStatus let lastUpdateTime: Date let isActive: Bool let mqttConnectionStatus: MQTTConnectionStatus @@ -449,7 +449,7 @@ struct OverviewPageView: View { OverviewPageView( brandName: "Mocker", vehicle: MockVehicleData.mockVehicle, - status: MockVehicleData.lowTirePressure, + status: KiaVehicleStatusMapper.map(state: MockVehicleData.lowTirePressure), lastUpdateTime: .now, isActive: true, mqttConnectionStatus: .disconnected, diff --git a/KiaMaps/App/Views/Vehicle/BatteryHeroView.swift b/KiaMaps/App/Views/Vehicle/BatteryHeroView.swift index 702ca8f..eec0ff1 100644 --- a/KiaMaps/App/Views/Vehicle/BatteryHeroView.swift +++ b/KiaMaps/App/Views/Vehicle/BatteryHeroView.swift @@ -201,8 +201,7 @@ private struct DetailItem: View { // MARK: - Convenience Initializers extension BatteryHeroView { - /// Create BatteryHeroView from VehicleState - init(from status: VehicleState) { + init(from status: VehicleStatus) { let batteryPercent = status.green.batteryManagement.batteryRemain.ratio let dte = status.drivetrain.fuelSystem.dte let rangeValue = dte.total @@ -265,10 +264,15 @@ extension BatteryHeroView { } } } + + /// Create BatteryHeroView from VehicleState + init(from status: VehicleState) { + self.init(from: KiaVehicleStatusMapper.map(state: status)) + } /// Create BatteryHeroView from VehicleStateResponse init(from response: VehicleStateResponse) { - self.init(from: response.state.vehicle) + self.init(from: KiaVehicleStatusMapper.map(response: response).status) } } diff --git a/KiaMaps/App/Views/Vehicle/QuickActionsView.swift b/KiaMaps/App/Views/Vehicle/QuickActionsView.swift index 87e3b6a..deccc3d 100644 --- a/KiaMaps/App/Views/Vehicle/QuickActionsView.swift +++ b/KiaMaps/App/Views/Vehicle/QuickActionsView.swift @@ -2,7 +2,7 @@ import SwiftUI /// Tesla-style quick action buttons for common vehicle operations struct QuickActionsView: View { - let VehicleState: VehicleState + let VehicleState: VehicleStatus let onLockAction: () async throws -> Void let onClimateAction: () async throws -> Void let onHornAction: () async throws -> Void @@ -212,7 +212,7 @@ extension ActionButton { // MARK: - Preview #Preview("Quick Actions - Standard") { QuickActionsView( - VehicleState: MockVehicleData.standard, + VehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.standard), onLockAction: { print("🔒 Lock vehicle") try? await Task.sleep(nanoseconds: 1_000_000_000) @@ -235,7 +235,7 @@ extension ActionButton { #Preview("Quick Actions - Charging") { QuickActionsView( - VehicleState: MockVehicleData.charging, + VehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.charging), onLockAction: { print("🔒 Lock vehicle") try? await Task.sleep(nanoseconds: 1_000_000_000) @@ -258,7 +258,7 @@ extension ActionButton { #Preview("Quick Actions - Low Battery") { QuickActionsView( - VehicleState: MockVehicleData.lowBattery, + VehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.lowBattery), onLockAction: { print("🔒 Lock vehicle") try? await Task.sleep(nanoseconds: 1_000_000_000) diff --git a/KiaMaps/App/Views/Vehicle/VehicleMapView.swift b/KiaMaps/App/Views/Vehicle/VehicleMapView.swift index 5481935..78e2a30 100644 --- a/KiaMaps/App/Views/Vehicle/VehicleMapView.swift +++ b/KiaMaps/App/Views/Vehicle/VehicleMapView.swift @@ -13,8 +13,8 @@ import CoreLocation /// Tesla-style map view with vehicle location and charging station integration struct VehicleMapView: View { let vehicle: Vehicle? - let vehicleState: VehicleState - let vehicleLocation: VehicleLocation + let vehicleState: VehicleStatus + let vehicleLocation: VehicleStatus.Location let onChargingStationTap: ((ChargingStation) -> Void)? let onVehicleTap: (() -> Void)? @@ -41,8 +41,8 @@ struct VehicleMapView: View { init( vehicle: Vehicle? = nil, - vehicleState: VehicleState, - vehicleLocation: VehicleLocation, + vehicleState: VehicleStatus, + vehicleLocation: VehicleStatus.Location, onChargingStationTap: ((ChargingStation) -> Void)? = nil, onVehicleTap: (() -> Void)? = nil ) { @@ -712,8 +712,8 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { #Preview("Vehicle Map - Standard") { VehicleMapView( vehicle: MockVehicleData.mockVehicle, - vehicleState: MockVehicleData.standard, - vehicleLocation: MockVehicleData.standard.location!, + vehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.standard), + vehicleLocation: KiaVehicleStatusMapper.map(state: MockVehicleData.standard).location!, onChargingStationTap: { station in print("Tapped charging station: \(station.name)") }, @@ -729,8 +729,8 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { #Preview("Vehicle Map - Charging") { VehicleMapView( vehicle: MockVehicleData.mockVehicle, - vehicleState: MockVehicleData.charging, - vehicleLocation: MockVehicleData.standard.location!, + vehicleState: KiaVehicleStatusMapper.map(state: MockVehicleData.charging), + vehicleLocation: KiaVehicleStatusMapper.map(state: MockVehicleData.standard).location!, onChargingStationTap: { station in print("Tapped charging station: \(station.name)") }, diff --git a/KiaMaps/App/Views/Vehicle/VehicleStatusModernView.swift b/KiaMaps/App/Views/Vehicle/VehicleStatusModernView.swift index abf3975..498ad5a 100644 --- a/KiaMaps/App/Views/Vehicle/VehicleStatusModernView.swift +++ b/KiaMaps/App/Views/Vehicle/VehicleStatusModernView.swift @@ -64,7 +64,7 @@ struct VehicleStateModernView: View { // Quick Actions KiaCard(elevation: .medium) { QuickActionsView( - VehicleState: VehicleState, + VehicleState: KiaVehicleStatusMapper.map(state: VehicleState), onLockAction: { handleLockAction() }, onClimateAction: { handleClimateAction() }, onHornAction: { handleHornAction() }, diff --git a/KiaMaps/Core/Api/Api.swift b/KiaMaps/Core/Api/Api.swift index d9fa57e..719a25d 100644 --- a/KiaMaps/Core/Api/Api.swift +++ b/KiaMaps/Core/Api/Api.swift @@ -9,6 +9,292 @@ import Foundation import os.log +protocol VehicleApiProvider { + func webLoginUrl() throws -> URL? + func login(username: String, password: String, recaptchaToken: String?) async throws -> AuthorizationData + func login(authorizationCode: String) async throws -> AuthorizationData + func logout() async throws + func vehicles() async throws -> VehicleResponse + func refreshVehicle(_ vehicleId: UUID) async throws -> UUID + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot + func profile() async throws -> String + func startClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin: String) async throws -> UUID + func stopClimate(_ vehicleId: UUID) async throws -> UUID +} + +enum VehicleApiProviderFactory { + static func provider(for api: Api) -> VehicleApiProvider { + switch api.configuration.apiProviderKind { + case .hmg: + HMGVehicleApiProvider(api: api) + case .porsche: + PorscheVehicleApiProvider(api: api) + } + } +} + +final class HMGVehicleApiProvider: VehicleApiProvider { + private let authClient: HMGAuthClient + private let vehicleClient: HMGVehicleClient + + init(api: Api) { + authClient = api.hmgAuthClient + vehicleClient = HMGVehicleClient(provider: api.provider) + } + + func webLoginUrl() throws -> URL? { try authClient.makeAuthorizeURL() } + func login(username: String, password: String, recaptchaToken: String?) async throws -> AuthorizationData { + try await authClient.authenticate(username: username, password: password, recaptchaToken: recaptchaToken) + } + + func login(authorizationCode: String) async throws -> AuthorizationData { + try await authClient.exchangeAuthorizationCode(authorizationCode) + } + + func logout() async throws { try await authClient.logout() } + func vehicles() async throws -> VehicleResponse { try await vehicleClient.vehicles() } + func refreshVehicle(_ vehicleId: UUID) async throws -> UUID { try await vehicleClient.refreshVehicle(vehicleId) } + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot { try await vehicleClient.vehicleCachedStatus(vehicleId) } + func profile() async throws -> String { try await vehicleClient.profile() } + func startClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin: String) async throws -> UUID { + try await vehicleClient.startClimate(vehicleId, options: options, pin: pin) + } + + func stopClimate(_ vehicleId: UUID) async throws -> UUID { + try await vehicleClient.stopClimate(vehicleId) + } +} + +final class PorscheVehicleApiProvider: VehicleApiProvider { + private unowned let api: Api + private let authClient: PorscheAuthClient + private let commandPollIntervalNanoseconds: UInt64 + private var vinByVehicleID: [UUID: String] = [:] + + init( + api: Api, + authClient: PorscheAuthClient? = nil, + commandPollIntervalNanoseconds: UInt64 = 1_000_000_000 + ) { + self.api = api + self.commandPollIntervalNanoseconds = commandPollIntervalNanoseconds + if let authClient { + self.authClient = authClient + } else { + self.authClient = PorscheAuthClient(configuration: Self.configuration(for: api)) + } + } + + func webLoginUrl() throws -> URL? { + try authClient.makeAuthorizeURL() + } + + func login(username: String, password: String, recaptchaToken _: String?) async throws -> AuthorizationData { + let tokenSet = try await authClient.authenticate(username: username, password: password) + let authorization = authorizationData(from: tokenSet, existing: api.authorization) + api.authorization = authorization + return authorization + } + + func login(authorizationCode: String) async throws -> AuthorizationData { + let tokenSet = try await authClient.exchangeAuthorizationCode(authorizationCode) + let authorization = authorizationData(from: tokenSet, existing: api.authorization) + api.authorization = authorization + return authorization + } + + func logout() async throws { + api.authorization = nil + vinByVehicleID.removeAll() + } + + func vehicles() async throws -> VehicleResponse { + let payload = try await authorizedJSONObject(endpoint: .vehicles) + guard let vehiclesPayload = payload as? [PorscheVehicleMapper.JSONObject] else { + throw PorscheApiError.decodingFailed("vehicle list payload") + } + let response = try PorscheVehicleMapper.mapVehicles(from: vehiclesPayload) + vinByVehicleID = Dictionary(uniqueKeysWithValues: response.vehicles.map { ($0.vehicleId, $0.vin) }) + return response + } + + func refreshVehicle(_ vehicleId: UUID) async throws -> UUID { + let vin = try await resolveVIN(for: vehicleId) + _ = try await authorizedJSONObject( + endpoint: .vehicle(vin), + queryItems: measurementQueryItems(wakeUp: true) + ) + return vehicleId + } + + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot { + let vin = try await resolveVIN(for: vehicleId) + let payload = try await authorizedJSONObject( + endpoint: .vehicle(vin), + queryItems: measurementQueryItems(wakeUp: false) + ) + guard let statusPayload = payload as? PorscheVehicleMapper.JSONObject else { + throw PorscheApiError.decodingFailed("vehicle status payload") + } + return try PorscheVehicleMapper.mapVehicleState(from: statusPayload) + } + + func profile() async throws -> String { + let payload = try await authorizedJSONObject(endpoint: .profile) + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + + func startClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin _: String) async throws -> UUID { + guard options.isValid else { + if !options.isTemperatureValid { + throw ClimateControlError.invalidTemperature(options.temperature) + } + if !options.areSeatLevelsValid { + throw ClimateControlError.invalidSeatLevel(-1) + } + throw ClimateControlError.invalidDuration(options.duration) + } + let vin = try await resolveVIN(for: vehicleId) + return try await sendCommand(.climateOn(vin: vin, temperatureC: Double(options.temperature))) + } + + func stopClimate(_ vehicleId: UUID) async throws -> UUID { + let vin = try await resolveVIN(for: vehicleId) + return try await sendCommand(.climateOff(vin: vin)) + } + + private static func configuration(for api: Api) -> PorscheApiConfiguration { + guard let configuration = api.configuration as? PorscheApiConfiguration else { + fatalError("Porsche provider requires PorscheApiConfiguration") + } + return configuration + } + + private var configuration: PorscheApiConfiguration { + Self.configuration(for: api) + } + + private func authorizationData(from tokenSet: PorscheTokenSet, existing: AuthorizationData?) -> AuthorizationData { + AuthorizationData( + stamp: existing?.stamp ?? "porsche", + deviceId: existing?.deviceId ?? UUID(), + accessToken: tokenSet.accessToken, + expiresIn: tokenSet.expiresIn, + refreshToken: tokenSet.refreshToken, + isCcuCCS2Supported: true, + providerKind: "porsche", + tokenIssuer: configuration.loginHost, + tokenAudience: configuration.audience, + tokenScope: tokenSet.scope ?? configuration.scope + ) + } + + private func resolveVIN(for vehicleId: UUID) async throws -> String { + if let vin = vinByVehicleID[vehicleId] { + return vin + } + let vehicles = try await self.vehicles() + guard let vehicle = vehicles.vehicles.first(where: { $0.vehicleId == vehicleId }) else { + throw PorscheApiError.missingVehicle(vehicleId.uuidString) + } + return vehicle.vin + } + + private func measurementQueryItems(wakeUp: Bool) -> [URLQueryItem] { + var items = PorscheMeasurementCatalog.overview.map { URLQueryItem(name: "mf", value: $0) } + if wakeUp { + items.append(URLQueryItem(name: "wakeUpJob", value: UUID().uuidString)) + } + return items + } + + private func authorizedJSONObject( + endpoint: PorscheApiEndpoint, + method: ApiMethod = .get, + queryItems: [URLQueryItem] = [], + body: Data? = nil, + retryOnUnauthorized: Bool = true + ) async throws -> Any { + guard let authorization = api.authorization else { + throw ApiError.unauthorized + } + + do { + let responseData = try await api.provider.request( + with: method, + endpoint: endpoint, + queryItems: queryItems, + body: body + ).rawData(acceptStatusCodes: [200, 202]) + + if responseData.isEmpty { + return [:] + } + return try JSONSerialization.jsonObject(with: responseData) + } catch ApiError.unauthorized { + guard retryOnUnauthorized else { + throw ApiError.unauthorized + } + let refreshedTokens = try await authClient.refreshToken(authorization.refreshToken) + let refreshedAuthorization = authorizationData(from: refreshedTokens, existing: authorization) + api.authorization = refreshedAuthorization + return try await authorizedJSONObject( + endpoint: endpoint, + method: method, + queryItems: queryItems, + body: body, + retryOnUnauthorized: false + ) + } + } + + private func sendCommand(_ request: PorscheCommandRequest) async throws -> UUID { + let payload = try await authorizedJSONObject( + endpoint: .commands(request.vin), + method: .post, + body: PorscheVehicleMapper.commandBody(for: request) + ) + + guard let json = payload as? PorscheVehicleMapper.JSONObject, + let status = json["status"] as? PorscheVehicleMapper.JSONObject, + let identifier = status["id"] as? String, + let requestID = UUID(uuidString: identifier) + else { + throw PorscheApiError.missingCommandRequestId + } + + let initialState = (status["result"] as? String).flatMap(PorscheCommandExecutionState.init(rawValue:)) ?? .unknown + if initialState == .accepted { + try await pollCommand(vin: request.vin, requestID: requestID) + } + return requestID + } + + private func pollCommand(vin: String, requestID: UUID) async throws { + for _ in 0..<10 { + if commandPollIntervalNanoseconds > 0 { + try await Task.sleep(nanoseconds: commandPollIntervalNanoseconds) + } + let payload = try await authorizedJSONObject(endpoint: .commandStatus(vin: vin, requestId: requestID.uuidString)) + guard let json = payload as? PorscheVehicleMapper.JSONObject else { + continue + } + let resultString = ((json["status"] as? PorscheVehicleMapper.JSONObject)?["result"] as? String) ?? "UNKNOWN" + switch PorscheCommandExecutionState(rawValue: resultString) ?? .unknown { + case .performed: + return + case .error: + throw PorscheApiError.commandFailed(resultString) + case .accepted, .unknown: + continue + } + } + throw PorscheApiError.commandFailed("timeout") + } + +} + /** * Api - Main interface for Kia/Hyundai/Genesis vehicle API communication * @@ -54,10 +340,13 @@ class Api { } /// Service for RSA encryption operations, used for password encryption during authentication - private let rsaService: RSAEncryptionService + let rsaService: RSAEncryptionService /// Provider that handles actual API request execution and token management - private let provider: ApiRequestProvider + let provider: ApiRequestProvider + private lazy var vehicleApiProvider: VehicleApiProvider = VehicleApiProviderFactory.provider(for: self) + fileprivate lazy var hmgAuthClient = HMGAuthClient(configuration: configuration, provider: provider, rsaService: rsaService) + private lazy var hmgMQTTClient = HMGMQTTClient(configuration: configuration, provider: provider) init(configuration: ApiConfiguration, rsaService: RSAEncryptionService) { self.configuration = configuration @@ -72,19 +361,7 @@ class Api { } func webLoginUrl() throws -> URL? { - let queryItems = [ - URLQueryItem(name: "client_id", value: configuration.serviceId), - URLQueryItem(name: "redirect_uri", value: "https://prd.eu-ccapi.kia.com:8080/api/v1/user/oauth2/redirect"), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "lang", value: "en"), - URLQueryItem(name: "state", value: "ccsp"), - ] - - return try provider.request( - endpoint: .oauth2UserAuthorize, - queryItems: queryItems, - headers: commonNavigationHeaders() - ).urlRequest.url + try vehicleApiProvider.webLoginUrl() } /// Authenticate user and establish session with vehicle API using RSA-encrypted authentication @@ -95,102 +372,28 @@ class Api { /// - Returns: Complete authorization data including tokens and device ID /// - Throws: Authentication errors, network errors, or validation failures func login(username: String, password: String, recaptchaToken: String? = nil) async throws -> AuthorizationData { - cleanCookies() - // Step 0: Get connector authorization (handles 302 redirect to get next_uri) - let referer: String - do { - referer = try await fetchConnectorAuthorization() - logInfo("Retrieved referer: \(referer)", category: .api) - } catch { - logError("Client connector authorization failed: \(error.localizedDescription)", category: .api) - throw AuthenticationError.clientConfigurationFailed - } - - // Step 1: Get client configuration - let clientConfig = try await fetchClientConfiguration(referer: referer) - logInfo("Client configured for: \(clientConfig.clientName)", category: .api) - - // Step 2: Check if password encryption is enabled - let encryptionSettings = try await fetchPasswordEncryptionSettings(referer: referer) - guard encryptionSettings.useEnabled && encryptionSettings.value1 == "true" else { - throw AuthenticationError.encryptionSettingsFailed - } - - // Step 3: Get RSA certificate for password encryption - let rsaKey: RSAEncryptionService.RSAKeyData - do { - rsaKey = try await fetchRSACertificate(referer: referer) - } catch { - logError("Fetch RSA Certificate failed: \(error.localizedDescription)", category: .api) - throw AuthenticationError.certificateRetrievalFailed - } - // Step 4: Initialize OAuth2 flow - let csrfToken = try await initializeOAuth2(referer: referer) - - // Step 5: Sign in with encrypted password - let authorizationCode = try await signIn( - referer: referer, - username: username, - password: password, - rsaKey: rsaKey, - csrfToken: csrfToken, - recaptchaToken: recaptchaToken - ) - - // Step 6: Exchange authorization code for tokens - return try await login(authorizationCode: authorizationCode) + try await vehicleApiProvider.login(username: username, password: password, recaptchaToken: recaptchaToken) } func login(authorizationCode: String) async throws -> AuthorizationData { - // Step 6: Exchange authorization code for tokens - let tokenResponse: TokenResponse - do { - tokenResponse = try await exchangeCodeForTokens(authorizationCode: authorizationCode) - } catch { - logError("Exchange code for token failed: \(error.localizedDescription)", category: .api) - throw AuthenticationError.tokenExchangeFailed - } - - // Generate device ID and stamp for compatibility - let stamp = AuthorizationData.generateStamp(for: configuration) - let deviceId = try await deviceId(stamp: stamp) - - // Convert to existing AuthorizationData format - let authorizationData = AuthorizationData( - stamp: stamp, - deviceId: deviceId, - accessToken: tokenResponse.accessToken, - expiresIn: tokenResponse.expiresIn, - refreshToken: tokenResponse.refreshToken, - isCcuCCS2Supported: true - ) + try await vehicleApiProvider.login(authorizationCode: authorizationCode) + } - provider.authorization = authorizationData - try await notificationRegister(deviceId: deviceId) - return authorizationData + func extractAuthorizationCode(from location: URL) throws -> (code: String, state: String, loginSuccess: Bool) { + try hmgAuthClient.extractAuthorizationCode(from: location) } /// Logout user and clean up session data /// - Throws: Network errors (non-critical - cleanup continues regardless) func logout() async throws { - do { - try await provider.request(with: .post, endpoint: .logout).empty() - logInfo("Successfully logout", category: .auth) - } catch { - logError("Failed to logout: \(error.localizedDescription)", category: .auth) - } - provider.authorization = nil - cleanCookies() + try await vehicleApiProvider.logout() } /// Retrieve list of vehicles associated with the user account /// - Returns: Complete vehicle response containing all registered vehicles /// - Throws: Network errors or authentication failures func vehicles() async throws -> VehicleResponse { - guard authorization != nil else { - throw ApiError.unauthorized - } - return try await provider.request(endpoint: .vehicles).response() + try await vehicleApiProvider.vehicles() } /// Request fresh vehicle status update from the vehicle @@ -199,11 +402,7 @@ class Api { /// - Note: Uses CCS2 endpoint if supported, fallback to standard endpoint /// - Throws: Network errors or vehicle communication failures func refreshVehicle(_ vehicleId: UUID) async throws -> UUID { - guard let authorization = authorization else { - throw ApiError.unauthorized - } - let endpoint: ApiEndpoint = authorization.isCcuCCS2Supported == true ? .refreshCCS2Vehicle(vehicleId) : .refreshVehicle(vehicleId) - return try await provider.request(endpoint: endpoint).responseEmpty().resultId + try await vehicleApiProvider.refreshVehicle(vehicleId) } /// Retrieve cached vehicle status (last known state) @@ -211,22 +410,15 @@ class Api { /// - Returns: Complete vehicle status including battery, location, and system states /// - Note: Uses CCS2 endpoint if supported, fallback to standard endpoint /// - Throws: Network errors or data parsing failures - func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { - guard let authorization = authorization else { - throw ApiError.unauthorized - } - let endpoint: ApiEndpoint = authorization.isCcuCCS2Supported == true ? .vehicleCachedCCS2Status(vehicleId) : .vehicleCachedStatus(vehicleId) - return try await provider.request(endpoint: endpoint).response() + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot { + try await vehicleApiProvider.vehicleCachedStatus(vehicleId) } /// Retrieve user profile information /// - Returns: User profile data as JSON string /// - Throws: Network errors or authentication failures func profile() async throws -> String { - guard authorization != nil else { - throw ApiError.unauthorized - } - return try await provider.request(endpoint: .userProfile).string() + try await vehicleApiProvider.profile() } // MARK: - Climate Control @@ -238,49 +430,14 @@ class Api { /// - pin: Vehicle PIN (required for climate control) /// - Returns: Operation result ID for tracking func startClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin: String) async throws -> UUID { - guard authorization?.accessToken != nil else { - throw ApiError.unauthorized - } - guard !pin.isEmpty else { - throw ClimateControlError.missingPin - } - - guard options.isValid else { - if !options.isTemperatureValid { - throw ClimateControlError.invalidTemperature(options.temperature) - } - if !options.areSeatLevelsValid { - let invalidLevel = [options.driverSeatLevel, options.passengerSeatLevel, - options.rearLeftSeatLevel, options.rearRightSeatLevel] - .first { $0 < 0 || $0 > 3 } ?? -1 - throw ClimateControlError.invalidSeatLevel(invalidLevel) - } - if !options.isDurationValid { - throw ClimateControlError.invalidDuration(options.duration) - } - throw ClimateControlError.vehicleNotReady - } - - let request = options.toClimateControlRequest(pin: pin) - - return try await provider.request( - with: .post, - endpoint: .startClimate(vehicleId), - encodable: request - ).responseEmpty().resultId + try await vehicleApiProvider.startClimate(vehicleId, options: options, pin: pin) } /// Stop climate control /// - Parameter vehicleId: The vehicle ID /// - Returns: Operation result ID for tracking func stopClimate(_ vehicleId: UUID) async throws -> UUID { - guard authorization?.accessToken != nil else { - throw ApiError.unauthorized - } - return try await provider.request( - with: .post, - endpoint: .stopClimate(vehicleId) - ).responseEmpty().resultId + try await vehicleApiProvider.stopClimate(vehicleId) } } @@ -292,16 +449,7 @@ extension Api { * GET /api/v3/servicehub/device/host */ func fetchMQTTDeviceHost() async throws -> MQTTHostInfo { - guard authorization?.accessToken != nil else { - throw ApiError.unauthorized - } - - let response: MQTTHostResponse = try await provider.request(endpoint: .mqttDeviceHost).data() - return MQTTHostInfo( - host: response.mqtt.host, - port: response.mqtt.port, - ssl: response.mqtt.ssl - ) + try await hmgMQTTClient.fetchDeviceHost() } /** @@ -309,19 +457,7 @@ extension Api { * POST /api/v3/servicehub/device/register */ func registerMQTTDevice() async throws -> MQTTDeviceInfo { - guard authorization?.accessToken != nil else { - throw ApiError.unauthorized - } - - let deviceUUID = "\(UUID().uuidString)_UVO" - let request = DeviceRegisterRequest(unit: "mobile", uuid: deviceUUID) - let response: DeviceRegisterResponse = try await provider.request(endpoint: .mqttRegisterDevice, encodable: request).data() - - return MQTTDeviceInfo( - clientId: response.clientId, - deviceId: response.deviceId, - uuid: deviceUUID - ) + try await hmgMQTTClient.registerDevice() } /** @@ -329,22 +465,7 @@ extension Api { * GET /api/v3/servicehub/vehicles/metadatalist?carId=&brand=K */ func fetchMQTTVehicleMetadata(for vehicleId: UUID, clientId: String) async throws -> [MQTTVehicleMetadata] { - guard authorization?.accessToken != nil else { - throw ApiError.unauthorized - } - - let response: VehicleMetadataResponse = try await provider.request( - endpoint: .mqttVehicleMetadata, - queryItems: [ - URLQueryItem(name: "carId", value: vehicleId.uuidString), - URLQueryItem(name: "brand", value: configuration.brandCode) - ], - headers: [ - "client-id": clientId - ] - ).data() - - return response.vehicles + try await hmgMQTTClient.fetchVehicleMetadata(for: vehicleId, clientId: clientId) } /** @@ -352,25 +473,12 @@ extension Api { * POST /api/v3/servicehub/device/protocol */ func subscribeMQTTVehicleProtocols(for vehicleId: UUID, clientId: String, protocolId: any MQTTProtocol, protocols: [any MQTTProtocol]) async throws { - guard authorization?.accessToken != nil else { - throw ApiError.unauthorized - } - - // Subscribe to CCU (Car Control Unit) real-time updates - let request = ProtocolSubscriptionRequest( - protocols: protocols, + try await hmgMQTTClient.subscribeVehicleProtocols( + for: vehicleId, + clientId: clientId, protocolId: protocolId, - carId: vehicleId, - brand: configuration.brandCode + protocols: protocols ) - - try await provider.request( - endpoint: .mqttDeviceProtocol, - headers: [ - "client-id": clientId - ], - encodable: request - ).empty() } /** @@ -378,306 +486,6 @@ extension Api { * GET /api/v3/vstatus/connstate?clientId= */ func checkMQTTConnectionState(clientId: String) async throws -> ConnectionStateResponse { - guard authorization?.accessToken != nil else { - throw ApiError.unauthorized - } - - return try await provider.request( - endpoint: .mqttConnectionState, - queryItems: [ - URLQueryItem(name: "clientId", value: clientId), - ], - headers: [ - "client-id": clientId - ] - ).data() + try await hmgMQTTClient.checkConnectionState(clientId: clientId) } } - -extension Api { - /// Login - Step 0: Get Connector Authorization - func fetchConnectorAuthorization() async throws -> String { - // Build the state parameter (base64 encoded JSON) - let stateObject = ConnectorAuthorizationState( - scope: nil, - state: nil, - lang: nil, - cert: "", - action: "idpc_auth_endpoint", - clientId: configuration.serviceId, - redirectUri: try makeRedirectUri(endpoint: .loginRedirect), - responseType: "code", - signupLink: nil, - hmgid2ClientId: configuration.authClientId, - hmgid2RedirectUri: try makeRedirectUri(), - hmgid2Scope: nil, - hmgid2State: "ccsp", - hmgid2UiLocales: nil - ) - let stateData = try JSONEncoder().encode(stateObject) - - let queryItems = [ - URLQueryItem(name: "client_id", value: configuration.serviceId), - URLQueryItem(name: "redirect_uri", value: try makeRedirectUri(endpoint: .loginRedirect).absoluteString), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "state", value: stateData.base64EncodedString()), - URLQueryItem(name: "cert", value: ""), - URLQueryItem(name: "action", value: "idpc_auth_endpoint"), - URLQueryItem(name: "sso_session_reset", value: "true") - ] - - let referalUrl = try await provider.request( - endpoint: .oauth2ConnectorAuthorize, - queryItems: queryItems, - headers: commonNavigationHeaders() - ).referalUrl() - - // Extract next_uri from Location header - guard let nextUri = extractNextUri(from: referalUrl) else { - throw AuthenticationError.oauth2InitializationFailed - } - return nextUri - } - - /// Login - Step 1: Get Client Configuration - func fetchClientConfiguration(referer: String) async throws -> ClientConfiguration { - try await provider.request( - endpoint: .loginConnectorClients(configuration.serviceId), - headers: commonJSONHeaders() - ).responseValue() - } - - /// Login - Step 2: Check Password Encryption Settings - func fetchPasswordEncryptionSettings(referer: String) async throws -> PasswordEncryptionSettings { - try await provider.request( - endpoint: .loginCodes, - headers: commonJSONHeaders(referer: referer) - ).responseValue() - } - - /// Login - Step 3: Get RSA Certificate - func fetchRSACertificate(referer: String) async throws -> RSAEncryptionService.RSAKeyData { - let certificate: RSACertificateResponse = try await provider.request( - endpoint: .loginCertificates, - headers: commonJSONHeaders(referer: referer) - ).responseValue() - - return RSAEncryptionService.RSAKeyData( - keyType: certificate.kty, - exponent: certificate.e, - keyId: certificate.kid, - modulus: certificate.n - ) - } - - /// Login - Step 4: Initialize OAuth2 Flow - func initializeOAuth2(referer: String) async throws -> String { - let queryItems = [ - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "client_id", value: configuration.serviceId), - URLQueryItem(name: "redirect_uri", value: try makeRedirectUri().absoluteString), - URLQueryItem(name: "lang", value: "en"), - URLQueryItem(name: "state", value: "ccsp") - ] - - _ = try await provider.request( - endpoint: .oauth2UserAuthorize, - queryItems: queryItems, - headers: commonNavigationHeaders(referer: referer) - ).empty(acceptStatusCode: 302) - - let cookies = HTTPCookieStorage.shared.cookies - - // Parse HTML response to extract CSRF token and session key - guard let cookie = cookies?.first(where: { $0.name == "account" }) else { - throw AuthenticationError.csrfTokenNotFound - } - return cookie.value - } - - /// Login - Step 5: Encrypted Sign-In - func signIn(referer: String, username: String, password: String, rsaKey: RSAEncryptionService.RSAKeyData, csrfToken: String, recaptchaToken: String? = nil) async throws -> String { - // Encrypt password - let encryptedPassword = try rsaService.encryptPassword(password, with: rsaKey) - - guard let connectorSessionKey = extractConnectorSessionKey(from: referer) else { - throw AuthenticationError.sessionKeyNotFound - } - - // Prepare form data - var form: [String: String] = [ - "client_id": configuration.serviceId, - "encryptedPassword": "true", - "orgHmgSid": "", - "password": encryptedPassword, - "kid": rsaKey.keyId, - "redirect_uri": try makeRedirectUri().absoluteString, - "scope": "", - "nonce": "", - "state": "ccsp", - "username": username, - "remember_me": "false", - "connector_session_key": connectorSessionKey, - "_csrf": csrfToken - ] - - // Add reCAPTCHA token if provided - if let recaptchaToken = recaptchaToken { - form["g-recaptcha-response"] = recaptchaToken - logInfo("Including reCAPTCHA token in sign-in request", category: .auth) - } - - let referalUrl = try await provider.request( - with: .post, - endpoint: .loginSignin, - headers: [ - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Dest": "document", - "Origin": "https://idpconnect-eu.\(configuration.key).com", - "Referer": referer - ], - form: form - ).referalUrl() - - let (code, _, loginSuccess) = try extractAuthorizationCode(from: referalUrl) - guard loginSuccess else { - throw AuthenticationError.signInFailed - } - return code - } - - /// Login - Step 6: Exchange Authorization Code for Tokens - func exchangeCodeForTokens(authorizationCode: String) async throws -> TokenResponse { - let form: [String: String] = [ - "client_id": configuration.serviceId, - "client_secret": "secret", // TODO: something generated - "code": authorizationCode, - "grant_type": "authorization_code", - "redirect_uri": try makeRedirectUri().absoluteString - ] - - return try await provider.request( - with: .post, - endpoint: .loginToken, - form: form - ).data() - } - - /// Register device and retrieve device ID for push notifications - /// - Parameter stamp: Authorization stamp for device registration - /// - Returns: Unique device ID for this installation - /// - Throws: Device registration failures or network errors - func deviceId(stamp: String) async throws -> UUID { - /* let number = Int.random(in: 80_000_000_000...100_000_000_000) - let myHex = String(format: "%064x", number) - String(myHex.prefix(64)) */ - let registrationId = "60a0cce8de8b3b51745f10bc35fe07cb000000ef" - let uuid = UUID().uuidString - - let headers = [ - "ccsp-service-id": configuration.serviceId, - "ccsp-application-id": configuration.appId, - "Stamp": stamp, - ] - let payload: [String: String] = [ - "pushRegId": registrationId, - "pushType": configuration.pushType, - "uuid": uuid, - ] - - let response: NotificationRegistrationResponse = try await provider.request( - endpoint: .notificationRegister, - headers: headers, - encodable: payload - ).response(acceptStatusCode: 302) - return response.deviceId - } - - /// Register device for push notifications with vehicle service - /// - Parameter deviceId: Device ID obtained from device registration - /// - Throws: Notification registration failures or network errors - func notificationRegister(deviceId: UUID) async throws { - var headers: ApiRequest.Headers = provider.authorization?.authorizatioHeaders(for: configuration) ?? [:] - headers["Content-Type"] = "application/json; charset=UTF-8" - headers["offset"] = "2" - try await provider.request(with: .post, endpoint: .notificationRegisterWithDeviceId(deviceId), headers: headers).empty(acceptStatusCode: 200) - } - - // MARK: - Helpers - - func makeRedirectUri(endpoint: ApiEndpoint = .oauth2Redirect) throws -> URL { - try provider.configuration.url(for: endpoint) - } - - func extractNextUri(from location: URL) -> String? { - guard let components = URLComponents(url: location, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - // Look for next_uri parameters - return queryItems.first(where: { $0.name == "next_uri" })?.value - } - - func extractConnectorSessionKey(from location: String) -> String? { - guard let url = URL(string: location), - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - return nil - } - - // Look for both next_uri parameters - return queryItems.first(where: { $0.name == "connector_session_key" })?.value - } - - func extractAuthorizationCode(from location: URL) throws -> (code: String, state: String, loginSuccess: Bool) { - guard let components = URLComponents(url: location, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - throw AuthenticationError.authorizationCodeNotFound - } - - guard let code = queryItems.first(where: { $0.name == "code" })?.value else { - throw AuthenticationError.authorizationCodeNotFound - } - - let state = queryItems.first(where: { $0.name == "state" })?.value ?? "ccsp" - let loginSuccess = queryItems.first(where: { $0.name == "login_success" })?.value == "y" - - return (code: code, state: state, loginSuccess: loginSuccess) - } - - func commonJSONHeaders(referer: String? = nil) -> [String: String] { - var headers = [ - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Dest": "empty", - ] - if let referer = referer { - headers["Referer"] = referer - } - return headers - } - - func commonNavigationHeaders(referer: String? = nil) -> [String: String] { - var headers = [ - "Sec-Fetch-Site": "none", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Dest": "document", - - ] - if let referer = referer { - headers["Referer"] = referer - } - return headers - } - - /// Clear all HTTP cookies to ensure clean authentication state - func cleanCookies() { - let cookies = HTTPCookieStorage.shared.cookies ?? [] - for cookie in cookies { - HTTPCookieStorage.shared.deleteCookie(cookie) - } - } -} - diff --git a/KiaMaps/Core/Api/ApiConfiguration.swift b/KiaMaps/Core/Api/ApiConfiguration.swift index cef03f2..f136540 100644 --- a/KiaMaps/Core/Api/ApiConfiguration.swift +++ b/KiaMaps/Core/Api/ApiConfiguration.swift @@ -13,6 +13,7 @@ enum ApiBrand: String { case kia case hyundai case genesis + case porsche /// Returns the appropriate API configuration for the brand and region combination /// - Parameter region: The geographic region for API endpoints @@ -22,17 +23,31 @@ enum ApiBrand: String { case .kia, .hyundai, .genesis: switch region { case .europe: - guard let configuration = ApiConfigurationEurope(rawValue: rawValue) else { + guard let configuration = KiaApiConfigurationEurope(rawValue: rawValue) else { fatalError("Api region not supported") } return configuration case .usa, .canada, .china, .korea: fatalError("Api region not supported") } + case .porsche: + switch region { + case .europe: + return PorscheApiConfiguration.europe + case .usa: + return PorscheApiConfiguration.usa + case .canada, .china, .korea: + fatalError("Api region not supported") + } } } } +enum ApiProviderKind { + case hmg + case porsche +} + /// Protocol defining required configuration properties for API communication protocol ApiConfiguration { /// Brand identifier key (e.g., "kia", "hyundai", "genesis") @@ -85,153 +100,157 @@ protocol ApiConfiguration { /// Push notification type ("APNS" for iOS, "GCM" for Android) var pushType: String { get } + + /// Backend provider kind. + var apiProviderKind: ApiProviderKind { get } } -/// European region API configuration for supported vehicle brands -/// Provides brand-specific endpoints, credentials, and service identifiers for EU market -enum ApiConfigurationEurope: String, ApiConfiguration { - case kia - case hyundai - case genesis +enum PorscheApiConfiguration: String, ApiConfiguration { + case europe + case usa var key: String { - switch self { - case .kia: - "kia" - case .hyundai: - "hyundai" - case .genesis: - "genesis" - } + "porsche" } var name: String { - switch self { - case .kia: - "Kia" - case .hyundai: - "Hyundai" - case .genesis: - "Genesis" - } + "Porsche" } var port: Int { - switch self { - case .kia: - 8080 - case .hyundai, .genesis: - 443 - } + 443 } var serviceAgent: String { - "okhttp/3.12.0" + "PorscheConnect/1.0" } var userAgent: String { let device = UIDevice.current - return "EU_BlueLink/2.1.18 (com.kia.connect.eu; build:10560; \(device.systemName) \(device.systemVersion)) Alamofire/5.8.0" + return "MyPorscheApp/1.0 (\(device.systemName) \(device.systemVersion))" } var acceptHeader: String { - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" + "application/json" } + // HMG-only host fields; kept for protocol compatibility. var baseHost: String { switch self { - case .kia: - "https://prd.eu-ccapi.kia.com" - case .hyundai: - "https://prd.eu-ccapi.hyundai.com" - case .genesis: - "https://prd-eu-ccapi.genesis.com" + case .europe: + "https://api.ppa.porsche.com" + case .usa: + "https://api.ppa.porsche.com" } } var loginHost: String { - "https://idpconnect-eu.\(key).com" + "https://identity.porsche.com" } var mqttHost: String { - "https://egw-svchub-ccs-\(brandCode.lowercased())-eu.eu-central.hmgmobility.com:31010" + "https://api.ppa.porsche.com" } var serviceId: String { switch self { - case .kia: - "fdc85c00-0a2f-4c64-bcb4-2cfb1500730a" - case .hyundai: - "6d477c38-3ca4-4cf3-9557-2a1929a94654" - case .genesis: - "3020afa2-30ff-412a-aa51-d28fbe901e10" + case .europe: + "porsche-eu-service" + case .usa: + "porsche-us-service" } } var appId: String { - switch self { - case .kia: - "a2b8469b-30a3-4361-8e13-6fceea8fbe74" - case .hyundai: - "014d2225-8495-4735-812d-2616334fd15d" - case .genesis: - "f11f2b86-e0e7-4851-90df-5600b01d8b70" - } + "porsche-app-id" } var senderId: Int { - 199_360_397_125 + 0 } var authClientId: String { - switch self { - case .kia: - "572e0304-5f8d-4b4c-9dd5-41aa84eed160" - case .hyundai: - "64621b96-0f0d-11ec-82a8-0242ac130003" - case .genesis: - "3020afa2-30ff-412a-aa51-d28fbe901e10" - } + "XhygisuebbrqQ80byOuU5VncxLIm8E6H" + } + + var xClientId: String { + "41843fb4-691d-4970-85c7-2673e8ecef40" } var cfb: String { - switch self { - case .kia: - "wLTVxwidmH8CfJYBWSnHD6E0huk0ozdiuygB4hLkM5XCgzAL1Dk5sE36d/bx5PFMbZs=" - case .hyundai: - "RFtoRq/vDXJmRndoZaZQyfOot7OrIqGVFj96iY2WL3yyH5Z/pUvlUhqmCxD2t+D65SQ=" - case .genesis: - "RFtoRq/vDXJmRndoZaZQyYo3/qFLtVReW8P7utRPcc0ZxOzOELm9mexvviBk/qqIp4A=" - } + // No HMG-style stamp for Porsche. + "cG9yc2NoZS1tb2NrLWNmYi10b2tlbi0xMjM0NTY3ODkwMTIzNA==" } var brandCode: String { - switch self { - case .kia: - "K" - case .hyundai: - "H" - case .genesis: - "G" - } + "P" } var brandName: String { + "Porsche" + } + + var pushType: String { + "APNS" + } + + var apiProviderKind: ApiProviderKind { + .porsche + } + + var audience: String { + "https://api.porsche.com" + } + + var redirectUri: String { + "my-porsche-app://auth0/callback" + } + + var scope: String { + [ + "openid", + "profile", + "email", + "offline_access", + "mbb", + "ssodb", + "badge", + "vin", + "dealers", + "cars", + "charging", + "manageCharging", + "plugAndCharge", + "climatisation", + "manageClimatisation", + "pid:user_profile.porscheid:read", + "pid:user_profile.name:read", + "pid:user_profile.vehicles:read", + "pid:user_profile.dealers:read", + "pid:user_profile.emails:read", + "pid:user_profile.phones:read", + "pid:user_profile.addresses:read", + "pid:user_profile.birthdate:read", + "pid:user_profile.locale:read", + "pid:user_profile.legal:read", + ].joined(separator: " ") + } + + var appApiBaseURL: String { switch self { - case .kia: - "Kia" - case .hyundai: - "Hyundai" - case .genesis: - "Genesis" + case .europe: + "https://api.ppa.porsche.com/app" + case .usa: + "https://api.ppa.porsche.com/app" } } - var pushType: String { - if self == .kia { - "APNS" - } else { - "GCM" + var locale: String { + switch self { + case .europe: + "de_DE" + case .usa: + "en_US" } } } @@ -274,4 +293,5 @@ struct MockApiConfiguration: ApiConfiguration { var brandCode: String = "M" var brandName: String = "Mocker" var pushType: String = "MOCK" + var apiProviderKind: ApiProviderKind = .hmg } diff --git a/KiaMaps/Core/Api/ApiEndpoints.swift b/KiaMaps/Core/Api/ApiEndpoints.swift index f3fb897..48ec116 100644 --- a/KiaMaps/Core/Api/ApiEndpoints.swift +++ b/KiaMaps/Core/Api/ApiEndpoints.swift @@ -8,174 +8,18 @@ import Foundation -/// Defines all API endpoints for vehicle communication -/// Organized by endpoint type and relative base URL -enum ApiEndpoint: CustomStringConvertible { - /// Specifies which base URL an endpoint is relative to +/// Specifies which base URL an endpoint is relative to. +enum ApiEndpointBase { enum RelativeTo { - case base // Main API base host - case login // Authentication host - case spa // Single Page Application API host - case user // User profile host - case mqtt // MQTT host - } - - // MARK: - OAuth2 Authentication Endpoints - case oauth2ConnectorAuthorize // Initial OAuth2 authorization with connector - case oauth2UserAuthorize // User-specific OAuth2 authorization - case oauth2Redirect // OAuth2 redirect callback handler - - // MARK: - Login and Authentication Endpoints - case loginConnectorClients(_ clinetId: String) // Fetch client configuration for authentication - case loginCodes // Get password encryption settings - case loginCertificates // Retrieve RSA certificate for password encryption - case loginSignin // Submit login credentials - case loginToken // Exchange authorization code for access token - case loginRedirect // Handle login redirect - - // MARK: - Session Management - case logout // End user session - case notificationRegister // Register device for push notifications - case notificationRegisterWithDeviceId(UUID) // Register with specific device ID - - // MARK: - User Profile - case userProfile // Retrieve user profile information - - // MARK: - Vehicle Data Endpoints - case vehicles // List all user vehicles - case refreshVehicle(UUID) // Request fresh vehicle status update - case refreshCCS2Vehicle(UUID) // Request fresh status (CCS2 protocol) - case vehicleCachedStatus(UUID) // Get cached vehicle status - case vehicleCachedCCS2Status(UUID) // Get cached status (CCS2 protocol) - - // MARK: - Climate Control Endpoints - case startClimate(UUID) // Start climate control with settings - case stopClimate(UUID) // Stop climate control - - // MARK: - MQTT Endpoints - case mqttDeviceHost // Get address for MQTT broker - case mqttRegisterDevice // To Register device for MQTT broker - case mqttVehicleMetadata // Get vehicle metadata for MQTT broker - case mqttDeviceProtocol // Set what protocols are allowed in MQTT broker - case mqttConnectionState // To get connection state with MQTT broker - - /// Returns the endpoint path and its relative base URL - /// - Returns: Tuple containing the path string and which base URL it's relative to - var path: (String, RelativeTo) { - switch self { - case .oauth2ConnectorAuthorize: - ("api/v1/user/oauth2/connector/common/authorize", .base) - case .oauth2UserAuthorize: - ("auth/api/v2/user/oauth2/authorize", .login) - case .oauth2Redirect: - ("api/v1/user/oauth2/redirect", .base) - case let .loginConnectorClients(clientId): - ("api/v1/clients/\(clientId)", .login) - case .loginCodes: - ("api/v1/commons/codes/HMG_DYNAMIC_CODE/details/PASSWORD_ENCRYPTION", .login) - case .loginCertificates: - ("auth/api/v1/accounts/certs", .login) - case .loginSignin: - ("auth/account/signin", .login) - case .loginToken: - ("auth/api/v2/user/oauth2/token", .login) - case .loginRedirect: - ("auth/redirect", .login) - case .notificationRegister: - ("notifications/register", .spa) - case let .notificationRegisterWithDeviceId(deviceId): - ("notifications/\(deviceId.formatted)/register", .spa) - case .logout: - ("devices/logout", .spa) - case .userProfile: - ("profile", .user) - case .vehicles: - ("vehicles", .spa) - case let .refreshVehicle(vehicleId): - ("vehicles/\(vehicleId.formatted)/status", .spa) - case let .vehicleCachedStatus(vehicleId): - ("vehicles/\(vehicleId.formatted)/status/latest", .spa) - case let .refreshCCS2Vehicle(vehicleId): - ("vehicles/\(vehicleId.formatted)/ccs2/carstatus", .spa) - case let .vehicleCachedCCS2Status(vehicleId): - ("vehicles/\(vehicleId.formatted)/ccs2/carstatus/latest", .spa) - case let .startClimate(vehicleId): - ("vehicles/\(vehicleId.formatted)/control/temperature", .spa) - case let .stopClimate(vehicleId): - ("vehicles/\(vehicleId.formatted)/control/temperature/off", .spa) - case .mqttDeviceHost: - ("api/v3/servicehub/device/host", .mqtt) - case .mqttRegisterDevice: - ("api/v3/servicehub/device/register", .mqtt) - case .mqttVehicleMetadata: - ("api/v3/servicehub/vehicles/metadatalist", .mqtt) - case .mqttDeviceProtocol: - ("api/v3/servicehub/device/protocol", .mqtt) - case .mqttConnectionState: - ("api/v3/vstatus/connstate", .mqtt) - } - } - - /// Human-readable description of the endpoint for logging and debugging - var description: String { - switch self { - case .oauth2ConnectorAuthorize: - "oauth2Authorize" - case .oauth2UserAuthorize: - "oauth2UserAuthorize" - case .oauth2Redirect: - "oauth2Authorize" - case .loginConnectorClients: - "loginConnectorClients" - case .loginCodes: - "loginCodes" - case .loginCertificates: - "loginCertificates" - case .loginSignin: - "loginSignin" - case .loginToken: - "loginToken" - case .loginRedirect: - "loginRedirect" - case .logout: - "logout" - case .notificationRegister: - "notificationRegister" - case .notificationRegisterWithDeviceId: - "notificationRegisterWithDeviceId" - case .userProfile: - "userProfile" - case .vehicles: - "vehicles" - case .refreshVehicle: - "refreshVehicle" - case .vehicleCachedStatus: - "vehicleCachedStatus" - case .refreshCCS2Vehicle: - "refreshCCS2Vehicle" - case .vehicleCachedCCS2Status: - "vehicleCachedCCS2Status" - case .startClimate: - "startClimate" - case .stopClimate: - "stopClimate" - case .mqttDeviceHost: - "mqttDeviceHost" - case .mqttRegisterDevice: - "mqttDeviceHost" - case .mqttVehicleMetadata: - "mqttVehicleMetadata" - case .mqttDeviceProtocol: - "mqttDeviceProtocol" - case .mqttConnectionState: - "mqttConnectionState" - } + case base // Main API base host + case login // Authentication host + case spa // Single Page Application API host + case user // User profile host + case mqtt // MQTT host } } -private extension UUID { - /// Formats UUID for use in API endpoints (lowercase string representation) - var formatted: String { - uuidString.lowercased() - } +/// Shared protocol for brand-specific endpoint enums. +protocol ApiEndpointProtocol: CustomStringConvertible { + var path: (String, ApiEndpointBase.RelativeTo) { get } } diff --git a/KiaMaps/Core/Api/ApiRequest.swift b/KiaMaps/Core/Api/ApiRequest.swift index 0203299..d6a1e58 100644 --- a/KiaMaps/Core/Api/ApiRequest.swift +++ b/KiaMaps/Core/Api/ApiRequest.swift @@ -41,6 +41,8 @@ enum ApiError: Error { case unexpectedStatusCode(Int?) /// Authentication failed (401 status) case unauthorized + /// Feature unavailable for active API provider + case unsupported(String) /// Human-readable error descriptions var localizedDescription: String { @@ -51,6 +53,8 @@ enum ApiError: Error { "unexpectedStatusCode:\(String(describing: int))" case .unauthorized: "unauthorized" + case let .unsupported(message): + message } } } @@ -70,7 +74,7 @@ extension ApiConfiguration { /// - Parameter endpoint: The endpoint to generate URL for /// - Returns: Complete URL for the endpoint /// - Throws: URLError.badURL if URL construction fails - func url(for endpoint: ApiEndpoint) throws -> URL { + func url(for endpoint: any ApiEndpointProtocol) throws -> URL { let result: URL? let (path, base) = endpoint.path switch base { @@ -91,7 +95,11 @@ extension ApiConfiguration { /// Base API URL constructed from host and port private var baseUrl: URL? { - URL(string: baseHost + ":\(port)") + if let configuration = self as? PorscheApiConfiguration { + let normalizedBase = configuration.appApiBaseURL.hasSuffix("/") ? configuration.appApiBaseURL : configuration.appApiBaseURL + "/" + return URL(string: normalizedBase) + } + return URL(string: baseHost + ":\(port)") } /// Login URL for authentication flows @@ -106,7 +114,11 @@ extension ApiConfiguration { /// User-specific API URL private var userUrl: URL? { - URL(string: baseHost + ":\(port)" + "/api/v1/user/") + if let configuration = self as? PorscheApiConfiguration { + let normalizedBase = configuration.loginHost.hasSuffix("/") ? configuration.loginHost : configuration.loginHost + "/" + return URL(string: normalizedBase) + } + return URL(string: baseHost + ":\(port)" + "/api/v1/user/") } /// MQTT API URL @@ -136,7 +148,7 @@ protocol ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, encodable: Encodable, @@ -155,7 +167,7 @@ protocol ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, body: Data?, @@ -174,7 +186,7 @@ protocol ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, form: Form, @@ -208,21 +220,31 @@ protocol ApiRequest { func string() async throws -> String /// Executes request with custom status code and returns raw string func string(acceptStatusCode: Int) async throws -> String + /// Executes request with accepted status codes and returns raw string + func string(acceptStatusCodes: Set) async throws -> String /// Executes request expecting 200 status and returns HTTPURLResponse func httpResponse() async throws -> HTTPURLResponse /// Executes request with custom status code and returns HTTPURLResponse func httpResponse(acceptStatusCode: Int) async throws -> HTTPURLResponse + /// Executes request with accepted status codes and returns HTTPURLResponse + func httpResponse(acceptStatusCodes: Set) async throws -> HTTPURLResponse /// Executes request expecting 200 status and returns decoded data directly func data() async throws -> Data /// Executes request with custom status code and returns decoded data directly func data(acceptStatusCode: Int) async throws -> Data + /// Executes request with accepted status codes and returns decoded data directly + func data(acceptStatusCodes: Set) async throws -> Data + /// Executes request with accepted status codes and returns raw body data + func rawData(acceptStatusCodes: Set) async throws -> Data /// Executes request expecting 302 redirect and returns redirect URL func referalUrl() async throws -> URL /// Executes request with custom status code and returns redirect URL func referalUrl(acceptStatusCode: Int) async throws -> URL + /// Executes request with accepted status codes and returns redirect URL + func referalUrl(acceptStatusCodes: Set) async throws -> URL } extension ApiRequest { @@ -261,17 +283,33 @@ extension ApiRequest { try await string(acceptStatusCode: 200) } + func string(acceptStatusCode: Int) async throws -> String { + try await string(acceptStatusCodes: [acceptStatusCode]) + } + func httpResponse() async throws -> HTTPURLResponse { try await httpResponse(acceptStatusCode: 200) } + func httpResponse(acceptStatusCode: Int) async throws -> HTTPURLResponse { + try await httpResponse(acceptStatusCodes: [acceptStatusCode]) + } + func data() async throws -> Data { try await data(acceptStatusCode: 200) } + func data(acceptStatusCode: Int) async throws -> Data { + try await data(acceptStatusCodes: [acceptStatusCode]) + } + func referalUrl() async throws -> URL { try await referalUrl(acceptStatusCode: 302) } + + func referalUrl(acceptStatusCode: Int) async throws -> URL { + try await referalUrl(acceptStatusCodes: [acceptStatusCode]) + } } /// Concrete implementation of ApiRequest protocol @@ -282,7 +320,7 @@ struct ApiRequestImpl: ApiRequest { /// HTTP method for the request let method: ApiMethod /// API endpoint to call - let endpoint: ApiEndpoint + let endpoint: any ApiEndpointProtocol /// URL query parameters let queryItems: [URLQueryItem] /// HTTP headers @@ -306,7 +344,7 @@ struct ApiRequestImpl: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, encodable: Encodable, @@ -331,7 +369,7 @@ struct ApiRequestImpl: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, body: Data?, @@ -356,13 +394,14 @@ struct ApiRequestImpl: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, form: Form, timeout: TimeInterval ) { - var headers = Self.commonFormHeaders + var headers = headers + headers.merge(Self.commonFormHeaders) { current, _ in current } headers["User-Agent"] = caller.configuration.userAgent headers["Accept"] = "*/*" headers["Accept-Language"] = "en-GB,en;q=0.9" @@ -415,11 +454,11 @@ struct ApiRequestImpl: ApiRequest { } func empty(acceptStatusCode: Int) async throws { - try await callRequest(acceptStatusCode: acceptStatusCode) + try await callRequest(acceptStatusCodes: [acceptStatusCode]) } - func string(acceptStatusCode: Int) async throws -> String { - let (data, _) = try await callRequest(acceptStatusCode: acceptStatusCode) + func string(acceptStatusCodes: Set) async throws -> String { + let (data, _) = try await callRequest(acceptStatusCodes: acceptStatusCodes) guard let string = String(data: data, encoding: .utf8) else { throw URLError(.cannotDecodeContentData) } @@ -427,8 +466,8 @@ struct ApiRequestImpl: ApiRequest { return string } - func httpResponse(acceptStatusCode: Int) async throws -> HTTPURLResponse { - let (_, response) = try await callRequest(acceptStatusCode: acceptStatusCode) + func httpResponse(acceptStatusCodes: Set) async throws -> HTTPURLResponse { + let (_, response) = try await callRequest(acceptStatusCodes: acceptStatusCodes) logDebug("\(String(describing: endpoint)) - result: \(String(describing: response))", category: .api) guard let response = response as? HTTPURLResponse else { throw URLError(.cannotDecodeContentData) @@ -436,15 +475,20 @@ struct ApiRequestImpl: ApiRequest { return response } - func data(acceptStatusCode: Int) async throws -> Data { - let (data, _) = try await callRequest(acceptStatusCode: acceptStatusCode) + func data(acceptStatusCodes: Set) async throws -> Data { + let data = try await rawData(acceptStatusCodes: acceptStatusCodes) let result = try JSONDecoders.default.decode(Data.self, from: data) logDebug("\(String(describing: endpoint)) - result: \(String(describing: result))", category: .api) return result } - func referalUrl(acceptStatusCode: Int) async throws -> URL { - let httpResponse = try await httpResponse(acceptStatusCode: acceptStatusCode) + func rawData(acceptStatusCodes: Set) async throws -> Data { + let (data, _) = try await callRequest(acceptStatusCodes: acceptStatusCodes) + return data + } + + func referalUrl(acceptStatusCodes: Set) async throws -> URL { + let httpResponse = try await httpResponse(acceptStatusCodes: acceptStatusCodes) guard let location = httpResponse.allHeaderFields["Location"] as? String, let url = URL(string: location) else { @@ -454,7 +498,7 @@ struct ApiRequestImpl: ApiRequest { } @discardableResult - private func callRequest(acceptStatusCode: Int) async throws -> (Data, URLResponse) { + private func callRequest(acceptStatusCodes: Set) async throws -> (Data, URLResponse) { let urlRequest = try self.urlRequest logDebug("\(String(describing: endpoint)) - request: \(String(describing: urlRequest.url)) \(String(describing: urlRequest.allHTTPHeaderFields))", category: .api) @@ -469,7 +513,7 @@ struct ApiRequestImpl: ApiRequest { } } - guard acceptStatusCode == acceptStatusCode else { + guard acceptStatusCodes.contains(response.status ?? 0) else { throw ApiError.unexpectedStatusCode(response.status) } return (data, response) @@ -554,7 +598,7 @@ class ApiRequestProvider: NSObject { /// - Throws: Encoding errors func request( with method: ApiMethod? = nil, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem] = [], headers: ApiRequest.Headers = [:], encodable: Encodable, @@ -582,7 +626,7 @@ class ApiRequestProvider: NSObject { /// - Returns: Configured API request func request( with method: ApiMethod? = nil, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem] = [], headers: ApiRequest.Headers = [:], body: Data? = nil, @@ -610,7 +654,7 @@ class ApiRequestProvider: NSObject { /// - Returns: Configured API request func request( with method: ApiMethod? = nil, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem] = [], headers: ApiRequest.Headers = [:], string: String, @@ -638,7 +682,7 @@ class ApiRequestProvider: NSObject { /// - Returns: Configured API request func request( with method: ApiMethod? = nil, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem] = [], headers: ApiRequest.Headers = [:], form: ApiRequest.Form, @@ -684,4 +728,3 @@ extension ApiRequestProvider: URLSessionTaskDelegate { } } } - diff --git a/KiaMaps/Core/Api/Kia/HMGAuthClient.swift b/KiaMaps/Core/Api/Kia/HMGAuthClient.swift new file mode 100644 index 0000000..857cfd4 --- /dev/null +++ b/KiaMaps/Core/Api/Kia/HMGAuthClient.swift @@ -0,0 +1,365 @@ +// +// HMGAuthClient.swift +// KiaMaps +// +// Created by Codex on 09.03.2026. +// + +import Foundation + +struct HMGAuthClient { + let configuration: ApiConfiguration + let provider: ApiRequestProvider + let rsaService: RSAEncryptionService + + func makeAuthorizeURL() throws -> URL? { + let queryItems = [ + URLQueryItem(name: "client_id", value: configuration.serviceId), + URLQueryItem(name: "redirect_uri", value: "https://prd.eu-ccapi.kia.com:8080/api/v1/user/oauth2/redirect"), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "lang", value: "en"), + URLQueryItem(name: "state", value: "ccsp"), + ] + + return try provider.request( + endpoint: KiaApiEndpoint.oauth2UserAuthorize, + queryItems: queryItems, + headers: commonNavigationHeaders() + ).urlRequest.url + } + + func authenticate(username: String, password: String, recaptchaToken: String? = nil) async throws -> AuthorizationData { + cleanCookies() + + let referer: String + do { + referer = try await fetchConnectorAuthorization() + logInfo("Retrieved referer: \(referer)", category: .api) + } catch { + logError("Client connector authorization failed: \(error.localizedDescription)", category: .api) + throw AuthenticationError.clientConfigurationFailed + } + + let clientConfig = try await fetchClientConfiguration(referer: referer) + logInfo("Client configured for: \(clientConfig.clientName)", category: .api) + + let encryptionSettings = try await fetchPasswordEncryptionSettings(referer: referer) + guard encryptionSettings.useEnabled && encryptionSettings.value1 == "true" else { + throw AuthenticationError.encryptionSettingsFailed + } + + let rsaKey: RSAEncryptionService.RSAKeyData + do { + rsaKey = try await fetchRSACertificate(referer: referer) + } catch { + logError("Fetch RSA Certificate failed: \(error.localizedDescription)", category: .api) + throw AuthenticationError.certificateRetrievalFailed + } + + let csrfToken = try await initializeOAuth2(referer: referer) + let authorizationCode = try await signIn( + referer: referer, + username: username, + password: password, + rsaKey: rsaKey, + csrfToken: csrfToken, + recaptchaToken: recaptchaToken + ) + + return try await exchangeAuthorizationCode(authorizationCode) + } + + func exchangeAuthorizationCode(_ authorizationCode: String) async throws -> AuthorizationData { + let tokenResponse: TokenResponse + do { + tokenResponse = try await exchangeCodeForTokens(authorizationCode: authorizationCode) + } catch { + logError("Exchange code for token failed: \(error.localizedDescription)", category: .api) + throw AuthenticationError.tokenExchangeFailed + } + + let stamp = AuthorizationData.generateStamp(for: configuration) + let deviceId = try await registerDeviceId(stamp: stamp) + let authorizationData = AuthorizationData( + stamp: stamp, + deviceId: deviceId, + accessToken: tokenResponse.accessToken, + expiresIn: tokenResponse.expiresIn, + refreshToken: tokenResponse.refreshToken, + isCcuCCS2Supported: true + ) + + provider.authorization = authorizationData + try await notificationRegister(deviceId: deviceId) + return authorizationData + } + + func logout() async throws { + do { + try await provider.request(with: .post, endpoint: KiaApiEndpoint.logout).empty() + logInfo("Successfully logout", category: .auth) + } catch { + logError("Failed to logout: \(error.localizedDescription)", category: .auth) + } + provider.authorization = nil + cleanCookies() + } + + private func fetchConnectorAuthorization() async throws -> String { + let stateObject = ConnectorAuthorizationState( + scope: nil, + state: nil, + lang: nil, + cert: "", + action: "idpc_auth_endpoint", + clientId: configuration.serviceId, + redirectUri: try makeRedirectUri(endpoint: .loginRedirect), + responseType: "code", + signupLink: nil, + hmgid2ClientId: configuration.authClientId, + hmgid2RedirectUri: try makeRedirectUri(), + hmgid2Scope: nil, + hmgid2State: "ccsp", + hmgid2UiLocales: nil + ) + let stateData = try JSONEncoder().encode(stateObject) + + let queryItems = [ + URLQueryItem(name: "client_id", value: configuration.serviceId), + URLQueryItem(name: "redirect_uri", value: try makeRedirectUri(endpoint: .loginRedirect).absoluteString), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "state", value: stateData.base64EncodedString()), + URLQueryItem(name: "cert", value: ""), + URLQueryItem(name: "action", value: "idpc_auth_endpoint"), + URLQueryItem(name: "sso_session_reset", value: "true") + ] + + let referralURL = try await provider.request( + endpoint: KiaApiEndpoint.oauth2ConnectorAuthorize, + queryItems: queryItems, + headers: commonNavigationHeaders() + ).referalUrl() + + guard let nextURI = extractNextURI(from: referralURL) else { + throw AuthenticationError.oauth2InitializationFailed + } + return nextURI + } + + private func fetchClientConfiguration(referer _: String) async throws -> ClientConfiguration { + try await provider.request( + endpoint: KiaApiEndpoint.loginConnectorClients(configuration.serviceId), + headers: commonJSONHeaders() + ).responseValue() + } + + private func fetchPasswordEncryptionSettings(referer: String) async throws -> PasswordEncryptionSettings { + try await provider.request( + endpoint: KiaApiEndpoint.loginCodes, + headers: commonJSONHeaders(referer: referer) + ).responseValue() + } + + private func fetchRSACertificate(referer: String) async throws -> RSAEncryptionService.RSAKeyData { + let certificate: RSACertificateResponse = try await provider.request( + endpoint: KiaApiEndpoint.loginCertificates, + headers: commonJSONHeaders(referer: referer) + ).responseValue() + + return RSAEncryptionService.RSAKeyData( + keyType: certificate.kty, + exponent: certificate.e, + keyId: certificate.kid, + modulus: certificate.n + ) + } + + private func initializeOAuth2(referer: String) async throws -> String { + let queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: configuration.serviceId), + URLQueryItem(name: "redirect_uri", value: try makeRedirectUri().absoluteString), + URLQueryItem(name: "lang", value: "en"), + URLQueryItem(name: "state", value: "ccsp") + ] + + _ = try await provider.request( + endpoint: KiaApiEndpoint.oauth2UserAuthorize, + queryItems: queryItems, + headers: commonNavigationHeaders(referer: referer) + ).empty(acceptStatusCode: 302) + + guard let cookie = HTTPCookieStorage.shared.cookies?.first(where: { $0.name == "account" }) else { + throw AuthenticationError.csrfTokenNotFound + } + return cookie.value + } + + private func signIn( + referer: String, + username: String, + password: String, + rsaKey: RSAEncryptionService.RSAKeyData, + csrfToken: String, + recaptchaToken: String? = nil + ) async throws -> String { + let encryptedPassword = try rsaService.encryptPassword(password, with: rsaKey) + + guard let connectorSessionKey = extractConnectorSessionKey(from: referer) else { + throw AuthenticationError.sessionKeyNotFound + } + + var form: [String: String] = [ + "client_id": configuration.serviceId, + "encryptedPassword": "true", + "orgHmgSid": "", + "password": encryptedPassword, + "kid": rsaKey.keyId, + "redirect_uri": try makeRedirectUri().absoluteString, + "scope": "", + "nonce": "", + "state": "ccsp", + "username": username, + "remember_me": "false", + "connector_session_key": connectorSessionKey, + "_csrf": csrfToken + ] + + if let recaptchaToken { + form["g-recaptcha-response"] = recaptchaToken + logInfo("Including reCAPTCHA token in sign-in request", category: .auth) + } + + let referralURL = try await provider.request( + with: .post, + endpoint: KiaApiEndpoint.loginSignin, + headers: [ + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + "Origin": "https://idpconnect-eu.\(configuration.key).com", + "Referer": referer + ], + form: form + ).referalUrl() + + let (code, _, loginSuccess) = try extractAuthorizationCode(from: referralURL) + guard loginSuccess else { + throw AuthenticationError.signInFailed + } + return code + } + + private func exchangeCodeForTokens(authorizationCode: String) async throws -> TokenResponse { + let form: [String: String] = [ + "client_id": configuration.serviceId, + "client_secret": "secret", + "code": authorizationCode, + "grant_type": "authorization_code", + "redirect_uri": try makeRedirectUri().absoluteString + ] + + return try await provider.request( + with: .post, + endpoint: KiaApiEndpoint.loginToken, + form: form + ).data() + } + + private func registerDeviceId(stamp: String) async throws -> UUID { + let registrationId = "60a0cce8de8b3b51745f10bc35fe07cb000000ef" + let uuid = UUID().uuidString + + let headers = [ + "ccsp-service-id": configuration.serviceId, + "ccsp-application-id": configuration.appId, + "Stamp": stamp, + ] + let payload: [String: String] = [ + "pushRegId": registrationId, + "pushType": configuration.pushType, + "uuid": uuid, + ] + + let response: NotificationRegistrationResponse = try await provider.request( + endpoint: KiaApiEndpoint.notificationRegister, + headers: headers, + encodable: payload + ).response(acceptStatusCode: 302) + return response.deviceId + } + + private func notificationRegister(deviceId: UUID) async throws { + var headers = provider.authorization?.authorizatioHeaders(for: configuration) ?? [:] + headers["Content-Type"] = "application/json; charset=UTF-8" + headers["offset"] = "2" + try await provider.request( + with: .post, + endpoint: KiaApiEndpoint.notificationRegisterWithDeviceId(deviceId), + headers: headers + ).empty(acceptStatusCode: 200) + } + + private func makeRedirectUri(endpoint: KiaApiEndpoint = .oauth2Redirect) throws -> URL { + try provider.configuration.url(for: endpoint) + } + + private func extractNextURI(from location: URL) -> String? { + guard let components = URLComponents(url: location, resolvingAgainstBaseURL: false) else { + return nil + } + return components.queryItems?.first(where: { $0.name == "next_uri" })?.value + } + + private func extractConnectorSessionKey(from location: String) -> String? { + guard let url = URL(string: location), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + return components.queryItems?.first(where: { $0.name == "connector_session_key" })?.value + } + + func extractAuthorizationCode(from location: URL) throws -> (code: String, state: String, loginSuccess: Bool) { + guard let components = URLComponents(url: location, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + let code = queryItems.first(where: { $0.name == "code" })?.value + else { + throw AuthenticationError.authorizationCodeNotFound + } + + let state = queryItems.first(where: { $0.name == "state" })?.value ?? "ccsp" + let loginSuccess = queryItems.first(where: { $0.name == "login_success" })?.value == "y" + return (code, state, loginSuccess) + } + + private func commonJSONHeaders(referer: String? = nil) -> [String: String] { + var headers = [ + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + ] + if let referer { + headers["Referer"] = referer + } + return headers + } + + private func commonNavigationHeaders(referer: String? = nil) -> [String: String] { + var headers = [ + "Sec-Fetch-Site": "none", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + ] + if let referer { + headers["Referer"] = referer + } + return headers + } + + private func cleanCookies() { + let cookies = HTTPCookieStorage.shared.cookies ?? [] + for cookie in cookies { + HTTPCookieStorage.shared.deleteCookie(cookie) + } + } +} diff --git a/KiaMaps/Core/Api/Kia/HMGMQTTClient.swift b/KiaMaps/Core/Api/Kia/HMGMQTTClient.swift new file mode 100644 index 0000000..528babe --- /dev/null +++ b/KiaMaps/Core/Api/Kia/HMGMQTTClient.swift @@ -0,0 +1,109 @@ +// +// HMGMQTTClient.swift +// KiaMaps +// +// Created by Codex on 09.03.2026. +// + +import Foundation + +struct HMGMQTTClient { + let configuration: ApiConfiguration + let provider: ApiRequestProvider + + func fetchDeviceHost() async throws -> MQTTHostInfo { + try ensureSupported() + guard provider.authorization?.accessToken != nil else { + throw ApiError.unauthorized + } + + let response: MQTTHostResponse = try await provider.request(endpoint: KiaApiEndpoint.mqttDeviceHost).data() + return MQTTHostInfo(host: response.mqtt.host, port: response.mqtt.port, ssl: response.mqtt.ssl) + } + + func registerDevice() async throws -> MQTTDeviceInfo { + try ensureSupported() + guard provider.authorization?.accessToken != nil else { + throw ApiError.unauthorized + } + + let deviceUUID = "\(UUID().uuidString)_UVO" + let request = DeviceRegisterRequest(unit: "mobile", uuid: deviceUUID) + let response: DeviceRegisterResponse = try await provider.request( + endpoint: KiaApiEndpoint.mqttRegisterDevice, + encodable: request + ).data() + + return MQTTDeviceInfo(clientId: response.clientId, deviceId: response.deviceId, uuid: deviceUUID) + } + + func fetchVehicleMetadata(for vehicleId: UUID, clientId: String) async throws -> [MQTTVehicleMetadata] { + try ensureSupported() + guard provider.authorization?.accessToken != nil else { + throw ApiError.unauthorized + } + + let response: VehicleMetadataResponse = try await provider.request( + endpoint: KiaApiEndpoint.mqttVehicleMetadata, + queryItems: [ + URLQueryItem(name: "carId", value: vehicleId.uuidString), + URLQueryItem(name: "brand", value: configuration.brandCode) + ], + headers: [ + "client-id": clientId + ] + ).data() + + return response.vehicles + } + + func subscribeVehicleProtocols( + for vehicleId: UUID, + clientId: String, + protocolId: any MQTTProtocol, + protocols: [any MQTTProtocol] + ) async throws { + try ensureSupported() + guard provider.authorization?.accessToken != nil else { + throw ApiError.unauthorized + } + + let request = ProtocolSubscriptionRequest( + protocols: protocols, + protocolId: protocolId, + carId: vehicleId, + brand: configuration.brandCode + ) + + try await provider.request( + endpoint: KiaApiEndpoint.mqttDeviceProtocol, + headers: [ + "client-id": clientId + ], + encodable: request + ).empty() + } + + func checkConnectionState(clientId: String) async throws -> ConnectionStateResponse { + try ensureSupported() + guard provider.authorization?.accessToken != nil else { + throw ApiError.unauthorized + } + + return try await provider.request( + endpoint: KiaApiEndpoint.mqttConnectionState, + queryItems: [ + URLQueryItem(name: "clientId", value: clientId), + ], + headers: [ + "client-id": clientId + ] + ).data() + } + + private func ensureSupported() throws { + guard configuration.apiProviderKind == .hmg else { + throw ApiError.unsupported("MQTT is not supported for Porsche.") + } + } +} diff --git a/KiaMaps/Core/Api/Kia/HMGVehicleClient.swift b/KiaMaps/Core/Api/Kia/HMGVehicleClient.swift new file mode 100644 index 0000000..150f493 --- /dev/null +++ b/KiaMaps/Core/Api/Kia/HMGVehicleClient.swift @@ -0,0 +1,86 @@ +// +// HMGVehicleClient.swift +// KiaMaps +// +// Created by Codex on 09.03.2026. +// + +import Foundation + +struct HMGVehicleClient { + let provider: ApiRequestProvider + + private var authorization: AuthorizationData? { + provider.authorization + } + + func vehicles() async throws -> VehicleResponse { + guard authorization != nil else { + throw ApiError.unauthorized + } + return try await provider.request(endpoint: KiaApiEndpoint.vehicles).response() + } + + func refreshVehicle(_ vehicleId: UUID) async throws -> UUID { + guard let authorization else { + throw ApiError.unauthorized + } + let endpoint: KiaApiEndpoint = authorization.isCcuCCS2Supported == true ? .refreshCCS2Vehicle(vehicleId) : .refreshVehicle(vehicleId) + return try await provider.request(endpoint: endpoint).responseEmpty().resultId + } + + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot { + guard let authorization else { + throw ApiError.unauthorized + } + let endpoint: KiaApiEndpoint = authorization.isCcuCCS2Supported == true ? .vehicleCachedCCS2Status(vehicleId) : .vehicleCachedStatus(vehicleId) + let response: VehicleStateResponse = try await provider.request(endpoint: endpoint).response() + return KiaVehicleStatusMapper.map(response: response) + } + + func profile() async throws -> String { + guard authorization != nil else { + throw ApiError.unauthorized + } + return try await provider.request(endpoint: KiaApiEndpoint.userProfile).string() + } + + func startClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin: String) async throws -> UUID { + guard authorization?.accessToken != nil else { + throw ApiError.unauthorized + } + guard !pin.isEmpty else { + throw ClimateControlError.missingPin + } + guard options.isValid else { + if !options.isTemperatureValid { + throw ClimateControlError.invalidTemperature(options.temperature) + } + if !options.areSeatLevelsValid { + let invalidLevel = [options.driverSeatLevel, options.passengerSeatLevel, options.rearLeftSeatLevel, options.rearRightSeatLevel] + .first { $0 < 0 || $0 > 3 } ?? -1 + throw ClimateControlError.invalidSeatLevel(invalidLevel) + } + if !options.isDurationValid { + throw ClimateControlError.invalidDuration(options.duration) + } + throw ClimateControlError.vehicleNotReady + } + + return try await provider.request( + with: .post, + endpoint: KiaApiEndpoint.startClimate(vehicleId), + encodable: options.toClimateControlRequest(pin: pin) + ).responseEmpty().resultId + } + + func stopClimate(_ vehicleId: UUID) async throws -> UUID { + guard authorization?.accessToken != nil else { + throw ApiError.unauthorized + } + return try await provider.request( + with: .post, + endpoint: KiaApiEndpoint.stopClimate(vehicleId) + ).responseEmpty().resultId + } +} diff --git a/KiaMaps/Core/Api/Kia/KiaApiConfiguration.swift b/KiaMaps/Core/Api/Kia/KiaApiConfiguration.swift new file mode 100644 index 0000000..a42d5df --- /dev/null +++ b/KiaMaps/Core/Api/Kia/KiaApiConfiguration.swift @@ -0,0 +1,162 @@ +// +// KiaApiConfiguration.swift +// KiaMaps +// +// Created by Lukas Foldyna on 31.05.2024. +// Copyright © 2024 Lukas Foldyna. All rights reserved. +// + +import UIKit + +/// European region API configuration for supported vehicle brands. +/// Provides brand-specific endpoints, credentials, and service identifiers for EU market. +enum KiaApiConfigurationEurope: String, ApiConfiguration { + case kia + case hyundai + case genesis + + var key: String { + switch self { + case .kia: + "kia" + case .hyundai: + "hyundai" + case .genesis: + "genesis" + } + } + + var name: String { + switch self { + case .kia: + "Kia" + case .hyundai: + "Hyundai" + case .genesis: + "Genesis" + } + } + + var port: Int { + switch self { + case .kia: + 8080 + case .hyundai, .genesis: + 443 + } + } + + var serviceAgent: String { + "okhttp/3.12.0" + } + + var userAgent: String { + let device = UIDevice.current + return "EU_BlueLink/2.1.18 (com.kia.connect.eu; build:10560; \(device.systemName) \(device.systemVersion)) Alamofire/5.8.0" + } + + var acceptHeader: String { + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" + } + + var baseHost: String { + switch self { + case .kia: + "https://prd.eu-ccapi.kia.com" + case .hyundai: + "https://prd.eu-ccapi.hyundai.com" + case .genesis: + "https://prd-eu-ccapi.genesis.com" + } + } + + var loginHost: String { + "https://idpconnect-eu.\(key).com" + } + + var mqttHost: String { + "https://egw-svchub-ccs-\(brandCode.lowercased())-eu.eu-central.hmgmobility.com:31010" + } + + var serviceId: String { + switch self { + case .kia: + "fdc85c00-0a2f-4c64-bcb4-2cfb1500730a" + case .hyundai: + "6d477c38-3ca4-4cf3-9557-2a1929a94654" + case .genesis: + "3020afa2-30ff-412a-aa51-d28fbe901e10" + } + } + + var appId: String { + switch self { + case .kia: + "a2b8469b-30a3-4361-8e13-6fceea8fbe74" + case .hyundai: + "014d2225-8495-4735-812d-2616334fd15d" + case .genesis: + "f11f2b86-e0e7-4851-90df-5600b01d8b70" + } + } + + var senderId: Int { + 199_360_397_125 + } + + var authClientId: String { + switch self { + case .kia: + "572e0304-5f8d-4b4c-9dd5-41aa84eed160" + case .hyundai: + "64621b96-0f0d-11ec-82a8-0242ac130003" + case .genesis: + "3020afa2-30ff-412a-aa51-d28fbe901e10" + } + } + + var cfb: String { + switch self { + case .kia: + "wLTVxwidmH8CfJYBWSnHD6E0huk0ozdiuygB4hLkM5XCgzAL1Dk5sE36d/bx5PFMbZs=" + case .hyundai: + "RFtoRq/vDXJmRndoZaZQyfOot7OrIqGVFj96iY2WL3yyH5Z/pUvlUhqmCxD2t+D65SQ=" + case .genesis: + "RFtoRq/vDXJmRndoZaZQyYo3/qFLtVReW8P7utRPcc0ZxOzOELm9mexvviBk/qqIp4A=" + } + } + + var brandCode: String { + switch self { + case .kia: + "K" + case .hyundai: + "H" + case .genesis: + "G" + } + } + + var brandName: String { + switch self { + case .kia: + "Kia" + case .hyundai: + "Hyundai" + case .genesis: + "Genesis" + } + } + + var pushType: String { + if self == .kia { + "APNS" + } else { + "GCM" + } + } + + var apiProviderKind: ApiProviderKind { + .hmg + } +} diff --git a/KiaMaps/Core/Api/Kia/KiaApiEndpoint.swift b/KiaMaps/Core/Api/Kia/KiaApiEndpoint.swift new file mode 100644 index 0000000..e0bd84e --- /dev/null +++ b/KiaMaps/Core/Api/Kia/KiaApiEndpoint.swift @@ -0,0 +1,169 @@ +// +// KiaApiEndpoint.swift +// KiaMaps +// +// Created by Lukas Foldyna on 31.05.2024. +// Copyright © 2024 Lukas Foldyna. All rights reserved. +// + +import Foundation + +/// Defines Kia/Hyundai/Genesis API endpoints. +/// Organized by endpoint type and relative base URL. +enum KiaApiEndpoint: ApiEndpointProtocol { + + // MARK: - OAuth2 Authentication Endpoints + case oauth2ConnectorAuthorize + case oauth2UserAuthorize + case oauth2Redirect + + // MARK: - Login and Authentication Endpoints + case loginConnectorClients(_ clinetId: String) + case loginCodes + case loginCertificates + case loginSignin + case loginToken + case loginRedirect + + // MARK: - Session Management + case logout + case notificationRegister + case notificationRegisterWithDeviceId(UUID) + + // MARK: - User Profile + case userProfile + + // MARK: - Vehicle Data Endpoints + case vehicles + case refreshVehicle(UUID) + case refreshCCS2Vehicle(UUID) + case vehicleCachedStatus(UUID) + case vehicleCachedCCS2Status(UUID) + + // MARK: - Climate Control Endpoints + case startClimate(UUID) + case stopClimate(UUID) + + // MARK: - MQTT Endpoints + case mqttDeviceHost + case mqttRegisterDevice + case mqttVehicleMetadata + case mqttDeviceProtocol + case mqttConnectionState + + var path: (String, ApiEndpointBase.RelativeTo) { + switch self { + case .oauth2ConnectorAuthorize: + ("api/v1/user/oauth2/connector/common/authorize", .base) + case .oauth2UserAuthorize: + ("auth/api/v2/user/oauth2/authorize", .login) + case .oauth2Redirect: + ("api/v1/user/oauth2/redirect", .base) + case let .loginConnectorClients(clientId): + ("api/v1/clients/\(clientId)", .login) + case .loginCodes: + ("api/v1/commons/codes/HMG_DYNAMIC_CODE/details/PASSWORD_ENCRYPTION", .login) + case .loginCertificates: + ("auth/api/v1/accounts/certs", .login) + case .loginSignin: + ("auth/account/signin", .login) + case .loginToken: + ("auth/api/v2/user/oauth2/token", .login) + case .loginRedirect: + ("auth/redirect", .login) + case .notificationRegister: + ("notifications/register", .spa) + case let .notificationRegisterWithDeviceId(deviceId): + ("notifications/\(deviceId.formatted)/register", .spa) + case .logout: + ("devices/logout", .spa) + case .userProfile: + ("profile", .user) + case .vehicles: + ("vehicles", .spa) + case let .refreshVehicle(vehicleId): + ("vehicles/\(vehicleId.formatted)/status", .spa) + case let .vehicleCachedStatus(vehicleId): + ("vehicles/\(vehicleId.formatted)/status/latest", .spa) + case let .refreshCCS2Vehicle(vehicleId): + ("vehicles/\(vehicleId.formatted)/ccs2/carstatus", .spa) + case let .vehicleCachedCCS2Status(vehicleId): + ("vehicles/\(vehicleId.formatted)/ccs2/carstatus/latest", .spa) + case let .startClimate(vehicleId): + ("vehicles/\(vehicleId.formatted)/control/temperature", .spa) + case let .stopClimate(vehicleId): + ("vehicles/\(vehicleId.formatted)/control/temperature/off", .spa) + case .mqttDeviceHost: + ("api/v3/servicehub/device/host", .mqtt) + case .mqttRegisterDevice: + ("api/v3/servicehub/device/register", .mqtt) + case .mqttVehicleMetadata: + ("api/v3/servicehub/vehicles/metadatalist", .mqtt) + case .mqttDeviceProtocol: + ("api/v3/servicehub/device/protocol", .mqtt) + case .mqttConnectionState: + ("api/v3/vstatus/connstate", .mqtt) + } + } + + var description: String { + switch self { + case .oauth2ConnectorAuthorize: + "oauth2Authorize" + case .oauth2UserAuthorize: + "oauth2UserAuthorize" + case .oauth2Redirect: + "oauth2Authorize" + case .loginConnectorClients: + "loginConnectorClients" + case .loginCodes: + "loginCodes" + case .loginCertificates: + "loginCertificates" + case .loginSignin: + "loginSignin" + case .loginToken: + "loginToken" + case .loginRedirect: + "loginRedirect" + case .logout: + "logout" + case .notificationRegister: + "notificationRegister" + case .notificationRegisterWithDeviceId: + "notificationRegisterWithDeviceId" + case .userProfile: + "userProfile" + case .vehicles: + "vehicles" + case .refreshVehicle: + "refreshVehicle" + case .vehicleCachedStatus: + "vehicleCachedStatus" + case .refreshCCS2Vehicle: + "refreshCCS2Vehicle" + case .vehicleCachedCCS2Status: + "vehicleCachedCCS2Status" + case .startClimate: + "startClimate" + case .stopClimate: + "stopClimate" + case .mqttDeviceHost: + "mqttDeviceHost" + case .mqttRegisterDevice: + "mqttDeviceHost" + case .mqttVehicleMetadata: + "mqttVehicleMetadata" + case .mqttDeviceProtocol: + "mqttDeviceProtocol" + case .mqttConnectionState: + "mqttConnectionState" + } + } +} + +private extension UUID { + var formatted: String { + uuidString.lowercased() + } +} diff --git a/KiaMaps/Core/Api/Models/MQTTModels.swift b/KiaMaps/Core/Api/Kia/KiaMQTTModels.swift similarity index 98% rename from KiaMaps/Core/Api/Models/MQTTModels.swift rename to KiaMaps/Core/Api/Kia/KiaMQTTModels.swift index 4155e70..6bdcc20 100644 --- a/KiaMaps/Core/Api/Models/MQTTModels.swift +++ b/KiaMaps/Core/Api/Kia/KiaMQTTModels.swift @@ -24,7 +24,7 @@ enum MQTTConnectionStatus { case .error: return "Error" } } - + var displayText: String { switch self { case .disconnected: return "Disconnected" @@ -146,7 +146,7 @@ struct MQTTHostResponse: Decodable { struct HTTPInfo: Decodable { let name: String - let `protocol`: String // Escaped Swift keyword + let `protocol`: String let host: String let port: Int let ssl: Bool @@ -154,7 +154,7 @@ struct MQTTHostResponse: Decodable { struct MQTTInfo: Decodable { let name: String - let `protocol`: String // Escaped Swift keyword + let `protocol`: String let host: String let port: Int let ssl: Bool diff --git a/KiaMaps/Core/Api/Kia/KiaVehicleStatusMapper.swift b/KiaMaps/Core/Api/Kia/KiaVehicleStatusMapper.swift new file mode 100644 index 0000000..cb8a194 --- /dev/null +++ b/KiaMaps/Core/Api/Kia/KiaVehicleStatusMapper.swift @@ -0,0 +1,174 @@ +// +// KiaVehicleStatusMapper.swift +// KiaMaps +// +// Created by Codex on 09.03.2026. +// + +import Foundation + +enum KiaVehicleStatusMapper { + static func map(response: VehicleStateResponse) -> VehicleStatusSnapshot { + VehicleStatusSnapshot( + lastUpdateTime: response.lastUpdateTime, + status: map(state: response.state.vehicle) + ) + } + + static func map(state: VehicleState) -> VehicleStatus { + VehicleStatus( + body: .init( + hood: .init( + open: state.body.hood.open, + frunk: .init(fault: state.body.hood.frunk.fault) + ), + trunk: .init(open: state.body.trunk.open) + ), + cabin: .init( + hvac: .init( + row1: .init( + driver: .init( + temperature: .init( + value: state.cabin.hvac.row1.driver.temperature.value, + unit: state.cabin.hvac.row1.driver.temperature.unit + ), + blower: .init(speedLevel: state.cabin.hvac.row1.driver.blower.speedLevel) + ) + ), + ventilation: .init( + airCleaning: .init(indicator: state.cabin.hvac.ventilation.airCleaning.indicator) + ) + ), + door: .init( + row1: .init( + passenger: .init( + lock: state.cabin.door.row1.passenger.lock, + open: state.cabin.door.row1.passenger.open + ), + driver: .init( + lock: state.cabin.door.row1.driver.lock, + open: state.cabin.door.row1.driver.open + ) + ), + row2: .init( + left: .init( + lock: state.cabin.door.row2.left.lock, + open: state.cabin.door.row2.left.open + ), + right: .init( + lock: state.cabin.door.row2.right.lock, + open: state.cabin.door.row2.right.open + ) + ) + ), + seat: .init( + row1: .init( + passenger: .init(climate: .init(state: state.cabin.seat.row1.passenger.climate.state)), + driver: .init(climate: .init(state: state.cabin.seat.row1.driver.climate.state)) + ), + row2: .init( + left: .init(climate: .init(state: state.cabin.seat.row2.left.climate.state)), + right: .init(climate: .init(state: state.cabin.seat.row2.right.climate.state)) + ) + ) + ), + chassis: .init( + axle: .init( + row1: .init( + left: .init(tire: .init( + pressureLow: state.chassis.axle.row1.left.tire.pressureLow, + pressure: state.chassis.axle.row1.left.tire.pressure + )), + right: .init(tire: .init( + pressureLow: state.chassis.axle.row1.right.tire.pressureLow, + pressure: state.chassis.axle.row1.right.tire.pressure + )) + ), + row2: .init( + left: .init(tire: .init( + pressureLow: state.chassis.axle.row2.left.tire.pressureLow, + pressure: state.chassis.axle.row2.left.tire.pressure + )), + right: .init(tire: .init( + pressureLow: state.chassis.axle.row2.right.tire.pressureLow, + pressure: state.chassis.axle.row2.right.tire.pressure + )) + ), + tire: .init( + pressureLow: state.chassis.axle.tire.pressureLow, + pressureUnit: state.chassis.axle.tire.pressureUnit + ) + ) + ), + drivetrain: .init( + fuelSystem: .init( + dte: .init( + unit: state.drivetrain.fuelSystem.dte.unit, + total: state.drivetrain.fuelSystem.dte.total + ), + averageFuelEconomy: .init( + drive: state.drivetrain.fuelSystem.averageFuelEconomy.drive, + afterRefuel: state.drivetrain.fuelSystem.averageFuelEconomy.afterRefuel, + accumulated: state.drivetrain.fuelSystem.averageFuelEconomy.accumulated, + unit: state.drivetrain.fuelSystem.averageFuelEconomy.unit + ) + ), + odometer: state.drivetrain.odometer, + transmission: .init(parkingPosition: state.drivetrain.transmission.parkingPosition) + ), + green: .init( + batteryManagement: .init( + soH: .init(ratio: state.green.batteryManagement.soH.ratio), + batteryRemain: .init( + value: state.green.batteryManagement.batteryRemain.value, + ratio: state.green.batteryManagement.batteryRemain.ratio + ) + ), + electric: .init( + smartGrid: .init( + vehicleToLoad: .init( + dischargeLimitation: .init( + soc: state.green.electric.smartGrid.vehicleToLoad.dischargeLimitation.soc, + remainTime: state.green.electric.smartGrid.vehicleToLoad.dischargeLimitation.remainTime + ) + ), + vehicleToGrid: .init(mode: state.green.electric.smartGrid.vehicleToGrid.mode), + realTimePower: state.green.electric.smartGrid.realTimePower + ) + ), + chargingInformation: .init( + connectorFastening: .init(state: state.green.chargingInformation.connectorFastening.state), + electricCurrentLevel: .init(state: state.green.chargingInformation.electricCurrentLevel.state), + charging: .init( + remainTime: state.green.chargingInformation.charging.remainTime, + remainTimeUnit: state.green.chargingInformation.charging.remainTimeUnit + ) + ), + chargingDoor: .init(state: state.green.chargingDoor.state), + drivingHistory: .init( + average: state.green.drivingHistory.average, + unit: state.green.drivingHistory.unit + ) + ), + drivingReady: state.drivingReady, + location: map(location: state.location) + ) + } + + static func map(location: VehicleLocation?) -> VehicleStatus.Location? { + guard let location else { return nil } + return .init( + date: location.date, + geoCoordinate: .init( + altitude: location.geoCoordinate.altitude, + latitude: location.geoCoordinate.latitude, + longitude: location.geoCoordinate.longitude + ), + heading: location.heading, + speed: .init( + unit: location.speed.unit, + value: location.speed.value + ) + ) + } +} diff --git a/KiaMaps/Core/Api/Models/VehicleStatus.swift b/KiaMaps/Core/Api/Models/VehicleStatus.swift new file mode 100644 index 0000000..0cacbb5 --- /dev/null +++ b/KiaMaps/Core/Api/Models/VehicleStatus.swift @@ -0,0 +1,281 @@ +// +// VehicleStatus.swift +// KiaMaps +// +// Created by Codex on 09.03.2026. +// + +import Foundation +import CoreLocation + +struct VehicleStatusSnapshot: Codable { + @DateValue private(set) var lastUpdateTime: Date + let status: VehicleStatus +} + +struct VehicleStatus: Codable { + let body: Body + let cabin: Cabin + let chassis: Chassis + let drivetrain: Drivetrain + let green: Green + let drivingReady: Bool + let location: Location? + + var isCharging: Bool { + let isConnectorFastened = green.chargingInformation.connectorFastening.state > 0 + let chargingDoorOpen = green.chargingDoor.state == ChargeDoorStatus.open + return isConnectorFastened && chargingDoorOpen + } + + struct Body: Codable { + let hood: Hood + let trunk: Trunk + + struct Hood: Codable { + let open: Bool + let frunk: Frunk + } + + struct Frunk: Codable { + let fault: Bool + } + + struct Trunk: Codable { + let open: Bool + } + } + + struct Cabin: Codable { + let hvac: HVAC + let door: Door + let seat: Seat + + struct HVAC: Codable { + let row1: Row1 + let ventilation: Ventilation + + struct Row1: Codable { + let driver: Driver + + struct Driver: Codable { + let temperature: Temperature + let blower: Blower + } + } + + struct Temperature: Codable { + let value: String + let unit: TemperatureUnit + } + + struct Blower: Codable { + let speedLevel: Int + } + + struct Ventilation: Codable { + let airCleaning: AirCleaning + + struct AirCleaning: Codable { + let indicator: Int + } + } + } + + struct Door: Codable { + let row1: Row1 + let row2: Row2 + + struct Status: Codable { + let lock: Bool + let open: Bool + } + + struct Row1: Codable { + let passenger: Status + let driver: Status + } + + struct Row2: Codable { + let left: Status + let right: Status + } + } + + struct Seat: Codable { + let row1: Row1 + let row2: Row2 + + struct Climate: Codable { + let state: Int + } + + struct Status: Codable { + let climate: Climate + } + + struct Row1: Codable { + let passenger: Status + let driver: Status + } + + struct Row2: Codable { + let left: Status + let right: Status + } + } + } + + struct Chassis: Codable { + let axle: Axle + + struct Axle: Codable { + let row1: TireRow + let row2: TireRow + let tire: Tire + + struct TireRow: Codable { + let left: Wheel + let right: Wheel + + struct Wheel: Codable { + let tire: Pressure + } + } + + struct Pressure: Codable { + let pressureLow: Bool + let pressure: Int + } + + struct Tire: Codable { + let pressureLow: Bool + let pressureUnit: Int + } + } + } + + struct Drivetrain: Codable { + let fuelSystem: FuelSystem + let odometer: Double + let transmission: Transmission + + struct FuelSystem: Codable { + let dte: DTE + let averageFuelEconomy: AverageFuelEconomy + + struct DTE: Codable { + let unit: DistanceUnit + let total: Int + } + + struct AverageFuelEconomy: Codable { + let drive: Double + let afterRefuel: Double + let accumulated: Double + let unit: EconomyUnit + } + } + + struct Transmission: Codable { + let parkingPosition: Bool + } + } + + struct Green: Codable { + let batteryManagement: BatteryManagement + let electric: Electric + let chargingInformation: ChargingInformation + let chargingDoor: ChargingDoor + let drivingHistory: DrivingHistory + + struct BatteryManagement: Codable { + let soH: SoH + let batteryRemain: BatteryRemain + + struct SoH: Codable { + let ratio: Double + } + + struct BatteryRemain: Codable { + let value: Double + let ratio: Double + } + } + + struct Electric: Codable { + let smartGrid: SmartGrid + + struct SmartGrid: Codable { + let vehicleToLoad: VehicleToLoad + let vehicleToGrid: VehicleToGrid + let realTimePower: Double + + struct VehicleToLoad: Codable { + let dischargeLimitation: DischargeLimitation + + struct DischargeLimitation: Codable { + let soc: Int + let remainTime: Int + } + } + + struct VehicleToGrid: Codable { + let mode: Bool + } + } + } + + struct ChargingInformation: Codable { + let connectorFastening: State + let electricCurrentLevel: State + let charging: Charging + + struct State: Codable { + let state: Int + } + + struct Charging: Codable { + let remainTime: Double + let remainTimeUnit: TimeUnit + } + } + + struct ChargingDoor: Codable { + let state: ChargeDoorStatus + } + + struct DrivingHistory: Codable { + let average: Double + let unit: DistanceUnit + } + } + + struct Location: Codable { + @DateValue private(set) var date: Date + let geoCoordinate: GeoCoordinate + let heading: Double + let speed: Speed + + struct GeoCoordinate: Codable { + let altitude: Double + let latitude: Double + let longitude: Double + + var location: CLLocation { + .init( + coordinate: .init(latitude: latitude, longitude: longitude), + altitude: altitude, + horizontalAccuracy: 100, + verticalAccuracy: 100, + timestamp: .now + ) + } + } + + struct Speed: Codable { + let unit: SpeedUnit + let value: Double + } + } +} diff --git a/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift b/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift new file mode 100644 index 0000000..0371b24 --- /dev/null +++ b/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift @@ -0,0 +1,71 @@ +// +// PorscheApiEndpoint.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +enum PorscheApiEndpoint: ApiEndpointProtocol { + case authorize + case loginIdentifier + case loginPassword + case mfaOTP + case token + case vehicles + case vehicle(String) + case commands(String) + case commandStatus(vin: String, requestId: String) + case profile + + var path: (String, ApiEndpointBase.RelativeTo) { + switch self { + case .authorize: + ("authorize", .user) + case .loginIdentifier: + ("u/login/identifier", .user) + case .loginPassword: + ("u/login/password", .user) + case .mfaOTP: + ("u/mfa-otp-challenge", .user) + case .token: + ("oauth/token", .user) + case .vehicles: + ("connect/v1/vehicles", .base) + case let .vehicle(vin): + ("connect/v1/vehicles/\(vin)", .base) + case let .commands(vin): + ("connect/v1/vehicles/\(vin)/commands", .base) + case let .commandStatus(vin, requestId): + ("connect/v1/vehicles/\(vin)/commands/\(requestId)", .base) + case .profile: + ("account/v1/profile", .base) + } + } + + var description: String { + switch self { + case .authorize: + "porscheAuthorize" + case .loginIdentifier: + "porscheLoginIdentifier" + case .loginPassword: + "porscheLoginPassword" + case .mfaOTP: + "porscheMfaOtp" + case .token: + "porscheToken" + case .vehicles: + "porscheVehicles" + case .vehicle: + "porscheVehicle" + case .commands: + "porscheCommands" + case .commandStatus: + "porscheCommandStatus" + case .profile: + "porscheProfile" + } + } +} diff --git a/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift b/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift new file mode 100644 index 0000000..cc84855 --- /dev/null +++ b/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift @@ -0,0 +1,328 @@ +// +// PorscheAuthClient.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +struct PorscheHTTPTransportResponse { + let data: Data + let response: HTTPURLResponse +} + +typealias PorscheHTTPTransport = (URLRequest) async throws -> PorscheHTTPTransportResponse + +private final class PorscheNoRedirectDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest _: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + +struct PorscheAuthClient { + let configuration: PorscheApiConfiguration + private let transport: PorscheHTTPTransport + private let now: () -> Date + + init( + configuration: PorscheApiConfiguration, + transport: PorscheHTTPTransport? = nil, + now: @escaping () -> Date = Date.init + ) { + self.configuration = configuration + self.transport = transport ?? Self.makeDefaultTransport() + self.now = now + } + + static func makeDefaultTransport() -> PorscheHTTPTransport { + let session = URLSession(configuration: .ephemeral) + return { request in + let (data, response) = try await session.data(for: request, delegate: PorscheNoRedirectDelegate()) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + return PorscheHTTPTransportResponse(data: data, response: httpResponse) + } + } + + func makeAuthorizeURL(state: String = UUID().uuidString) throws -> URL { + let endpointURL = try configuration.url(for: PorscheApiEndpoint.authorize) + guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: true) else { + throw PorscheAuthError.invalidRedirect + } + components.queryItems = [ + .init(name: "response_type", value: "code"), + .init(name: "client_id", value: configuration.authClientId), + .init(name: "redirect_uri", value: configuration.redirectUri), + .init(name: "audience", value: configuration.audience), + .init(name: "scope", value: configuration.scope), + .init(name: "state", value: state), + .init(name: "ui_locales", value: configuration.locale), + ] + guard let url = components.url else { + throw PorscheAuthError.invalidRedirect + } + return url + } + + func authenticate(username: String, password: String, state: String = UUID().uuidString) async throws -> PorscheTokenSet { + let initialRedirect = try await authorize(state: state) + if let code = queryValue(named: "code", in: initialRedirect), !code.isEmpty { + return try await exchangeAuthorizationCode(code) + } + if let challenge = mfaChallenge(from: initialRedirect) { + throw PorscheAuthError.mfaRequired(challenge) + } + + let loginState = queryValue(named: "state", in: initialRedirect) ?? state + try await submitIdentifier(username: username, state: loginState) + let passwordRedirect = try await submitPassword(username: username, password: password, state: loginState) + if let challenge = mfaChallenge(from: passwordRedirect) { + throw PorscheAuthError.mfaRequired(challenge) + } + + let callback = try await resumeAuthorization(from: passwordRedirect) + switch try parseRedirect(callback) { + case let .authorizationCode(code): + return try await exchangeAuthorizationCode(code) + case let .mfaRequired(challenge): + throw PorscheAuthError.mfaRequired(challenge) + } + } + + func exchangeAuthorizationCode(_ authorizationCode: String) async throws -> PorscheTokenSet { + let response: PorscheTokenResponse = try await formRequest( + endpoint: PorscheApiEndpoint.token, + form: [ + "client_id": configuration.authClientId, + "grant_type": "authorization_code", + "code": authorizationCode, + "redirect_uri": configuration.redirectUri, + ] + ) + return response.tokenSet(obtainedAt: now()) + } + + func refreshToken(_ refreshToken: String) async throws -> PorscheTokenSet { + let response: PorscheTokenResponse = try await formRequest( + endpoint: PorscheApiEndpoint.token, + form: [ + "client_id": configuration.authClientId, + "grant_type": "refresh_token", + "refresh_token": refreshToken, + ] + ) + return response.tokenSet(obtainedAt: now()) + } + + func parseAuthorizationCallback(_ callback: URL) throws -> PorscheAuthorizationCallback { + try parseRedirect(callback) + } + + func mapMFASubmitResult(_ result: PorscheMFASubmitResult) throws { + switch result { + case .success: + return + case .invalidCode: + throw PorscheAuthError.invalidMFACode + } + } + + private func authorize(state: String) async throws -> URL { + let request = try request(url: makeAuthorizeURL(state: state)) + let response = try await send(request, accept: [302]) + return try redirectURL(from: response.response) + } + + private func submitIdentifier(username: String, state: String) async throws { + let response = try await send( + request( + endpoint: PorscheApiEndpoint.loginIdentifier, + queryItems: [.init(name: "state", value: state)], + form: [ + "state": state, + "username": username, + "js-available": "true", + "webauthn-available": "false", + "is-brave": "false", + "webauthn-platform-available": "false", + "action": "default", + ] + ), + accept: [200, 204, 400, 401] + ) + + switch response.response.statusCode { + case 200, 204: + return + case 400: + if let html = String(data: response.data, encoding: .utf8), html.localizedCaseInsensitiveContains("captcha") { + throw PorscheApiError.blockedByCaptchaOrDeviceBinding + } + throw PorscheAuthError.backendError("identifier_failed") + case 401: + throw PorscheAuthError.invalidCredentials + default: + throw ApiError.unexpectedStatusCode(response.response.statusCode) + } + } + + private func submitPassword(username: String, password: String, state: String) async throws -> URL { + let response = try await send( + request( + endpoint: PorscheApiEndpoint.loginPassword, + queryItems: [.init(name: "state", value: state)], + form: [ + "state": state, + "username": username, + "password": password, + "action": "default", + ] + ), + accept: [302, 400] + ) + + if response.response.statusCode == 400 { + throw PorscheAuthError.invalidCredentials + } + return try redirectURL(from: response.response) + } + + private func resumeAuthorization(from redirect: URL) async throws -> URL { + let request = try request(url: absoluteIdentityURL(for: redirect)) + let response = try await send(request, accept: [302]) + return try redirectURL(from: response.response) + } + + private func parseRedirect(_ redirect: URL) throws -> PorscheAuthorizationCallback { + if let challenge = mfaChallenge(from: redirect) { + return .mfaRequired(challenge) + } + + guard let components = URLComponents(url: redirect, resolvingAgainstBaseURL: false) else { + throw PorscheAuthError.invalidRedirect + } + let queryItems = components.queryItems ?? [] + + if let error = queryItems.first(where: { $0.name == "error" })?.value { + if error == "mfa_required" { + let state = queryItems.first(where: { $0.name == "state" })?.value ?? "" + return .mfaRequired(.init(state: state, challengeType: "otp")) + } + throw PorscheAuthError.backendError(error) + } + + guard let code = queryItems.first(where: { $0.name == "code" })?.value, !code.isEmpty else { + throw PorscheAuthError.missingAuthorizationCode + } + return .authorizationCode(code) + } + + private func formRequest(endpoint: PorscheApiEndpoint, form: [String: String]) async throws -> Response { + let request = request(endpoint: endpoint, form: form) + let response = try await send(request, accept: [200]) + do { + return try JSONDecoder().decode(Response.self, from: response.data) + } catch { + throw PorscheApiError.decodingFailed(error.localizedDescription) + } + } + + private func send(_ request: URLRequest, accept statusCodes: Set) async throws -> PorscheHTTPTransportResponse { + let response = try await transport(request) + guard statusCodes.contains(response.response.statusCode) else { + if response.response.statusCode == 401 { + throw ApiError.unauthorized + } + throw ApiError.unexpectedStatusCode(response.response.statusCode) + } + return response + } + + private func request( + endpoint: PorscheApiEndpoint, + queryItems: [URLQueryItem] = [], + form: [String: String]? = nil + ) -> URLRequest { + let baseURL = try! configuration.url(for: endpoint) + return request(url: baseURL, queryItems: queryItems, form: form) + } + + private func request( + url: URL, + queryItems: [URLQueryItem] = [], + form: [String: String]? = nil + ) -> URLRequest { + var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! + if !queryItems.isEmpty { + var mergedItems = components.queryItems ?? [] + mergedItems.append(contentsOf: queryItems) + components.queryItems = mergedItems + } + + var request = URLRequest(url: components.url ?? url) + request.httpMethod = form == nil ? "GET" : "POST" + request.setValue(configuration.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(configuration.xClientId, forHTTPHeaderField: "X-Client-ID") + if let form { + request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") + let body = form + .sorted(by: { $0.key < $1.key }) + .map { key, value in + "\(percentEncode(key))=\(percentEncode(value))" + } + .joined(separator: "&") + request.httpBody = body.data(using: .utf8) + } + return request + } + + private func percentEncode(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(.init(charactersIn: "+&=?"))) ?? value + } + + private func redirectURL(from response: HTTPURLResponse) throws -> URL { + guard let location = response.value(forHTTPHeaderField: "Location"), + let url = URL(string: location) + else { + if let location = response.value(forHTTPHeaderField: "Location") { + return absoluteIdentityURL(for: URL(string: location)!) + } + throw PorscheAuthError.invalidRedirect + } + return absoluteIdentityURL(for: url) + } + + private func absoluteIdentityURL(for url: URL) -> URL { + if url.host != nil { + return url + } + return URL(string: url.relativeString, relativeTo: URL(string: configuration.loginHost)) ?? url + } + + private func queryValue(named name: String, in url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: true)? + .queryItems? + .first(where: { $0.name == name })? + .value + } + + private func mfaChallenge(from redirect: URL) -> PorscheMFAChallenge? { + if redirect.path.contains("mfa-otp-challenge") { + let state = queryValue(named: "state", in: redirect) ?? "" + return .init(state: state, challengeType: "otp") + } + if queryValue(named: "error", in: redirect) == "mfa_required" { + let state = queryValue(named: "state", in: redirect) ?? "" + return .init(state: state, challengeType: "otp") + } + return nil + } +} diff --git a/KiaMaps/Core/Api/Porsche/PorscheModels.swift b/KiaMaps/Core/Api/Porsche/PorscheModels.swift new file mode 100644 index 0000000..2beb6e2 --- /dev/null +++ b/KiaMaps/Core/Api/Porsche/PorscheModels.swift @@ -0,0 +1,299 @@ +// +// PorscheModels.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +struct PorscheTokenSet: Codable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + let scope: String? + let obtainedAt: Date + + var expiresAt: Date { + obtainedAt.addingTimeInterval(TimeInterval(expiresIn)) + } + + func isExpired(leeway: TimeInterval = 60) -> Bool { + Date().addingTimeInterval(leeway) >= expiresAt + } +} + +struct PorscheTokenResponse: Codable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + let scope: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } + + func tokenSet(obtainedAt: Date) -> PorscheTokenSet { + PorscheTokenSet( + accessToken: accessToken, + refreshToken: refreshToken, + tokenType: tokenType, + expiresIn: expiresIn, + scope: scope, + obtainedAt: obtainedAt + ) + } +} + +struct PorscheMFAChallenge: Codable, Equatable { + let state: String + let challengeType: String +} + +enum PorscheAuthorizationCallback: Equatable { + case authorizationCode(String) + case mfaRequired(PorscheMFAChallenge) +} + +struct PorscheVehicleSummary: Codable { + struct Capabilities: Codable { + let canLock: Bool? + let canClimatise: Bool? + let canCharge: Bool? + } + + let vin: String + let displayName: String + let model: String + let modelYear: Int? + let batterySoc: Double? + let rangeKm: Double? + let charging: Bool? + let locked: Bool? + let latitude: Double? + let longitude: Double? + let odometerKm: Double? + let climateActive: Bool? + let chargingPowerKw: Double? + let capabilities: Capabilities? +} + +struct PorscheVehicleSnapshot: Equatable { + struct Capabilities: Equatable { + let canLock: Bool + let canClimatise: Bool + let canCharge: Bool + } + + let vin: String + let batterySoc: Double + let rangeKm: Double + let charging: Bool + let locked: Bool + let latitude: Double? + let longitude: Double? + let odometerKm: Double + let climateActive: Bool + let chargingPowerKw: Double + let capabilities: Capabilities +} + +enum PorscheCommandRequest { + case lock(vin: String) + case climateOn(vin: String, temperatureC: Double) + case climateOff(vin: String) + case startCharging(vin: String) + case stopCharging(vin: String) + + var vin: String { + switch self { + case let .lock(vin), + let .climateOn(vin, _), + let .climateOff(vin), + let .startCharging(vin), + let .stopCharging(vin): + vin + } + } + + var commandKey: String { + switch self { + case .lock: + "LOCK" + case .climateOn: + "REMOTE_CLIMATIZER_START" + case .climateOff: + "REMOTE_CLIMATIZER_STOP" + case .startCharging: + "DIRECT_CHARGING_START" + case .stopCharging: + "DIRECT_CHARGING_STOP" + } + } +} + +struct PorscheCommandResult: Equatable { + let requestId: UUID +} + +enum PorscheAuthError: LocalizedError, Equatable { + case mfaRequired(PorscheMFAChallenge) + case invalidMFACode + case invalidCredentials + case missingAuthorizationCode + case invalidRedirect + case backendError(String) + + var errorDescription: String? { + switch self { + case let .mfaRequired(challenge): + "MFA required (\(challenge.challengeType))." + case .invalidMFACode: + "Invalid MFA code." + case .invalidCredentials: + "Invalid Porsche credentials." + case .missingAuthorizationCode: + "Authorization code not present in callback." + case .invalidRedirect: + "Invalid redirect callback URL." + case let .backendError(message): + "Porsche auth backend error: \(message)" + } + } +} + +enum PorscheApiError: LocalizedError, Equatable { + case unsupportedOperation(String) + case blockedByCaptchaOrDeviceBinding + case decodingFailed(String) + case missingVehicle(String) + case missingCommandRequestId + case commandFailed(String) + + var errorDescription: String? { + switch self { + case let .unsupportedOperation(operation): + "Unsupported Porsche operation in current implementation: \(operation)." + case .blockedByCaptchaOrDeviceBinding: + "Porsche account requires captcha/device-binding; complete login in My Porsche app and retry." + case let .decodingFailed(message): + "Failed to decode Porsche API response: \(message)" + case let .missingVehicle(identifier): + "Missing Porsche vehicle for identifier: \(identifier)." + case .missingCommandRequestId: + "Porsche command did not return a request identifier." + case let .commandFailed(message): + "Porsche command failed: \(message)" + } + } +} + +enum PorscheMFASubmitResult { + case success + case invalidCode +} + +enum PorscheCommandExecutionState: String { + case accepted = "ACCEPTED" + case performed = "PERFORMED" + case error = "ERROR" + case unknown = "UNKNOWN" +} + +enum PorscheMeasurementCatalog { + static let overview = [ + "ACV_STATE", + "ALARM_STATE", + "BATTERY_CHARGING_STATE", + "BATTERY_LEVEL", + "BLEID_DDADATA", + "CHARGING_PROFILES", + "CHARGING_RATE", + "CHARGING_SETTINGS", + "CHARGING_SUMMARY", + "CLIMATIZER_STATE", + "DEPARTURES", + "E_RANGE", + "FUEL_LEVEL", + "FUEL_RESERVE", + "GLOBAL_PRIVACY_MODE", + "GPS_LOCATION", + "HEATING_STATE", + "HVAC_STATE", + "INTERMEDIATE_SERVICE_RANGE", + "INTERMEDIATE_SERVICE_TIME", + "LOCK_STATE_VEHICLE", + "MAIN_SERVICE_RANGE", + "MAIN_SERVICE_TIME", + "MILEAGE", + "OIL_LEVEL_CURRENT", + "OIL_LEVEL_MAX", + "OIL_LEVEL_MIN_WARNING", + "OIL_SERVICE_RANGE", + "OIL_SERVICE_TIME", + "OPEN_STATE_CHARGE_FLAP_LEFT", + "OPEN_STATE_CHARGE_FLAP_RIGHT", + "OPEN_STATE_DOOR_FRONT_LEFT", + "OPEN_STATE_DOOR_FRONT_RIGHT", + "OPEN_STATE_DOOR_REAR_LEFT", + "OPEN_STATE_DOOR_REAR_RIGHT", + "OPEN_STATE_LID_FRONT", + "OPEN_STATE_LID_REAR", + "OPEN_STATE_SERVICE_FLAP", + "OPEN_STATE_SPOILER", + "OPEN_STATE_SUNROOF", + "OPEN_STATE_SUNROOF_REAR", + "OPEN_STATE_TOP", + "OPEN_STATE_WINDOW_FRONT_LEFT", + "OPEN_STATE_WINDOW_FRONT_RIGHT", + "OPEN_STATE_WINDOW_REAR_LEFT", + "OPEN_STATE_WINDOW_REAR_RIGHT", + "PAIRING_CODE", + "PARKING_BRAKE", + "PARKING_LIGHT", + "PRED_PRECON_LOCATION_EXCEPTIONS", + "PRED_PRECON_USER_SETTINGS", + "RANGE", + "REMOTE_ACCESS_AUTHORIZATION", + "SERVICE_PREDICTIONS", + "THEFT_STATE", + "TIMERS", + "TIRE_PRESSURE", + "VTS_MODES", + ] + + static let commandCapabilities = [ + "CHARGING_STOP", + "DIRECT_CHARGING_START", + "DIRECT_CHARGING_STOP", + "LOCK", + "REMOTE_CLIMATIZER_START", + "REMOTE_CLIMATIZER_STOP", + "SPIN_CHALLENGE", + "UNLOCK", + ] +} + +extension UUID { + static func porscheVehicleID(for vin: String) -> UUID { + let source = Array("porsche:\(vin.uppercased())".utf8) + var bytes = [UInt8](repeating: 0, count: 16) + for (index, byte) in source.enumerated() { + bytes[index % 16] = bytes[index % 16] &+ byte + } + bytes[6] = (bytes[6] & 0x0F) | 0x40 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } +} diff --git a/KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift b/KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift new file mode 100644 index 0000000..bec8919 --- /dev/null +++ b/KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift @@ -0,0 +1,406 @@ +// +// PorscheVehicleMapper.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +enum PorscheVehicleMapper { + typealias JSONObject = [String: Any] + + static func map(summary: PorscheVehicleSummary) -> PorscheVehicleSnapshot { + PorscheVehicleSnapshot( + vin: summary.vin, + batterySoc: summary.batterySoc ?? 0, + rangeKm: summary.rangeKm ?? 0, + charging: summary.charging ?? false, + locked: summary.locked ?? false, + latitude: summary.latitude, + longitude: summary.longitude, + odometerKm: summary.odometerKm ?? 0, + climateActive: summary.climateActive ?? false, + chargingPowerKw: summary.chargingPowerKw ?? 0, + capabilities: .init( + canLock: summary.capabilities?.canLock ?? false, + canClimatise: summary.capabilities?.canClimatise ?? false, + canCharge: summary.capabilities?.canCharge ?? false + ) + ) + } + + static func mapVehicles(from payload: [JSONObject], now: Date = Date()) throws -> VehicleResponse { + let vehicles = try payload.map { vehiclePayload in + try decodeVehicle(json: [ + "vin": string("vin", from: vehiclePayload) ?? "", + "type": vehicleTypeCode(from: vehiclePayload), + "vehicleId": UUID.porscheVehicleID(for: string("vin", from: vehiclePayload) ?? "").uuidString, + "vehicleName": vehicleName(from: vehiclePayload), + "nickname": string("customName", from: vehiclePayload) ?? string("modelName", from: vehiclePayload) ?? "Porsche", + "tmuNum": String((string("vin", from: vehiclePayload) ?? "").suffix(8)), + "year": modelTypeValue("year", from: vehiclePayload) ?? String(Calendar.current.component(.year, from: now)), + "regDate": MillisecondDateFormatter().string(from: now), + "master": true, + "carShare": 0, + "personalFlag": "Y", + "detailInfo": [ + "bodyType": modelTypeValue("bodyType", from: vehiclePayload) ?? "Porsche", + "inColor": "", + "outColor": "", + "saleCarmdlCd": modelTypeValue("model", from: vehiclePayload) ?? "Porsche", + "saleCarmdlEnNm": string("modelName", from: vehiclePayload) ?? "Porsche", + ], + "protocolType": 1, + "ccuCCS2ProtocolSupport": 1, + ]) + } + return VehicleResponse(vehicles: vehicles) + } + + static func mapVehicleState(from payload: JSONObject, now: Date = Date()) throws -> VehicleStatusSnapshot { + let summary = mapSummary(from: payload) + let snapshot = map(summary: summary) + let doorLockValue = snapshot.locked + let doorOpenFrontLeft = measurementBool("OPEN_STATE_DOOR_FRONT_LEFT", payload: payload) + let doorOpenFrontRight = measurementBool("OPEN_STATE_DOOR_FRONT_RIGHT", payload: payload) + let doorOpenRearLeft = measurementBool("OPEN_STATE_DOOR_REAR_LEFT", payload: payload) + let doorOpenRearRight = measurementBool("OPEN_STATE_DOOR_REAR_RIGHT", payload: payload) + let hoodOpen = measurementBool("OPEN_STATE_LID_FRONT", payload: payload) + let trunkOpen = measurementBool("OPEN_STATE_LID_REAR", payload: payload) + + return VehicleStatusSnapshot( + lastUpdateTime: now, + status: VehicleStatus( + body: .init( + hood: .init( + open: hoodOpen, + frunk: .init(fault: false) + ), + trunk: .init(open: trunkOpen) + ), + cabin: .init( + hvac: .init( + row1: .init( + driver: .init( + temperature: .init( + value: String(format: "%.0f", climateTargetTemperature(from: payload) ?? 22), + unit: .celsius + ), + blower: .init(speedLevel: snapshot.climateActive ? 1 : 0) + ) + ), + ventilation: .init(airCleaning: .init(indicator: 0)) + ), + door: .init( + row1: .init( + passenger: .init(lock: doorLockValue, open: doorOpenFrontRight), + driver: .init(lock: doorLockValue, open: doorOpenFrontLeft) + ), + row2: .init( + left: .init(lock: doorLockValue, open: doorOpenRearLeft), + right: .init(lock: doorLockValue, open: doorOpenRearRight) + ) + ), + seat: .init( + row1: .init( + passenger: .init(climate: .init(state: 0)), + driver: .init(climate: .init(state: 0)) + ), + row2: .init( + left: .init(climate: .init(state: 0)), + right: .init(climate: .init(state: 0)) + ) + ) + ), + chassis: .init( + axle: .init( + row1: .init( + left: .init(tire: .init(pressureLow: false, pressure: 0)), + right: .init(tire: .init(pressureLow: false, pressure: 0)) + ), + row2: .init( + left: .init(tire: .init(pressureLow: false, pressure: 0)), + right: .init(tire: .init(pressureLow: false, pressure: 0)) + ), + tire: .init(pressureLow: false, pressureUnit: 0) + ) + ), + drivetrain: .init( + fuelSystem: .init( + dte: .init( + unit: .kilometers, + total: snapshot.rangeKm > 0 ? Int(snapshot.rangeKm.rounded()) : 0 + ), + averageFuelEconomy: .init( + drive: 0, + afterRefuel: 0, + accumulated: 0, + unit: .km1Kwh + ) + ), + odometer: snapshot.odometerKm, + transmission: .init(parkingPosition: true) + ), + green: .init( + batteryManagement: .init( + soH: .init(ratio: 100), + batteryRemain: .init( + value: max(snapshot.batterySoc, 1) * 2_300, + ratio: snapshot.batterySoc + ) + ), + electric: .init( + smartGrid: .init( + vehicleToLoad: .init(dischargeLimitation: .init(soc: 0, remainTime: 0)), + vehicleToGrid: .init(mode: false), + realTimePower: snapshot.chargingPowerKw * 1_000 + ) + ), + chargingInformation: .init( + connectorFastening: .init(state: snapshot.charging ? 1 : 0), + electricCurrentLevel: .init(state: 0), + charging: .init(remainTime: 0, remainTimeUnit: .minute) + ), + chargingDoor: .init(state: snapshot.charging ? .open : .closed), + drivingHistory: .init(average: 0, unit: .kilometers) + ), + drivingReady: false, + location: mapLocation(snapshot: snapshot, payload: payload, fallbackDate: now) + ) + ) + } + + static func mapSummary(from payload: JSONObject) -> PorscheVehicleSummary { + let measurements = measurementDictionary(from: payload) + let location = locationComponents(from: measurements["GPS_LOCATION"]) + let capabilities = PorscheVehicleSummary.Capabilities( + canLock: commandEnabled("LOCK", payload: payload), + canClimatise: commandEnabled("REMOTE_CLIMATIZER_START", payload: payload), + canCharge: commandEnabled("DIRECT_CHARGING_START", payload: payload) + ) + + return PorscheVehicleSummary( + vin: string("vin", from: payload) ?? "", + displayName: string("customName", from: payload) ?? string("modelName", from: payload) ?? "Porsche", + model: string("modelName", from: payload) ?? "Porsche", + modelYear: Int(modelTypeValue("year", from: payload) ?? ""), + batterySoc: number(["BATTERY_LEVEL", "percent"], in: measurements), + rangeKm: number(["E_RANGE", "distance"], in: measurements) ?? number(["RANGE", "distance"], in: measurements), + charging: chargingState(from: measurements), + locked: bool(["LOCK_STATE_VEHICLE", "isLocked"], in: measurements), + latitude: location?.latitude, + longitude: location?.longitude, + odometerKm: number(["MILEAGE", "kilometers"], in: measurements), + climateActive: bool(["CLIMATIZER_STATE", "isOn"], in: measurements), + chargingPowerKw: (number(["CHARGING_RATE", "chargingPower"], in: measurements) ?? 0) / 1_000, + capabilities: capabilities + ) + } + + static func commandBody(for request: PorscheCommandRequest) -> Data { + let payload: JSONObject + switch request { + case .lock: + payload = ["key": request.commandKey, "payload": ["spin": NSNull()]] + case let .climateOn(_, temperatureC): + payload = [ + "key": request.commandKey, + "payload": [ + "climateZonesEnabled": [ + "frontLeft": false, + "frontRight": false, + "rearLeft": false, + "rearRight": false, + ], + "targetTemperature": temperatureC + 273.15, + ], + ] + case .climateOff: + payload = ["key": request.commandKey, "payload": [:]] + case .startCharging, .stopCharging: + payload = ["key": request.commandKey, "payload": ["spin": NSNull()]] + } + + return try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + } + + private static func decodeVehicle(json: JSONObject) throws -> Vehicle { + let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + return try JSONDecoder().decode(Vehicle.self, from: data) + } + + private static func mapLocation( + snapshot: PorscheVehicleSnapshot, + payload: JSONObject, + fallbackDate: Date + ) -> VehicleStatus.Location? { + guard let latitude = snapshot.latitude, let longitude = snapshot.longitude else { + return nil + } + + let date: Date + if let dateString = locationDateString(from: payload), + let parsed = TimeIntervalDateFormatter().date(from: dateString) { + date = parsed + } else { + date = fallbackDate + } + + return .init( + date: date, + geoCoordinate: .init( + altitude: 0, + latitude: latitude, + longitude: longitude + ), + heading: locationHeading(from: payload) ?? 0, + speed: .init(unit: .km, value: 0) + ) + } + + private static func vehicleTypeCode(from payload: JSONObject) -> String { + switch modelTypeValue("engine", from: payload) { + case "PHEV": + VehicleType.plugInHybrid.rawValue + case "BEV": + VehicleType.electric.rawValue + default: + VehicleType.internalCombustionEngine.rawValue + } + } + + private static func vehicleName(from payload: JSONObject) -> String { + let modelName = string("modelName", from: payload) ?? "Porsche" + if let modelVariant = modelTypeValue("model", from: payload), !modelVariant.isEmpty { + return "\(modelName) \(modelVariant)" + } + return modelName + } + + private static func climateTargetTemperature(from payload: JSONObject) -> Double? { + let kelvin = number(["CLIMATIZER_STATE", "targetTemperature"], in: measurementDictionary(from: payload)) + guard let kelvin else { return nil } + return kelvin - 273.15 + } + + private static func chargingState(from measurements: JSONObject) -> Bool { + if let mode = string(["CHARGING_SUMMARY", "mode"], in: measurements), mode == "DIRECT" { + return true + } + if let status = string(["BATTERY_CHARGING_STATE", "status"], in: measurements) { + return ["CHARGING", "ON"].contains(status.uppercased()) + } + return false + } + + private static func locationDateString(from payload: JSONObject) -> String? { + guard let value = string(["GPS_LOCATION", "lastModified"], in: measurementDictionary(from: payload)), + let date = ISO8601DateFormatter().date(from: value) + else { + return nil + } + return TimeIntervalDateFormatter().string(from: date) + } + + private static func locationHeading(from payload: JSONObject) -> Double? { + number(["GPS_LOCATION", "direction"], in: measurementDictionary(from: payload)) + } + + private static func locationComponents(from value: Any?) -> (latitude: Double, longitude: Double)? { + guard let dictionary = value as? JSONObject, + let location = dictionary["location"] as? String + else { + return nil + } + let components = location.split(separator: ",").compactMap { Double($0) } + guard components.count == 2 else { + return nil + } + return (components[0], components[1]) + } + + private static func measurementDictionary(from payload: JSONObject) -> JSONObject { + guard let measurements = payload["measurements"] as? [JSONObject] else { + return [:] + } + + var result: JSONObject = [:] + for measurement in measurements { + guard let key = measurement["key"] as? String else { continue } + if let status = measurement["status"] as? JSONObject, + let isEnabled = status["isEnabled"] as? Bool, + !isEnabled { + continue + } + result[key] = measurement["value"] ?? [:] + } + return result + } + + private static func commandEnabled(_ key: String, payload: JSONObject) -> Bool? { + guard let commands = payload["commands"] as? [JSONObject] else { + return nil + } + return commands.first(where: { ($0["key"] as? String) == key })?["isEnabled"] as? Bool + } + + private static func measurementBool(_ key: String, payload: JSONObject) -> Bool { + bool([key, "isOpen"], in: measurementDictionary(from: payload)) ?? false + } + + private static func modelTypeValue(_ key: String, from payload: JSONObject) -> String? { + (payload["modelType"] as? JSONObject)?[key] as? String + } + + private static func string(_ key: String, from payload: JSONObject) -> String? { + payload[key] as? String + } + + private static func string(_ path: [String], in payload: JSONObject) -> String? { + value(at: path, in: payload) as? String + } + + private static func number(_ path: [String], in payload: JSONObject) -> Double? { + if let number = value(at: path, in: payload) as? Double { + return number + } + if let number = value(at: path, in: payload) as? Int { + return Double(number) + } + if let number = value(at: path, in: payload) as? NSNumber { + return number.doubleValue + } + return nil + } + + private static func bool(_ path: [String], in payload: JSONObject) -> Bool? { + if let value = value(at: path, in: payload) as? Bool { + return value + } + if let value = value(at: path, in: payload) as? Int { + return value == 1 + } + return nil + } + + private static func value(at path: [String], in payload: JSONObject) -> Any? { + var current: Any? = payload + for key in path { + current = (current as? JSONObject)?[key] + } + return current + } + + private static func set(value: Any, at path: [String], in payload: inout JSONObject) { + guard let first = path.first else { return } + if path.count == 1 { + payload[first] = value + return + } + + var child = payload[first] as? JSONObject ?? [:] + set(value: value, at: Array(path.dropFirst()), in: &child) + payload[first] = child + } +} diff --git a/KiaMaps/Core/Authorization/Authorization.swift b/KiaMaps/Core/Authorization/Authorization.swift index b1ad0ba..c6e1aee 100644 --- a/KiaMaps/Core/Authorization/Authorization.swift +++ b/KiaMaps/Core/Authorization/Authorization.swift @@ -30,11 +30,33 @@ struct AuthorizationData: Codable { /// Flag indicating if vehicle supports CCS2 protocol var isCcuCCS2Supported: Bool + /// Provider identifier for non-HMG integrations. + var providerKind: String? = nil + + /// Optional token issuer metadata for provider-specific auth flows. + var tokenIssuer: String? = nil + + /// Optional token audience metadata for provider-specific auth flows. + var tokenAudience: String? = nil + + /// Optional scope metadata for provider-specific auth flows. + var tokenScope: String? = nil + /// Generates authorization headers for API requests /// - Parameter configuration: API configuration for header generation /// - Returns: Dictionary of authorization headers func authorizatioHeaders(for configuration: ApiConfiguration) -> ApiRequest.Headers { - [ + if providerKind == "porsche" { + var headers: ApiRequest.Headers = [ + "Authorization": "Bearer \(accessToken)", + ] + if let configuration = configuration as? PorscheApiConfiguration { + headers["X-Client-ID"] = configuration.xClientId + } + return headers + } + + return [ "Authorization": "Bearer \(accessToken)", "Stamp": Self.generateStamp(for: configuration), "ccsp-application-id": configuration.appId, diff --git a/KiaMaps/Core/LocalServer/LocalCredentialClient.swift b/KiaMaps/Core/LocalServer/LocalCredentialClient.swift index 6a84aba..c5baff3 100644 --- a/KiaMaps/Core/LocalServer/LocalCredentialClient.swift +++ b/KiaMaps/Core/LocalServer/LocalCredentialClient.swift @@ -18,8 +18,8 @@ enum LocalCredentialClientError: Error { /// Used by both the main app and extensions final class LocalCredentialClient { private let queue = DispatchQueue(label: "com.kiamaps.localclient", qos: .userInitiated) - private let serverHost = "127.0.0.1" - private let serverPort: UInt16 = 8765 + private let serverHost: String + private let serverPort: UInt16 private let extensionIdentifier: String private let serverPassword: String private let maxRetryAttempts: Int @@ -40,7 +40,15 @@ final class LocalCredentialClient { } /// Initialize with explicit parameters - init(extensionIdentifier: String, serverPassword: String? = nil, maxRetryAttempts: Int = 3) { + init( + extensionIdentifier: String, + serverHost: String = "127.0.0.1", + serverPort: UInt16 = 8765, + serverPassword: String? = nil, + maxRetryAttempts: Int = 3 + ) { + self.serverHost = serverHost + self.serverPort = serverPort self.extensionIdentifier = extensionIdentifier // Use provided password or get from environment/fallback self.serverPassword = serverPassword ?? ProcessInfo.processInfo.environment["KIAMAPS_SERVER_PASSWORD"] ?? "KiaMapsSecurePassword2025" @@ -48,8 +56,19 @@ final class LocalCredentialClient { } /// Convenience initializer for extensions - convenience init(extensionIdentifier: String, serverPassword: String? = nil) { - self.init(extensionIdentifier: extensionIdentifier, serverPassword: serverPassword, maxRetryAttempts: 3) + convenience init( + extensionIdentifier: String, + serverHost: String = "127.0.0.1", + serverPort: UInt16 = 8765, + serverPassword: String? = nil + ) { + self.init( + extensionIdentifier: extensionIdentifier, + serverHost: serverHost, + serverPort: serverPort, + serverPassword: serverPassword, + maxRetryAttempts: 3 + ) } /// Fetches credentials from the local server diff --git a/KiaMaps/Core/Logging/AbstractLogger.swift b/KiaMaps/Core/Logging/AbstractLogger.swift index 910af0a..ee7efc8 100644 --- a/KiaMaps/Core/Logging/AbstractLogger.swift +++ b/KiaMaps/Core/Logging/AbstractLogger.swift @@ -124,6 +124,7 @@ public final class SharedLogger { /// The actual logger implementation (app or extension specific) private var implementation: AbstractLoggerProtocol + private let queue = DispatchQueue(label: "com.kiamaps.sharedlogger", attributes: .concurrent) private init() { // Default to a simple implementation - will be replaced during app startup @@ -132,12 +133,14 @@ public final class SharedLogger { /// Configure the logger implementation (called during app/extension startup) public func configure(with logger: AbstractLoggerProtocol) { - self.implementation = logger + queue.sync(flags: .barrier) { + implementation = logger + } } /// Get the current logger implementation public var logger: AbstractLoggerProtocol { - return implementation + queue.sync { implementation } } } @@ -162,4 +165,4 @@ public func logError(_ message: String, category: LogCategory = .general, file: public func logFault(_ message: String, category: LogCategory = .general, file: String = #file, function: String = #function, line: Int = #line) { SharedLogger.shared.logger.fault(message, category: category, file: file, function: function, line: line) -} \ No newline at end of file +} diff --git a/KiaMaps/Core/Logging/RemoteLogger.swift b/KiaMaps/Core/Logging/RemoteLogger.swift index 63d2b73..e6e522c 100644 --- a/KiaMaps/Core/Logging/RemoteLogger.swift +++ b/KiaMaps/Core/Logging/RemoteLogger.swift @@ -27,7 +27,7 @@ public final class RemoteLogger { private var _isEnabled: Bool = false public var isEnabled: Bool { - return _isEnabled + bufferQueue.sync { _isEnabled } } private let enabledKey = "RemoteLoggingEnabled" @@ -55,16 +55,15 @@ public final class RemoteLogger { /// Enable or disable remote logging public func setEnabled(_ enabled: Bool) { - bufferQueue.async { [weak self] in - guard let self = self else { return } - self._isEnabled = enabled - UserDefaults.standard.set(enabled, forKey: self.enabledKey) + bufferQueue.sync { + _isEnabled = enabled + UserDefaults.standard.set(enabled, forKey: enabledKey) if enabled { - self.startFlushTimer() + startFlushTimer() } else { - self.stopFlushTimer() - self.logBuffer.removeAll() + stopFlushTimer() + logBuffer.removeAll() } } } diff --git a/KiaMaps/Core/MQTT/MQTTManager.swift b/KiaMaps/Core/MQTT/MQTTManager.swift index 6676c74..79c1c8e 100644 --- a/KiaMaps/Core/MQTT/MQTTManager.swift +++ b/KiaMaps/Core/MQTT/MQTTManager.swift @@ -101,6 +101,9 @@ class MQTTManager: ObservableObject { * Activates MQTT communication following the documented sequence */ func activateMQTTCommunication(for vehicleId: UUID) async throws { + guard api.configuration.apiProviderKind == .hmg else { + throw ApiError.unsupported("MQTT is not supported for Porsche.") + } logDebug("MQTT 5.0 activation sequence started", category: .mqtt) connectionStatus = .connecting lastError = nil diff --git a/KiaMaps/Core/Vehicle/VehicleManager.swift b/KiaMaps/Core/Vehicle/VehicleManager.swift index 189aa20..8af40a1 100644 --- a/KiaMaps/Core/Vehicle/VehicleManager.swift +++ b/KiaMaps/Core/Vehicle/VehicleManager.swift @@ -65,12 +65,12 @@ struct VehicleManager { } /// Retrieves cached vehicle status if it's still valid (within 2 minutes) - /// - Returns: Cached VehicleStateResponse if valid, nil if expired or not found + /// - Returns: Cached VehicleStatusSnapshot if valid, nil if expired or not found /// - Throws: Decoding errors if cached data is corrupted - var vehicleState: VehicleStateResponse? { + var vehicleState: VehicleStatusSnapshot? { get throws { guard let lastUpdate = dateValue(for: .vehicleLastUpdateDate), lastUpdate + 2 * 60 > Date.now, - let cachedStatus: VehicleStateResponse = try value(for: .vehicleState) + let cachedStatus: VehicleStatusSnapshot = try value(for: .vehicleState) else { return nil } @@ -86,9 +86,9 @@ struct VehicleManager { } /// Stores vehicle status response with current timestamp - /// - Parameter status: VehicleStateResponse to cache + /// - Parameter status: VehicleStatusSnapshot to cache /// - Throws: Encoding errors if status cannot be serialized - func store(status: VehicleStateResponse) throws { + func store(status: VehicleStatusSnapshot) throws { try setValue(with: .vehicleState, encodable: status) setValue(with: .vehicleLastUpdateDate, value: Date.now) UserDefaults.standard.synchronize() diff --git a/KiaTests/AbstractLoggerTests.swift b/KiaTests/AbstractLoggerTests.swift index 701a6f0..a3b4639 100644 --- a/KiaTests/AbstractLoggerTests.swift +++ b/KiaTests/AbstractLoggerTests.swift @@ -515,8 +515,8 @@ final class AbstractLoggerIntegrationTests: XCTestCase { let group = DispatchGroup() for queueIndex in 0.. 5, "Error description should be meaningful for \(error)") } } - - // MARK: - Integration Tests - - func testCompleteLoginFlow() async throws { - // This is a mock integration test - in real scenario, would need actual server - - // Setup all mock responses in sequence - mockProvider.mockRedirectURL = URL(string: "https://idpconnect-eu.kia.com/auth/redirect?next_uri=https%3A%2F%2Fidpconnect-eu.kia.com%2Fauth%2Fapi%2Fv2%2Fuser%2Foauth2%2Fauthorize")! - - mockProvider.mockClientConfiguration = ClientConfiguration( - clientId: "test-client", - companyCode: "TestClient", - clientName: "Test Client", - scope: "PUBLIC", - clientAddition: .init( - clientId: nil, - serviceId: "Service Id", - ssoEnabled: true, - accountConnectorEnabled: true, - accountManagedItems: "Account Managed Items", - accountOptionItems: "Account Options Items", - secondAuthManagedItems: "Second Auth Managed Items", - loginMethodItems: "Login Method Items", - socialAuthManagedItems: "Social Auth", - externalAuthManagedItem: nil, - headerLogo: nil, - formSkin: "Form Skin", - accessLimitBirthYear: 1, - rememberMeEnabled: false, - serviceRegion: "Service Region", - emailAuthLaterEnabled: true - ), - clientRedirectUris: [ - .init(redirectUri: "https://test.example.com/redirect", userAgent: "User Agent", usage: "Usage") - ], - clientConnectors: [ - .init(connectorClientId: "Connector Client Id") - ], - connectorAddition: .init(idpCd: "Idp Cd") - ) - - let expectedSettings = PasswordEncryptionSettings( - groupCode: "Group Code", - detailCode: "Detail Code", - detailDescription: "Detail Description", - useEnabled: true, - value1: "true", - value2: "RSA", - value3: "Value 3", - value4: "Value 4", - value5: "Value 5" - ) - - mockProvider.mockPasswordSettings = expectedSettings - - let expectedCert = RSACertificateResponse( + func testMockCertificateStillEncryptsPassword() throws { + let certificate = RSACertificateResponse( kty: "RSA", e: "AQAB", kid: "HMGID2_CIPHER_KEY1", n: "o5OJwXceU_cJOYJyNP5pUxeTdMybhJ7rhx3f_VYzU8VgUlHbHhBqjlqoHM1_ie7OJNyOtKs0ijFebO7QKq-3bw" ) - mockProvider.mockRSACertificate = expectedCert - // Test that all pieces work together - XCTAssertNotNil(mockProvider.mockClientConfiguration) - XCTAssertNotNil(mockProvider.mockPasswordSettings) - XCTAssertNotNil(mockProvider.mockRSACertificate) - - let certificate = try XCTUnwrap(mockProvider.mockRSACertificate) - - // Verify RSA encryption works with the certificate let rsaKey = RSAEncryptionService.RSAKeyData( keyType: certificate.kty, exponent: certificate.e, @@ -480,7 +175,7 @@ class MockApiProvider: ApiRequestProvider, ApiCaller { struct MockApiRequest: ApiRequest { let caller: ApiCaller let method: ApiMethod - let endpoint: ApiEndpoint + let endpoint: any ApiEndpointProtocol let queryItems: [URLQueryItem] let headers: Headers let body: Data? @@ -498,7 +193,7 @@ struct MockApiRequest: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, encodable: Encodable, @@ -523,7 +218,7 @@ struct MockApiRequest: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, body: Data?, @@ -548,7 +243,7 @@ struct MockApiRequest: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, form: Form, @@ -600,6 +295,10 @@ struct MockApiRequest: ApiRequest { return url } + func referalUrl(acceptStatusCodes _: Set) async throws -> URL { + try await referalUrl(acceptStatusCode: 302) + } + func response(acceptStatusCode: Int) async throws -> Data { guard let provider = caller as? MockApiProvider else { throw URLError(.badServerResponse) @@ -641,10 +340,18 @@ struct MockApiRequest: ApiRequest { throw URLError(.badServerResponse) } + func string(acceptStatusCodes _: Set) async throws -> String { + throw URLError(.badServerResponse) + } + func httpResponse(acceptStatusCode: Int) async throws -> HTTPURLResponse { throw URLError(.badServerResponse) } + func httpResponse(acceptStatusCodes _: Set) async throws -> HTTPURLResponse { + throw URLError(.badServerResponse) + } + func data(acceptStatusCode: Int) async throws -> T { guard let provider = caller as? MockApiProvider else { @@ -657,4 +364,12 @@ struct MockApiRequest: ApiRequest { throw URLError(.badServerResponse) } + + func data(acceptStatusCodes _: Set) async throws -> T { + try await data(acceptStatusCode: 200) + } + + func rawData(acceptStatusCodes _: Set) async throws -> Data { + throw URLError(.badServerResponse) + } } diff --git a/KiaTests/ExtensionIntegrationTests.swift b/KiaTests/ExtensionIntegrationTests.swift index 4196bae..525d667 100644 --- a/KiaTests/ExtensionIntegrationTests.swift +++ b/KiaTests/ExtensionIntegrationTests.swift @@ -10,18 +10,33 @@ import XCTest @testable import KiaMaps final class ExtensionIntegrationTests: XCTestCase { + private let selectedVehicleVINKey = "selectedVehicleVIN" override func setUpWithError() throws { - // Start the server for integration tests + clearSharedState() + LocalCredentialServer.shared.stop() LocalCredentialServer.shared.start() - Thread.sleep(forTimeInterval: 0.5) + waitForServerToStart() } override func tearDownWithError() throws { - // Stop the server and clean up LocalCredentialServer.shared.stop() + clearSharedState() + } + + private func clearSharedState() { Authorization.remove() SharedVehicleManager.shared.selectedVehicleVIN = nil + UserDefaults.standard.removeObject(forKey: selectedVehicleVINKey) + } + + private func waitForServerToStart(timeout: TimeInterval = 2.0) { + let deadline = Date().addingTimeInterval(timeout) + while !LocalCredentialServer.shared.isRunning && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + XCTAssertTrue(LocalCredentialServer.shared.isRunning, "Local credential server did not start in time") } func testMainAppToExtensionCredentialFlow() throws { diff --git a/KiaTests/HMGClientTests.swift b/KiaTests/HMGClientTests.swift new file mode 100644 index 0000000..a4adc0b --- /dev/null +++ b/KiaTests/HMGClientTests.swift @@ -0,0 +1,300 @@ +// +// HMGClientTests.swift +// KiaTests +// +// Created by Codex on 09.03.2026. +// + +import XCTest +@testable import KiaMaps + +final class HMGAuthClientTests: XCTestCase { + func testMakeAuthorizeURLUsesHMGAuthorizeEndpointAndQuery() throws { + let provider = MockApiProvider() + let client = HMGAuthClient(configuration: MockApiConfiguration(), provider: provider, rsaService: .init()) + + let url = try XCTUnwrap(client.makeAuthorizeURL()) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + + XCTAssertEqual(url.host, "idpconnect-mock.test.com") + XCTAssertEqual(url.path, "/auth/api/v2/user/oauth2/authorize") + XCTAssertEqual(items["client_id"], "mock-service-id-123") + XCTAssertEqual(items["redirect_uri"], "https://prd.eu-ccapi.kia.com:8080/api/v1/user/oauth2/redirect") + XCTAssertEqual(items["response_type"], "code") + XCTAssertEqual(items["lang"], "en") + XCTAssertEqual(items["state"], "ccsp") + } + + func testExtractAuthorizationCodeParsesExpectedFields() throws { + let client = HMGAuthClient(configuration: MockApiConfiguration(), provider: MockApiProvider(), rsaService: .init()) + let callback = try XCTUnwrap(URL(string: "https://prd.eu-ccapi.kia.com:8080/api/v1/user/oauth2/redirect?code=AUTH_CODE_123&state=ccsp&login_success=y")) + + let result = try client.extractAuthorizationCode(from: callback) + + XCTAssertEqual(result.code, "AUTH_CODE_123") + XCTAssertEqual(result.state, "ccsp") + XCTAssertTrue(result.loginSuccess) + } + + func testExtractAuthorizationCodeThrowsWhenCodeMissing() throws { + let client = HMGAuthClient(configuration: MockApiConfiguration(), provider: MockApiProvider(), rsaService: .init()) + let callback = try XCTUnwrap(URL(string: "https://prd.eu-ccapi.kia.com:8080/api/v1/user/oauth2/redirect?state=ccsp&login_success=y")) + + XCTAssertThrowsError(try client.extractAuthorizationCode(from: callback)) { error in + XCTAssertEqual(error as? AuthenticationError, .authorizationCodeNotFound) + } + } +} + +final class HMGMQTTClientTests: XCTestCase { + func testApiFetchMQTTDeviceHostThrowsUnsupportedForPorscheBeforeRequest() async { + let provider = MQTTStubApiProvider(configuration: PorscheApiConfiguration.europe) + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init(), provider: provider) + + do { + _ = try await api.fetchMQTTDeviceHost() + XCTFail("Expected MQTT unsupported error") + } catch let error as ApiError { + guard case let .unsupported(message) = error else { + return XCTFail("Expected unsupported error, got \(error)") + } + XCTAssertEqual(message, "MQTT is not supported for Porsche.") + XCTAssertEqual(provider.requestCount, 0) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testFetchDeviceHostMapsResponse() async throws { + let provider = MQTTStubApiProvider(configuration: MockApiConfiguration()) + provider.authorization = makeAuthorizationData() + provider.hostResponse = MQTTHostResponse( + http: .init(name: "http", protocol: "https", host: "http.example.com", port: 443, ssl: true), + mqtt: .init(name: "mqtt", protocol: "mqtt", host: "broker.example.com", port: 8883, ssl: true) + ) + let client = HMGMQTTClient(configuration: MockApiConfiguration(), provider: provider) + + let response = try await client.fetchDeviceHost() + + XCTAssertEqual(response.host, "broker.example.com") + XCTAssertEqual(response.port, 8883) + XCTAssertTrue(response.ssl) + XCTAssertEqual(provider.recordedRequests.first?.url?.path, "/api/v3/servicehub/device/host") + } + + func testRegisterDeviceReturnsIdsAndGeneratedUUIDSuffix() async throws { + let provider = MQTTStubApiProvider(configuration: MockApiConfiguration()) + provider.authorization = makeAuthorizationData() + provider.deviceRegisterResponse = DeviceRegisterResponse(clientId: "client-123", deviceId: "device-456") + let client = HMGMQTTClient(configuration: MockApiConfiguration(), provider: provider) + + let response = try await client.registerDevice() + + XCTAssertEqual(response.clientId, "client-123") + XCTAssertEqual(response.deviceId, "device-456") + XCTAssertTrue(response.uuid.hasSuffix("_UVO")) + XCTAssertEqual(provider.recordedRequests.first?.url?.path, "/api/v3/servicehub/device/register") + } + + private func makeAuthorizationData() -> AuthorizationData { + AuthorizationData( + stamp: "stamp", + deviceId: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + accessToken: "access-token", + expiresIn: 3600, + refreshToken: "refresh-token", + isCcuCCS2Supported: true + ) + } +} + +private final class MQTTStubApiProvider: ApiRequestProvider, ApiCaller, @unchecked Sendable { + let urlSession: URLSession + + override var caller: ApiCaller { + self + } + + var requestCount = 0 + var recordedRequests: [URLRequest] = [] + var hostResponse: MQTTHostResponse? + var deviceRegisterResponse: DeviceRegisterResponse? + + init(configuration: ApiConfiguration) { + urlSession = .shared + super.init(configuration: configuration, callerType: Self.self, requestType: MQTTStubApiRequest.self) + } + + required init(configuration: any ApiConfiguration, urlSession: URLSession, authorization: AuthorizationData?) { + self.urlSession = urlSession + super.init(configuration: configuration, callerType: Self.self, requestType: MQTTStubApiRequest.self) + self.authorization = authorization + } +} + +private struct MQTTStubApiRequest: ApiRequest { + let caller: ApiCaller + let method: ApiMethod + let endpoint: any ApiEndpointProtocol + let queryItems: [URLQueryItem] + let headers: Headers + let body: Data? + let timeout: TimeInterval + + init( + caller: ApiCaller, + method: ApiMethod?, + endpoint: any ApiEndpointProtocol, + queryItems: [URLQueryItem], + headers: Headers, + encodable: Encodable, + timeout: TimeInterval + ) throws { + self.caller = caller + self.method = method ?? .post + self.endpoint = endpoint + self.queryItems = queryItems + self.headers = headers + body = try JSONEncoders.default.encode(AnyEncodable(encodable)) + self.timeout = timeout + } + + init( + caller: ApiCaller, + method: ApiMethod?, + endpoint: any ApiEndpointProtocol, + queryItems: [URLQueryItem], + headers: Headers, + body: Data?, + timeout: TimeInterval + ) { + self.caller = caller + self.method = method ?? (body == nil ? .get : .post) + self.endpoint = endpoint + self.queryItems = queryItems + self.headers = headers + self.body = body + self.timeout = timeout + } + + init( + caller: ApiCaller, + method: ApiMethod?, + endpoint: any ApiEndpointProtocol, + queryItems: [URLQueryItem], + headers: Headers, + form: Form, + timeout: TimeInterval + ) { + self.caller = caller + self.method = method ?? .post + self.endpoint = endpoint + self.queryItems = queryItems + self.headers = headers + body = form + .map { "\($0.key)=\($0.value)" } + .joined(separator: "&") + .data(using: .utf8) + self.timeout = timeout + } + + var urlRequest: URLRequest { + get throws { + var url = try caller.configuration.url(for: endpoint) + if !queryItems.isEmpty { + url.append(queryItems: queryItems) + } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData, timeoutInterval: timeout) + request.httpMethod = method.rawValue + request.allHTTPHeaderFields = headers + request.httpBody = body + return request + } + } + + func referalUrl(acceptStatusCode _: Int) async throws -> URL { + throw URLError(.badServerResponse) + } + + func referalUrl(acceptStatusCodes _: Set) async throws -> URL { + throw URLError(.badServerResponse) + } + + func response(acceptStatusCode _: Int) async throws -> Data { + throw URLError(.badServerResponse) + } + + func responseValue(acceptStatusCode _: Int) async throws -> Data { + throw URLError(.badServerResponse) + } + + func responseEmpty(acceptStatusCode _: Int) async throws -> ApiResponseEmpty { + throw URLError(.badServerResponse) + } + + func empty(acceptStatusCode _: Int) async throws { + _ = try recordRequest() + } + + func string(acceptStatusCode _: Int) async throws -> String { + throw URLError(.badServerResponse) + } + + func string(acceptStatusCodes _: Set) async throws -> String { + throw URLError(.badServerResponse) + } + + func httpResponse(acceptStatusCode _: Int) async throws -> HTTPURLResponse { + throw URLError(.badServerResponse) + } + + func httpResponse(acceptStatusCodes _: Set) async throws -> HTTPURLResponse { + throw URLError(.badServerResponse) + } + + func data(acceptStatusCode _: Int) async throws -> T { + let provider = try recordRequest() + + if T.self == MQTTHostResponse.self, let response = provider.hostResponse { + return response as! T + } + + if T.self == DeviceRegisterResponse.self, let response = provider.deviceRegisterResponse { + return response as! T + } + + throw URLError(.badServerResponse) + } + + func data(acceptStatusCodes _: Set) async throws -> T { + try await data(acceptStatusCode: 200) + } + + func rawData(acceptStatusCodes _: Set) async throws -> Data { + throw URLError(.badServerResponse) + } + + private func recordRequest() throws -> MQTTStubApiProvider { + guard let provider = caller as? MQTTStubApiProvider else { + throw URLError(.badServerResponse) + } + provider.requestCount += 1 + provider.recordedRequests.append(try urlRequest) + return provider + } +} + +private struct AnyEncodable: Encodable { + private let encodeImpl: (Encoder) throws -> Void + + init(_ value: Encodable) { + encodeImpl = { encoder in + try value.encode(to: encoder) + } + } + + func encode(to encoder: Encoder) throws { + try encodeImpl(encoder) + } +} diff --git a/KiaTests/LocalCredentialServerTests.swift b/KiaTests/LocalCredentialServerTests.swift index 8e9b010..8f393cc 100644 --- a/KiaTests/LocalCredentialServerTests.swift +++ b/KiaTests/LocalCredentialServerTests.swift @@ -12,15 +12,21 @@ import Network final class LocalCredentialServerTests: XCTestCase { var server: LocalCredentialServer! - let testPort: UInt16 = 8766 // Use different port for testing + let testPort: UInt16 = 8766 + let testPassword = "test" override func setUpWithError() throws { - // Use separate server instance for testing to avoid conflicts - server = LocalCredentialServer(password: "test") + server = LocalCredentialServer(port: testPort, password: testPassword) + Authorization.remove() + SharedVehicleManager.shared.selectedVehicleVIN = nil + UserDefaults.standard.removeObject(forKey: "selectedVehicleVIN") } override func tearDownWithError() throws { server.stop() + Authorization.remove() + SharedVehicleManager.shared.selectedVehicleVIN = nil + UserDefaults.standard.removeObject(forKey: "selectedVehicleVIN") server = nil } @@ -54,7 +60,11 @@ final class LocalCredentialServerTests: XCTestCase { SharedVehicleManager.shared.selectedVehicleVIN = "TEST123VIN" // Create client to test server - let client = LocalCredentialClient(extensionIdentifier: "TestExtension") + let client = LocalCredentialClient( + extensionIdentifier: "TestExtension", + serverPort: testPort, + serverPassword: testPassword + ) let credentials = try await client.fetchCredentials() XCTAssertNotNil(credentials) @@ -69,15 +79,18 @@ final class LocalCredentialServerTests: XCTestCase { server.start() try await Task.sleep(for: .milliseconds(500)) - // Create client with wrong password - let client = LocalCredentialClient(extensionIdentifier: "TestExtension") - - // Override the password to test rejection - // Note: This is a simplified test - in practice we'd need to modify the client - // to accept a custom password for testing - - // For now, test with correct password but check that server validates requests - let _ = try await client.fetchCredentials() + let client = LocalCredentialClient( + extensionIdentifier: "TestExtension", + serverPort: testPort, + serverPassword: "wrong-password" + ) + + do { + _ = try await client.fetchCredentials() + XCTFail("Expected invalid password error") + } catch { + XCTAssertTrue(error.localizedDescription.contains("Invalid password")) + } server.stop() } @@ -86,9 +99,6 @@ final class LocalCredentialServerTests: XCTestCase { server.start() try await Task.sleep(for: .milliseconds(500)) - let expectation1 = XCTestExpectation(description: "Client 1 gets response") - let expectation2 = XCTestExpectation(description: "Client 2 gets response") - // Store test data let testAuth = AuthorizationData( stamp: "test-stamp", @@ -100,32 +110,20 @@ final class LocalCredentialServerTests: XCTestCase { ) Authorization.store(data: testAuth) - // Test concurrent access - DispatchQueue.global().async { - Task { - do { - let client1 = LocalCredentialClient(extensionIdentifier: "TestExtension1") - _ = try await client1.fetchCredentials() - expectation1.fulfill() - } catch { - XCTFail("Failed to get credentials for TestExtension1") - } - } - } - - DispatchQueue.global().async { - Task { - do { - let client2 = LocalCredentialClient(extensionIdentifier: "TestExtension2") - _ = try await client2.fetchCredentials() - expectation2.fulfill() - } catch { - XCTFail("Failed to get credentials for TestExtension2") - } - } - } + async let response1 = LocalCredentialClient( + extensionIdentifier: "TestExtension1", + serverPort: testPort, + serverPassword: testPassword + ).fetchCredentials() + async let response2 = LocalCredentialClient( + extensionIdentifier: "TestExtension2", + serverPort: testPort, + serverPassword: testPassword + ).fetchCredentials() - await fulfillment(of: [expectation1, expectation2], timeout: 10.0) + let credentials = try await [response1, response2] + XCTAssertEqual(credentials.count, 2) + XCTAssertTrue(credentials.allSatisfy { $0.authorization?.accessToken == "test-token" }) // Cleanup Authorization.remove() @@ -136,12 +134,14 @@ final class LocalCredentialServerTests: XCTestCase { server.start() try await Task.sleep(for: .milliseconds(500)) - let expectation = XCTestExpectation(description: "Server handles no credentials gracefully") - // Ensure no credentials are stored Authorization.remove() - let client = LocalCredentialClient(extensionIdentifier: "TestExtension", serverPassword: "") + let client = LocalCredentialClient( + extensionIdentifier: "TestExtension", + serverPort: testPort, + serverPassword: "" + ) // Server should still respond, but with nil authorization do { @@ -160,4 +160,3 @@ final class LocalCredentialServerTests: XCTestCase { server.stop() } } - diff --git a/KiaTests/PorscheAuthClientTests.swift b/KiaTests/PorscheAuthClientTests.swift new file mode 100644 index 0000000..a6e67c1 --- /dev/null +++ b/KiaTests/PorscheAuthClientTests.swift @@ -0,0 +1,184 @@ +// +// PorscheAuthClientTests.swift +// KiaTests +// +// Created by Codex on 06.03.2026. +// + +import XCTest +@testable import KiaMaps + +private final class PorscheTransportStub { + struct StubResponse { + let statusCode: Int + let headers: [String: String] + let body: Data + + init(statusCode: Int, headers: [String: String] = [:], json: Any? = nil) { + self.statusCode = statusCode + self.headers = headers + if let json { + self.body = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + } else { + self.body = Data() + } + } + } + + private(set) var requests: [URLRequest] = [] + private var responses: [StubResponse] + + init(responses: [StubResponse]) { + self.responses = responses + } + + func transport(_ request: URLRequest) async throws -> PorscheHTTPTransportResponse { + requests.append(request) + let response = responses.removeFirst() + let httpResponse = HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: response.statusCode, + httpVersion: nil, + headerFields: response.headers + )! + return PorscheHTTPTransportResponse(data: response.body, response: httpResponse) + } +} + +final class PorscheAuthClientTests: XCTestCase { + func testAuthorizeURLContainsExpectedQueryForEU() throws { + let client = PorscheAuthClient(configuration: .europe) + let url = try client.makeAuthorizeURL(state: "state-eu") + + XCTAssertEqual(url.host, "identity.porsche.com") + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + XCTAssertEqual(items["client_id"], PorscheApiConfiguration.europe.authClientId) + XCTAssertEqual(items["redirect_uri"], PorscheApiConfiguration.europe.redirectUri) + XCTAssertEqual(items["audience"], PorscheApiConfiguration.europe.audience) + XCTAssertEqual(items["scope"], PorscheApiConfiguration.europe.scope) + XCTAssertEqual(items["state"], "state-eu") + XCTAssertEqual(items["ui_locales"], "de_DE") + } + + func testAuthorizeURLContainsExpectedQueryForUS() throws { + let client = PorscheAuthClient(configuration: .usa) + let url = try client.makeAuthorizeURL(state: "state-us") + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + XCTAssertEqual(items["ui_locales"], "en_US") + XCTAssertEqual(items["state"], "state-us") + } + + func testAuthenticateRunsIdentifierFirstFlowAndExchangesToken() async throws { + let stub = PorscheTransportStub(responses: [ + .init(statusCode: 302, headers: ["Location": "/u/login/identifier?state=state-eu"]), + .init(statusCode: 200), + .init(statusCode: 302, headers: ["Location": "/authorize/resume?state=state-eu"]), + .init(statusCode: 302, headers: ["Location": "my-porsche-app://auth0/callback?code=abc123&state=state-eu"]), + .init(statusCode: 200, json: [ + "access_token": "access-token", + "refresh_token": "refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid cars", + ]), + ]) + let now = Date(timeIntervalSince1970: 1_773_000_000) + let client = PorscheAuthClient(configuration: .europe, transport: stub.transport, now: { now }) + + let token = try await client.authenticate(username: "test@example.com", password: "secret", state: "state-eu") + + XCTAssertEqual(token.accessToken, "access-token") + XCTAssertEqual(token.refreshToken, "refresh-token") + XCTAssertEqual(token.scope, "openid cars") + XCTAssertEqual(token.obtainedAt, now) + XCTAssertEqual(stub.requests.count, 5) + XCTAssertEqual(stub.requests[1].url?.path, "/u/login/identifier") + XCTAssertEqual(stub.requests[2].url?.path, "/u/login/password") + XCTAssertEqual(stub.requests[4].url?.path, "/oauth/token") + } + + func testAuthenticateThrowsMFAChallengeWhenPasswordRedirectRequestsOTP() async throws { + let stub = PorscheTransportStub(responses: [ + .init(statusCode: 302, headers: ["Location": "/u/login/identifier?state=s-mfa"]), + .init(statusCode: 200), + .init(statusCode: 302, headers: ["Location": "/u/mfa-otp-challenge?state=s-mfa"]), + ]) + let client = PorscheAuthClient(configuration: .europe, transport: stub.transport) + + await XCTAssertThrowsErrorAsync(try await client.authenticate(username: "test@example.com", password: "secret", state: "s-mfa")) { error in + XCTAssertEqual(error as? PorscheAuthError, .mfaRequired(.init(state: "s-mfa", challengeType: "otp"))) + } + } + + func testRefreshTokenUsesRefreshGrant() async throws { + let stub = PorscheTransportStub(responses: [ + .init(statusCode: 200, json: [ + "access_token": "new-access", + "refresh_token": "new-refresh", + "token_type": "Bearer", + "expires_in": 7200, + "scope": "openid profile", + ]), + ]) + let client = PorscheAuthClient(configuration: .europe, transport: stub.transport, now: { Date(timeIntervalSince1970: 42) }) + + let token = try await client.refreshToken("refresh-1") + + XCTAssertEqual(token.accessToken, "new-access") + let body = String(decoding: stub.requests[0].httpBody ?? Data(), as: UTF8.self) + XCTAssertTrue(body.contains("grant_type=refresh_token")) + XCTAssertTrue(body.contains("refresh_token=refresh-1")) + } + + func testParseAuthorizationCallbackCode() throws { + let client = PorscheAuthClient(configuration: .europe) + let callback = try XCTUnwrap(URL(string: "my-porsche-app://auth0/callback?code=abc123&state=s1")) + let result = try client.parseAuthorizationCallback(callback) + XCTAssertEqual(result, .authorizationCode("abc123")) + } + + func testParseAuthorizationCallbackMFA() throws { + let client = PorscheAuthClient(configuration: .europe) + let callback = try XCTUnwrap(URL(string: "my-porsche-app://auth0/callback?error=mfa_required&state=s-mfa")) + let result = try client.parseAuthorizationCallback(callback) + XCTAssertEqual(result, .mfaRequired(.init(state: "s-mfa", challengeType: "otp"))) + } + + func testTokenExpiryHandling() { + let token = PorscheTokenSet( + accessToken: "a", + refreshToken: "r", + tokenType: "Bearer", + expiresIn: 3600, + scope: nil, + obtainedAt: Date().addingTimeInterval(-3500) + ) + XCTAssertTrue(token.isExpired(leeway: 120)) + XCTAssertFalse(token.isExpired(leeway: 10)) + } + + func testMFAResultMapping() throws { + let client = PorscheAuthClient(configuration: .europe) + XCTAssertNoThrow(try client.mapMFASubmitResult(.success)) + XCTAssertThrowsError(try client.mapMFASubmitResult(.invalidCode)) { error in + XCTAssertEqual(error as? PorscheAuthError, .invalidMFACode) + } + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> some Any, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (_ error: Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + XCTFail(message(), file: file, line: line) + } catch { + errorHandler(error) + } +} diff --git a/KiaTests/PorscheEndpointAndMapperTests.swift b/KiaTests/PorscheEndpointAndMapperTests.swift new file mode 100644 index 0000000..ecfc36b --- /dev/null +++ b/KiaTests/PorscheEndpointAndMapperTests.swift @@ -0,0 +1,439 @@ +// +// PorscheEndpointAndMapperTests.swift +// KiaTests +// +// Created by Codex on 06.03.2026. +// + +import XCTest +@testable import KiaMaps + +private final class PorscheProviderTransportStub { + struct StubResponse { + let statusCode: Int + let headers: [String: String] + let body: Data + + init(statusCode: Int, headers: [String: String] = [:], json: Any? = nil) { + self.statusCode = statusCode + self.headers = headers + if let json { + self.body = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + } else { + self.body = Data() + } + } + } + + private(set) var requests: [URLRequest] = [] + private var responses: [StubResponse] + + init(responses: [StubResponse]) { + self.responses = responses + } + + func transport(_ request: URLRequest) async throws -> PorscheHTTPTransportResponse { + requests.append(request) + let response = responses.removeFirst() + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: response.statusCode, + httpVersion: nil, + headerFields: response.headers + )! + return PorscheHTTPTransportResponse(data: response.body, response: httpResponse) + } +} + +private final class PorscheRequestProviderStub: ApiRequestProvider, ApiCaller { + let urlSession: URLSession + private(set) var requests: [URLRequest] = [] + private var responses: [StubResponse] + + struct StubResponse { + let statusCode: Int + let headers: [String: String] + let body: Data + + init(statusCode: Int, headers: [String: String] = [:], json: Any? = nil) { + self.statusCode = statusCode + self.headers = headers + if let json { + self.body = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + } else { + self.body = Data() + } + } + } + + override var caller: ApiCaller { + self + } + + init(configuration: ApiConfiguration = PorscheApiConfiguration.europe, responses: [StubResponse]) { + self.urlSession = .shared + self.responses = responses + super.init(configuration: configuration, callerType: Self.self, requestType: PorscheRequestProviderApiRequest.self) + } + + required init(configuration: any ApiConfiguration, urlSession: URLSession, authorization: AuthorizationData?) { + self.urlSession = urlSession + responses = [] + super.init(configuration: configuration, callerType: Self.self, requestType: PorscheRequestProviderApiRequest.self) + self.authorization = authorization + } + + func dequeueResponse(for request: URLRequest) throws -> StubResponse { + requests.append(request) + guard !responses.isEmpty else { + throw URLError(.badServerResponse) + } + return responses.removeFirst() + } +} + +private struct PorscheRequestProviderApiRequest: ApiRequest { + let caller: ApiCaller + let method: ApiMethod + let endpoint: any ApiEndpointProtocol + let queryItems: [URLQueryItem] + let headers: Headers + let body: Data? + let timeout: TimeInterval + + init( + caller: ApiCaller, + method: ApiMethod?, + endpoint: any ApiEndpointProtocol, + queryItems: [URLQueryItem], + headers: Headers, + encodable: Encodable, + timeout: TimeInterval + ) throws { + self.caller = caller + self.method = method ?? .post + self.endpoint = endpoint + self.queryItems = queryItems + self.headers = headers + body = try JSONEncoders.default.encode(encodable) + self.timeout = timeout + } + + init( + caller: ApiCaller, + method: ApiMethod?, + endpoint: any ApiEndpointProtocol, + queryItems: [URLQueryItem], + headers: Headers, + body: Data?, + timeout: TimeInterval + ) { + self.caller = caller + self.method = method ?? (body == nil ? .get : .post) + self.endpoint = endpoint + self.queryItems = queryItems + self.headers = headers + self.body = body + self.timeout = timeout + } + + init( + caller: ApiCaller, + method: ApiMethod?, + endpoint: any ApiEndpointProtocol, + queryItems: [URLQueryItem], + headers: Headers, + form: Form, + timeout: TimeInterval + ) { + self.caller = caller + self.method = method ?? .post + self.endpoint = endpoint + self.queryItems = queryItems + self.headers = headers + self.body = form + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\($0.value)" } + .joined(separator: "&") + .data(using: .utf8) + self.timeout = timeout + } + + var urlRequest: URLRequest { + get throws { + var url = try caller.configuration.url(for: endpoint) + if !queryItems.isEmpty { + url.append(queryItems: queryItems) + } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData, timeoutInterval: timeout) + request.httpMethod = method.rawValue + var allHeaders = headers + if let authorization = caller.authorization { + for (key, value) in authorization.authorizatioHeaders(for: caller.configuration) { + allHeaders[key] = value + } + } + request.allHTTPHeaderFields = allHeaders + request.httpBody = body + return request + } + } + + func response(acceptStatusCode _: Int) async throws -> Data { + throw URLError(.badServerResponse) + } + + func responseValue(acceptStatusCode _: Int) async throws -> Data { + throw URLError(.badServerResponse) + } + + func responseEmpty(acceptStatusCode _: Int) async throws -> ApiResponseEmpty { + throw URLError(.badServerResponse) + } + + func empty(acceptStatusCode: Int) async throws { + _ = try await rawData(acceptStatusCodes: [acceptStatusCode]) + } + + func string(acceptStatusCodes: Set) async throws -> String { + String(decoding: try await rawData(acceptStatusCodes: acceptStatusCodes), as: UTF8.self) + } + + func httpResponse(acceptStatusCodes: Set) async throws -> HTTPURLResponse { + let provider = try provider() + let request = try urlRequest + let response = try provider.dequeueResponse(for: request) + if response.statusCode == 401 { + throw ApiError.unauthorized + } + guard acceptStatusCodes.contains(response.statusCode) else { + throw ApiError.unexpectedStatusCode(response.statusCode) + } + return HTTPURLResponse(url: request.url!, statusCode: response.statusCode, httpVersion: nil, headerFields: response.headers)! + } + + func data(acceptStatusCodes _: Set) async throws -> T { + throw URLError(.badServerResponse) + } + + func rawData(acceptStatusCodes: Set) async throws -> Data { + let provider = try provider() + let request = try urlRequest + let response = try provider.dequeueResponse(for: request) + if response.statusCode == 401 { + throw ApiError.unauthorized + } + guard acceptStatusCodes.contains(response.statusCode) else { + throw ApiError.unexpectedStatusCode(response.statusCode) + } + return response.body + } + + func referalUrl(acceptStatusCodes _: Set) async throws -> URL { + throw URLError(.badServerResponse) + } + + private func provider() throws -> PorscheRequestProviderStub { + guard let provider = caller as? PorscheRequestProviderStub else { + throw URLError(.badServerResponse) + } + return provider + } +} + +final class PorscheEndpointAndMapperTests: XCTestCase { + func testApiBrandPorscheSelectsRegionSpecificConfiguration() { + let euConfiguration = ApiBrand.porsche.configuration(for: .europe) + let usConfiguration = ApiBrand.porsche.configuration(for: .usa) + XCTAssertTrue(euConfiguration is PorscheApiConfiguration) + XCTAssertTrue(usConfiguration is PorscheApiConfiguration) + } + + func testPorscheEndpointURLCompositionEU() throws { + let config = PorscheApiConfiguration.europe + let vehicleURL = try config.url(for: PorscheApiEndpoint.vehicle("WP0ZZZ99ZTS392124")) + XCTAssertEqual(vehicleURL.absoluteString, "https://api.ppa.porsche.com/app/connect/v1/vehicles/WP0ZZZ99ZTS392124") + } + + func testPorscheEndpointURLCompositionUS() throws { + let config = PorscheApiConfiguration.usa + let commandsURL = try config.url(for: PorscheApiEndpoint.commands("VIN123")) + XCTAssertEqual(commandsURL.absoluteString, "https://api.ppa.porsche.com/app/connect/v1/vehicles/VIN123/commands") + } + + func testProviderFactoryChoosesPorscheProvider() { + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init()) + let provider = VehicleApiProviderFactory.provider(for: api) + XCTAssertTrue(provider is PorscheVehicleApiProvider) + } + + func testMapperUsesMeasurementPayloadAndSafeDefaults() throws { + let payload: [String: Any] = [ + "vin": "VIN", + "modelName": "Taycan", + "modelType": [ + "year": "2024", + "engine": "BEV", + "model": "4S", + ], + "customName": "My Porsche", + "measurements": [ + [ + "key": "BATTERY_LEVEL", + "status": ["isEnabled": true], + "value": ["percent": 62.5], + ], + [ + "key": "GPS_LOCATION", + "status": ["isEnabled": true], + "value": [ + "location": "50.1,14.4", + "direction": 90, + "lastModified": "2026-03-06T16:00:00Z", + ], + ], + [ + "key": "LOCK_STATE_VEHICLE", + "status": ["isEnabled": true], + "value": ["isLocked": true], + ], + [ + "key": "MILEAGE", + "status": ["isEnabled": true], + "value": ["kilometers": 15432], + ], + [ + "key": "CLIMATIZER_STATE", + "status": ["isEnabled": true], + "value": ["isOn": true, "targetTemperature": 294.15], + ], + [ + "key": "CHARGING_RATE", + "status": ["isEnabled": true], + "value": ["chargingPower": 11000], + ], + ], + ] + + let summary = PorscheVehicleMapper.mapSummary(from: payload) + let snapshot = PorscheVehicleMapper.map(summary: summary) + let state = try PorscheVehicleMapper.mapVehicleState(from: payload) + + XCTAssertEqual(snapshot.batterySoc, 62.5) + XCTAssertEqual(snapshot.latitude, 50.1) + XCTAssertEqual(snapshot.longitude, 14.4) + XCTAssertEqual(snapshot.odometerKm, 15_432) + XCTAssertTrue(snapshot.locked) + XCTAssertTrue(snapshot.climateActive) + XCTAssertEqual(snapshot.chargingPowerKw, 11) + XCTAssertEqual(state.status.green.batteryManagement.batteryRemain.ratio, 62.5) + XCTAssertEqual(state.status.drivetrain.odometer, 15_432) + XCTAssertEqual(state.status.location?.geoCoordinate.latitude, 50.1) + XCTAssertEqual(state.status.location?.geoCoordinate.longitude, 14.4) + XCTAssertEqual(state.status.cabin.hvac.row1.driver.blower.speedLevel, 1) + } + + func testCommandBodyUsesRemoteClimatizerStartPayload() throws { + let data = PorscheVehicleMapper.commandBody(for: .climateOn(vin: "VIN", temperatureC: 22)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["key"] as? String, "REMOTE_CLIMATIZER_START") + let payload = try XCTUnwrap(json["payload"] as? [String: Any]) + let targetTemperature = try XCTUnwrap(payload["targetTemperature"] as? Double) + XCTAssertEqual(targetTemperature, 295.15, accuracy: 0.001) + } + + func testProviderRefreshesTokenAfterUnauthorizedVehiclesCall() async throws { + let vehiclePayload: [[String: Any]] = [[ + "vin": "WP0AA2Y1XNSA00001", + "modelName": "Taycan", + "modelType": [ + "year": "2025", + "engine": "BEV", + "model": "Turbo", + ], + "customName": "Turbo", + ]] + + let authStub = PorscheProviderTransportStub(responses: [ + .init(statusCode: 200, json: [ + "access_token": "fresh-access", + "refresh_token": "fresh-refresh", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid cars", + ]), + ]) + let requestProvider = PorscheRequestProviderStub(responses: [ + .init(statusCode: 401), + .init(statusCode: 200, json: vehiclePayload), + ]) + + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init(), provider: requestProvider) + api.authorization = AuthorizationData( + stamp: "porsche", + deviceId: UUID(), + accessToken: "expired", + expiresIn: 3600, + refreshToken: "refresh-token", + isCcuCCS2Supported: true, + providerKind: "porsche", + tokenIssuer: PorscheApiConfiguration.europe.loginHost, + tokenAudience: PorscheApiConfiguration.europe.audience, + tokenScope: PorscheApiConfiguration.europe.scope + ) + let authClient = PorscheAuthClient(configuration: .europe, transport: authStub.transport, now: { Date(timeIntervalSince1970: 42) }) + let provider = PorscheVehicleApiProvider(api: api, authClient: authClient, commandPollIntervalNanoseconds: 0) + + let response = try await provider.vehicles() + + XCTAssertEqual(response.vehicles.count, 1) + XCTAssertEqual(api.authorization?.accessToken, "fresh-access") + XCTAssertEqual(authStub.requests[0].url?.path, "/oauth/token") + } + + func testProviderStartClimateUsesCommandsEndpoint() async throws { + let vehicleID = UUID.porscheVehicleID(for: "WP0AA2Y1XNSA00001") + let requestProvider = PorscheRequestProviderStub(responses: [ + .init(statusCode: 200, json: [[ + "vin": "WP0AA2Y1XNSA00001", + "modelName": "Taycan", + "modelType": [ + "year": "2025", + "engine": "BEV", + "model": "4S", + ], + ]]), + .init(statusCode: 200, json: [ + "status": [ + "id": "4FD78B24-3C94-4EC2-8BE4-7D53FA3B84B6", + "result": "ACCEPTED", + ], + ]), + .init(statusCode: 200, json: [ + "status": [ + "result": "PERFORMED", + ], + ]), + ]) + + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init(), provider: requestProvider) + api.authorization = AuthorizationData( + stamp: "porsche", + deviceId: UUID(), + accessToken: "access", + expiresIn: 3600, + refreshToken: "refresh", + isCcuCCS2Supported: true, + providerKind: "porsche" + ) + let provider = PorscheVehicleApiProvider(api: api, commandPollIntervalNanoseconds: 0) + + let commandID = try await provider.startClimate(vehicleID, options: .init(temperature: 21), pin: "") + + XCTAssertEqual(commandID.uuidString, "4FD78B24-3C94-4EC2-8BE4-7D53FA3B84B6") + XCTAssertEqual(requestProvider.requests[1].url?.path, "/app/connect/v1/vehicles/WP0AA2Y1XNSA00001/commands") + let body = try XCTUnwrap(requestProvider.requests[1].httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["key"] as? String, "REMOTE_CLIMATIZER_START") + } +} diff --git a/KiaTests/RemoteLoggerTests.swift b/KiaTests/RemoteLoggerTests.swift index 64b969f..0e2a888 100644 --- a/KiaTests/RemoteLoggerTests.swift +++ b/KiaTests/RemoteLoggerTests.swift @@ -24,12 +24,14 @@ final class RemoteLoggerTests: XCTestCase { // Create RemoteLogger instance remoteLogger = RemoteLogger.shared - remoteLogger.setEnabled(false) // Start disabled + UserDefaults.standard.removeObject(forKey: "RemoteLoggingEnabled") + remoteLogger.setEnabled(false) } override func tearDownWithError() throws { mockServer?.stop() remoteLogger?.setEnabled(false) + UserDefaults.standard.removeObject(forKey: "RemoteLoggingEnabled") try super.tearDownWithError() } diff --git a/KiaTests/RemoteLoggingIntegrationTests.swift b/KiaTests/RemoteLoggingIntegrationTests.swift index 68d4bba..26f5e09 100644 --- a/KiaTests/RemoteLoggingIntegrationTests.swift +++ b/KiaTests/RemoteLoggingIntegrationTests.swift @@ -23,6 +23,10 @@ final class RemoteLoggingIntegrationTests: XCTestCase { server = RemoteLoggingServer.shared server.stop() server.clearLogs() + server.filterText = "" + server.selectedLevel = nil + server.selectedSource = nil + server.selectedCategory = nil // Set up remote logger remoteLogger = RemoteLogger.shared @@ -35,6 +39,10 @@ final class RemoteLoggingIntegrationTests: XCTestCase { override func tearDownWithError() throws { server?.stop() server?.clearLogs() + server?.filterText = "" + server?.selectedLevel = nil + server?.selectedSource = nil + server?.selectedCategory = nil remoteLogger?.setEnabled(false) UserDefaults.standard.removeObject(forKey: "RemoteLoggingEnabled") diff --git a/KiaTests/RemoteLoggingServerTests.swift b/KiaTests/RemoteLoggingServerTests.swift index a5b5c0b..37f8217 100644 --- a/KiaTests/RemoteLoggingServerTests.swift +++ b/KiaTests/RemoteLoggingServerTests.swift @@ -25,11 +25,19 @@ final class RemoteLoggingServerTests: XCTestCase { // Ensure server is stopped before each test server.stop() server.clearLogs() + server.filterText = "" + server.selectedLevel = nil + server.selectedSource = nil + server.selectedCategory = nil } override func tearDownWithError() throws { server?.stop() server?.clearLogs() + server?.filterText = "" + server?.selectedLevel = nil + server?.selectedSource = nil + server?.selectedCategory = nil cancellables?.removeAll() try super.tearDownWithError() diff --git a/KiaTests/TestPlan.xctestplan b/KiaTests/TestPlan.xctestplan new file mode 100644 index 0000000..cc9b409 --- /dev/null +++ b/KiaTests/TestPlan.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "D66604B5-EC73-4333-952F-5F72F38FC31B", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:KiaMaps.xcodeproj", + "identifier" : "803D467C2E2E7413003EFC10", + "name" : "KiaTests" + } + } + ], + "version" : 1 +} diff --git a/KiaTests/UIComponentMockDataTests.swift b/KiaTests/UIComponentMockDataTests.swift index 9cefb04..e0c346f 100644 --- a/KiaTests/UIComponentMockDataTests.swift +++ b/KiaTests/UIComponentMockDataTests.swift @@ -11,6 +11,9 @@ import SwiftUI @testable import KiaMaps final class UIComponentMockDataTests: XCTestCase { + private func mappedStatus(_ state: VehicleState) -> VehicleStatus { + KiaVehicleStatusMapper.map(state: state) + } // MARK: - CircularBatteryView Tests @@ -78,7 +81,7 @@ final class UIComponentMockDataTests: XCTestCase { ] for (index, VehicleState) in scenarios.enumerated() { - let view = BatteryHeroView(from: VehicleState) + let view = BatteryHeroView(from: mappedStatus(VehicleState)) // View should be created successfully XCTAssertNotNil(view) @@ -109,7 +112,7 @@ final class UIComponentMockDataTests: XCTestCase { var locateActionCalled = false let view = QuickActionsView( - VehicleState: MockVehicleData.standard, + VehicleState: mappedStatus(MockVehicleData.standard), onLockAction: { lockActionCalled = true }, onClimateAction: { climateActionCalled = true }, onHornAction: { hornActionCalled = true }, @@ -248,7 +251,7 @@ final class UIComponentMockDataTests: XCTestCase { func testVehicleSilhouetteViewWithMockData() { let VehicleState = MockVehicleData.preconditioning - let view = VehicleSilhouetteView(VehicleState: VehicleState) + let view = VehicleSilhouetteView(vehicleState: mappedStatus(VehicleState)) // View should be created successfully XCTAssertNotNil(view) @@ -259,7 +262,7 @@ final class UIComponentMockDataTests: XCTestCase { func testVehicleSilhouetteViewDoorStates() { let VehicleState = MockVehicleData.standard - let view = VehicleSilhouetteView(VehicleState: VehicleState) + let view = VehicleSilhouetteView(vehicleState: mappedStatus(VehicleState)) XCTAssertNotNil(view) @@ -283,7 +286,7 @@ final class UIComponentMockDataTests: XCTestCase { isCharging: isCharging ) - let batteryHero = BatteryHeroView(from: VehicleState) + let batteryHero = BatteryHeroView(from: mappedStatus(VehicleState)) let progressBar = KiaProgressBar( value: batteryLevel, @@ -335,10 +338,10 @@ final class UIComponentMockDataTests: XCTestCase { isCharging: MockVehicleData.isCharging(VehicleState) ) - _ = BatteryHeroView(from: VehicleState) + _ = BatteryHeroView(from: mappedStatus(VehicleState)) _ = QuickActionsView( - VehicleState: VehicleState, + VehicleState: mappedStatus(VehicleState), onLockAction: {}, onClimateAction: {}, onHornAction: {},