From 1294e787ee27d379c7f3171e9f988ec325f570c2 Mon Sep 17 00:00:00 2001 From: Lukas Foldyna Date: Fri, 6 Mar 2026 16:40:51 +0100 Subject: [PATCH 1/8] feat: scaffold porsche api integration --- KiaMaps/App/Porsche/PorscheApiEndpoint.swift | 62 +++++++ KiaMaps/App/Porsche/PorscheAuthClient.swift | 62 +++++++ KiaMaps/App/Porsche/PorscheModels.swift | 129 ++++++++++++++ .../App/Porsche/PorscheVehicleMapper.swift | 28 +++ KiaMaps/Core/Api/Api.swift | 159 +++++++++++++++++- KiaMaps/Core/Api/ApiConfiguration.swift | 153 +++++++++++++++++ KiaTests/PorscheAuthClientTests.swift | 70 ++++++++ KiaTests/PorscheEndpointAndMapperTests.swift | 62 +++++++ 8 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 KiaMaps/App/Porsche/PorscheApiEndpoint.swift create mode 100644 KiaMaps/App/Porsche/PorscheAuthClient.swift create mode 100644 KiaMaps/App/Porsche/PorscheModels.swift create mode 100644 KiaMaps/App/Porsche/PorscheVehicleMapper.swift create mode 100644 KiaTests/PorscheAuthClientTests.swift create mode 100644 KiaTests/PorscheEndpointAndMapperTests.swift diff --git a/KiaMaps/App/Porsche/PorscheApiEndpoint.swift b/KiaMaps/App/Porsche/PorscheApiEndpoint.swift new file mode 100644 index 0000000..550f5c3 --- /dev/null +++ b/KiaMaps/App/Porsche/PorscheApiEndpoint.swift @@ -0,0 +1,62 @@ +// +// PorscheApiEndpoint.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +enum PorscheApiEndpoint { + case authorize + case token + case vehicles + case summary(String) + case lock(String) + case unlock(String) + case climateOn(String) + case climateOff(String) + case chargeStart(String) + case chargeStop(String) + + var path: String { + switch self { + case .authorize: + "authorize" + case .token: + "oauth/token" + case .vehicles: + "vehicles" + case let .summary(vin): + "vehicles/\(vin)/summary" + case let .lock(vin): + "vehicles/\(vin)/commands/lock" + case let .unlock(vin): + "vehicles/\(vin)/commands/unlock" + case let .climateOn(vin): + "vehicles/\(vin)/commands/climate/on" + case let .climateOff(vin): + "vehicles/\(vin)/commands/climate/off" + case let .chargeStart(vin): + "vehicles/\(vin)/commands/charging/start" + case let .chargeStop(vin): + "vehicles/\(vin)/commands/charging/stop" + } + } +} + +extension PorscheApiConfiguration { + func url(for endpoint: PorscheApiEndpoint) throws -> URL { + let base: String + switch endpoint { + case .authorize, .token: + base = "https://identity.porsche.com/" + default: + base = appApiBaseURL.hasSuffix("/") ? appApiBaseURL : appApiBaseURL + "/" + } + guard let url = URL(string: endpoint.path, relativeTo: URL(string: base)) else { + throw URLError(.badURL) + } + return url + } +} diff --git a/KiaMaps/App/Porsche/PorscheAuthClient.swift b/KiaMaps/App/Porsche/PorscheAuthClient.swift new file mode 100644 index 0000000..7b8072e --- /dev/null +++ b/KiaMaps/App/Porsche/PorscheAuthClient.swift @@ -0,0 +1,62 @@ +// +// PorscheAuthClient.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +struct PorscheAuthClient { + let configuration: PorscheApiConfiguration + + func makeAuthorizeURL(state: String = UUID().uuidString) throws -> URL { + let endpointUrl = try configuration.url(for: .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 parseAuthorizationCallback(_ callback: URL) throws -> PorscheAuthorizationCallback { + guard let components = URLComponents(url: callback, 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 ?? "" + let challenge = PorscheMFAChallenge(state: state, challengeType: "otp") + return .mfaRequired(challenge) + } + throw PorscheAuthError.backendError(error) + } + + guard let code = queryItems.first(where: { $0.name == "code" })?.value, !code.isEmpty else { + throw PorscheAuthError.missingAuthorizationCode + } + return .authorizationCode(code) + } + + func mapMFASubmitResult(_ result: PorscheMFASubmitResult) throws { + switch result { + case .success: + return + case .invalidCode: + throw PorscheAuthError.invalidMFACode + } + } +} diff --git a/KiaMaps/App/Porsche/PorscheModels.swift b/KiaMaps/App/Porsche/PorscheModels.swift new file mode 100644 index 0000000..9db80a7 --- /dev/null +++ b/KiaMaps/App/Porsche/PorscheModels.swift @@ -0,0 +1,129 @@ +// +// 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 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 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 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) +} + +struct PorscheCommandResult: Equatable { + let requestId: UUID +} + +enum PorscheAuthError: LocalizedError, Equatable { + case mfaRequired(PorscheMFAChallenge) + case invalidMFACode + 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 .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) + + 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)" + } + } +} + +enum PorscheMFASubmitResult { + case success + case invalidCode +} diff --git a/KiaMaps/App/Porsche/PorscheVehicleMapper.swift b/KiaMaps/App/Porsche/PorscheVehicleMapper.swift new file mode 100644 index 0000000..05aacaa --- /dev/null +++ b/KiaMaps/App/Porsche/PorscheVehicleMapper.swift @@ -0,0 +1,28 @@ +// +// PorscheVehicleMapper.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +enum PorscheVehicleMapper { + 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, + capabilities: .init( + canLock: summary.capabilities?.canLock ?? false, + canClimatise: summary.capabilities?.canClimatise ?? false, + canCharge: summary.capabilities?.canCharge ?? false + ) + ) + } + +} diff --git a/KiaMaps/Core/Api/Api.swift b/KiaMaps/Core/Api/Api.swift index d9fa57e..2e95159 100644 --- a/KiaMaps/Core/Api/Api.swift +++ b/KiaMaps/Core/Api/Api.swift @@ -9,6 +9,123 @@ 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 -> VehicleStateResponse + 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 unowned let api: Api + + init(api: Api) { + self.api = api + } + + func webLoginUrl() throws -> URL? { try api.hmgWebLoginUrl() } + func login(username: String, password: String, recaptchaToken: String?) async throws -> AuthorizationData { + try await api.hmgLogin(username: username, password: password, recaptchaToken: recaptchaToken) + } + + func login(authorizationCode: String) async throws -> AuthorizationData { + try await api.hmgLogin(authorizationCode: authorizationCode) + } + + func logout() async throws { try await api.hmgLogout() } + func vehicles() async throws -> VehicleResponse { try await api.hmgVehicles() } + func refreshVehicle(_ vehicleId: UUID) async throws -> UUID { try await api.hmgRefreshVehicle(vehicleId) } + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { try await api.hmgVehicleCachedStatus(vehicleId) } + func profile() async throws -> String { try await api.hmgProfile() } + func startClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin: String) async throws -> UUID { + try await api.hmgStartClimate(vehicleId, options: options, pin: pin) + } + + func stopClimate(_ vehicleId: UUID) async throws -> UUID { + try await api.hmgStopClimate(vehicleId) + } +} + +final class PorscheVehicleApiProvider: VehicleApiProvider { + private let api: Api + + init(api: Api) { + self.api = api + } + + func webLoginUrl() throws -> URL? { + guard let porscheConfiguration = api.configuration as? PorscheApiConfiguration else { + throw ApiError.unexpectedStatusCode(nil) + } + guard var components = URLComponents(string: "https://identity.porsche.com/authorize") else { + throw ApiError.unexpectedStatusCode(nil) + } + components.queryItems = [ + .init(name: "response_type", value: "code"), + .init(name: "client_id", value: porscheConfiguration.authClientId), + .init(name: "redirect_uri", value: porscheConfiguration.redirectUri), + .init(name: "audience", value: porscheConfiguration.audience), + .init(name: "scope", value: porscheConfiguration.scope), + .init(name: "state", value: UUID().uuidString), + .init(name: "ui_locales", value: porscheConfiguration.locale), + ] + return components.url + } + + func login(username _: String, password _: String, recaptchaToken _: String?) async throws -> AuthorizationData { + throw ApiError.unexpectedStatusCode(nil) + } + + func login(authorizationCode _: String) async throws -> AuthorizationData { + throw ApiError.unexpectedStatusCode(nil) + } + + func logout() async throws { + api.authorization = nil + } + + func vehicles() async throws -> VehicleResponse { + throw ApiError.unexpectedStatusCode(nil) + } + + func refreshVehicle(_: UUID) async throws -> UUID { + throw ApiError.unexpectedStatusCode(nil) + } + + func vehicleCachedStatus(_: UUID) async throws -> VehicleStateResponse { + throw ApiError.unexpectedStatusCode(nil) + } + + func profile() async throws -> String { + throw ApiError.unexpectedStatusCode(nil) + } + + func startClimate(_: UUID, options _: ClimateControlOptions, pin _: String) async throws -> UUID { + throw ApiError.unexpectedStatusCode(nil) + } + + func stopClimate(_: UUID) async throws -> UUID { + throw ApiError.unexpectedStatusCode(nil) + } +} + /** * Api - Main interface for Kia/Hyundai/Genesis vehicle API communication * @@ -58,6 +175,7 @@ class Api { /// Provider that handles actual API request execution and token management private let provider: ApiRequestProvider + private lazy var vehicleApiProvider: VehicleApiProvider = VehicleApiProviderFactory.provider(for: self) init(configuration: ApiConfiguration, rsaService: RSAEncryptionService) { self.configuration = configuration @@ -72,6 +190,10 @@ class Api { } func webLoginUrl() throws -> URL? { + try vehicleApiProvider.webLoginUrl() + } + + func hmgWebLoginUrl() 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"), @@ -95,6 +217,10 @@ 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 { + try await vehicleApiProvider.login(username: username, password: password, recaptchaToken: recaptchaToken) + } + + func hmgLogin(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 @@ -142,6 +268,10 @@ class Api { } func login(authorizationCode: String) async throws -> AuthorizationData { + try await vehicleApiProvider.login(authorizationCode: authorizationCode) + } + + func hmgLogin(authorizationCode: String) async throws -> AuthorizationData { // Step 6: Exchange authorization code for tokens let tokenResponse: TokenResponse do { @@ -173,6 +303,10 @@ class Api { /// Logout user and clean up session data /// - Throws: Network errors (non-critical - cleanup continues regardless) func logout() async throws { + try await vehicleApiProvider.logout() + } + + func hmgLogout() async throws { do { try await provider.request(with: .post, endpoint: .logout).empty() logInfo("Successfully logout", category: .auth) @@ -187,6 +321,10 @@ class Api { /// - Returns: Complete vehicle response containing all registered vehicles /// - Throws: Network errors or authentication failures func vehicles() async throws -> VehicleResponse { + try await vehicleApiProvider.vehicles() + } + + func hmgVehicles() async throws -> VehicleResponse { guard authorization != nil else { throw ApiError.unauthorized } @@ -199,6 +337,10 @@ 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 { + try await vehicleApiProvider.refreshVehicle(vehicleId) + } + + func hmgRefreshVehicle(_ vehicleId: UUID) async throws -> UUID { guard let authorization = authorization else { throw ApiError.unauthorized } @@ -212,6 +354,10 @@ class Api { /// - Note: Uses CCS2 endpoint if supported, fallback to standard endpoint /// - Throws: Network errors or data parsing failures func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { + try await vehicleApiProvider.vehicleCachedStatus(vehicleId) + } + + func hmgVehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { guard let authorization = authorization else { throw ApiError.unauthorized } @@ -223,6 +369,10 @@ class Api { /// - Returns: User profile data as JSON string /// - Throws: Network errors or authentication failures func profile() async throws -> String { + try await vehicleApiProvider.profile() + } + + func hmgProfile() async throws -> String { guard authorization != nil else { throw ApiError.unauthorized } @@ -238,6 +388,10 @@ 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 { + try await vehicleApiProvider.startClimate(vehicleId, options: options, pin: pin) + } + + func hmgStartClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin: String) async throws -> UUID { guard authorization?.accessToken != nil else { throw ApiError.unauthorized } @@ -274,6 +428,10 @@ class Api { /// - Parameter vehicleId: The vehicle ID /// - Returns: Operation result ID for tracking func stopClimate(_ vehicleId: UUID) async throws -> UUID { + try await vehicleApiProvider.stopClimate(vehicleId) + } + + func hmgStopClimate(_ vehicleId: UUID) async throws -> UUID { guard authorization?.accessToken != nil else { throw ApiError.unauthorized } @@ -680,4 +838,3 @@ extension Api { } } } - diff --git a/KiaMaps/Core/Api/ApiConfiguration.swift b/KiaMaps/Core/Api/ApiConfiguration.swift index cef03f2..2dcb9f9 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 @@ -29,10 +30,24 @@ enum ApiBrand: String { 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,6 +100,9 @@ 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 @@ -234,6 +252,140 @@ enum ApiConfigurationEurope: String, ApiConfiguration { "GCM" } } + + var apiProviderKind: ApiProviderKind { + .hmg + } +} + +enum PorscheApiConfiguration: String, ApiConfiguration { + case europe + case usa + + var key: String { + "porsche" + } + + var name: String { + "Porsche" + } + + var port: Int { + 443 + } + + var serviceAgent: String { + "PorscheConnect/1.0" + } + + var userAgent: String { + let device = UIDevice.current + return "MyPorscheApp/1.0 (\(device.systemName) \(device.systemVersion))" + } + + var acceptHeader: String { + "application/json" + } + + // HMG-only host fields; kept for protocol compatibility. + var baseHost: String { + switch self { + case .europe: + "https://api.ppa.porsche.com" + case .usa: + "https://api.ppa.porsche.com" + } + } + + var loginHost: String { + "https://identity.porsche.com" + } + + var mqttHost: String { + "https://api.ppa.porsche.com" + } + + var serviceId: String { + switch self { + case .europe: + "porsche-eu-service" + case .usa: + "porsche-us-service" + } + } + + var appId: String { + "porsche-app-id" + } + + var senderId: Int { + 0 + } + + var authClientId: String { + "XhygisuebbrqQ80byOuU5VncxLIm8E6H" + } + + var cfb: String { + // No HMG-style stamp for Porsche. + "cG9yc2NoZS1tb2NrLWNmYi10b2tlbi0xMjM0NTY3ODkwMTIzNA==" + } + + var brandCode: String { + "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", + "cars", + "charging", + "manageCharging", + "climatisation", + "manageClimatisation", + ].joined(separator: " ") + } + + var appApiBaseURL: String { + switch self { + case .europe: + "https://api.ppa.porsche.com/app" + case .usa: + "https://api.ppa.porsche.com/app" + } + } + + var locale: String { + switch self { + case .europe: + "de_DE" + case .usa: + "en_US" + } + } } /// Geographic regions where the API is available @@ -274,4 +426,5 @@ struct MockApiConfiguration: ApiConfiguration { var brandCode: String = "M" var brandName: String = "Mocker" var pushType: String = "MOCK" + var apiProviderKind: ApiProviderKind = .hmg } diff --git a/KiaTests/PorscheAuthClientTests.swift b/KiaTests/PorscheAuthClientTests.swift new file mode 100644 index 0000000..b9df11d --- /dev/null +++ b/KiaTests/PorscheAuthClientTests.swift @@ -0,0 +1,70 @@ +// +// PorscheAuthClientTests.swift +// KiaTests +// +// Created by Codex on 06.03.2026. +// + +import XCTest +@testable import KiaMaps + +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 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) + } + } +} diff --git a/KiaTests/PorscheEndpointAndMapperTests.swift b/KiaTests/PorscheEndpointAndMapperTests.swift new file mode 100644 index 0000000..e1d443c --- /dev/null +++ b/KiaTests/PorscheEndpointAndMapperTests.swift @@ -0,0 +1,62 @@ +// +// PorscheEndpointAndMapperTests.swift +// KiaTests +// +// Created by Codex on 06.03.2026. +// + +import XCTest +@testable import KiaMaps + +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 lockURL = try config.url(for: .lock("WP0ZZZ99ZTS392124")) + XCTAssertEqual(lockURL.absoluteString, "https://api.ppa.porsche.com/app/vehicles/WP0ZZZ99ZTS392124/commands/lock") + } + + func testPorscheEndpointURLCompositionUS() throws { + let config = PorscheApiConfiguration.usa + let climateURL = try config.url(for: .climateOn("VIN123")) + XCTAssertEqual(climateURL.absoluteString, "https://api.ppa.porsche.com/app/vehicles/VIN123/commands/climate/on") + } + + func testProviderFactoryChoosesPorscheProvider() { + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init()) + let provider = VehicleApiProviderFactory.provider(for: api) + XCTAssertTrue(provider is PorscheVehicleApiProvider) + } + + func testMapperUsesSafeDefaultsWhenCapabilitiesMissing() { + let summary = PorscheVehicleSummary( + vin: "VIN", + displayName: "My Porsche", + model: "Taycan", + modelYear: 2024, + batterySoc: 62.5, + rangeKm: 280.0, + charging: nil, + locked: nil, + latitude: 50.1, + longitude: 14.4, + capabilities: nil + ) + + let snapshot = PorscheVehicleMapper.map(summary: summary) + XCTAssertEqual(snapshot.vin, "VIN") + XCTAssertEqual(snapshot.batterySoc, 62.5) + XCTAssertEqual(snapshot.rangeKm, 280.0) + XCTAssertFalse(snapshot.charging) + XCTAssertFalse(snapshot.locked) + XCTAssertFalse(snapshot.capabilities.canLock) + XCTAssertFalse(snapshot.capabilities.canClimatise) + XCTAssertFalse(snapshot.capabilities.canCharge) + } +} From 747c1e26a60c8035725f3bc9e950b9efc72c3530 Mon Sep 17 00:00:00 2001 From: Lukas Foldyna Date: Fri, 6 Mar 2026 16:56:41 +0100 Subject: [PATCH 2/8] test: stabilize logging and local server coverage --- .../LocalServer/LocalCredentialClient.swift | 29 ++++-- KiaMaps/Core/Logging/AbstractLogger.swift | 9 +- KiaMaps/Core/Logging/RemoteLogger.swift | 15 ++-- KiaTests/AbstractLoggerTests.swift | 2 +- KiaTests/ExtensionIntegrationTests.swift | 21 ++++- KiaTests/LocalCredentialServerTests.swift | 89 +++++++++---------- KiaTests/RemoteLoggerTests.swift | 4 +- KiaTests/RemoteLoggingIntegrationTests.swift | 8 ++ KiaTests/RemoteLoggingServerTests.swift | 8 ++ KiaTests/UIComponentMockDataTests.swift | 4 +- 10 files changed, 121 insertions(+), 68 deletions(-) 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/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.. Date: Fri, 6 Mar 2026 17:23:08 +0100 Subject: [PATCH 3/8] feat: implement porsche auth and vehicle flows --- KiaMaps.xcodeproj/project.pbxproj | 4 + KiaMaps/App/Porsche/PorscheApiEndpoint.swift | 62 ---- KiaMaps/App/Porsche/PorscheAuthClient.swift | 62 ---- KiaMaps/App/Porsche/PorscheModels.swift | 129 ------- .../App/Porsche/PorscheVehicleMapper.swift | 28 -- KiaMaps/Core/Api/Api.swift | 274 +++++++++++++-- KiaMaps/Core/Api/ApiConfiguration.swift | 20 ++ .../Core/Api/Porsche/PorscheApiEndpoint.swift | 66 ++++ .../Core/Api/Porsche/PorscheAuthClient.swift | 328 ++++++++++++++++++ KiaMaps/Core/Api/Porsche/PorscheModels.swift | 299 ++++++++++++++++ .../Api/Porsche/PorscheVehicleMapper.swift | 317 +++++++++++++++++ .../Core/Authorization/Authorization.swift | 12 + KiaTests/PorscheAuthClientTests.swift | 114 ++++++ KiaTests/PorscheEndpointAndMapperTests.swift | 228 ++++++++++-- 14 files changed, 1604 insertions(+), 339 deletions(-) delete mode 100644 KiaMaps/App/Porsche/PorscheApiEndpoint.swift delete mode 100644 KiaMaps/App/Porsche/PorscheAuthClient.swift delete mode 100644 KiaMaps/App/Porsche/PorscheModels.swift delete mode 100644 KiaMaps/App/Porsche/PorscheVehicleMapper.swift create mode 100644 KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift create mode 100644 KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift create mode 100644 KiaMaps/Core/Api/Porsche/PorscheModels.swift create mode 100644 KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift diff --git a/KiaMaps.xcodeproj/project.pbxproj b/KiaMaps.xcodeproj/project.pbxproj index ca6ee91..4ecc1aa 100644 --- a/KiaMaps.xcodeproj/project.pbxproj +++ b/KiaMaps.xcodeproj/project.pbxproj @@ -101,6 +101,10 @@ Api/Models/VehicleResponse.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/Porsche/PorscheApiEndpoint.swift b/KiaMaps/App/Porsche/PorscheApiEndpoint.swift deleted file mode 100644 index 550f5c3..0000000 --- a/KiaMaps/App/Porsche/PorscheApiEndpoint.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// PorscheApiEndpoint.swift -// KiaMaps -// -// Created by Codex on 06.03.2026. -// - -import Foundation - -enum PorscheApiEndpoint { - case authorize - case token - case vehicles - case summary(String) - case lock(String) - case unlock(String) - case climateOn(String) - case climateOff(String) - case chargeStart(String) - case chargeStop(String) - - var path: String { - switch self { - case .authorize: - "authorize" - case .token: - "oauth/token" - case .vehicles: - "vehicles" - case let .summary(vin): - "vehicles/\(vin)/summary" - case let .lock(vin): - "vehicles/\(vin)/commands/lock" - case let .unlock(vin): - "vehicles/\(vin)/commands/unlock" - case let .climateOn(vin): - "vehicles/\(vin)/commands/climate/on" - case let .climateOff(vin): - "vehicles/\(vin)/commands/climate/off" - case let .chargeStart(vin): - "vehicles/\(vin)/commands/charging/start" - case let .chargeStop(vin): - "vehicles/\(vin)/commands/charging/stop" - } - } -} - -extension PorscheApiConfiguration { - func url(for endpoint: PorscheApiEndpoint) throws -> URL { - let base: String - switch endpoint { - case .authorize, .token: - base = "https://identity.porsche.com/" - default: - base = appApiBaseURL.hasSuffix("/") ? appApiBaseURL : appApiBaseURL + "/" - } - guard let url = URL(string: endpoint.path, relativeTo: URL(string: base)) else { - throw URLError(.badURL) - } - return url - } -} diff --git a/KiaMaps/App/Porsche/PorscheAuthClient.swift b/KiaMaps/App/Porsche/PorscheAuthClient.swift deleted file mode 100644 index 7b8072e..0000000 --- a/KiaMaps/App/Porsche/PorscheAuthClient.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// PorscheAuthClient.swift -// KiaMaps -// -// Created by Codex on 06.03.2026. -// - -import Foundation - -struct PorscheAuthClient { - let configuration: PorscheApiConfiguration - - func makeAuthorizeURL(state: String = UUID().uuidString) throws -> URL { - let endpointUrl = try configuration.url(for: .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 parseAuthorizationCallback(_ callback: URL) throws -> PorscheAuthorizationCallback { - guard let components = URLComponents(url: callback, 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 ?? "" - let challenge = PorscheMFAChallenge(state: state, challengeType: "otp") - return .mfaRequired(challenge) - } - throw PorscheAuthError.backendError(error) - } - - guard let code = queryItems.first(where: { $0.name == "code" })?.value, !code.isEmpty else { - throw PorscheAuthError.missingAuthorizationCode - } - return .authorizationCode(code) - } - - func mapMFASubmitResult(_ result: PorscheMFASubmitResult) throws { - switch result { - case .success: - return - case .invalidCode: - throw PorscheAuthError.invalidMFACode - } - } -} diff --git a/KiaMaps/App/Porsche/PorscheModels.swift b/KiaMaps/App/Porsche/PorscheModels.swift deleted file mode 100644 index 9db80a7..0000000 --- a/KiaMaps/App/Porsche/PorscheModels.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// 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 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 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 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) -} - -struct PorscheCommandResult: Equatable { - let requestId: UUID -} - -enum PorscheAuthError: LocalizedError, Equatable { - case mfaRequired(PorscheMFAChallenge) - case invalidMFACode - 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 .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) - - 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)" - } - } -} - -enum PorscheMFASubmitResult { - case success - case invalidCode -} diff --git a/KiaMaps/App/Porsche/PorscheVehicleMapper.swift b/KiaMaps/App/Porsche/PorscheVehicleMapper.swift deleted file mode 100644 index 05aacaa..0000000 --- a/KiaMaps/App/Porsche/PorscheVehicleMapper.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// PorscheVehicleMapper.swift -// KiaMaps -// -// Created by Codex on 06.03.2026. -// - -import Foundation - -enum PorscheVehicleMapper { - 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, - capabilities: .init( - canLock: summary.capabilities?.canLock ?? false, - canClimatise: summary.capabilities?.canClimatise ?? false, - canCharge: summary.capabilities?.canCharge ?? false - ) - ) - } - -} diff --git a/KiaMaps/Core/Api/Api.swift b/KiaMaps/Core/Api/Api.swift index 2e95159..0a4cdf2 100644 --- a/KiaMaps/Core/Api/Api.swift +++ b/KiaMaps/Core/Api/Api.swift @@ -64,65 +64,273 @@ final class HMGVehicleApiProvider: VehicleApiProvider { } final class PorscheVehicleApiProvider: VehicleApiProvider { - private let api: Api - - init(api: Api) { + private unowned let api: Api + private let transport: PorscheHTTPTransport + private let authClient: PorscheAuthClient + private let commandPollIntervalNanoseconds: UInt64 + private var vinByVehicleID: [UUID: String] = [:] + + init( + api: Api, + transport: PorscheHTTPTransport? = nil, + authClient: PorscheAuthClient? = nil, + commandPollIntervalNanoseconds: UInt64 = 1_000_000_000 + ) { self.api = api + let resolvedTransport = transport ?? PorscheAuthClient.makeDefaultTransport() + self.transport = resolvedTransport + self.commandPollIntervalNanoseconds = commandPollIntervalNanoseconds + if let authClient { + self.authClient = authClient + } else { + self.authClient = PorscheAuthClient(configuration: Self.configuration(for: api), transport: resolvedTransport) + } } func webLoginUrl() throws -> URL? { - guard let porscheConfiguration = api.configuration as? PorscheApiConfiguration else { - throw ApiError.unexpectedStatusCode(nil) - } - guard var components = URLComponents(string: "https://identity.porsche.com/authorize") else { - throw ApiError.unexpectedStatusCode(nil) - } - components.queryItems = [ - .init(name: "response_type", value: "code"), - .init(name: "client_id", value: porscheConfiguration.authClientId), - .init(name: "redirect_uri", value: porscheConfiguration.redirectUri), - .init(name: "audience", value: porscheConfiguration.audience), - .init(name: "scope", value: porscheConfiguration.scope), - .init(name: "state", value: UUID().uuidString), - .init(name: "ui_locales", value: porscheConfiguration.locale), - ] - return components.url + try authClient.makeAuthorizeURL() } - func login(username _: String, password _: String, recaptchaToken _: String?) async throws -> AuthorizationData { - throw ApiError.unexpectedStatusCode(nil) + 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 { - throw ApiError.unexpectedStatusCode(nil) + 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 { - throw ApiError.unexpectedStatusCode(nil) + 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(_: UUID) async throws -> UUID { - throw ApiError.unexpectedStatusCode(nil) + 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(_: UUID) async throws -> VehicleStateResponse { - throw ApiError.unexpectedStatusCode(nil) + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { + 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 { - throw ApiError.unexpectedStatusCode(nil) + 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(_: UUID, options _: ClimateControlOptions, pin _: String) async throws -> UUID { - throw ApiError.unexpectedStatusCode(nil) + 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) } - func stopClimate(_: UUID) async throws -> UUID { - throw ApiError.unexpectedStatusCode(nil) + 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: String = "GET", + queryItems: [URLQueryItem] = [], + body: Data? = nil, + retryOnUnauthorized: Bool = true + ) async throws -> Any { + guard let authorization = api.authorization else { + throw ApiError.unauthorized + } + + let response = try await send( + endpoint: endpoint, + method: method, + queryItems: queryItems, + body: body, + accessToken: authorization.accessToken, + accept: [200, 202, 401] + ) + + if response.response.statusCode == 401 { + 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 + ) + } + + if response.data.isEmpty { + return [:] + } + return try JSONSerialization.jsonObject(with: response.data) + } + + 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") + } + + private func send( + endpoint: PorscheApiEndpoint, + method: String, + queryItems: [URLQueryItem], + body: Data?, + accessToken: String, + accept statusCodes: Set + ) async throws -> PorscheHTTPTransportResponse { + var components = URLComponents(url: try configuration.url(for: endpoint), resolvingAgainstBaseURL: true) + if !queryItems.isEmpty { + let existingItems = components?.queryItems ?? [] + components?.queryItems = existingItems + queryItems + } + + guard let url = components?.url else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.setValue(configuration.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(configuration.xClientId, forHTTPHeaderField: "X-Client-ID") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let response = try await transport(request) + guard statusCodes.contains(response.response.statusCode) else { + throw ApiError.unexpectedStatusCode(response.response.statusCode) + } + return response } } diff --git a/KiaMaps/Core/Api/ApiConfiguration.swift b/KiaMaps/Core/Api/ApiConfiguration.swift index 2dcb9f9..b6f225b 100644 --- a/KiaMaps/Core/Api/ApiConfiguration.swift +++ b/KiaMaps/Core/Api/ApiConfiguration.swift @@ -326,6 +326,10 @@ enum PorscheApiConfiguration: String, ApiConfiguration { "XhygisuebbrqQ80byOuU5VncxLIm8E6H" } + var xClientId: String { + "41843fb4-691d-4970-85c7-2673e8ecef40" + } + var cfb: String { // No HMG-style stamp for Porsche. "cG9yc2NoZS1tb2NrLWNmYi10b2tlbi0xMjM0NTY3ODkwMTIzNA==" @@ -361,11 +365,27 @@ enum PorscheApiConfiguration: String, ApiConfiguration { "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: " ") } diff --git a/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift b/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift new file mode 100644 index 0000000..ce0af60 --- /dev/null +++ b/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift @@ -0,0 +1,66 @@ +// +// PorscheApiEndpoint.swift +// KiaMaps +// +// Created by Codex on 06.03.2026. +// + +import Foundation + +enum PorscheApiEndpoint { + 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 { + switch self { + case .authorize: + "authorize" + case .loginIdentifier: + "u/login/identifier" + case .loginPassword: + "u/login/password" + case .mfaOTP: + "u/mfa-otp-challenge" + case .token: + "oauth/token" + case .vehicles: + "connect/v1/vehicles" + case let .vehicle(vin): + "connect/v1/vehicles/\(vin)" + case let .commands(vin): + "connect/v1/vehicles/\(vin)/commands" + case let .commandStatus(vin, requestId): + "connect/v1/vehicles/\(vin)/commands/\(requestId)" + case .profile: + "account/v1/profile" + } + } + + var usesIdentityHost: Bool { + switch self { + case .authorize, .loginIdentifier, .loginPassword, .mfaOTP, .token: + true + case .vehicles, .vehicle, .commands, .commandStatus, .profile: + false + } + } +} + +extension PorscheApiConfiguration { + func url(for endpoint: PorscheApiEndpoint) throws -> URL { + let base = endpoint.usesIdentityHost ? loginHost : appApiBaseURL + let normalizedBase = base.hasSuffix("/") ? base : base + "/" + guard let url = URL(string: endpoint.path, relativeTo: URL(string: normalizedBase)) else { + throw URLError(.badURL) + } + return url + } +} diff --git a/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift b/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift new file mode 100644 index 0000000..31d7146 --- /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: .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: .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: .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: .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: .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..0d496e0 --- /dev/null +++ b/KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift @@ -0,0 +1,317 @@ +// +// 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 -> VehicleStateResponse { + let summary = mapSummary(from: payload) + let snapshot = map(summary: summary) + var json = try responseJSONTemplate() + + set(value: TimeIntervalDateFormatter().string(from: now), at: ["lastUpdateTime"], in: &json) + set(value: TimeIntervalDateFormatter().string(from: now), at: ["state", "Vehicle", "Date"], in: &json) + set(value: snapshot.batterySoc, at: ["state", "Vehicle", "Green", "BatteryManagement", "BatteryRemain", "Ratio"], in: &json) + set(value: max(snapshot.batterySoc, 1) * 2_300, at: ["state", "Vehicle", "Green", "BatteryManagement", "BatteryRemain", "Value"], in: &json) + set(value: snapshot.charging ? ChargeDoorStatus.open.rawValue : ChargeDoorStatus.closed.rawValue, at: ["state", "Vehicle", "Green", "ChargingDoor", "State"], in: &json) + set(value: snapshot.charging ? 1 : 0, at: ["state", "Vehicle", "Green", "ChargingInformation", "ConnectorFastening", "State"], in: &json) + set(value: snapshot.chargingPowerKw * 1_000, at: ["state", "Vehicle", "Green", "Electric", "SmartGrid", "RealTimePower"], in: &json) + set(value: snapshot.climateActive ? 1 : 0, at: ["state", "Vehicle", "Cabin", "HVAC", "Row1", "Driver", "Blower", "SpeedLevel"], in: &json) + set(value: String(format: "%.0fC", climateTargetTemperature(from: payload) ?? 22), at: ["state", "Vehicle", "Cabin", "HVAC", "Row1", "Driver", "Temperature", "Value"], in: &json) + set(value: snapshot.odometerKm, at: ["state", "Vehicle", "Drivetrain", "Odometer"], in: &json) + set(value: snapshot.rangeKm > 0 ? Int(snapshot.rangeKm.rounded()) : 0, at: ["state", "Vehicle", "Drivetrain", "FuelSystem", "DTE", "Total"], in: &json) + set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Driver", "Lock"], in: &json) + set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Passenger", "Lock"], in: &json) + set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Left", "Lock"], in: &json) + set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Right", "Lock"], in: &json) + set(value: measurementBool("OPEN_STATE_DOOR_FRONT_LEFT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Driver", "Open"], in: &json) + set(value: measurementBool("OPEN_STATE_DOOR_FRONT_RIGHT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Passenger", "Open"], in: &json) + set(value: measurementBool("OPEN_STATE_DOOR_REAR_LEFT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Left", "Open"], in: &json) + set(value: measurementBool("OPEN_STATE_DOOR_REAR_RIGHT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Right", "Open"], in: &json) + set(value: measurementBool("OPEN_STATE_LID_REAR", payload: payload), at: ["state", "Vehicle", "Body", "Trunk", "Open"], in: &json) + set(value: measurementBool("OPEN_STATE_LID_FRONT", payload: payload), at: ["state", "Vehicle", "Body", "Hood", "Open"], in: &json) + + if let latitude = snapshot.latitude, let longitude = snapshot.longitude { + set(value: latitude, at: ["state", "Vehicle", "Location", "GeoCoord", "Latitude"], in: &json) + set(value: longitude, at: ["state", "Vehicle", "Location", "GeoCoord", "Longitude"], in: &json) + } + if let heading = locationHeading(from: payload) { + set(value: heading, at: ["state", "Vehicle", "Location", "Heading"], in: &json) + } + let locationDate = locationDateString(from: payload) ?? TimeIntervalDateFormatter().string(from: now) + set(value: locationDate, at: ["state", "Vehicle", "Location", "Date"], in: &json) + + let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + do { + return try JSONDecoder().decode(VehicleStateResponse.self, from: data) + } catch { + throw PorscheApiError.decodingFailed(error.localizedDescription) + } + } + + 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 responseJSONTemplate() throws -> JSONObject { + let data = try JSONEncoder().encode(MockVehicleData.standardResponse) + guard let json = try JSONSerialization.jsonObject(with: data) as? JSONObject else { + throw PorscheApiError.decodingFailed("invalid response template") + } + return json + } + + 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 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..e7a39c5 100644 --- a/KiaMaps/Core/Authorization/Authorization.swift +++ b/KiaMaps/Core/Authorization/Authorization.swift @@ -30,6 +30,18 @@ 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 diff --git a/KiaTests/PorscheAuthClientTests.swift b/KiaTests/PorscheAuthClientTests.swift index b9df11d..a6e67c1 100644 --- a/KiaTests/PorscheAuthClientTests.swift +++ b/KiaTests/PorscheAuthClientTests.swift @@ -8,6 +8,43 @@ 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) @@ -33,6 +70,68 @@ final class PorscheAuthClientTests: XCTestCase { 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")) @@ -68,3 +167,18 @@ final class PorscheAuthClientTests: XCTestCase { } } } + +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 index e1d443c..893f27e 100644 --- a/KiaTests/PorscheEndpointAndMapperTests.swift +++ b/KiaTests/PorscheEndpointAndMapperTests.swift @@ -8,6 +8,43 @@ 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) + } +} + final class PorscheEndpointAndMapperTests: XCTestCase { func testApiBrandPorscheSelectsRegionSpecificConfiguration() { let euConfiguration = ApiBrand.porsche.configuration(for: .europe) @@ -18,14 +55,14 @@ final class PorscheEndpointAndMapperTests: XCTestCase { func testPorscheEndpointURLCompositionEU() throws { let config = PorscheApiConfiguration.europe - let lockURL = try config.url(for: .lock("WP0ZZZ99ZTS392124")) - XCTAssertEqual(lockURL.absoluteString, "https://api.ppa.porsche.com/app/vehicles/WP0ZZZ99ZTS392124/commands/lock") + let vehicleURL = try config.url(for: .vehicle("WP0ZZZ99ZTS392124")) + XCTAssertEqual(vehicleURL.absoluteString, "https://api.ppa.porsche.com/app/connect/v1/vehicles/WP0ZZZ99ZTS392124") } func testPorscheEndpointURLCompositionUS() throws { let config = PorscheApiConfiguration.usa - let climateURL = try config.url(for: .climateOn("VIN123")) - XCTAssertEqual(climateURL.absoluteString, "https://api.ppa.porsche.com/app/vehicles/VIN123/commands/climate/on") + let commandsURL = try config.url(for: .commands("VIN123")) + XCTAssertEqual(commandsURL.absoluteString, "https://api.ppa.porsche.com/app/connect/v1/vehicles/VIN123/commands") } func testProviderFactoryChoosesPorscheProvider() { @@ -34,29 +71,170 @@ final class PorscheEndpointAndMapperTests: XCTestCase { XCTAssertTrue(provider is PorscheVehicleApiProvider) } - func testMapperUsesSafeDefaultsWhenCapabilitiesMissing() { - let summary = PorscheVehicleSummary( - vin: "VIN", - displayName: "My Porsche", - model: "Taycan", - modelYear: 2024, - batterySoc: 62.5, - rangeKm: 280.0, - charging: nil, - locked: nil, - latitude: 50.1, - longitude: 14.4, - capabilities: nil - ) + 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) - XCTAssertEqual(snapshot.vin, "VIN") + let state = try PorscheVehicleMapper.mapVehicleState(from: payload) + XCTAssertEqual(snapshot.batterySoc, 62.5) - XCTAssertEqual(snapshot.rangeKm, 280.0) - XCTAssertFalse(snapshot.charging) - XCTAssertFalse(snapshot.locked) - XCTAssertFalse(snapshot.capabilities.canLock) - XCTAssertFalse(snapshot.capabilities.canClimatise) - XCTAssertFalse(snapshot.capabilities.canCharge) + 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.state.vehicle.green.batteryManagement.batteryRemain.ratio, 62.5) + XCTAssertEqual(state.state.vehicle.drivetrain.odometer, 15_432) + XCTAssertEqual(state.state.vehicle.location?.geoCoordinate.latitude, 50.1) + XCTAssertEqual(state.state.vehicle.location?.geoCoordinate.longitude, 14.4) + XCTAssertEqual(state.state.vehicle.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 stub = PorscheProviderTransportStub(responses: [ + .init(statusCode: 401), + .init(statusCode: 200, json: [ + "access_token": "fresh-access", + "refresh_token": "fresh-refresh", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid cars", + ]), + .init(statusCode: 200, json: vehiclePayload), + ]) + + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init()) + 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 provider = PorscheVehicleApiProvider(api: api, transport: stub.transport, commandPollIntervalNanoseconds: 0) + + let response = try await provider.vehicles() + + XCTAssertEqual(response.vehicles.count, 1) + XCTAssertEqual(api.authorization?.accessToken, "fresh-access") + XCTAssertEqual(stub.requests[1].url?.path, "/oauth/token") + } + + func testProviderStartClimateUsesCommandsEndpoint() async throws { + let vehicleID = UUID.porscheVehicleID(for: "WP0AA2Y1XNSA00001") + let stub = PorscheProviderTransportStub(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()) + api.authorization = AuthorizationData( + stamp: "porsche", + deviceId: UUID(), + accessToken: "access", + expiresIn: 3600, + refreshToken: "refresh", + isCcuCCS2Supported: true, + providerKind: "porsche" + ) + let provider = PorscheVehicleApiProvider(api: api, transport: stub.transport, commandPollIntervalNanoseconds: 0) + + let commandID = try await provider.startClimate(vehicleID, options: .init(temperature: 21), pin: "") + + XCTAssertEqual(commandID.uuidString, "4FD78B24-3C94-4EC2-8BE4-7D53FA3B84B6") + XCTAssertEqual(stub.requests[1].url?.path, "/app/connect/v1/vehicles/WP0AA2Y1XNSA00001/commands") + let body = try XCTUnwrap(stub.requests[1].httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["key"] as? String, "REMOTE_CLIMATIZER_START") } } From 70d5f922b59379f5aa27b88a47e0f23dda879f6d Mon Sep 17 00:00:00 2001 From: Lukas Foldyna Date: Mon, 9 Mar 2026 20:15:58 +0100 Subject: [PATCH 4/8] refactor: align porsche api with shared request architecture --- KiaMaps/Core/Api/Api.swift | 123 ++++------ KiaMaps/Core/Api/ApiConfiguration.swift | 4 +- KiaMaps/Core/Api/ApiEndpoints.swift | 28 ++- KiaMaps/Core/Api/ApiRequest.swift | 93 +++++--- .../Core/Api/Porsche/PorscheApiEndpoint.swift | 61 ++--- .../Core/Api/Porsche/PorscheAuthClient.swift | 10 +- .../Core/Authorization/Authorization.swift | 12 +- KiaTests/AuthenticationTests.swift | 28 ++- KiaTests/PorscheEndpointAndMapperTests.swift | 223 +++++++++++++++++- 9 files changed, 411 insertions(+), 171 deletions(-) diff --git a/KiaMaps/Core/Api/Api.swift b/KiaMaps/Core/Api/Api.swift index 0a4cdf2..f7ca06d 100644 --- a/KiaMaps/Core/Api/Api.swift +++ b/KiaMaps/Core/Api/Api.swift @@ -65,25 +65,21 @@ final class HMGVehicleApiProvider: VehicleApiProvider { final class PorscheVehicleApiProvider: VehicleApiProvider { private unowned let api: Api - private let transport: PorscheHTTPTransport private let authClient: PorscheAuthClient private let commandPollIntervalNanoseconds: UInt64 private var vinByVehicleID: [UUID: String] = [:] init( api: Api, - transport: PorscheHTTPTransport? = nil, authClient: PorscheAuthClient? = nil, commandPollIntervalNanoseconds: UInt64 = 1_000_000_000 ) { self.api = api - let resolvedTransport = transport ?? PorscheAuthClient.makeDefaultTransport() - self.transport = resolvedTransport self.commandPollIntervalNanoseconds = commandPollIntervalNanoseconds if let authClient { self.authClient = authClient } else { - self.authClient = PorscheAuthClient(configuration: Self.configuration(for: api), transport: resolvedTransport) + self.authClient = PorscheAuthClient(configuration: Self.configuration(for: api)) } } @@ -213,7 +209,7 @@ final class PorscheVehicleApiProvider: VehicleApiProvider { private func authorizedJSONObject( endpoint: PorscheApiEndpoint, - method: String = "GET", + method: ApiMethod = .get, queryItems: [URLQueryItem] = [], body: Data? = nil, retryOnUnauthorized: Bool = true @@ -222,16 +218,19 @@ final class PorscheVehicleApiProvider: VehicleApiProvider { throw ApiError.unauthorized } - let response = try await send( - endpoint: endpoint, - method: method, - queryItems: queryItems, - body: body, - accessToken: authorization.accessToken, - accept: [200, 202, 401] - ) + do { + let responseData = try await api.provider.request( + with: method, + endpoint: endpoint, + queryItems: queryItems, + body: body + ).rawData(acceptStatusCodes: [200, 202]) - if response.response.statusCode == 401 { + if responseData.isEmpty { + return [:] + } + return try JSONSerialization.jsonObject(with: responseData) + } catch ApiError.unauthorized { guard retryOnUnauthorized else { throw ApiError.unauthorized } @@ -246,17 +245,12 @@ final class PorscheVehicleApiProvider: VehicleApiProvider { retryOnUnauthorized: false ) } - - if response.data.isEmpty { - return [:] - } - return try JSONSerialization.jsonObject(with: response.data) } private func sendCommand(_ request: PorscheCommandRequest) async throws -> UUID { let payload = try await authorizedJSONObject( endpoint: .commands(request.vin), - method: "POST", + method: .post, body: PorscheVehicleMapper.commandBody(for: request) ) @@ -297,41 +291,6 @@ final class PorscheVehicleApiProvider: VehicleApiProvider { throw PorscheApiError.commandFailed("timeout") } - private func send( - endpoint: PorscheApiEndpoint, - method: String, - queryItems: [URLQueryItem], - body: Data?, - accessToken: String, - accept statusCodes: Set - ) async throws -> PorscheHTTPTransportResponse { - var components = URLComponents(url: try configuration.url(for: endpoint), resolvingAgainstBaseURL: true) - if !queryItems.isEmpty { - let existingItems = components?.queryItems ?? [] - components?.queryItems = existingItems + queryItems - } - - guard let url = components?.url else { - throw URLError(.badURL) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.httpBody = body - request.setValue(configuration.userAgent, forHTTPHeaderField: "User-Agent") - request.setValue(configuration.xClientId, forHTTPHeaderField: "X-Client-ID") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - if body != nil { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - } - - let response = try await transport(request) - guard statusCodes.contains(response.response.statusCode) else { - throw ApiError.unexpectedStatusCode(response.response.statusCode) - } - return response - } } /** @@ -382,7 +341,7 @@ class Api { private let rsaService: RSAEncryptionService /// Provider that handles actual API request execution and token management - private let provider: ApiRequestProvider + fileprivate let provider: ApiRequestProvider private lazy var vehicleApiProvider: VehicleApiProvider = VehicleApiProviderFactory.provider(for: self) init(configuration: ApiConfiguration, rsaService: RSAEncryptionService) { @@ -411,7 +370,7 @@ class Api { ] return try provider.request( - endpoint: .oauth2UserAuthorize, + endpoint: KiaApiEndpoint.oauth2UserAuthorize, queryItems: queryItems, headers: commonNavigationHeaders() ).urlRequest.url @@ -516,7 +475,7 @@ class Api { func hmgLogout() async throws { do { - try await provider.request(with: .post, endpoint: .logout).empty() + try await provider.request(with: .post, endpoint: KiaApiEndpoint.logout).empty() logInfo("Successfully logout", category: .auth) } catch { logError("Failed to logout: \(error.localizedDescription)", category: .auth) @@ -536,7 +495,7 @@ class Api { guard authorization != nil else { throw ApiError.unauthorized } - return try await provider.request(endpoint: .vehicles).response() + return try await provider.request(endpoint: KiaApiEndpoint.vehicles).response() } /// Request fresh vehicle status update from the vehicle @@ -552,7 +511,7 @@ class Api { guard let authorization = authorization else { throw ApiError.unauthorized } - let endpoint: ApiEndpoint = authorization.isCcuCCS2Supported == true ? .refreshCCS2Vehicle(vehicleId) : .refreshVehicle(vehicleId) + let endpoint: KiaApiEndpoint = authorization.isCcuCCS2Supported == true ? .refreshCCS2Vehicle(vehicleId) : .refreshVehicle(vehicleId) return try await provider.request(endpoint: endpoint).responseEmpty().resultId } @@ -569,7 +528,7 @@ class Api { guard let authorization = authorization else { throw ApiError.unauthorized } - let endpoint: ApiEndpoint = authorization.isCcuCCS2Supported == true ? .vehicleCachedCCS2Status(vehicleId) : .vehicleCachedStatus(vehicleId) + let endpoint: KiaApiEndpoint = authorization.isCcuCCS2Supported == true ? .vehicleCachedCCS2Status(vehicleId) : .vehicleCachedStatus(vehicleId) return try await provider.request(endpoint: endpoint).response() } @@ -584,7 +543,7 @@ class Api { guard authorization != nil else { throw ApiError.unauthorized } - return try await provider.request(endpoint: .userProfile).string() + return try await provider.request(endpoint: KiaApiEndpoint.userProfile).string() } // MARK: - Climate Control @@ -627,7 +586,7 @@ class Api { return try await provider.request( with: .post, - endpoint: .startClimate(vehicleId), + endpoint: KiaApiEndpoint.startClimate(vehicleId), encodable: request ).responseEmpty().resultId } @@ -645,7 +604,7 @@ class Api { } return try await provider.request( with: .post, - endpoint: .stopClimate(vehicleId) + endpoint: KiaApiEndpoint.stopClimate(vehicleId) ).responseEmpty().resultId } } @@ -662,7 +621,7 @@ extension Api { throw ApiError.unauthorized } - let response: MQTTHostResponse = try await provider.request(endpoint: .mqttDeviceHost).data() + let response: MQTTHostResponse = try await provider.request(endpoint: KiaApiEndpoint.mqttDeviceHost).data() return MQTTHostInfo( host: response.mqtt.host, port: response.mqtt.port, @@ -681,7 +640,7 @@ extension Api { let deviceUUID = "\(UUID().uuidString)_UVO" let request = DeviceRegisterRequest(unit: "mobile", uuid: deviceUUID) - let response: DeviceRegisterResponse = try await provider.request(endpoint: .mqttRegisterDevice, encodable: request).data() + let response: DeviceRegisterResponse = try await provider.request(endpoint: KiaApiEndpoint.mqttRegisterDevice, encodable: request).data() return MQTTDeviceInfo( clientId: response.clientId, @@ -700,7 +659,7 @@ extension Api { } let response: VehicleMetadataResponse = try await provider.request( - endpoint: .mqttVehicleMetadata, + endpoint: KiaApiEndpoint.mqttVehicleMetadata, queryItems: [ URLQueryItem(name: "carId", value: vehicleId.uuidString), URLQueryItem(name: "brand", value: configuration.brandCode) @@ -731,7 +690,7 @@ extension Api { ) try await provider.request( - endpoint: .mqttDeviceProtocol, + endpoint: KiaApiEndpoint.mqttDeviceProtocol, headers: [ "client-id": clientId ], @@ -749,7 +708,7 @@ extension Api { } return try await provider.request( - endpoint: .mqttConnectionState, + endpoint: KiaApiEndpoint.mqttConnectionState, queryItems: [ URLQueryItem(name: "clientId", value: clientId), ], @@ -771,7 +730,7 @@ extension Api { cert: "", action: "idpc_auth_endpoint", clientId: configuration.serviceId, - redirectUri: try makeRedirectUri(endpoint: .loginRedirect), + redirectUri: try makeRedirectUri(endpoint: KiaApiEndpoint.loginRedirect), responseType: "code", signupLink: nil, hmgid2ClientId: configuration.authClientId, @@ -784,7 +743,7 @@ extension Api { let queryItems = [ URLQueryItem(name: "client_id", value: configuration.serviceId), - URLQueryItem(name: "redirect_uri", value: try makeRedirectUri(endpoint: .loginRedirect).absoluteString), + URLQueryItem(name: "redirect_uri", value: try makeRedirectUri(endpoint: KiaApiEndpoint.loginRedirect).absoluteString), URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "state", value: stateData.base64EncodedString()), URLQueryItem(name: "cert", value: ""), @@ -793,7 +752,7 @@ extension Api { ] let referalUrl = try await provider.request( - endpoint: .oauth2ConnectorAuthorize, + endpoint: KiaApiEndpoint.oauth2ConnectorAuthorize, queryItems: queryItems, headers: commonNavigationHeaders() ).referalUrl() @@ -808,7 +767,7 @@ extension Api { /// Login - Step 1: Get Client Configuration func fetchClientConfiguration(referer: String) async throws -> ClientConfiguration { try await provider.request( - endpoint: .loginConnectorClients(configuration.serviceId), + endpoint: KiaApiEndpoint.loginConnectorClients(configuration.serviceId), headers: commonJSONHeaders() ).responseValue() } @@ -816,7 +775,7 @@ extension Api { /// Login - Step 2: Check Password Encryption Settings func fetchPasswordEncryptionSettings(referer: String) async throws -> PasswordEncryptionSettings { try await provider.request( - endpoint: .loginCodes, + endpoint: KiaApiEndpoint.loginCodes, headers: commonJSONHeaders(referer: referer) ).responseValue() } @@ -824,7 +783,7 @@ extension Api { /// Login - Step 3: Get RSA Certificate func fetchRSACertificate(referer: String) async throws -> RSAEncryptionService.RSAKeyData { let certificate: RSACertificateResponse = try await provider.request( - endpoint: .loginCertificates, + endpoint: KiaApiEndpoint.loginCertificates, headers: commonJSONHeaders(referer: referer) ).responseValue() @@ -847,7 +806,7 @@ extension Api { ] _ = try await provider.request( - endpoint: .oauth2UserAuthorize, + endpoint: KiaApiEndpoint.oauth2UserAuthorize, queryItems: queryItems, headers: commonNavigationHeaders(referer: referer) ).empty(acceptStatusCode: 302) @@ -895,7 +854,7 @@ extension Api { let referalUrl = try await provider.request( with: .post, - endpoint: .loginSignin, + endpoint: KiaApiEndpoint.loginSignin, headers: [ "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "navigate", @@ -925,7 +884,7 @@ extension Api { return try await provider.request( with: .post, - endpoint: .loginToken, + endpoint: KiaApiEndpoint.loginToken, form: form ).data() } @@ -953,7 +912,7 @@ extension Api { ] let response: NotificationRegistrationResponse = try await provider.request( - endpoint: .notificationRegister, + endpoint: KiaApiEndpoint.notificationRegister, headers: headers, encodable: payload ).response(acceptStatusCode: 302) @@ -967,12 +926,12 @@ extension Api { 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) + try await provider.request(with: .post, endpoint: KiaApiEndpoint.notificationRegisterWithDeviceId(deviceId), headers: headers).empty(acceptStatusCode: 200) } // MARK: - Helpers - func makeRedirectUri(endpoint: ApiEndpoint = .oauth2Redirect) throws -> URL { + func makeRedirectUri(endpoint: KiaApiEndpoint = .oauth2Redirect) throws -> URL { try provider.configuration.url(for: endpoint) } diff --git a/KiaMaps/Core/Api/ApiConfiguration.swift b/KiaMaps/Core/Api/ApiConfiguration.swift index b6f225b..22d0ebf 100644 --- a/KiaMaps/Core/Api/ApiConfiguration.swift +++ b/KiaMaps/Core/Api/ApiConfiguration.swift @@ -23,7 +23,7 @@ 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 @@ -107,7 +107,7 @@ protocol ApiConfiguration { /// European region API configuration for supported vehicle brands /// Provides brand-specific endpoints, credentials, and service identifiers for EU market -enum ApiConfigurationEurope: String, ApiConfiguration { +enum KiaApiConfigurationEurope: String, ApiConfiguration { case kia case hyundai case genesis diff --git a/KiaMaps/Core/Api/ApiEndpoints.swift b/KiaMaps/Core/Api/ApiEndpoints.swift index f3fb897..e674cd8 100644 --- a/KiaMaps/Core/Api/ApiEndpoints.swift +++ b/KiaMaps/Core/Api/ApiEndpoints.swift @@ -8,17 +8,25 @@ 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 + 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 } +} + +/// Shared protocol for brand-specific endpoint enums. +protocol ApiEndpointProtocol: CustomStringConvertible { + var path: (String, ApiEndpointBase.RelativeTo) { get } +} + +/// Defines Kia/Hyundai/Genesis API endpoints. +/// Organized by endpoint type and relative base URL. +enum KiaApiEndpoint: ApiEndpointProtocol { // MARK: - OAuth2 Authentication Endpoints case oauth2ConnectorAuthorize // Initial OAuth2 authorization with connector @@ -61,7 +69,7 @@ enum ApiEndpoint: CustomStringConvertible { /// 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) { + var path: (String, ApiEndpointBase.RelativeTo) { switch self { case .oauth2ConnectorAuthorize: ("api/v1/user/oauth2/connector/common/authorize", .base) diff --git a/KiaMaps/Core/Api/ApiRequest.swift b/KiaMaps/Core/Api/ApiRequest.swift index 0203299..d9c2c24 100644 --- a/KiaMaps/Core/Api/ApiRequest.swift +++ b/KiaMaps/Core/Api/ApiRequest.swift @@ -70,7 +70,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 +91,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 +110,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 +144,7 @@ protocol ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, encodable: Encodable, @@ -155,7 +163,7 @@ protocol ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, body: Data?, @@ -174,7 +182,7 @@ protocol ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, form: Form, @@ -208,21 +216,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 +279,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 +316,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 +340,7 @@ struct ApiRequestImpl: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, encodable: Encodable, @@ -331,7 +365,7 @@ struct ApiRequestImpl: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, body: Data?, @@ -356,13 +390,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 +450,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 +462,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 +471,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 +494,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 +509,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 +594,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 +622,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 +650,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 +678,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 +724,3 @@ extension ApiRequestProvider: URLSessionTaskDelegate { } } } - diff --git a/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift b/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift index ce0af60..0371b24 100644 --- a/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift +++ b/KiaMaps/Core/Api/Porsche/PorscheApiEndpoint.swift @@ -7,7 +7,7 @@ import Foundation -enum PorscheApiEndpoint { +enum PorscheApiEndpoint: ApiEndpointProtocol { case authorize case loginIdentifier case loginPassword @@ -19,48 +19,53 @@ enum PorscheApiEndpoint { case commandStatus(vin: String, requestId: String) case profile - var path: String { + var path: (String, ApiEndpointBase.RelativeTo) { switch self { case .authorize: - "authorize" + ("authorize", .user) case .loginIdentifier: - "u/login/identifier" + ("u/login/identifier", .user) case .loginPassword: - "u/login/password" + ("u/login/password", .user) case .mfaOTP: - "u/mfa-otp-challenge" + ("u/mfa-otp-challenge", .user) case .token: - "oauth/token" + ("oauth/token", .user) case .vehicles: - "connect/v1/vehicles" + ("connect/v1/vehicles", .base) case let .vehicle(vin): - "connect/v1/vehicles/\(vin)" + ("connect/v1/vehicles/\(vin)", .base) case let .commands(vin): - "connect/v1/vehicles/\(vin)/commands" + ("connect/v1/vehicles/\(vin)/commands", .base) case let .commandStatus(vin, requestId): - "connect/v1/vehicles/\(vin)/commands/\(requestId)" + ("connect/v1/vehicles/\(vin)/commands/\(requestId)", .base) case .profile: - "account/v1/profile" + ("account/v1/profile", .base) } } - var usesIdentityHost: Bool { + var description: String { switch self { - case .authorize, .loginIdentifier, .loginPassword, .mfaOTP, .token: - true - case .vehicles, .vehicle, .commands, .commandStatus, .profile: - false - } - } -} - -extension PorscheApiConfiguration { - func url(for endpoint: PorscheApiEndpoint) throws -> URL { - let base = endpoint.usesIdentityHost ? loginHost : appApiBaseURL - let normalizedBase = base.hasSuffix("/") ? base : base + "/" - guard let url = URL(string: endpoint.path, relativeTo: URL(string: normalizedBase)) else { - throw URLError(.badURL) + 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" } - return url } } diff --git a/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift b/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift index 31d7146..cc84855 100644 --- a/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift +++ b/KiaMaps/Core/Api/Porsche/PorscheAuthClient.swift @@ -53,7 +53,7 @@ struct PorscheAuthClient { } func makeAuthorizeURL(state: String = UUID().uuidString) throws -> URL { - let endpointURL = try configuration.url(for: .authorize) + let endpointURL = try configuration.url(for: PorscheApiEndpoint.authorize) guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: true) else { throw PorscheAuthError.invalidRedirect } @@ -99,7 +99,7 @@ struct PorscheAuthClient { func exchangeAuthorizationCode(_ authorizationCode: String) async throws -> PorscheTokenSet { let response: PorscheTokenResponse = try await formRequest( - endpoint: .token, + endpoint: PorscheApiEndpoint.token, form: [ "client_id": configuration.authClientId, "grant_type": "authorization_code", @@ -112,7 +112,7 @@ struct PorscheAuthClient { func refreshToken(_ refreshToken: String) async throws -> PorscheTokenSet { let response: PorscheTokenResponse = try await formRequest( - endpoint: .token, + endpoint: PorscheApiEndpoint.token, form: [ "client_id": configuration.authClientId, "grant_type": "refresh_token", @@ -144,7 +144,7 @@ struct PorscheAuthClient { private func submitIdentifier(username: String, state: String) async throws { let response = try await send( request( - endpoint: .loginIdentifier, + endpoint: PorscheApiEndpoint.loginIdentifier, queryItems: [.init(name: "state", value: state)], form: [ "state": state, @@ -177,7 +177,7 @@ struct PorscheAuthClient { private func submitPassword(username: String, password: String, state: String) async throws -> URL { let response = try await send( request( - endpoint: .loginPassword, + endpoint: PorscheApiEndpoint.loginPassword, queryItems: [.init(name: "state", value: state)], form: [ "state": state, diff --git a/KiaMaps/Core/Authorization/Authorization.swift b/KiaMaps/Core/Authorization/Authorization.swift index e7a39c5..c6e1aee 100644 --- a/KiaMaps/Core/Authorization/Authorization.swift +++ b/KiaMaps/Core/Authorization/Authorization.swift @@ -46,7 +46,17 @@ struct AuthorizationData: Codable { /// - 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/KiaTests/AuthenticationTests.swift b/KiaTests/AuthenticationTests.swift index 5576ecc..60dd49e 100644 --- a/KiaTests/AuthenticationTests.swift +++ b/KiaTests/AuthenticationTests.swift @@ -480,7 +480,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 +498,7 @@ struct MockApiRequest: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, encodable: Encodable, @@ -523,7 +523,7 @@ struct MockApiRequest: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, body: Data?, @@ -548,7 +548,7 @@ struct MockApiRequest: ApiRequest { init( caller: ApiCaller, method: ApiMethod?, - endpoint: ApiEndpoint, + endpoint: any ApiEndpointProtocol, queryItems: [URLQueryItem], headers: Headers, form: Form, @@ -600,6 +600,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 +645,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 +669,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/PorscheEndpointAndMapperTests.swift b/KiaTests/PorscheEndpointAndMapperTests.swift index 893f27e..b7c99fe 100644 --- a/KiaTests/PorscheEndpointAndMapperTests.swift +++ b/KiaTests/PorscheEndpointAndMapperTests.swift @@ -45,6 +45,202 @@ private final class PorscheProviderTransportStub { } } +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) @@ -55,13 +251,13 @@ final class PorscheEndpointAndMapperTests: XCTestCase { func testPorscheEndpointURLCompositionEU() throws { let config = PorscheApiConfiguration.europe - let vehicleURL = try config.url(for: .vehicle("WP0ZZZ99ZTS392124")) + 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: .commands("VIN123")) + let commandsURL = try config.url(for: PorscheApiEndpoint.commands("VIN123")) XCTAssertEqual(commandsURL.absoluteString, "https://api.ppa.porsche.com/app/connect/v1/vehicles/VIN123/commands") } @@ -158,8 +354,7 @@ final class PorscheEndpointAndMapperTests: XCTestCase { "customName": "Turbo", ]] - let stub = PorscheProviderTransportStub(responses: [ - .init(statusCode: 401), + let authStub = PorscheProviderTransportStub(responses: [ .init(statusCode: 200, json: [ "access_token": "fresh-access", "refresh_token": "fresh-refresh", @@ -167,10 +362,13 @@ final class PorscheEndpointAndMapperTests: XCTestCase { "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()) + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init(), provider: requestProvider) api.authorization = AuthorizationData( stamp: "porsche", deviceId: UUID(), @@ -183,18 +381,19 @@ final class PorscheEndpointAndMapperTests: XCTestCase { tokenAudience: PorscheApiConfiguration.europe.audience, tokenScope: PorscheApiConfiguration.europe.scope ) - let provider = PorscheVehicleApiProvider(api: api, transport: stub.transport, commandPollIntervalNanoseconds: 0) + 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(stub.requests[1].url?.path, "/oauth/token") + XCTAssertEqual(authStub.requests[0].url?.path, "/oauth/token") } func testProviderStartClimateUsesCommandsEndpoint() async throws { let vehicleID = UUID.porscheVehicleID(for: "WP0AA2Y1XNSA00001") - let stub = PorscheProviderTransportStub(responses: [ + let requestProvider = PorscheRequestProviderStub(responses: [ .init(statusCode: 200, json: [[ "vin": "WP0AA2Y1XNSA00001", "modelName": "Taycan", @@ -217,7 +416,7 @@ final class PorscheEndpointAndMapperTests: XCTestCase { ]), ]) - let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init()) + let api = Api(configuration: PorscheApiConfiguration.europe, rsaService: .init(), provider: requestProvider) api.authorization = AuthorizationData( stamp: "porsche", deviceId: UUID(), @@ -227,13 +426,13 @@ final class PorscheEndpointAndMapperTests: XCTestCase { isCcuCCS2Supported: true, providerKind: "porsche" ) - let provider = PorscheVehicleApiProvider(api: api, transport: stub.transport, commandPollIntervalNanoseconds: 0) + 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(stub.requests[1].url?.path, "/app/connect/v1/vehicles/WP0AA2Y1XNSA00001/commands") - let body = try XCTUnwrap(stub.requests[1].httpBody) + 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") } From f90e8ec758dd27b3c2c2dfd396ce7033eb66579f Mon Sep 17 00:00:00 2001 From: Lukas Foldyna Date: Mon, 9 Mar 2026 20:21:31 +0100 Subject: [PATCH 5/8] refactor: move kia api types into kia subdirectory --- KiaMaps.xcodeproj/project.pbxproj | 6 +- KiaMaps/Core/Api/ApiConfiguration.swift | 153 ---------------- KiaMaps/Core/Api/ApiEndpoints.swift | 164 ----------------- .../Core/Api/Kia/KiaApiConfiguration.swift | 162 +++++++++++++++++ KiaMaps/Core/Api/Kia/KiaApiEndpoint.swift | 169 ++++++++++++++++++ 5 files changed, 335 insertions(+), 319 deletions(-) create mode 100644 KiaMaps/Core/Api/Kia/KiaApiConfiguration.swift create mode 100644 KiaMaps/Core/Api/Kia/KiaApiEndpoint.swift diff --git a/KiaMaps.xcodeproj/project.pbxproj b/KiaMaps.xcodeproj/project.pbxproj index 4ecc1aa..3f66837 100644 --- a/KiaMaps.xcodeproj/project.pbxproj +++ b/KiaMaps.xcodeproj/project.pbxproj @@ -90,8 +90,10 @@ Api/ApiRequest.swift, Api/ApiResponse.swift, Api/Helpers/BoolPropertyWrapper.swift, - Api/Helpers/DatePropertyWrapper.swift, - Api/Models/AuthenticationModels.swift, + Api/Helpers/DatePropertyWrapper.swift, + Api/Kia/KiaApiConfiguration.swift, + Api/Kia/KiaApiEndpoint.swift, + Api/Models/AuthenticationModels.swift, Api/Models/ClimateControlModels.swift, Api/Models/ConnectorAuthorizationModels.swift, Api/Models/MQTTModels.swift, diff --git a/KiaMaps/Core/Api/ApiConfiguration.swift b/KiaMaps/Core/Api/ApiConfiguration.swift index 22d0ebf..f136540 100644 --- a/KiaMaps/Core/Api/ApiConfiguration.swift +++ b/KiaMaps/Core/Api/ApiConfiguration.swift @@ -105,159 +105,6 @@ protocol ApiConfiguration { var apiProviderKind: ApiProviderKind { get } } -/// 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 - } -} - enum PorscheApiConfiguration: String, ApiConfiguration { case europe case usa diff --git a/KiaMaps/Core/Api/ApiEndpoints.swift b/KiaMaps/Core/Api/ApiEndpoints.swift index e674cd8..48ec116 100644 --- a/KiaMaps/Core/Api/ApiEndpoints.swift +++ b/KiaMaps/Core/Api/ApiEndpoints.swift @@ -23,167 +23,3 @@ enum ApiEndpointBase { protocol ApiEndpointProtocol: CustomStringConvertible { var path: (String, ApiEndpointBase.RelativeTo) { get } } - -/// Defines Kia/Hyundai/Genesis API endpoints. -/// Organized by endpoint type and relative base URL. -enum KiaApiEndpoint: ApiEndpointProtocol { - - // 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, 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) - } - } - - /// 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" - } - } -} - -private extension UUID { - /// Formats UUID for use in API endpoints (lowercase string representation) - var formatted: String { - uuidString.lowercased() - } -} 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() + } +} From 7fe38f646e32a2080076eac36ccfe1cd787e8328 Mon Sep 17 00:00:00 2001 From: Lukas Foldyna Date: Mon, 9 Mar 2026 21:53:48 +0100 Subject: [PATCH 6/8] Abstract Kia Porsche API models --- .../GetCarPowerLevelStatusHandler.swift | 51 +- KiaMaps.xcodeproj/project.pbxproj | 15 +- KiaMaps/App/ApiExtensions.swift | 2 +- KiaMaps/App/MainView.swift | 37 +- .../Views/Components/TemperatureDial.swift | 4 +- .../Components/VehicleSilhouetteView.swift | 20 +- KiaMaps/App/Views/Pages/ClimatePageView.swift | 4 +- KiaMaps/App/Views/Pages/MapPageView.swift | 8 +- .../App/Views/Pages/OverviewPageView.swift | 4 +- .../App/Views/Vehicle/BatteryHeroView.swift | 10 +- .../App/Views/Vehicle/QuickActionsView.swift | 8 +- .../App/Views/Vehicle/VehicleMapView.swift | 16 +- .../Vehicle/VehicleStatusModernView.swift | 2 +- KiaMaps/Core/Api/Api.swift | 578 +----------------- KiaMaps/Core/Api/ApiRequest.swift | 4 + KiaMaps/Core/Api/Kia/HMGAuthClient.swift | 365 +++++++++++ KiaMaps/Core/Api/Kia/HMGMQTTClient.swift | 109 ++++ KiaMaps/Core/Api/Kia/HMGVehicleClient.swift | 86 +++ .../KiaMQTTModels.swift} | 6 +- .../Core/Api/Kia/KiaVehicleStatusMapper.swift | 174 ++++++ KiaMaps/Core/Api/Models/VehicleStatus.swift | 281 +++++++++ .../Api/Porsche/PorscheVehicleMapper.swift | 187 ++++-- KiaMaps/Core/MQTT/MQTTManager.swift | 3 + KiaMaps/Core/Vehicle/VehicleManager.swift | 10 +- 24 files changed, 1295 insertions(+), 689 deletions(-) create mode 100644 KiaMaps/Core/Api/Kia/HMGAuthClient.swift create mode 100644 KiaMaps/Core/Api/Kia/HMGMQTTClient.swift create mode 100644 KiaMaps/Core/Api/Kia/HMGVehicleClient.swift rename KiaMaps/Core/Api/{Models/MQTTModels.swift => Kia/KiaMQTTModels.swift} (98%) create mode 100644 KiaMaps/Core/Api/Kia/KiaVehicleStatusMapper.swift create mode 100644 KiaMaps/Core/Api/Models/VehicleStatus.swift 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 3f66837..e24adee 100644 --- a/KiaMaps.xcodeproj/project.pbxproj +++ b/KiaMaps.xcodeproj/project.pbxproj @@ -90,17 +90,22 @@ Api/ApiRequest.swift, Api/ApiResponse.swift, Api/Helpers/BoolPropertyWrapper.swift, - Api/Helpers/DatePropertyWrapper.swift, - Api/Kia/KiaApiConfiguration.swift, - Api/Kia/KiaApiEndpoint.swift, - Api/Models/AuthenticationModels.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, 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 f7ca06d..719a25d 100644 --- a/KiaMaps/Core/Api/Api.swift +++ b/KiaMaps/Core/Api/Api.swift @@ -16,7 +16,7 @@ protocol VehicleApiProvider { func logout() async throws func vehicles() async throws -> VehicleResponse func refreshVehicle(_ vehicleId: UUID) async throws -> UUID - func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse + 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 @@ -34,32 +34,34 @@ enum VehicleApiProviderFactory { } final class HMGVehicleApiProvider: VehicleApiProvider { - private unowned let api: Api + private let authClient: HMGAuthClient + private let vehicleClient: HMGVehicleClient init(api: Api) { - self.api = api + authClient = api.hmgAuthClient + vehicleClient = HMGVehicleClient(provider: api.provider) } - func webLoginUrl() throws -> URL? { try api.hmgWebLoginUrl() } + func webLoginUrl() throws -> URL? { try authClient.makeAuthorizeURL() } func login(username: String, password: String, recaptchaToken: String?) async throws -> AuthorizationData { - try await api.hmgLogin(username: username, password: password, recaptchaToken: recaptchaToken) + try await authClient.authenticate(username: username, password: password, recaptchaToken: recaptchaToken) } func login(authorizationCode: String) async throws -> AuthorizationData { - try await api.hmgLogin(authorizationCode: authorizationCode) + try await authClient.exchangeAuthorizationCode(authorizationCode) } - func logout() async throws { try await api.hmgLogout() } - func vehicles() async throws -> VehicleResponse { try await api.hmgVehicles() } - func refreshVehicle(_ vehicleId: UUID) async throws -> UUID { try await api.hmgRefreshVehicle(vehicleId) } - func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { try await api.hmgVehicleCachedStatus(vehicleId) } - func profile() async throws -> String { try await api.hmgProfile() } + 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 api.hmgStartClimate(vehicleId, options: options, pin: pin) + try await vehicleClient.startClimate(vehicleId, options: options, pin: pin) } func stopClimate(_ vehicleId: UUID) async throws -> UUID { - try await api.hmgStopClimate(vehicleId) + try await vehicleClient.stopClimate(vehicleId) } } @@ -125,7 +127,7 @@ final class PorscheVehicleApiProvider: VehicleApiProvider { return vehicleId } - func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot { let vin = try await resolveVIN(for: vehicleId) let payload = try await authorizedJSONObject( endpoint: .vehicle(vin), @@ -338,11 +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 - fileprivate 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 @@ -360,22 +364,6 @@ class Api { try vehicleApiProvider.webLoginUrl() } - func hmgWebLoginUrl() 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 - } - /// Authenticate user and establish session with vehicle API using RSA-encrypted authentication /// - Parameters: /// - username: User's login username/email @@ -387,84 +375,12 @@ class Api { try await vehicleApiProvider.login(username: username, password: password, recaptchaToken: recaptchaToken) } - func hmgLogin(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) - } - func login(authorizationCode: String) async throws -> AuthorizationData { try await vehicleApiProvider.login(authorizationCode: authorizationCode) } - func hmgLogin(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 - ) - - 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 @@ -473,17 +389,6 @@ class Api { try await vehicleApiProvider.logout() } - func hmgLogout() 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() - } - /// Retrieve list of vehicles associated with the user account /// - Returns: Complete vehicle response containing all registered vehicles /// - Throws: Network errors or authentication failures @@ -491,13 +396,6 @@ class Api { try await vehicleApiProvider.vehicles() } - func hmgVehicles() async throws -> VehicleResponse { - guard authorization != nil else { - throw ApiError.unauthorized - } - return try await provider.request(endpoint: KiaApiEndpoint.vehicles).response() - } - /// Request fresh vehicle status update from the vehicle /// - Parameter vehicleId: The vehicle's unique identifier /// - Returns: Operation result ID for tracking the refresh request @@ -507,44 +405,21 @@ class Api { try await vehicleApiProvider.refreshVehicle(vehicleId) } - func hmgRefreshVehicle(_ vehicleId: UUID) async throws -> UUID { - guard let authorization = authorization else { - throw ApiError.unauthorized - } - let endpoint: KiaApiEndpoint = authorization.isCcuCCS2Supported == true ? .refreshCCS2Vehicle(vehicleId) : .refreshVehicle(vehicleId) - return try await provider.request(endpoint: endpoint).responseEmpty().resultId - } - /// Retrieve cached vehicle status (last known state) /// - Parameter vehicleId: The vehicle's unique identifier /// - 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 { + func vehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStatusSnapshot { try await vehicleApiProvider.vehicleCachedStatus(vehicleId) } - func hmgVehicleCachedStatus(_ vehicleId: UUID) async throws -> VehicleStateResponse { - guard let authorization = authorization else { - throw ApiError.unauthorized - } - let endpoint: KiaApiEndpoint = authorization.isCcuCCS2Supported == true ? .vehicleCachedCCS2Status(vehicleId) : .vehicleCachedStatus(vehicleId) - return try await provider.request(endpoint: endpoint).response() - } - /// Retrieve user profile information /// - Returns: User profile data as JSON string /// - Throws: Network errors or authentication failures func profile() async throws -> String { try await vehicleApiProvider.profile() } - - func hmgProfile() async throws -> String { - guard authorization != nil else { - throw ApiError.unauthorized - } - return try await provider.request(endpoint: KiaApiEndpoint.userProfile).string() - } // MARK: - Climate Control @@ -557,39 +432,6 @@ class Api { func startClimate(_ vehicleId: UUID, options: ClimateControlOptions, pin: String) async throws -> UUID { try await vehicleApiProvider.startClimate(vehicleId, options: options, pin: pin) } - - func hmgStartClimate(_ 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: KiaApiEndpoint.startClimate(vehicleId), - encodable: request - ).responseEmpty().resultId - } /// Stop climate control /// - Parameter vehicleId: The vehicle ID @@ -597,16 +439,6 @@ class Api { func stopClimate(_ vehicleId: UUID) async throws -> UUID { try await vehicleApiProvider.stopClimate(vehicleId) } - - func hmgStopClimate(_ 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 - } } extension Api { @@ -617,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: KiaApiEndpoint.mqttDeviceHost).data() - return MQTTHostInfo( - host: response.mqtt.host, - port: response.mqtt.port, - ssl: response.mqtt.ssl - ) + try await hmgMQTTClient.fetchDeviceHost() } /** @@ -634,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: KiaApiEndpoint.mqttRegisterDevice, encodable: request).data() - - return MQTTDeviceInfo( - clientId: response.clientId, - deviceId: response.deviceId, - uuid: deviceUUID - ) + try await hmgMQTTClient.registerDevice() } /** @@ -654,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: KiaApiEndpoint.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) } /** @@ -677,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: KiaApiEndpoint.mqttDeviceProtocol, - headers: [ - "client-id": clientId - ], - encodable: request - ).empty() } /** @@ -703,305 +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: KiaApiEndpoint.mqttConnectionState, - queryItems: [ - URLQueryItem(name: "clientId", value: clientId), - ], - headers: [ - "client-id": clientId - ] - ).data() - } -} - -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: KiaApiEndpoint.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: KiaApiEndpoint.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: KiaApiEndpoint.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: KiaApiEndpoint.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: KiaApiEndpoint.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: KiaApiEndpoint.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: KiaApiEndpoint.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: 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: 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: KiaApiEndpoint.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: KiaApiEndpoint.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: KiaApiEndpoint.notificationRegisterWithDeviceId(deviceId), headers: headers).empty(acceptStatusCode: 200) - } - - // MARK: - Helpers - - func makeRedirectUri(endpoint: KiaApiEndpoint = .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) - } + try await hmgMQTTClient.checkConnectionState(clientId: clientId) } } diff --git a/KiaMaps/Core/Api/ApiRequest.swift b/KiaMaps/Core/Api/ApiRequest.swift index d9c2c24..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 } } } 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/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/PorscheVehicleMapper.swift b/KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift index 0d496e0..bec8919 100644 --- a/KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift +++ b/KiaMaps/Core/Api/Porsche/PorscheVehicleMapper.swift @@ -58,49 +58,117 @@ enum PorscheVehicleMapper { return VehicleResponse(vehicles: vehicles) } - static func mapVehicleState(from payload: JSONObject, now: Date = Date()) throws -> VehicleStateResponse { + static func mapVehicleState(from payload: JSONObject, now: Date = Date()) throws -> VehicleStatusSnapshot { let summary = mapSummary(from: payload) let snapshot = map(summary: summary) - var json = try responseJSONTemplate() - - set(value: TimeIntervalDateFormatter().string(from: now), at: ["lastUpdateTime"], in: &json) - set(value: TimeIntervalDateFormatter().string(from: now), at: ["state", "Vehicle", "Date"], in: &json) - set(value: snapshot.batterySoc, at: ["state", "Vehicle", "Green", "BatteryManagement", "BatteryRemain", "Ratio"], in: &json) - set(value: max(snapshot.batterySoc, 1) * 2_300, at: ["state", "Vehicle", "Green", "BatteryManagement", "BatteryRemain", "Value"], in: &json) - set(value: snapshot.charging ? ChargeDoorStatus.open.rawValue : ChargeDoorStatus.closed.rawValue, at: ["state", "Vehicle", "Green", "ChargingDoor", "State"], in: &json) - set(value: snapshot.charging ? 1 : 0, at: ["state", "Vehicle", "Green", "ChargingInformation", "ConnectorFastening", "State"], in: &json) - set(value: snapshot.chargingPowerKw * 1_000, at: ["state", "Vehicle", "Green", "Electric", "SmartGrid", "RealTimePower"], in: &json) - set(value: snapshot.climateActive ? 1 : 0, at: ["state", "Vehicle", "Cabin", "HVAC", "Row1", "Driver", "Blower", "SpeedLevel"], in: &json) - set(value: String(format: "%.0fC", climateTargetTemperature(from: payload) ?? 22), at: ["state", "Vehicle", "Cabin", "HVAC", "Row1", "Driver", "Temperature", "Value"], in: &json) - set(value: snapshot.odometerKm, at: ["state", "Vehicle", "Drivetrain", "Odometer"], in: &json) - set(value: snapshot.rangeKm > 0 ? Int(snapshot.rangeKm.rounded()) : 0, at: ["state", "Vehicle", "Drivetrain", "FuelSystem", "DTE", "Total"], in: &json) - set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Driver", "Lock"], in: &json) - set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Passenger", "Lock"], in: &json) - set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Left", "Lock"], in: &json) - set(value: snapshot.locked, at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Right", "Lock"], in: &json) - set(value: measurementBool("OPEN_STATE_DOOR_FRONT_LEFT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Driver", "Open"], in: &json) - set(value: measurementBool("OPEN_STATE_DOOR_FRONT_RIGHT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row1", "Passenger", "Open"], in: &json) - set(value: measurementBool("OPEN_STATE_DOOR_REAR_LEFT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Left", "Open"], in: &json) - set(value: measurementBool("OPEN_STATE_DOOR_REAR_RIGHT", payload: payload), at: ["state", "Vehicle", "Cabin", "Door", "Row2", "Right", "Open"], in: &json) - set(value: measurementBool("OPEN_STATE_LID_REAR", payload: payload), at: ["state", "Vehicle", "Body", "Trunk", "Open"], in: &json) - set(value: measurementBool("OPEN_STATE_LID_FRONT", payload: payload), at: ["state", "Vehicle", "Body", "Hood", "Open"], in: &json) - - if let latitude = snapshot.latitude, let longitude = snapshot.longitude { - set(value: latitude, at: ["state", "Vehicle", "Location", "GeoCoord", "Latitude"], in: &json) - set(value: longitude, at: ["state", "Vehicle", "Location", "GeoCoord", "Longitude"], in: &json) - } - if let heading = locationHeading(from: payload) { - set(value: heading, at: ["state", "Vehicle", "Location", "Heading"], in: &json) - } - let locationDate = locationDateString(from: payload) ?? TimeIntervalDateFormatter().string(from: now) - set(value: locationDate, at: ["state", "Vehicle", "Location", "Date"], in: &json) - - let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) - do { - return try JSONDecoder().decode(VehicleStateResponse.self, from: data) - } catch { - throw PorscheApiError.decodingFailed(error.localizedDescription) - } + 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 { @@ -157,19 +225,40 @@ enum PorscheVehicleMapper { return try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) } - private static func responseJSONTemplate() throws -> JSONObject { - let data = try JSONEncoder().encode(MockVehicleData.standardResponse) - guard let json = try JSONSerialization.jsonObject(with: data) as? JSONObject else { - throw PorscheApiError.decodingFailed("invalid response template") - } - return json - } - 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": 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() From acd6b1c6634986c36e590dfc2b4ea2b957d8343e Mon Sep 17 00:00:00 2001 From: Lukas Foldyna Date: Mon, 9 Mar 2026 22:10:59 +0100 Subject: [PATCH 7/8] Create abstract shared Porsche Kia A --- KiaTests/AuthenticationTests.swift | 327 +------------------ KiaTests/HMGClientTests.swift | 300 +++++++++++++++++ KiaTests/PorscheEndpointAndMapperTests.swift | 10 +- KiaTests/UIComponentMockDataTests.swift | 17 +- 4 files changed, 326 insertions(+), 328 deletions(-) create mode 100644 KiaTests/HMGClientTests.swift diff --git a/KiaTests/AuthenticationTests.swift b/KiaTests/AuthenticationTests.swift index 60dd49e..3d6eea9 100644 --- a/KiaTests/AuthenticationTests.swift +++ b/KiaTests/AuthenticationTests.swift @@ -30,63 +30,16 @@ final class AuthenticationTests: XCTestCase { super.tearDown() } - // MARK: - Helper Functions Tests + func testWebLoginURLBuildsAuthorizeRequest() throws { + let url = try XCTUnwrap(api.webLoginUrl()) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) - func testCommonJSONHeaders() { - // Test JSON headers helper - let headers = api.commonJSONHeaders(referer: "https://example.com/test") - - XCTAssertEqual(headers["Sec-Fetch-Site"], "same-origin") - XCTAssertEqual(headers["Sec-Fetch-Mode"], "cors") - XCTAssertEqual(headers["Sec-Fetch-Dest"], "empty") - XCTAssertEqual(headers["Referer"], "https://example.com/test") - } - - func testCommonNavigationHeaders() { - // Test navigation headers helper - let headers = api.commonNavigationHeaders(referer: "https://example.com/nav") - - XCTAssertEqual(headers["Sec-Fetch-Site"], "none") - XCTAssertEqual(headers["Sec-Fetch-Mode"], "navigate") - XCTAssertEqual(headers["Sec-Fetch-Dest"], "document") - XCTAssertEqual(headers["Referer"], "https://example.com/nav") - } - - func testCommonNavigationHeadersWithoutReferer() { - // Test navigation headers without referer - let headers = api.commonNavigationHeaders(referer: nil) - - XCTAssertEqual(headers["Sec-Fetch-Site"], "none") - XCTAssertEqual(headers["Sec-Fetch-Mode"], "navigate") - XCTAssertEqual(headers["Sec-Fetch-Dest"], "document") - XCTAssertNil(headers["Referer"]) - } - - // MARK: - URL Extraction Tests - - func testExtractNextUri() { - let testCases = [ - ( - url: URL(string: "https://idpconnect-eu.kia.com/auth/redirect?next_uri=https%3A%2F%2Fidpconnect-eu.kia.com%2Fauth%2Fapi%2Fv2%2Fuser%2Foauth2%2Fauthorize")!, - expected: "https://idpconnect-eu.kia.com/auth/api/v2/user/oauth2/authorize" - ), - ( - url: URL(string: "https://example.com/redirect?other=value&next_uri=https%3A%2F%2Fexample.com%2Fnext")!, - expected: "https://example.com/next" - ) - ] - - for testCase in testCases { - let result = api.extractNextUri(from: testCase.url) - XCTAssertEqual(result, testCase.expected, "Failed to extract next_uri from \(testCase.url)") - } - } - - func testExtractConnectorSessionKey() { - let testURL = "https://idpconnect-eu.kia.com/auth/api/v2/user/oauth2/authorize?client_id=test&connector_session_key=abc123def&state=ccsp" - - let result = api.extractConnectorSessionKey(from: testURL) - XCTAssertEqual(result, "abc123def") + XCTAssertEqual(url.host, "idpconnect-mock.test.com") + XCTAssertEqual(url.path, "/auth/api/v2/user/oauth2/authorize") + XCTAssertEqual(items["client_id"], mockProvider.configuration.serviceId) + XCTAssertEqual(items["response_type"], "code") + XCTAssertEqual(items["state"], "ccsp") } func testExtractAuthorizationCode() throws { @@ -148,201 +101,6 @@ final class AuthenticationTests: XCTestCase { }, "Should be hex encoded") } - // MARK: - Authentication Flow Steps Tests - - func testFetchConnectorAuthorization() async throws { - // Setup mock response - 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")! - - let result = try await api.fetchConnectorAuthorization() - - XCTAssertEqual(result, "https://idpconnect-eu.kia.com/auth/api/v2/user/oauth2/authorize") - } - - func testFetchClientConfiguration() async throws { - let expectedConfig = 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") - ) - - mockProvider.mockClientConfiguration = expectedConfig - - let result = try await api.fetchClientConfiguration(referer: "https://test.com") - - XCTAssertEqual(result.clientId, expectedConfig.clientId) - XCTAssertEqual(result.clientName, expectedConfig.clientName) - } - - func testFetchPasswordEncryptionSettings() async throws { - 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 result = try await api.fetchPasswordEncryptionSettings(referer: "https://test.com") - - XCTAssertTrue(result.useEnabled) - XCTAssertEqual(result.value1, "true") - } - - func testFetchRSACertificate() async throws { - let expectedCert = RSACertificateResponse( - kty: "RSA", - e: "AQAB", - kid: "TEST_KEY", - n: "testModulus123" - ) - - mockProvider.mockRSACertificate = expectedCert - - let result = try await api.fetchRSACertificate(referer: "https://test.com") - - XCTAssertEqual(result.keyId, "TEST_KEY") - XCTAssertEqual(result.modulus, "testModulus123") - } - - func testInitializeOAuth2() async throws { - // Setup mock cookie - let cookie = HTTPCookie(properties: [ - .name: "account", - .value: "test_csrf_token", - .domain: "idpconnect-eu.kia.com", - .path: "/" - ])! - HTTPCookieStorage.shared.setCookie(cookie) - - mockProvider.mockEmpty = true - - let result = try await api.initializeOAuth2(referer: "https://test.com") - - XCTAssertEqual(result, "test_csrf_token") - } - - func testSignIn() async throws { - let rsaKey = RSAEncryptionService.RSAKeyData( - keyType: "RSA", - exponent: "AQAB", - keyId: "TEST_KEY", - modulus: "o5OJwXceU_cJOYJyNP5pUxeTdMybhJ7rhx3f_VYzU8VgUlHbHhBqjlqoHM1_ie7OJNyOtKs0ijFebO7QKq-3bw" - ) - - mockProvider.mockRedirectURL = URL(string: "https://prd.eu-ccapi.kia.com:8080/api/v1/user/oauth2/redirect?code=AUTH_CODE_123&state=ccsp&login_success=y")! - - let referer = "https://idpconnect-eu.kia.com/auth/api/v2/user/oauth2/authorize?connector_session_key=test_session" - let result = try await api.signIn( - referer: referer, - username: "test@example.com", - password: "password123", - rsaKey: rsaKey, - csrfToken: "test_csrf" - ) - - XCTAssertEqual(result, "AUTH_CODE_123") - } - - func testExchangeCodeForTokens() async throws { - let expectedTokens = TokenResponse( - scope: nil, - connector: [ - "test": .init( - scope: nil, - idpCd: "Idp Cd", - accessToken: "Access Token", - refreshToken: "Refresh Token", - idToken: nil, - tokenType: "Token Type", - expiresIn: 10 - ) - ], - accessToken: "access_123", - refreshToken: "refresh_456", - idToken: nil, - tokenType: "Bearer", - expiresIn: 3600 - ) - - mockProvider.mockTokenResponse = expectedTokens - - let result = try await api.exchangeCodeForTokens(authorizationCode: "AUTH_CODE_123") - - XCTAssertNil(result.scope) - XCTAssertEqual(result.connector?.count ?? 0, 1) - XCTAssertEqual(result.accessToken, "access_123") - XCTAssertEqual(result.refreshToken, "refresh_456") - XCTAssertNil(result.idToken) - XCTAssertEqual(result.tokenType, "Bearer") - XCTAssertEqual(result.expiresIn, 3600) - } - - // MARK: - Cookie Management Tests - - func testCleanCookies() { - // Add some test cookies - let cookie1 = HTTPCookie(properties: [ - .name: "test1", - .value: "value1", - .domain: "example.com", - .path: "/" - ])! - - let cookie2 = HTTPCookie(properties: [ - .name: "test2", - .value: "value2", - .domain: "example.com", - .path: "/" - ])! - - HTTPCookieStorage.shared.setCookie(cookie1) - HTTPCookieStorage.shared.setCookie(cookie2) - - // Verify cookies exist - XCTAssertNotNil(HTTPCookieStorage.shared.cookies?.first(where: { $0.name == "test1" })) - XCTAssertNotNil(HTTPCookieStorage.shared.cookies?.first(where: { $0.name == "test2" })) - - // Clean cookies - api.cleanCookies() - - // Verify cookies are removed - XCTAssertNil(HTTPCookieStorage.shared.cookies?.first(where: { $0.name == "test1" })) - XCTAssertNil(HTTPCookieStorage.shared.cookies?.first(where: { $0.name == "test2" })) - } - // MARK: - Error Handling Tests func testAuthenticationErrorDescriptions() { @@ -364,77 +122,14 @@ final class AuthenticationTests: XCTestCase { XCTAssertTrue(description.count > 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, 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/PorscheEndpointAndMapperTests.swift b/KiaTests/PorscheEndpointAndMapperTests.swift index b7c99fe..ecfc36b 100644 --- a/KiaTests/PorscheEndpointAndMapperTests.swift +++ b/KiaTests/PorscheEndpointAndMapperTests.swift @@ -326,11 +326,11 @@ final class PorscheEndpointAndMapperTests: XCTestCase { XCTAssertTrue(snapshot.locked) XCTAssertTrue(snapshot.climateActive) XCTAssertEqual(snapshot.chargingPowerKw, 11) - XCTAssertEqual(state.state.vehicle.green.batteryManagement.batteryRemain.ratio, 62.5) - XCTAssertEqual(state.state.vehicle.drivetrain.odometer, 15_432) - XCTAssertEqual(state.state.vehicle.location?.geoCoordinate.latitude, 50.1) - XCTAssertEqual(state.state.vehicle.location?.geoCoordinate.longitude, 14.4) - XCTAssertEqual(state.state.vehicle.cabin.hvac.row1.driver.blower.speedLevel, 1) + 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 { diff --git a/KiaTests/UIComponentMockDataTests.swift b/KiaTests/UIComponentMockDataTests.swift index 9a21c90..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: {}, From 77d394fb26d1e4f515d477a1d34186909f43608c Mon Sep 17 00:00:00 2001 From: Lukas Foldyna Date: Tue, 10 Mar 2026 22:36:56 +0100 Subject: [PATCH 8/8] Fixed kia test plan --- KiaTests/TestPlan.xctestplan | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 KiaTests/TestPlan.xctestplan 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 +}