diff options
| author | Steffen Ernst <steffen.ernst@mullvad.net> | 2025-04-09 10:33:39 +0200 |
|---|---|---|
| committer | Steffen Ernst <steffen.ernst@mullvad.net> | 2025-05-08 09:31:27 +0200 |
| commit | 01660f7d4af6fc539ce938f44f26bdf55337b57a (patch) | |
| tree | 4a15da846efe1f5fe9bf486d2c35727b1e2d1b31 | |
| parent | a74a0c0aa3270bb2cf7861f94ad7dc590ed50d22 (diff) | |
| download | mullvadvpn-01660f7d4af6fc539ce938f44f26bdf55337b57a.tar.xz mullvadvpn-01660f7d4af6fc539ce938f44f26bdf55337b57a.zip | |
Add storekit 2 functions to mullvad api
21 files changed, 524 insertions, 57 deletions
diff --git a/ios/MullvadMockData/MullvadREST/APIProxy+Stubs.swift b/ios/MullvadMockData/MullvadREST/APIProxy+Stubs.swift index 9880512524..218c43f195 100644 --- a/ios/MullvadMockData/MullvadREST/APIProxy+Stubs.swift +++ b/ios/MullvadMockData/MullvadREST/APIProxy+Stubs.swift @@ -52,4 +52,21 @@ struct APIProxyStub: APIQuerying { ) -> Cancellable { AnyCancellable() } + + func initStorekitPayment( + accountNumber: String, + retryStrategy: MullvadREST.REST.RetryStrategy, + completionHandler: @escaping MullvadREST.ProxyCompletionHandler<String> + ) -> any MullvadTypes.Cancellable { + AnyCancellable() + } + + func checkStorekitPayment( + accountNumber: String, + transaction: MullvadTypes.StorekitTransaction, + retryStrategy: MullvadREST.REST.RetryStrategy, + completionHandler: @escaping MullvadREST.ProxyCompletionHandler<Void> + ) -> any MullvadTypes.Cancellable { + AnyCancellable() + } } diff --git a/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift b/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift index d45745446e..e7b775124c 100644 --- a/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift +++ b/ios/MullvadREST/ApiHandlers/RESTAPIProxy.swift @@ -250,6 +250,25 @@ extension REST { return executor.execute(retryStrategy: retryStrategy, completionHandler: completionHandler) } + + /// Not implemented. Use `MullvadAPIProxy` instead. + public func initStorekitPayment( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping ProxyCompletionHandler<String> + ) -> any MullvadTypes.Cancellable { + AnyCancellable() + } + + /// Not implemented. Use `MullvadAPIProxy` instead. + public func checkStorekitPayment( + accountNumber: String, + transaction: MullvadTypes.StorekitTransaction, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping ProxyCompletionHandler<Void> + ) -> any MullvadTypes.Cancellable { + AnyCancellable() + } } // MARK: - Response types diff --git a/ios/MullvadREST/MullvadAPI/APIHandlers/MullvadAPIProxy.swift b/ios/MullvadREST/MullvadAPI/APIHandlers/MullvadAPIProxy.swift index 5cf3ee0e37..898809ee1a 100644 --- a/ios/MullvadREST/MullvadAPI/APIHandlers/MullvadAPIProxy.swift +++ b/ios/MullvadREST/MullvadAPI/APIHandlers/MullvadAPIProxy.swift @@ -40,6 +40,19 @@ public protocol APIQuerying: Sendable { retryStrategy: REST.RetryStrategy, completionHandler: @escaping @Sendable ProxyCompletionHandler<REST.SubmitVoucherResponse> ) -> Cancellable + + func initStorekitPayment( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping @Sendable ProxyCompletionHandler<String> + ) -> Cancellable + + func checkStorekitPayment( + accountNumber: String, + transaction: StorekitTransaction, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping @Sendable ProxyCompletionHandler<Void> + ) -> Cancellable } extension REST { @@ -138,7 +151,49 @@ extension REST { AnyCancellable() } - private func createNetworkOperation<Success: Any>( + public func initStorekitPayment( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping ProxyCompletionHandler<String> + ) -> any MullvadTypes.Cancellable { + struct InitStorekitPaymentResponse: Codable { + let paymentToken: String + } + + let responseHandler = rustResponseHandler( + decoding: InitStorekitPaymentResponse.self, + with: responseDecoder + ) + + return createNetworkOperation( + request: + .initStorekitPayment(retryStrategy: retryStrategy, accountNumber: accountNumber), + responseHandler: responseHandler, + completionHandler: { completionHandler($0.map { $0.paymentToken }) } + ) + } + + public func checkStorekitPayment( + accountNumber: String, + transaction: StorekitTransaction, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping ProxyCompletionHandler<Void> + ) -> any MullvadTypes.Cancellable { + let responseHandler = rustEmptyResponseHandler() + + return createNetworkOperation( + request: + .checkStorekitPayment( + retryStrategy: retryStrategy, + accountNumber: accountNumber, + transaction: transaction + ), + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + private func createNetworkOperation<Success>( request: APIRequest, responseHandler: RustResponseHandler<Success>, completionHandler: @escaping @Sendable ProxyCompletionHandler<Success> diff --git a/ios/MullvadREST/MullvadAPI/APIRequest/APIRequest.swift b/ios/MullvadREST/MullvadAPI/APIRequest/APIRequest.swift index ab1087fe5e..f2ecafdbf6 100644 --- a/ios/MullvadREST/MullvadAPI/APIRequest/APIRequest.swift +++ b/ios/MullvadREST/MullvadAPI/APIRequest/APIRequest.swift @@ -18,6 +18,12 @@ public enum APIRequest: Codable, Sendable { case createAccount(_ retryStrategy: REST.RetryStrategy) case getAccount(_ retryStrategy: REST.RetryStrategy, accountNumber: String) case deleteAccount(_ retryStrategy: REST.RetryStrategy, accountNumber: String) + case initStorekitPayment(retryStrategy: REST.RetryStrategy, accountNumber: String) + case checkStorekitPayment( + retryStrategy: REST.RetryStrategy, + accountNumber: String, + transaction: StorekitTransaction + ) // Device Proxy case getDevice(_ retryStrategy: REST.RetryStrategy, accountNumber: String, identifier: String) @@ -55,6 +61,10 @@ public enum APIRequest: Codable, Sendable { "rotate-device-key" case .createDevice: "create-device" + case .initStorekitPayment: + "init-storekit-payment" + case .checkStorekitPayment: + "check-storekit-payment" } } @@ -70,8 +80,10 @@ public enum APIRequest: Codable, Sendable { let .getDevice(strategy, _, _), let .getDevices(strategy, _), let .deleteDevice(strategy, _, _), - let .rotateDeviceKey(strategy, _, _, _): - return strategy + let .rotateDeviceKey(strategy, _, _, _), + let .initStorekitPayment(strategy, _), + let .checkStorekitPayment(strategy, _, _): + strategy } } } diff --git a/ios/MullvadREST/MullvadAPI/APIRequest/APIRequestProxy.swift b/ios/MullvadREST/MullvadAPI/APIRequest/APIRequestProxy.swift index 8e2ac4fad2..d204165c0d 100644 --- a/ios/MullvadREST/MullvadAPI/APIRequest/APIRequestProxy.swift +++ b/ios/MullvadREST/MullvadAPI/APIRequest/APIRequestProxy.swift @@ -45,20 +45,32 @@ public final class APIRequestProxy: APIRequestProxyProtocol, @unchecked Sendable completion(ProxyAPIResponse(data: nil, error: nil)) return } + do { + let cancellable = try transport.sendRequest(proxyRequest.request) { [weak self] response in + guard let self else { return } - let cancellable = transport.sendRequest(proxyRequest.request) { [weak self] response in - guard let self else { return } - - // Use `dispatchQueue` to guarantee thread safe access to `proxiedRequests` - dispatchQueue.async { - _ = self.removeRequest(identifier: proxyRequest.id) - completion(response) + // Use `dispatchQueue` to guarantee thread safe access to `proxiedRequests` + dispatchQueue.async { + _ = self.removeRequest(identifier: proxyRequest.id) + completion(response) + } } - } - // Cancel old task, if there's one scheduled. - let oldTask = self.addRequest(identifier: proxyRequest.id, task: cancellable) - oldTask?.cancel() + // Cancel old task, if there's one scheduled. + let oldTask = self.addRequest(identifier: proxyRequest.id, task: cancellable) + oldTask?.cancel() + } catch { + completion( + ProxyAPIResponse( + data: nil, + error: APIError( + statusCode: 0, + errorDescription: error.localizedDescription, + serverResponseCode: nil + ) + ) + ) + } } } diff --git a/ios/MullvadREST/MullvadAPI/MullvadApiNetworkOperation.swift b/ios/MullvadREST/MullvadAPI/MullvadApiNetworkOperation.swift index 12d9c0346d..643e24fcdb 100644 --- a/ios/MullvadREST/MullvadAPI/MullvadApiNetworkOperation.swift +++ b/ios/MullvadREST/MullvadAPI/MullvadApiNetworkOperation.swift @@ -70,28 +70,32 @@ extension REST { } let transport = transportProvider.makeTransport() - networkTask = transport?.sendRequest(request) { [weak self] response in - guard let self else { return } + do { + networkTask = try transport?.sendRequest(request) { [weak self] response in + guard let self else { return } - if let apiError = response.error { - finish(result: .failure(restError(apiError: apiError))) - return - } + if let apiError = response.error { + finish(result: .failure(restError(apiError: apiError))) + return + } - let decodedResponse = responseHandler.handleResponse(response) + let decodedResponse = responseHandler.handleResponse(response) - switch decodedResponse { - case let .success(value): - finish(result: .success(value)) - case let .decoding(block): - do { - finish(result: .success(try block())) - } catch { - finish(result: .failure(REST.Error.unhandledResponse(0, nil))) + switch decodedResponse { + case let .success(value): + finish(result: .success(value)) + case let .decoding(block): + do { + finish(result: .success(try block())) + } catch { + finish(result: .failure(REST.Error.unhandledResponse(0, nil))) + } + case let .unhandledResponse(error): + finish(result: .failure(REST.Error.unhandledResponse(0, error))) } - case let .unhandledResponse(error): - finish(result: .failure(REST.Error.unhandledResponse(0, error))) } + } catch { + finish(result: .failure(error)) } } diff --git a/ios/MullvadREST/MullvadAPI/MullvadApiRequestFactory.swift b/ios/MullvadREST/MullvadAPI/MullvadApiRequestFactory.swift index 3f29468d9b..20b15d2971 100644 --- a/ios/MullvadREST/MullvadAPI/MullvadApiRequestFactory.swift +++ b/ios/MullvadREST/MullvadAPI/MullvadApiRequestFactory.swift @@ -11,11 +11,14 @@ import MullvadTypes public struct MullvadApiRequestFactory: Sendable { public let apiContext: MullvadApiContext + private let encoder: JSONEncoder - public init(apiContext: MullvadApiContext) { + public init(apiContext: MullvadApiContext, encoder: JSONEncoder) { self.apiContext = apiContext + self.encoder = encoder } + // swiftlint:disable:next function_body_length public func makeRequest(_ request: APIRequest) -> REST.MullvadApiRequestHandler { { completion in let completionPointer = MullvadApiCompletion { apiResponse in @@ -111,11 +114,35 @@ public struct MullvadApiRequestFactory: Sendable { accountNumber, request.publicKey.rawValue.map { $0 } )) + case let .initStorekitPayment( + retryStrategy: retryStrategy, + accountNumber: accountNumber + ): + return MullvadApiCancellable(handle: mullvad_ios_init_storekit_payment( + apiContext.context, + rawCompletionPointer, + retryStrategy.toRustStrategy(), + accountNumber + )) + case let .checkStorekitPayment( + retryStrategy: retryStrategy, + accountNumber: accountNumber, + transaction: transaction + ): + let body = try encoder.encode(transaction) + return MullvadApiCancellable(handle: mullvad_ios_check_storekit_payment( + apiContext.context, + rawCompletionPointer, + retryStrategy.toRustStrategy(), + accountNumber, + body.map { $0 }, + UInt(body.count) + )) } } } } extension REST { - public typealias MullvadApiRequestHandler = (((MullvadApiResponse) throws -> Void)?) -> MullvadApiCancellable + public typealias MullvadApiRequestHandler = (((MullvadApiResponse) throws -> Void)?) throws -> MullvadApiCancellable } diff --git a/ios/MullvadREST/Transport/APITransport.swift b/ios/MullvadREST/Transport/APITransport.swift index d0bf7db48d..ed761f1479 100644 --- a/ios/MullvadREST/Transport/APITransport.swift +++ b/ios/MullvadREST/Transport/APITransport.swift @@ -12,7 +12,7 @@ import MullvadTypes public protocol APITransportProtocol { var name: String { get } - func sendRequest(_ request: APIRequest, completion: @escaping @Sendable (ProxyAPIResponse) -> Void) + func sendRequest(_ request: APIRequest, completion: @escaping @Sendable (ProxyAPIResponse) -> Void) throws -> Cancellable } @@ -30,10 +30,10 @@ public final class APITransport: APITransportProtocol { public func sendRequest( _ request: APIRequest, completion: @escaping @Sendable (ProxyAPIResponse) -> Void - ) -> Cancellable { + ) throws -> Cancellable { let apiRequest = requestFactory.makeRequest(request) - return apiRequest { response in + return try apiRequest { response in let error: APIError? = if !response.success { APIError( statusCode: Int(response.statusCode), diff --git a/ios/MullvadRESTTests/MullvadApiTests.swift b/ios/MullvadRESTTests/MullvadApiTests.swift index 0290f11e66..506c7240f8 100644 --- a/ios/MullvadRESTTests/MullvadApiTests.swift +++ b/ios/MullvadRESTTests/MullvadApiTests.swift @@ -43,7 +43,10 @@ class MullvadApiTests: XCTestCase { let proxy = REST.MullvadAPIProxy( transportProvider: APITransportProvider( - requestFactory: .init(apiContext: context) + requestFactory: .init( + apiContext: context, + encoder: JSONEncoder() + ) ), dispatchQueue: .main, responseDecoder: REST.Coding.makeJSONDecoder() diff --git a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h index fa24141e42..e053294fb8 100644 --- a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h +++ b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h @@ -639,6 +639,50 @@ extern const void *swift_get_shadowsocks_bridges(const void *rawBridgeProvider); struct SwiftShadowsocksLoaderWrapper init_swift_shadowsocks_loader_wrapper(const void *shadowsocks_loader); /** + * # Safety + * + * `api_context` must be pointing to a valid instance of `SwiftApiContext`. A `SwiftApiContext` is created + * by calling `mullvad_api_init_new`. + * + * This function takes ownership of `completion_cookie`, which must be pointing to a valid instance of Swift + * object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish` + * when completion finishes (in completion.finish). + * + * `account_number` must be a pointer to a null terminated string. + * + * This function is not safe to call multiple times with the same `CompletionCookie`. + */ +struct SwiftCancelHandle mullvad_ios_init_storekit_payment(struct SwiftApiContext api_context, + void *completion_cookie, + struct SwiftRetryStrategy retry_strategy, + const char *account_number); + +/** + * # Safety + * + * `api_context` must be pointing to a valid instance of `SwiftApiContext`. A `SwiftApiContext` is created + * by calling `mullvad_api_init_new`. + * + * This function takes ownership of `completion_cookie`, which must be pointing to a valid instance of Swift + * object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish` + * when completion finishes (in completion.finish). + * + * `account_number` must be a pointer to a null terminated string. + * + * `body` must be a pointer to a contiguous memory segment + * + * `body_size` must be the size of the body + * + * This function is not safe to call multiple times with the same `CompletionCookie`. + */ +struct SwiftCancelHandle mullvad_ios_check_storekit_payment(struct SwiftApiContext api_context, + void *completion_cookie, + struct SwiftRetryStrategy retry_strategy, + const char *account_number, + const uint8_t *body, + uintptr_t body_size); + +/** * Initializes a valid pointer to an instance of `EncryptedDnsProxyState`. * * # Safety diff --git a/ios/MullvadTypes/Storekit2.swift b/ios/MullvadTypes/Storekit2.swift new file mode 100644 index 0000000000..3407b76f3c --- /dev/null +++ b/ios/MullvadTypes/Storekit2.swift @@ -0,0 +1,7 @@ +public struct StorekitTransaction: Codable, Sendable { + let transaction: String + + public init(transaction: String) { + self.transaction = transaction + } +} diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 9d291a7ae5..64f83215c5 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -1109,6 +1109,7 @@ F910A4312D4A1B41002FF3BB /* InAppPurchaseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4302D4A1B3B002FF3BB /* InAppPurchaseCoordinator.swift */; }; F910A43A2D4A283D002FF3BB /* InAppPurchaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */; }; F910A8572D523812002FF3BB /* TunnelSettingsV7.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */; }; + F924C5A42DA65F28001F4660 /* Storekit2.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C5A32DA65F28001F4660 /* Storekit2.swift */; }; F924C65F2DAE4554001F4660 /* ServerRelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */; }; F924C4532D70692E001F4660 /* MullvadApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C4522D706929001F4660 /* MullvadApiTests.swift */; }; F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; }; @@ -2523,6 +2524,7 @@ F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseViewController.swift; sourceTree = "<group>"; }; F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV7.swift; sourceTree = "<group>"; }; F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRelayTests.swift; sourceTree = "<group>"; }; + F924C5A32DA65F28001F4660 /* Storekit2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storekit2.swift; sourceTree = "<group>"; }; F924C4522D706929001F4660 /* MullvadApiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiTests.swift; sourceTree = "<group>"; }; F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -3044,6 +3046,7 @@ 581DA2722A1E227D0046ED47 /* RESTTypes.swift */, 58F1311427E0B2AB007AC5BC /* Result+Extensions.swift */, 58DFF7D92B02862E00F864E0 /* ShadowsocksCipherOptions.swift */, + F924C5A32DA65F28001F4660 /* Storekit2.swift */, F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */, A91614D02B108D1B00F416EB /* TransportLayer.swift */, 58E511E028DDB7F100B0BCDE /* WrappingError.swift */, @@ -6700,6 +6703,7 @@ 7A307AD92A8CD8DA0017618B /* Duration.swift in Sources */, 58D2240A294C90210029F5F8 /* IPAddress+Codable.swift in Sources */, 58E45A5729F12C5100281ECF /* Result+Extensions.swift in Sources */, + F924C5A42DA65F28001F4660 /* Storekit2.swift in Sources */, A90C48672C36BC2600DCB94C /* EphemeralPeerReceiver.swift in Sources */, A9E031782ACB09930095D843 /* BackgroundTaskProvider.swift in Sources */, 58D2240B294C90210029F5F8 /* Cancellable.swift in Sources */, diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 2b8b15042d..e056368264 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -175,7 +175,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD encryptedDNSTransport: encryptedDNSTransport ) - let apiRequestFactory = MullvadApiRequestFactory(apiContext: apiContext) + let apiRequestFactory = MullvadApiRequestFactory( + apiContext: apiContext, + encoder: REST.Coding.makeJSONEncoder() + ) let apiTransportProvider = APITransportProvider(requestFactory: apiRequestFactory) apiTransportMonitor = APITransportMonitor( diff --git a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift index 8a6f44df71..c11899906a 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift @@ -49,10 +49,35 @@ final class AccountInteractor: Sendable { await tunnelManager.unsetAccount() } - func sendStoreKitReceipt(_ transaction: VerificationResult<Transaction>, for accountNumber: String) async throws { - _ = try await apiProxy.createApplePayment( - accountNumber: accountNumber, - receiptString: transaction.jwsRepresentation.data(using: .utf8)! - ).execute() + // This function is for testing only + func getPaymentToken(for accountNumber: String) async -> Result<String, Error> { + await withCheckedContinuation { continuation in + _ = apiProxy + .initStorekitPayment( + accountNumber: accountNumber, + retryStrategy: .noRetry, + completionHandler: { result in + continuation.resume(returning: result) + } + ) + } + } + + // This function is for testing only + func sendStoreKitReceipt( + _ transaction: VerificationResult<Transaction>, + for accountNumber: String + ) async -> Result<Void, Error> { + await withCheckedContinuation { c in + _ = apiProxy + .checkStorekitPayment( + accountNumber: accountNumber, + transaction: StorekitTransaction(transaction: transaction.jwsRepresentation), + retryStrategy: .noRetry, + completionHandler: { result in + c.resume(returning: result) + } + ) + } } } diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift index 6f362e96db..eb3d2acd3b 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift @@ -225,6 +225,7 @@ class AccountViewController: UIViewController, @unchecked Sendable { actionHandler?(.showRestorePurchases) } + // This function is for testing only @objc private func handleStoreKit2Purchase() { guard let accountData = interactor.deviceState.accountData else { return @@ -234,13 +235,30 @@ class AccountViewController: UIViewController, @unchecked Sendable { Task { do { - let product = try await Product.products(for: [storeKit2TestProduct]).first! - let result = try await product.purchase() + let product = try await Product.products( + for: [ + storeKit2TestProduct, + ] + ).first! + let token = switch await interactor + .getPaymentToken(for: accountData.number) { + case let .success(token): + UUID(uuidString: token)! + case let .failure(error): + throw error + } + + let result = try await product.purchase( + options: [.appAccountToken(token)] + ) switch result { case let .success(verification): let transaction = try checkVerified(verification) - await sendReceiptToAPI(accountNumber: accountData.identifier, receipt: verification) + await sendReceiptToAPI( + accountNumber: accountData.number, + receipt: verification + ) await transaction.finish() case .userCancelled: print("User cancelled the purchase") @@ -303,10 +321,10 @@ class AccountViewController: UIViewController, @unchecked Sendable { } private func sendReceiptToAPI(accountNumber: String, receipt: VerificationResult<Transaction>) async { - do { - try await interactor.sendStoreKitReceipt(receipt, for: accountNumber) + switch await interactor.sendStoreKitReceipt(receipt, for: accountNumber) { + case .success: print("Receipt sent successfully") - } catch { + case let .failure(error): print("Error sending receipt: \(error)") errorPresenter.showAlertForStoreKitError(error, context: .purchase) } diff --git a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift index 391e637e23..3119af256f 100644 --- a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift +++ b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift @@ -156,7 +156,10 @@ class TunnelManagerTests: XCTestCase { relaySelector: relaySelector, transportProvider: transportProvider, apiTransportProvider: APITransportProvider( - requestFactory: MullvadApiRequestFactory(apiContext: apiContext) + requestFactory: MullvadApiRequestFactory( + apiContext: apiContext, + encoder: REST.Coding.makeJSONEncoder() + ) ) ) SimulatorTunnelProvider.shared.delegate = simulatorTunnelProviderHost @@ -227,7 +230,10 @@ class TunnelManagerTests: XCTestCase { relaySelector: relaySelector, transportProvider: transportProvider, apiTransportProvider: APITransportProvider( - requestFactory: MullvadApiRequestFactory(apiContext: apiContext) + requestFactory: MullvadApiRequestFactory( + apiContext: apiContext, + encoder: REST.Coding.makeJSONEncoder() + ) ) ) SimulatorTunnelProvider.shared.delegate = simulatorTunnelProviderHost diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift index 90f1aa8d69..3f2df2cbdb 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift @@ -68,7 +68,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { ) let apiTransportProvider = APITransportProvider( - requestFactory: MullvadApiRequestFactory(apiContext: apiContext) + requestFactory: MullvadApiRequestFactory( + apiContext: apiContext, + encoder: REST.Coding.makeJSONEncoder() + ) ) adapter = WgAdapter(packetTunnelProvider: self) diff --git a/mullvad-api/src/lib.rs b/mullvad-api/src/lib.rs index fb44b5ba89..d203d4dd04 100644 --- a/mullvad-api/src/lib.rs +++ b/mullvad-api/src/lib.rs @@ -65,6 +65,10 @@ pub const API_IP_CACHE_FILENAME: &str = "api-ip-address.txt"; const ACCOUNTS_URL_PREFIX: &str = "accounts/v1"; const APP_URL_PREFIX: &str = "app/v1"; + +#[cfg(target_os = "ios")] +const APPLE_PAYMENT_URL_PREFIX: &str = "payments/apple/v2"; + #[cfg(target_os = "android")] const GOOGLE_PAYMENTS_URL_PREFIX: &str = "payments/google-play/v1"; @@ -599,6 +603,35 @@ impl AccountsProxy { } } + #[cfg(target_os = "ios")] + pub async fn init_storekit_payment( + &self, + account: AccountNumber, + ) -> Result<rest::Response<Incoming>, rest::Error> { + let request = self + .handle + .factory + .post(&format!("{APPLE_PAYMENT_URL_PREFIX}/init"))? + .expected_status(&[StatusCode::OK]) + .account(account)?; + self.handle.service.request(request).await + } + + #[cfg(target_os = "ios")] + pub async fn check_storekit_payment( + &self, + account: AccountNumber, + body: Vec<u8>, + ) -> Result<rest::Response<Incoming>, rest::Error> { + let request = self + .handle + .factory + .post_json_bytes(&format!("{APPLE_PAYMENT_URL_PREFIX}/check"), body)? + .expected_status(&[StatusCode::OK]) + .account(account)?; + self.handle.service.request(request).await + } + #[cfg(target_os = "android")] pub fn init_play_purchase( &mut self, diff --git a/mullvad-api/src/rest.rs b/mullvad-api/src/rest.rs index 94c43392ad..14e37ec29e 100644 --- a/mullvad-api/src/rest.rs +++ b/mullvad-api/src/rest.rs @@ -606,6 +606,10 @@ impl RequestFactory { self.json_request(Method::POST, path, body) } + pub fn post_json_bytes(&self, path: &str, body: Vec<u8>) -> Result<Request<Full<Bytes>>> { + self.json_request_with_bytes(Method::POST, path, body) + } + pub fn put_json<S: serde::Serialize>( &self, path: &str, @@ -618,17 +622,16 @@ impl RequestFactory { self.default_timeout = timeout; self } - fn json_request<S: serde::Serialize>( + fn json_request_with_bytes( &self, method: Method, path: &str, - body: &S, + body: Vec<u8>, ) -> Result<Request<Full<Bytes>>> { let mut request = self.hyper_request(path, method)?; - let json_body = serde_json::to_vec(&body)?; - let body_length = json_body.len(); - *request.body_mut() = Full::new(Bytes::from(json_body)); + let body_length = body.len(); + *request.body_mut() = Full::new(Bytes::from(body)); let headers = request.headers_mut(); headers.insert(header::CONTENT_LENGTH, HeaderValue::from(body_length)); @@ -640,6 +643,16 @@ impl RequestFactory { Ok(Request::new(request, self.token_store.clone()).timeout(self.default_timeout)) } + fn json_request<S: serde::Serialize>( + &self, + method: Method, + path: &str, + body: &S, + ) -> Result<Request<Full<Bytes>>> { + let json_body = serde_json::to_vec(&body)?; + self.json_request_with_bytes(method, path, json_body) + } + fn hyper_request<B: Default>(&self, path: &str, method: Method) -> Result<http::Request<B>> { let uri = self.get_uri(path)?; let request = http::request::Builder::new() diff --git a/mullvad-ios/src/api_client/mod.rs b/mullvad-ios/src/api_client/mod.rs index bf849bc45c..c65fe524a0 100644 --- a/mullvad-ios/src/api_client/mod.rs +++ b/mullvad-ios/src/api_client/mod.rs @@ -28,6 +28,7 @@ mod problem_report; mod response; mod retry_strategy; mod shadowsocks_loader; +mod storekit; #[repr(C)] pub struct SwiftApiContext(*const ApiContext); diff --git a/mullvad-ios/src/api_client/storekit.rs b/mullvad-ios/src/api_client/storekit.rs new file mode 100644 index 0000000000..c1ee0d26ca --- /dev/null +++ b/mullvad-ios/src/api_client/storekit.rs @@ -0,0 +1,161 @@ +use std::os::raw::c_char; + +use mullvad_api::{ + rest::{self, MullvadRestHandle}, + AccountsProxy, +}; +use mullvad_types::account::AccountNumber; + +use super::{ + cancellation::{RequestCancelHandle, SwiftCancelHandle}, + completion::{CompletionCookie, SwiftCompletionHandler}, + do_request, + helpers::convert_c_string, + response::SwiftMullvadApiResponse, + retry_strategy::{RetryStrategy, SwiftRetryStrategy}, + SwiftApiContext, +}; + +/// # Safety +/// +/// `api_context` must be pointing to a valid instance of `SwiftApiContext`. A `SwiftApiContext` is created +/// by calling `mullvad_api_init_new`. +/// +/// This function takes ownership of `completion_cookie`, which must be pointing to a valid instance of Swift +/// object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish` +/// when completion finishes (in completion.finish). +/// +/// `account_number` must be a pointer to a null terminated string. +/// +/// This function is not safe to call multiple times with the same `CompletionCookie`. +#[no_mangle] +pub unsafe extern "C" fn mullvad_ios_init_storekit_payment( + api_context: SwiftApiContext, + completion_cookie: *mut libc::c_void, + retry_strategy: SwiftRetryStrategy, + account_number: *const c_char, +) -> SwiftCancelHandle { + let completion_handler = SwiftCompletionHandler::new(CompletionCookie::new(completion_cookie)); + + let Ok(tokio_handle) = crate::mullvad_ios_runtime() else { + completion_handler.finish(SwiftMullvadApiResponse::no_tokio_runtime()); + return SwiftCancelHandle::empty(); + }; + + let api_context = api_context.rust_context(); + + // SAFETY: See SwiftRetryStrategy::into_rust. + let retry_strategy = unsafe { retry_strategy.into_rust() }; + + let completion = completion_handler.clone(); + + // SAFETY: See param documentation for `account_number`. + let account_number = unsafe { AccountNumber::from(convert_c_string(account_number)) }; + + let task = tokio_handle.spawn(async move { + match mullvad_ios_init_storekit_payment_inner( + api_context.rest_handle(), + retry_strategy, + account_number, + ) + .await + { + Ok(response) => completion.finish(response), + Err(err) => { + log::error!("{err:?}"); + completion.finish(SwiftMullvadApiResponse::rest_error(err)); + } + } + }); + + RequestCancelHandle::new(task, completion_handler.clone()).into_swift() +} + +async fn mullvad_ios_init_storekit_payment_inner( + rest_client: MullvadRestHandle, + retry_strategy: RetryStrategy, + account_number: AccountNumber, +) -> Result<SwiftMullvadApiResponse, rest::Error> { + let account_proxy = AccountsProxy::new(rest_client); + + let future_factory = || account_proxy.init_storekit_payment(account_number.clone()); + + do_request(retry_strategy, future_factory).await +} + +/// # Safety +/// +/// `api_context` must be pointing to a valid instance of `SwiftApiContext`. A `SwiftApiContext` is created +/// by calling `mullvad_api_init_new`. +/// +/// This function takes ownership of `completion_cookie`, which must be pointing to a valid instance of Swift +/// object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish` +/// when completion finishes (in completion.finish). +/// +/// `account_number` must be a pointer to a null terminated string. +/// +/// `body` must be a pointer to a contiguous memory segment +/// +/// `body_size` must be the size of the body +/// +/// This function is not safe to call multiple times with the same `CompletionCookie`. +#[no_mangle] +pub unsafe extern "C" fn mullvad_ios_check_storekit_payment( + api_context: SwiftApiContext, + completion_cookie: *mut libc::c_void, + retry_strategy: SwiftRetryStrategy, + account_number: *const c_char, + body: *const u8, + body_size: usize, +) -> SwiftCancelHandle { + let completion_handler = SwiftCompletionHandler::new(CompletionCookie::new(completion_cookie)); + + let Ok(tokio_handle) = crate::mullvad_ios_runtime() else { + completion_handler.finish(SwiftMullvadApiResponse::no_tokio_runtime()); + return SwiftCancelHandle::empty(); + }; + + let api_context = api_context.rust_context(); + // SAFETY: See SwiftRetryStrategy::into_rust. + let retry_strategy = unsafe { retry_strategy.into_rust() }; + + let completion = completion_handler.clone(); + + // SAFETY: See param documentation for `account_number`. + let account_number = unsafe { AccountNumber::from(convert_c_string(account_number)) }; + + // SAFETY: See param documentation for `body`. + let body = unsafe { std::slice::from_raw_parts(body, body_size) }.to_vec(); + let task = tokio_handle.spawn(async move { + match mullvad_ios_check_storekit_payment_inner( + api_context.rest_handle(), + retry_strategy, + account_number, + body, + ) + .await + { + Ok(response) => completion.finish(response), + Err(err) => { + log::error!("{err:?}"); + completion.finish(SwiftMullvadApiResponse::rest_error(err)); + } + } + }); + + RequestCancelHandle::new(task, completion_handler.clone()).into_swift() +} + +async fn mullvad_ios_check_storekit_payment_inner( + rest_client: MullvadRestHandle, + retry_strategy: RetryStrategy, + account_number: AccountNumber, + body: Vec<u8>, +) -> Result<SwiftMullvadApiResponse, rest::Error> { + let account_proxy = AccountsProxy::new(rest_client); + + let future_factory = + || account_proxy.check_storekit_payment(account_number.clone(), body.clone()); + + do_request(retry_strategy, future_factory).await +} |
