From c0d41b1003201831d6a513a2b012042479c58917 Mon Sep 17 00:00:00 2001 From: Juan D Date: Wed, 30 Aug 2023 15:22:50 +0200 Subject: [PATCH] Update library --- .github/workflows/build-android.yaml | 14 + .github/workflows/push.yaml | 12 + .github/workflows/test-android.yaml | 14 + .gitignore | 49 + LICENSE | 7 + README.md | 88 + account/build.gradle.kts | 94 + account/src/androidMain/AndroidManifest.xml | 2 + .../account/AccountContentProvider.kt | 47 + .../internals/AccountContextProvider.kt | 32 + .../account/internals/AccountHttpClient.kt | 193 ++ .../internals/EncryptedSettingsFactory.kt | 27 + .../secureSettings/SecureSettingsProvider.kt | 15 + .../account/AccountAPI.kt | 392 ++++ .../account/internals/Account.kt | 1916 +++++++++++++++++ .../account/internals/AndroidAccount.kt | 638 ++++++ .../account/internals/IOSAccount.kt | 545 +++++ .../request/AmazonLoginReceiptRequest.kt | 14 + .../request/AndroidLoginReceiptRequest.kt | 41 + .../model/request/DedicatedIPRequest.kt | 29 + .../model/request/IOSLoginReceiptRequest.kt | 29 + .../model/response/ApiTokenResponse.kt | 35 + .../model/response/SetEmailResponse.kt | 31 + .../model/response/VpnTokenResponse.kt | 38 + .../persistency/AccountPersistence.kt | 13 + .../SecureSettingsPersistence.kt | 50 + .../secureSettings/SecureSettingsProvider.kt | 7 + .../account/internals/utils/AccountUtils.kt | 81 + .../account/internals/utils/NetworkUtils.kt | 21 + .../model/request/AmazonSignupInformation.kt | 33 + .../model/request/AndroidSignupInformation.kt | 43 + .../model/request/IOSPaymentInformation.kt | 35 + .../model/request/IOSSignupInformation.kt | 37 + .../model/response/AccountInformation.kt | 57 + .../response/AmazonSubscriptionInformation.kt | 43 + .../AndroidSubscriptionsInformation.kt | 43 + .../model/response/ClientStatusInformation.kt | 31 + .../model/response/DedicatedIPInformation.kt | 36 + .../model/response/FeatureFlagsInformation.kt | 31 + .../response/IOSSubscriptionInformation.kt | 53 + .../response/InvitesDetailsInformation.kt | 49 + .../model/response/MessageInformation.kt | 51 + .../model/response/RedeemInformation.kt | 33 + .../model/response/SignUpInformation.kt | 33 + .../account/internals/AccountHttpClient.kt | 130 ++ .../secureSettings/SecureSettingsProvider.kt | 12 + build.gradle.kts | 9 + create-account-framework.sh | 5 + gradle.properties | 13 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 58694 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 183 ++ settings.gradle.kts | 17 + 53 files changed, 5456 insertions(+) create mode 100644 .github/workflows/build-android.yaml create mode 100644 .github/workflows/push.yaml create mode 100644 .github/workflows/test-android.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 account/build.gradle.kts create mode 100644 account/src/androidMain/AndroidManifest.xml create mode 100644 account/src/androidMain/kotlin/com/privateinternetaccess/account/AccountContentProvider.kt create mode 100644 account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountContextProvider.kt create mode 100644 account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt create mode 100644 account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/EncryptedSettingsFactory.kt create mode 100644 account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/AccountAPI.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/Account.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/AndroidAccount.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/IOSAccount.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AmazonLoginReceiptRequest.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AndroidLoginReceiptRequest.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/DedicatedIPRequest.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/IOSLoginReceiptRequest.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/ApiTokenResponse.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/SetEmailResponse.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/VpnTokenResponse.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/AccountPersistence.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsPersistence.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/AccountUtils.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/NetworkUtils.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AmazonSignupInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AndroidSignupInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSPaymentInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSSignupInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AccountInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AmazonSubscriptionInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AndroidSubscriptionsInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/ClientStatusInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/DedicatedIPInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/FeatureFlagsInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/IOSSubscriptionInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/InvitesDetailsInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/MessageInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/RedeemInformation.kt create mode 100644 account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/SignUpInformation.kt create mode 100644 account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt create mode 100644 account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt create mode 100644 build.gradle.kts create mode 100755 create-account-framework.sh create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle.kts diff --git a/.github/workflows/build-android.yaml b/.github/workflows/build-android.yaml new file mode 100644 index 0000000..ed6a06d --- /dev/null +++ b/.github/workflows/build-android.yaml @@ -0,0 +1,14 @@ +name: Build Android +on: [workflow_call] +jobs: + build-android: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: gradle + - name: build + run: ./gradlew bundleReleaseAar assembleAccountReleaseXCFramework --parallel diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml new file mode 100644 index 0000000..8799ff9 --- /dev/null +++ b/.github/workflows/push.yaml @@ -0,0 +1,12 @@ +name: Push checks +on: + push: +concurrency: + group: ${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build: + uses: ./.github/workflows/build-android.yaml + test: + uses: ./.github/workflows/test-android.yaml diff --git a/.github/workflows/test-android.yaml b/.github/workflows/test-android.yaml new file mode 100644 index 0000000..fe1f4c2 --- /dev/null +++ b/.github/workflows/test-android.yaml @@ -0,0 +1,14 @@ +name: Test Android +on: [workflow_call] +jobs: + unit-tests: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: gradle + - name: unit-tests + run: ./gradlew test --parallel diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9c35b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +account.podspec + +# Android +.DS_Store +.hg +*.o +*.patch +obj +bin +libs +gen +depcomp +t_client.sh +ics-openvpn.zip +zh-CN.zip +zh-TW.zip +.gradle +app/build +gradlew.bat +local.properties +ovpnlibs +.idea +*.iml +.metadata +.project +.settings +.classpath +project.properties +*.apk + +# iOS +*.swp +*.pbxuser +**/*.xcworkspace/xcuserdata +**/*.xcodeproj/project.xcworkspace +**/*.xcodeproj/xcuserdata +Pods +fastlane/**/*.html +fastlane/README.md +fastlane/report.xml +fastlane/test_output +fastlane/review_information/demo_* +build +dist +apple_dist +Preview.html +Gemfile.lock +*.podspec +*.xcframework diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2eb0339 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2020-Present Private Internet Access + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bee65cf --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +[![PIA logo][pia-image]][pia-url] + +# Private Internet Access + +Private Internet Access is the world's leading consumer VPN service. At Private Internet Access we believe in unfettered access for all, and as a firm supporter of the open source ecosystem we have made the decision to open source our VPN clients. For more information about the PIA service, please visit our website [privateinternetaccess.com][pia-url] or check out the [Wiki][pia-wiki]. + +# Account common library for Android and Apple platforms + +With this library, clients from iOS and Android can communicate easily with the Private Internet Access account's services. + +## Installation + +### Requirements + - Git (latest) + - Xcode (latest) + - Android Studio (latest) + - Gradle (latest) + - ADB installed + - NDK (latest) + - Android 4.1+ + +#### Download Codebase +Using the terminal: + +`git clone https://github.com/pia-foss/mobile-common-account.git *folder-name*` + +type in what folder you want to put in without the ** + +#### Building + +Once the project is cloned, you can build the binaries by running the tasks `./gradlew bundleDebugAar` or `./gradlew bundleReleaseAar` for Android. And, `./gradlew assembleAccountDebugXCFramework` or `./gradlew assembleAccountReleaseXCFramework` for iOS. You can find the binaries at `[PROJECT_DIR]/account/build/outputs/aar` and `[PROJECT_DIR]/account/build/XCFrameworks` accordingly. + +## Usage + +### Android + +To use this project in Android, you can run the task `./gradlew publishAndroidReleasePublicationToMavenLocal`. This will publish the package to your maven local (Make sure to have included `mavenLocal()` as part of your gradle repositories). Once successful, you can set the dependency as per any other package, e.g.: +``` +implementation("com.kape.android:account:[version_number]") +``` +where `[version_number]` is the version as set in `account/build.gradle.kts`. + +### iOS + +To use this project in iOS, once you have built `account.xcframework`. You can go to your project target. Build Phases. Link Binary With Libraries. (or alternatively drag the file there and skip the rest) Click the `+`. Add Other. Add Files. And look for `account.xcframework`. + +## Documentation + +#### Architecture + +The library is formed by two layers. The common layer. Containing the business logic for all platforms. And, the bridging layer. Containing the platform specific logic being injected into the common layer. + +Code structure via packages: + +* `commonMain` - Common business logic. +* `androidMain` - Android's bridging layer, providing the platform specific dependencies. +* `iosMain` - iOS's bridging layer, providing the platform specific dependencies. + +#### Significant Classes and Interfaces + +* `AccountBuilder` - Public builder class responsible for creating an instance of an object conforming to either the `IOSAccountAPI` or `AndroidAccountAPI` interface for the client side. +* `AccountAPI` - Public interfaces defining the API to be offered by the library to the clients. +* `AccountHttpClient` - Class defining the certificate pinning logic on each platform. + +## Contributing + +By contributing to this project you are agreeing to the terms stated in the Contributor License Agreement (CLA) [here](/CLA.rst). + +For more details please see [CONTRIBUTING](/CONTRIBUTING.md). + +Issues and Pull Requests should use these templates: [ISSUE](/.github/ISSUE_TEMPLATE.md) and [PULL REQUEST](/.github/PULL_REQUEST_TEMPLATE.md). + +## Authors + +- Jose Blaya - [ueshiba](https://github.com/ueshiba) +- Juan Docal - [tatostao](https://github.com/tatostao) + +## License + +This project is licensed under the [MIT (Expat) license](https://choosealicense.com/licenses/mit/), which can be found [here](/LICENSE). + +## Acknowledgements + +- Ktor - © 2020 (http://ktor.io) + +[pia-image]: https://assets-cms.privateinternetaccess.com/img/frontend/pia_menu_logo_light.svg +[pia-url]: https://www.privateinternetaccess.com/ +[pia-wiki]: https://en.wikipedia.org/wiki/Private_Internet_Access diff --git a/account/build.gradle.kts b/account/build.gradle.kts new file mode 100644 index 0000000..efa7f67 --- /dev/null +++ b/account/build.gradle.kts @@ -0,0 +1,94 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("com.android.library") + id("maven-publish") +} + +publishing { + repositories { + maven { + url = uri("https://maven.pkg.github.com/xvpn/cpz_pia-mobile_shared_account/") + credentials { + username = System.getenv("GITHUB_USERNAME") + password = System.getenv("GITHUB_TOKEN") + } + } + } +} + +android { + namespace = "com.kape.account" + + compileSdk = 34 + defaultConfig { + minSdk = 21 + targetSdk = 34 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } +} + +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +kotlin { + group = "com.kape.android" + version = "1.4.0" + + // Enable the default target hierarchy. + // It's a template for all possible targets and their shared source sets hardcoded in the + // Kotlin Gradle plugin. + targetHierarchy.default() + + // Android + android { + publishLibraryVariants("release") + compilations.all { + kotlinOptions { + jvmTarget = "17" + } + } + } + + // iOS + val xcf = XCFramework() + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + xcf.add(this) + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation("io.ktor:ktor-client-core:2.3.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.5.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") + implementation("com.russhwolf:multiplatform-settings:1.0.0") + } + } + val androidMain by getting { + dependencies { + implementation("androidx.security:security-crypto:1.1.0-alpha03") + implementation("com.madgag.spongycastle:core:1.58.0.0") + implementation("io.ktor:ktor-client-okhttp:2.3.3") + } + } + val iosMain by getting { + dependencies { + implementation("io.ktor:ktor-client-ios:2.3.3") + } + } + } +} \ No newline at end of file diff --git a/account/src/androidMain/AndroidManifest.xml b/account/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..10728cc --- /dev/null +++ b/account/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/account/src/androidMain/kotlin/com/privateinternetaccess/account/AccountContentProvider.kt b/account/src/androidMain/kotlin/com/privateinternetaccess/account/AccountContentProvider.kt new file mode 100644 index 0000000..d47c1f1 --- /dev/null +++ b/account/src/androidMain/kotlin/com/privateinternetaccess/account/AccountContentProvider.kt @@ -0,0 +1,47 @@ +package com.privateinternetaccess.account + +/* + * Copyright (c) 2021 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import com.privateinternetaccess.account.internals.AccountContextProvider + + +class AccountContentProvider : ContentProvider() { + + override fun onCreate(): Boolean { + context?.let { + AccountContextProvider.context(it) + return true + } + return false + } + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 +} \ No newline at end of file diff --git a/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountContextProvider.kt b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountContextProvider.kt new file mode 100644 index 0000000..c0779ef --- /dev/null +++ b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountContextProvider.kt @@ -0,0 +1,32 @@ +package com.privateinternetaccess.account.internals + +/* + * Copyright (c) 2021 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +import android.content.Context + + +object AccountContextProvider { + + @Volatile + internal var applicationContext: Context? = null + + internal fun context(context: Context) { + applicationContext = context + } +} \ No newline at end of file diff --git a/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt new file mode 100644 index 0000000..dd49bea --- /dev/null +++ b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.* +import okhttp3.OkHttpClient +import org.spongycastle.asn1.x500.X500Name +import org.spongycastle.asn1.x500.style.BCStyle +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.lang.IllegalStateException +import java.security.* +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.* +import java.util.concurrent.TimeUnit +import javax.net.ssl.* +import javax.security.auth.x500.X500Principal + + +internal actual object AccountHttpClient { + + actual fun client( + certificate: String?, + pinnedEndpoint: Pair? + ) : Pair { + var httpClient: HttpClient? = null + var exception: Exception? = null + try { + httpClient = HttpClient(OkHttp) { + expectSuccess = false + install(HttpTimeout) { + requestTimeoutMillis = Account.REQUEST_TIMEOUT_MS + } + + if (certificate != null && pinnedEndpoint != null) { + engine { + preconfigured = AccountCertificatePinner.getOkHttpClient( + certificate, + pinnedEndpoint.first, + pinnedEndpoint.second + ) + } + } + } + } catch (e: KeyStoreException) { + exception = e + } catch (e: IOException) { + exception = e + } catch (e: CertificateException) { + exception = e + } catch (e: NoSuchAlgorithmException) { + exception = e + } catch (e: KeyManagementException) { + exception = e + } catch (e: IllegalStateException) { + exception = e + } catch (e: Throwable) { + val exceptionName = e::class.simpleName ?: "Unknown Exception Name" + exception = Exception(exceptionName) + } + return Pair(httpClient, exception) + } +} + +private class AccountCertificatePinner { + + companion object { + + @Throws( + KeyStoreException::class, + IOException::class, + CertificateException::class, + NoSuchAlgorithmException::class, + KeyManagementException::class, + IllegalStateException::class + ) + fun getOkHttpClient(certificate: String, requestHostname: String, commonName: String): OkHttpClient { + val builder = OkHttpClient.Builder() + val keyStore = KeyStore.getInstance("BKS") + keyStore.load(null) + val inputStream = certificate.toByteArray().inputStream() + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificateObject = certificateFactory.generateCertificate(inputStream) + keyStore.setCertificateEntry("account", certificateObject) + inputStream.close() + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) + val trustManagers = trustManagerFactory.trustManagers + check(!(trustManagers.size != 1 || trustManagers[0] !is X509TrustManager)) { + "Unexpected default trust managers:" + Arrays.toString(trustManagers) + } + val trustManager = trustManagers[0] as X509TrustManager + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustManagers, SecureRandom()) + val sslSocketFactory = sslContext.socketFactory + builder.connectTimeout(Account.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (sslSocketFactory != null) { + builder.sslSocketFactory(sslSocketFactory, trustManager) + } + builder.hostnameVerifier(AccountHostnameVerifier(trustManager, requestHostname, commonName)) + return builder.build() + } + } + + private class AccountHostnameVerifier( + private val trustManager: X509TrustManager?, + private val requestHostname: String, + private val commonName: String + ) : HostnameVerifier { + + override fun verify(hostname: String?, session: SSLSession?): Boolean { + var verified = false + try { + val x509CertificateChain = session?.peerCertificates as Array + trustManager?.checkServerTrusted(x509CertificateChain, "RSA") + val sessionCertificate = session.peerCertificates.first() + verified = verifyCommonName(hostname, sessionCertificate as X509Certificate) + } catch (e: SSLPeerUnverifiedException) { + e.printStackTrace() + } catch (e: CertificateException) { + e.printStackTrace() + } catch (e: InvalidKeyException) { + e.printStackTrace() + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } catch (e: NoSuchProviderException) { + e.printStackTrace() + } catch (e: SignatureException) { + e.printStackTrace() + } + return verified + } + + private fun verifyCommonName(hostname: String?, certificate: X509Certificate): Boolean { + var verified = false + val principal = certificate.subjectDN as X500Principal + certificateCommonName(X500Name.getInstance(principal.encoded))?.let { certCommonName -> + verified = hostname?.let { + isEqual(it.toByteArray(), requestHostname.toByteArray()) && + isEqual(commonName.toByteArray(), certCommonName.toByteArray()) + } ?: isEqual(commonName.toByteArray(), certCommonName.toByteArray()) + } + return verified + } + + private fun certificateCommonName(name: X500Name): String? { + val rdns = name.getRDNs(BCStyle.CN) + return if (rdns.isEmpty()) { + null + } else rdns.first().first.value.toString() + } + + private fun isEqual(a: ByteArray, b: ByteArray): Boolean { + val messageDigest = MessageDigest.getInstance("SHA-256") + val random = SecureRandom() + val randomBytes = ByteArray(20) + random.nextBytes(randomBytes) + + val concatA = ByteArrayOutputStream() + concatA.write(randomBytes) + concatA.write(a) + val digestA = messageDigest.digest(concatA.toByteArray()) + + val concatB = ByteArrayOutputStream() + concatB.write(randomBytes) + concatB.write(b) + val digestB = messageDigest.digest(concatB.toByteArray()) + + return MessageDigest.isEqual(digestA, digestB) + } + } +} \ No newline at end of file diff --git a/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/EncryptedSettingsFactory.kt b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/EncryptedSettingsFactory.kt new file mode 100644 index 0000000..d025076 --- /dev/null +++ b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/EncryptedSettingsFactory.kt @@ -0,0 +1,27 @@ +package com.privateinternetaccess.account.internals + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.russhwolf.settings.SharedPreferencesSettings +import com.russhwolf.settings.Settings + +internal class EncryptedSettingsFactory(private val context: Context) : Settings.Factory { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .setUserAuthenticationRequired(false) + .build() + + override fun create(name: String?): Settings { + val preferencesName = name ?: "${context.packageName}_preferences" + return SharedPreferencesSettings( + EncryptedSharedPreferences.create( + context, + preferencesName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + ) + } +} \ No newline at end of file diff --git a/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt new file mode 100644 index 0000000..5ee611d --- /dev/null +++ b/account/src/androidMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt @@ -0,0 +1,15 @@ +package com.privateinternetaccess.account.internals.persistency.secureSettings + +import com.privateinternetaccess.account.internals.AccountContextProvider +import com.privateinternetaccess.account.internals.EncryptedSettingsFactory +import com.russhwolf.settings.Settings + +internal actual object SecureSettingsProvider { + + private const val SHARED_PREFS_NAME = "account_shared_preferences" + + actual val settings: Settings? + get() = AccountContextProvider.applicationContext?.let { context -> + EncryptedSettingsFactory(context).create(SHARED_PREFS_NAME) + } +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/AccountAPI.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/AccountAPI.kt new file mode 100644 index 0000000..3c8749a --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/AccountAPI.kt @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account + +import com.privateinternetaccess.account.internals.AndroidAccount +import com.privateinternetaccess.account.internals.IOSAccount +import com.privateinternetaccess.account.model.request.AmazonSignupInformation +import com.privateinternetaccess.account.model.request.AndroidSignupInformation +import com.privateinternetaccess.account.model.request.IOSPaymentInformation +import com.privateinternetaccess.account.model.request.IOSSignupInformation +import com.privateinternetaccess.account.model.response.DedicatedIPInformationResponse.DedicatedIPInformation +import com.privateinternetaccess.account.model.response.* +import io.ktor.util.* + + +/** + * Enum containing the supported platforms. + */ +public enum class Platform { + IOS, + ANDROID +} + +/** + * Interface defining the base API for the supported platforms. + */ +public interface AccountAPI { + + /** + * @return `String?` + */ + fun apiToken(): String? + + /** + * @return `String?` + */ + fun vpnToken(): String? + + /** + * @param apiToken `String` + * @param callback `(error: List) -> Unit` + */ + fun migrateApiToken(apiToken: String, callback: (error: List) -> Unit) + + /** + * @param email `String` + * @param callback `(error: List) -> Unit` + */ + fun loginLink(email: String, callback: (error: List) -> Unit) + + /** + * @param username `String` + * @param password `String` + * @param callback `(error: List) -> Unit` + */ + fun loginWithCredentials( + username: String, + password: String, + callback: (error: List) -> Unit + ) + + /** + * @param callback `(error: List) -> Unit` + */ + fun logout(callback: (error: List) -> Unit) + + /** + * @param callback `(details: AccountInformation, error: List) -> Unit` + */ + fun accountDetails(callback: (details: AccountInformation?, error: List) -> Unit) + + /** + * @param callback `(error: List) -> Unit` + */ + fun deleteAccount(callback: (error: List) -> Unit) + + /** + * @param ipTokens `List` + * @param callback `(details: DedicatedIPInformation, error: List) -> Unit` + */ + fun dedicatedIPs( + ipTokens: List, + callback: (details: List, error: List) -> Unit + ) + + /** + * @param ipToken `String` + * @param callback `(details: DedicatedIPInformation, error: List) -> Unit` + */ + fun renewDedicatedIP( + ipToken: String, + callback: (error: List) -> Unit + ) + + /** + * @param callback `(status: ClientStatusInformation?, error: List) -> Unit` + */ + fun clientStatus(callback: (status: ClientStatusInformation?, error: List) -> Unit) + + /** + * @param email `String` + * @param resetPassword `Boolean` + * @param callback `(temporaryPassword: String, error: List) -> Unit` + */ + fun setEmail( + email: String, + resetPassword: Boolean, + callback: (temporaryPassword: String?, error: List) -> Unit + ) + + /** + * @param recipientEmail `String` + * @param recipientName `String` + * @param callback `(error: List) -> Unit` + */ + fun sendInvite( + recipientEmail: String, + recipientName: String, + callback: (error: List) -> Unit + ) + + /** + * @param callback `(details: InvitesDetailsInformation?, error: List) -> Unit -> Unit` + */ + fun invitesDetails(callback: (details: InvitesDetailsInformation?, error: List) -> Unit) + + /** + * @param email `String` + * @param code `String` + * @param callback `(details: RedeemInformation?, error: List) -> Unit` + */ + fun redeem( + email: String, + code: String, + callback: (details: RedeemInformation?, error: List) -> Unit + ) + + /** + * It returns an in-app communication message for each platform. + * + * @param appVersion `String` + * @param callback `(message: MessageInformation?, error: List) -> Unit` + */ + fun message(appVersion: String, callback: (message: MessageInformation?, error: List) -> Unit) + + /** + * @param callback `(details: FeatureFlagsInformation?, error: List) -> Unit` + */ + fun featureFlags( + callback: (details: FeatureFlagsInformation?, error: List) -> Unit + ) +} + +/** + * Interface defining the Android specifics API deriving from the base one `AccountAPI` + */ +public interface AndroidAccountAPI: AccountAPI { + + /** + * @param store `String` + * @param token `String` + * @param productId `String` + * @param applicationPackage `String` + * @param callback `(error:List) -> Unit`. + */ + fun loginWithReceipt( + store: String, + token: String, + productId: String, + applicationPackage: String, + callback: (error: List) -> Unit + ) + + + /** + * @param receiptId `String` + * @param userId `String` + * @param store `String` + * @param callback `(error:List) -> Unit`. + */ + fun amazonLoginWithReceipt( + receiptId: String, + userId: String, + store: String, + callback: (error: List) -> Unit + ) + + /** + * @param information `AndroidSignupInformation` + * @param callback `(details: SignUpInformation?, error: List) -> Unit` + */ + fun signUp( + information: AndroidSignupInformation, + callback: (details: SignUpInformation?, error: List) -> Unit + ) + + /** + * @param information `AndroidSignupInformation` + * @param callback `(details: SignUpInformation?, error: List) -> Unit` + */ + fun amazonSignUp( + information: AmazonSignupInformation, + callback: (details: SignUpInformation?, error: List) -> Unit + ) + + /** + * @param callback `(details: AndroidSubscriptionsInformation?, error: List) -> Unit` + */ + fun subscriptions(callback: (details: AndroidSubscriptionsInformation?, error: List) -> Unit) + + /** + * @param callback `(details: AndroidSubscriptionsInformation?, error: List) -> Unit` + */ + fun amazonSubscriptions(callback: (details: AmazonSubscriptionsInformation?, error: List) -> Unit) + +} + +/** + * Interface defining the iOS specifics API deriving from the base one `AccountAPI` + */ +public interface IOSAccountAPI: AccountAPI { + + /** + * @param receiptBase64 `String` + * @param callback `(error: List) -> Unit`. + */ + @InternalAPI + fun loginWithReceipt( + receiptBase64: String, + callback: (error: List) -> Unit + ) + + /** + * @param username `String` + * @param password `String` + * @param email `String` + * @param resetPassword `Boolean` + * @param callback `(temporaryPassword: String, error: List) -> Unit` + */ + @InternalAPI + fun setEmail( + username: String, + password: String, + email: String, + resetPassword: Boolean, + callback: (temporaryPassword: String?, error: List) -> Unit + ) + + /** + * @param username `String` + * @param username `String` + * @param information `IOSPaymentInformation` + * @param callback `(error: List) -> Unit` + */ + @InternalAPI + fun payment( + username: String, + password: String, + information: IOSPaymentInformation, + callback: (error: List) -> Unit + ) + + /** + * @param information `IOSSignupInformation` + * @param callback `(details: SignUpInformation?, error: List) -> Unit` + */ + fun signUp( + information: IOSSignupInformation, + callback: (details: SignUpInformation?, error: List) -> Unit + ) + + /** + * @param receipt `String?` + * @param callback `(details: IOSSubscriptionInformation?, error: List) -> Unit` + */ + fun subscriptions( + receipt: String?, + callback: (details: IOSSubscriptionInformation?, error: List) -> Unit + ) +} + +/** + * Interface defining the client's endpoint provider. + */ +public interface IAccountEndpointProvider { + + /** + * It returns the list of endpoints to try to reach when performing a request. Order is relevant. + * + * @return `List` + */ + fun accountEndpoints(): List +} + +/** + * Builder class responsible for creating an instance of an object conforming to + * either `AndroidAccountAPI` or `IOSAccountAPI` interface. Depending on the platform. + */ +public class AccountBuilder { + private var endpointsProvider: IAccountEndpointProvider? = null + private var certificate: String? = null + private var platform: Platform? = null + private var userAgentValue: String? = null + + /** + * It sets the endpoints provider, that is queried for the current endpoint list. Required. + * + * @param endpointsProvider `IAccountEndpointProvider`. + */ + fun setEndpointProvider(endpointsProvider: IAccountEndpointProvider): AccountBuilder = + apply { this.endpointsProvider = endpointsProvider } + + /** + * It sets the certificate to use when using an endpoint with pinning enabled. Optional. + * + * @param certificate `String`. + */ + fun setCertificate(certificate: String?): AccountBuilder = apply { + this.certificate = certificate + } + + /** + * It sets the platform for which we are building the API. + * + * @param platform `Platform`. + */ + fun setPlatform(platform: Platform): AccountBuilder = + apply { this.platform = platform } + + /** + * It sets the User-Agent value to be used in the requests. + * + * @param userAgentValue `String`. + */ + fun setUserAgentValue(userAgentValue: String): AccountBuilder = + apply { this.userAgentValue = userAgentValue } + + /** + * @return `AndroidAccountAPI` or `IOSAccountAPI` interface. Depending on the platform. + */ + fun build(): T { + val endpointsProvider = this.endpointsProvider + ?: throw Exception("Endpoints provider missing.") + val platform = this.platform + ?: throw Exception("Platform definition missing.") + val userAgentValue = this.userAgentValue + ?: throw Exception("User-Agent value missing.") + return when (platform) { + Platform.IOS -> IOSAccount(endpointsProvider, certificate, userAgentValue) as T + Platform.ANDROID -> AndroidAccount(endpointsProvider, certificate, userAgentValue) as T + } + } +} + +/** + * Request error message containing the http code, description and retryAfterSeconds in seconds. + */ +public data class AccountRequestError(val code: Int, val message: String?, val retryAfterSeconds: Long = 0) + +/** + * Data class defining the endpoints data needed when performing a request on it. + * + * @param ipOrRootDomain `String`. Indicates the ip or root domain to use for the requests. + * e.g. `127.0.0.1` or `privateinternetaccess.com` + * @param isProxy `Boolean`. Indicates whether the given address should be treated as a proxy. Excluding it from + * proxy sensitive request. e.g. showing the user its ip. + * @param usePinnedCertificate `Boolean`. Indicates whether this address should be pinned to the provided certificate. + * @param certificateCommonName `String?`. When pinning is enabled. Provide the common name for it. + */ + +public data class AccountEndpoint( + val ipOrRootDomain: String, + val isProxy: Boolean, + val usePinnedCertificate: Boolean = false, + val certificateCommonName: String? = null +) diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/Account.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/Account.kt new file mode 100644 index 0000000..e0c55c7 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/Account.kt @@ -0,0 +1,1916 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals + +import com.privateinternetaccess.account.* +import com.privateinternetaccess.account.internals.model.request.DedicatedIPRequest +import com.privateinternetaccess.account.internals.model.response.ApiTokenResponse +import com.privateinternetaccess.account.internals.model.response.SetEmailResponse +import com.privateinternetaccess.account.internals.model.response.VpnTokenResponse +import com.privateinternetaccess.account.internals.persistency.AccountPersistence +import com.privateinternetaccess.account.internals.persistency.secureSettings.SecureSettingsPersistence +import com.privateinternetaccess.account.internals.utils.AccountUtils +import com.privateinternetaccess.account.internals.utils.NetworkUtils.mapStatusCodeToAccountError +import com.privateinternetaccess.account.model.response.* +import com.privateinternetaccess.account.model.response.DedicatedIPInformationResponse.DedicatedIPInformation +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.* +import kotlinx.datetime.* +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlin.coroutines.CoroutineContext +import kotlin.time.ExperimentalTime + + +internal expect object AccountHttpClient { + + /** + * @param certificate String?. Certificate required for pinning capabilities. + * @param pinnedEndpoint Pair?. Contains endpoint as first, commonName as second. + * + * @return `Pair`. + * + */ + fun client( + certificate: String? = null, + pinnedEndpoint: Pair? = null + ): Pair +} + + +internal open class Account( + internal val endpointsProvider: IAccountEndpointProvider, + internal val certificate: String?, + private val userAgentValue: String, + private val platform: Platform, + internal val persistence: AccountPersistence = SecureSettingsPersistence +) : CoroutineScope, AccountAPI { + + internal enum class Path(val url: String) { + LOGIN("/api/client/v5/api_token"), + VPN_TOKEN("/api/client/v5/vpn_token"), + REFRESH_API_TOKEN("/api/client/v5/refresh"), + SIGNUP("/api/client/signup"), + SIGNUP_AMAZON("/api/client/amazon/signup"), + SET_EMAIL("/api/client/account"), + LOGIN_LINK("/api/client/v2/login_link"), + LOGOUT("/api/client/v2/expire_token"), + ACCOUNT_DETAILS("/api/client/v2/account"), + DELETE_ACCOUNT("/api/client/v5/account"), + CLIENT_STATUS("/api/client/status"), + INVITES("/api/client/invites"), + REDEEM("/api/client/giftcard_redeem"), + REFRESH_TOKEN("/api/client/v4/refresh"), + MESSAGES("/api/client/v2/messages"), + DEDICATED_IP("/api/client/v2/dedicated_ip"), + RENEW_DEDICATED_IP("/api/client/v2/check_renew_dip"), + ANDROID_SUBSCRIPTIONS("/api/client/android"), + AMAZON_SUBSCRIPTIONS("/api/client/amazon"), + ANDROID_FEATURE_FLAG("/clients/desktop/android-flags"), + IOS_PAYMENT("/api/client/payment"), + IOS_SUBSCRIPTIONS("/api/client/ios"), + IOS_FEATURE_FLAG("/clients/desktop/ios-flags") + } + + companion object { + internal const val API_TOKEN_KEY = "API_TOKEN_KEY" + internal const val VPN_TOKEN_KEY = "VPN_TOKEN_KEY" + internal const val REQUEST_TIMEOUT_MS = 3000L + internal const val MIN_EXPIRATION_THRESHOLD_DAYS = 21.0 + internal val json = Json { ignoreUnknownKeys = true; encodeDefaults = false } + internal val SUBDOMAINS = mapOf( + Path.LOGIN to "apiv5", + Path.VPN_TOKEN to "apiv5", + Path.REFRESH_API_TOKEN to "apiv5", + Path.SIGNUP to "api", + Path.SIGNUP_AMAZON to "api", + Path.SET_EMAIL to "api", + Path.LOGIN_LINK to "apiv2", + Path.LOGOUT to "apiv2", + Path.ACCOUNT_DETAILS to "apiv2", + Path.DELETE_ACCOUNT to "apiv5", + Path.CLIENT_STATUS to "api", + Path.INVITES to "api", + Path.REDEEM to "api", + Path.REFRESH_TOKEN to "apiv4", + Path.MESSAGES to "apiv2", + Path.DEDICATED_IP to "apiv2", + Path.RENEW_DEDICATED_IP to "apiv2", + Path.ANDROID_SUBSCRIPTIONS to "api", + Path.AMAZON_SUBSCRIPTIONS to "api", + Path.ANDROID_FEATURE_FLAG to "api", + Path.IOS_PAYMENT to "api", + Path.IOS_SUBSCRIPTIONS to "api", + Path.IOS_FEATURE_FLAG to "api", + ) + } + + /** + * Defines those requests going into the active requests pipeline while they are in progress. + * To be re-evaluated once we have a dedicated background thread to run a selected list of requests sequentially. + */ + private enum class RequestPipeline { + API_TOKEN, + VPN_TOKEN + } + + /** + * Pipeline containing all those requests of interest that are active at any moment. + */ + private val requestsPipeline = mutableListOf() + + // region CoroutineScope + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + // endregion + + override fun apiToken(): String? { + return persistence.apiTokenResponse()?.apiToken + } + + override fun vpnToken(): String? { + return persistence.vpnTokenResponse()?.let { + "vpn_token_${it.vpnUsernameToken}:${it.vpnPasswordToken}" + } + } + + override fun migrateApiToken( + apiToken: String, + callback: (error: List) -> Unit + ) { + launch { + migrateApiTokenAsync(apiToken, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun loginLink(email: String, callback: (error: List) -> Unit) { + launch { + loginLinkAsync(email, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun loginWithCredentials( + username: String, + password: String, + callback: (error: List) -> Unit + ) { + launch { + loginWithCredentialsAsync( + username, + password, + endpointsProvider.accountEndpoints(), + callback + ) + } + } + + override fun logout(callback: (error: List) -> Unit) { + launch { + logoutAsync(endpointsProvider.accountEndpoints(), callback) + } + } + + override fun accountDetails(callback: (details: AccountInformation?, error: List) -> Unit) { + launch { + accountDetailsAsync(endpointsProvider.accountEndpoints(), callback) + } + } + + override fun deleteAccount(callback: (error: List) -> Unit) { + launch { + deleteAccountAsync(endpointsProvider.accountEndpoints(), callback) + } + } + + override fun dedicatedIPs( + ipTokens: List, + callback: (details: List, error: List) -> Unit + ) { + launch { + dedicatedIPsAsync(ipTokens, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun renewDedicatedIP( + ipToken: String, + callback: (error: List) -> Unit + ) { + launch { + renewDedicatedIPAsync(ipToken, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun clientStatus( + callback: (status: ClientStatusInformation?, error: List) -> Unit + ) { + launch { + clientStatusAsync(endpointsProvider.accountEndpoints(), callback) + } + } + + override fun setEmail( + email: String, + resetPassword: Boolean, + callback: (temporaryPassword: String?, error: List) -> Unit + ) { + launch { + setEmailAsync(email, resetPassword, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun sendInvite( + recipientEmail: String, + recipientName: String, + callback: (error: List) -> Unit + ) { + launch { + sendInviteAsync( + recipientEmail, + recipientName, + endpointsProvider.accountEndpoints(), + callback + ) + } + } + + override fun invitesDetails( + callback: (details: InvitesDetailsInformation?, error: List) -> Unit + ) { + launch { + invitesDetailsAsync(endpointsProvider.accountEndpoints(), callback) + } + } + + override fun redeem( + email: String, + code: String, + callback: (details: RedeemInformation?, error: List) -> Unit + ) { + launch { + redeemAsync(email, code, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun message( + appVersion: String, + callback: (message: MessageInformation?, error: List) -> Unit + ) { + launch { + messageAsync(appVersion, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun featureFlags( + callback: (details: FeatureFlagsInformation?, error: List) -> Unit + ) { + launch { + featureFlagsAsync(endpointsProvider.accountEndpoints(), callback) + } + } + // endregion + + // region private + private suspend fun migrateApiTokenAsync( + apiToken: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + listErrors.addAll(refreshApiToken(apiToken, endpoints)) + //If we succeeded Get a VPN token as well + apiToken()?.let { + listErrors.addAll(refreshVpnToken(it, endpoints)) + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun loginLinkAsync( + email: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.LOGIN_LINK) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.LOGIN_LINK.url}")) + continue + } + + var succeeded = false + val formParameters = Parameters.build { + append("email", email) + } + val response = httpClient.postCatching>(formParameters = formParameters) { + url(url) + } + + response.first?.let { + succeeded = AccountUtils.isErrorStatusCode(it.status.value).not() + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(it.mapStatusCodeToAccountError()) + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun loginWithCredentialsAsync( + username: String, + password: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.LOGIN) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.LOGIN.url}")) + continue + } + + var succeeded = false + val formParameters = Parameters.build { + append("username", username) + append("password", password) + } + val requestResponse = httpClient.postCatching>(formParameters = formParameters) { + url(url) + } + + requestResponse.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(it.mapStatusCodeToAccountError()) + } else { + try { + val apiTokenResponse = json.decodeFromString(ApiTokenResponse.serializer(), it.bodyAsText()) + persistence.persistApiTokenResponse(apiTokenResponse) + refreshVpnToken(apiTokenResponse.apiToken, endpoints) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + requestResponse.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun logoutAsync( + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.LOGOUT) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.LOGOUT.url}")) + continue + } + + var succeeded = false + val response = httpClient.postCatching> { + url(url) + header("Authorization", "Token $apiToken") + } + + response.first?.let { + succeeded = AccountUtils.isErrorStatusCode(it.status.value).not() + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + // Regardless of the request result. The client has stated we are logging out. Wiped the persisted tokens. + persistence.clearApiTokenResponse() + persistence.clearVpnTokenResponse() + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun accountDetailsAsync( + endpoints: List, + callback: (details: AccountInformation?, error: List) -> Unit + ) { + var accountInformation: AccountInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.ACCOUNT_DETAILS) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.ACCOUNT_DETAILS.url}")) + continue + } + + var succeeded = false + val response = httpClient.getCatching> { + url(url) + header("Authorization", "Token $apiToken") + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + accountInformation = json.decodeFromString(AccountInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(accountInformation, listErrors) + } + } + + private suspend fun deleteAccountAsync( + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.DELETE_ACCOUNT) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.DELETE_ACCOUNT.url}")) + continue + } + + var succeeded = false + val response = httpClient.deleteCatching> { + url(url) + header("Authorization", "Token $apiToken") + } + + response.first?.let { + succeeded = AccountUtils.isErrorStatusCode(it.status.value).not() + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + // If we are receiving a 200, we should remove the persisted tokens for the client as the account has been now deleted. + persistence.clearApiTokenResponse() + persistence.clearVpnTokenResponse() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun dedicatedIPsAsync( + ipTokens: List, + endpoints: List, + callback: (details: List, error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + var dedicatedIPsInformation: List = emptyList() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.DEDICATED_IP) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.DEDICATED_IP.url}")) + continue + } + + var succeeded = false + val response = httpClient.postCatching> { + url(url) + header("Authorization", "Token $apiToken") + contentType(ContentType.Application.Json) + setBody(json.encodeToString(DedicatedIPRequest.serializer(), DedicatedIPRequest(ipTokens))) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(it.mapStatusCodeToAccountError()) + } else { + try { + dedicatedIPsInformation = + json.decodeFromString( + DedicatedIPInformationResponse.serializer(), "{\"result\":${it.bodyAsText()}}" + ).result + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(dedicatedIPsInformation, listErrors) + } + } + + private suspend fun renewDedicatedIPAsync( + ipToken: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.RENEW_DEDICATED_IP) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.RENEW_DEDICATED_IP.url}")) + continue + } + + var succeeded = false + val formParameters = Parameters.build { + append("token", ipToken) + } + val response = httpClient.postCatching>(formParameters = formParameters) { + url(url) + header("Authorization", "Token $apiToken") + } + + response.first?.let { + succeeded = AccountUtils.isErrorStatusCode(it.status.value).not() + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun clientStatusAsync( + endpoints: List, + callback: (status: ClientStatusInformation?, error: List) -> Unit + ) { + var clientStatus: ClientStatusInformation? = null + val listErrors: MutableList = mutableListOf() + val filteredOutProxies = endpoints.filterNot { it.isProxy } + if (filteredOutProxies.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in filteredOutProxies) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.CLIENT_STATUS) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.CLIENT_STATUS.url}")) + continue + } + + var succeeded = false + val response = httpClient.getCatching> { + url(url) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + clientStatus = json.decodeFromString(ClientStatusInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(clientStatus, listErrors) + } + } + + private suspend fun setEmailAsync( + email: String, + resetPassword: Boolean, + endpoints: List, + callback: (temporaryPassword: String?, error: List) -> Unit + ) { + var temporaryPassword: String? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.SET_EMAIL) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.SET_EMAIL.url}")) + continue + } + + var succeeded = false + val formParameters = Parameters.build { + append("email", email) + append("reset_password", resetPassword.toString()) + } + val response = httpClient.postCatching>(formParameters = formParameters) { + url(url) + header("Authorization", "Token $apiToken") + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + temporaryPassword = json.decodeFromString(SetEmailResponse.serializer(), it.bodyAsText()).password + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(temporaryPassword, listErrors) + } + } + + private suspend fun sendInviteAsync( + recipientEmail: String, + recipientName: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.INVITES) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.INVITES.url}")) + continue + } + + var succeeded = false + val formParameters = Parameters.build { + append("invitee_email", recipientEmail) + append("invitee_name", recipientName) + } + val response = httpClient.postCatching>(formParameters = formParameters) { + url(url) + header("Authorization", "Token $apiToken") + } + + response.first?.let { + succeeded = AccountUtils.isErrorStatusCode(it.status.value).not() + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun invitesDetailsAsync( + endpoints: List, + callback: (details: InvitesDetailsInformation?, error: List) -> Unit + ) { + var invitesDetailsInformation: InvitesDetailsInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.INVITES) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.INVITES.url}")) + continue + } + + var succeeded = false + val response = httpClient.getCatching> { + url(url) + header("Authorization", "Token $apiToken") + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + invitesDetailsInformation = json.decodeFromString(InvitesDetailsInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(invitesDetailsInformation, listErrors) + } + } + + private suspend fun redeemAsync( + email: String, + code: String, + endpoints: List, + callback: (details: RedeemInformation?, error: List) -> Unit + ) { + var redeemInformation: RedeemInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.REDEEM) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.REDEEM.url}")) + continue + } + + var succeeded = false + val formParameters = Parameters.build { + append("email", email) + append("pin", code) + } + val response = httpClient.postCatching>(formParameters = formParameters) { + url(url) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + redeemInformation = json.decodeFromString(RedeemInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(redeemInformation, listErrors) + } + } + + private suspend fun messageAsync( + appVersion: String, + endpoints: List, + callback: (message: MessageInformation?, error: List) -> Unit + ) { + var messageInformation: MessageInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + val apiToken = persistence.apiTokenResponse()?.apiToken + if (apiToken == null) { + listErrors.add(AccountRequestError(600, "Invalid request token")) + break + } + + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.MESSAGES) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.MESSAGES.url}")) + continue + } + + var succeeded = false + val platform = when (platform) { + Platform.IOS -> "ios" + Platform.ANDROID -> "android" + } + val response = httpClient.getCatching> { + url(url) + header("Authorization", "Token $apiToken") + parameter("client", platform) + parameter("version", appVersion) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + messageInformation = json.decodeFromString(MessageInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(messageInformation, listErrors) + } + } + + private suspend fun featureFlagsAsync( + endpoints: List, + callback: (details: FeatureFlagsInformation?, error: List) -> Unit + ) { + var flagsInformation: FeatureFlagsInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val path = when (platform) { + Platform.IOS -> Path.IOS_FEATURE_FLAG + Platform.ANDROID -> Path.ANDROID_FEATURE_FLAG + } + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, path) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${path.url}")) + continue + } + + var succeeded = false + val response = httpClient.getCatching> { + url(url) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + flagsInformation = json.decodeFromString(FeatureFlagsInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(flagsInformation, listErrors) + } + } + + // region tokens + @OptIn(ExperimentalTime::class) + internal suspend fun refreshTokensIfNeeded(endpoints: List) { + val currentInstant = + Clock.System.now().toLocalDateTime(TimeZone.UTC).toInstant(TimeZone.UTC) + + persistence.apiTokenResponse()?.let { apiTokenResponse -> + if (currentInstant.daysUntil( + apiTokenResponse.expiresAt.toInstant(), + TimeZone.UTC + ) < MIN_EXPIRATION_THRESHOLD_DAYS + ) { + refreshApiToken(apiTokenResponse.apiToken, endpoints) + } + + persistence.vpnTokenResponse()?.let { vpnTokenResponse -> + if (currentInstant.daysUntil( + vpnTokenResponse.expiresAt.toInstant(), + TimeZone.UTC + ) < MIN_EXPIRATION_THRESHOLD_DAYS + ) { + refreshVpnToken(apiTokenResponse.apiToken, endpoints) + } + } ?: refreshVpnToken(apiTokenResponse.apiToken, endpoints) + } + } + + private suspend fun refreshApiToken( + apiToken: String, + endpoints: List + ): List { + val listErrors: MutableList = mutableListOf() + if (requestsPipeline.contains(RequestPipeline.API_TOKEN)) { + listErrors.add( + AccountRequestError( + 600, + "There is a refresh api token request already in progress" + ) + ) + return listErrors + } + + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + return listErrors + } + + requestsPipeline.add(RequestPipeline.API_TOKEN) + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.REFRESH_API_TOKEN) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.REFRESH_API_TOKEN.url}")) + continue + } + + var succeeded = false + val requestResponse = httpClient.postCatching> { + url(url) + header("Authorization", "Token $apiToken") + } + + requestResponse.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + val apiTokenResponse = json.decodeFromString(ApiTokenResponse.serializer(), it.bodyAsText()) + persistence.persistApiTokenResponse(apiTokenResponse) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + requestResponse.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + requestsPipeline.remove(RequestPipeline.API_TOKEN) + return listErrors + } + + internal suspend fun refreshVpnToken( + apiToken: String, + endpoints: List + ): List { + val listErrors: MutableList = mutableListOf() + if (requestsPipeline.contains(RequestPipeline.VPN_TOKEN)) { + listErrors.add( + AccountRequestError( + 600, + "There is a refresh vpn token request already in progress" + ) + ) + return listErrors + } + + if (endpoints.isEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available endpoints to perform the request" + ) + ) + return listErrors + } + + requestsPipeline.add(RequestPipeline.VPN_TOKEN) + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.VPN_TOKEN) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.VPN_TOKEN.url}")) + continue + } + + var succeeded = false + val requestResponse = httpClient.postCatching> { + url(url) + header("Authorization", "Token $apiToken") + } + + requestResponse.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + persistence.persistVpnTokenResponse(json.decodeFromString(VpnTokenResponse.serializer(), it.bodyAsText())) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + requestResponse.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + requestsPipeline.remove(RequestPipeline.VPN_TOKEN) + return listErrors + } + // endregion + + // endregion + + // region HttpClient extensions + internal suspend inline fun HttpClient.getCatching( + block: HttpRequestBuilder.() -> Unit = {} + ): Pair { + var exception: Exception? = null + var response: HttpResponse? = null + try { + response = request { + method = HttpMethod.Get + userAgent(userAgentValue) + apply(block) + } + } catch (e: Exception) { + exception = e + } + return Pair(response, exception) + } + + internal suspend inline fun HttpClient.postCatching( + formParameters: Parameters = Parameters.Empty, + block: HttpRequestBuilder.() -> Unit = {} + ): Pair { + var exception: Exception? = null + var response: HttpResponse? = null + try { + response = submitForm(formParameters = formParameters) { + method = HttpMethod.Post + userAgent(userAgentValue) + apply(block) + } + } catch (e: Exception) { + exception = e + } + return Pair(response, exception) + } + + internal suspend inline fun HttpClient.deleteCatching( + formParameters: Parameters = Parameters.Empty, + block: HttpRequestBuilder.() -> Unit = {} + ): Pair { + var exception: Exception? = null + var response: HttpResponse? = null + try { + response = submitForm(formParameters = formParameters) { + method = HttpMethod.Delete + userAgent(userAgentValue) + apply(block) + } + } catch (e: Exception) { + exception = e + } + return Pair(response, exception) + } + // endregion +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/AndroidAccount.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/AndroidAccount.kt new file mode 100644 index 0000000..99e5ae1 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/AndroidAccount.kt @@ -0,0 +1,638 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals + +import com.privateinternetaccess.account.* +import com.privateinternetaccess.account.internals.model.request.AmazonLoginReceiptRequest +import com.privateinternetaccess.account.internals.model.request.AndroidLoginReceiptRequest +import com.privateinternetaccess.account.internals.model.response.ApiTokenResponse +import com.privateinternetaccess.account.internals.utils.AccountUtils +import com.privateinternetaccess.account.model.request.AmazonSignupInformation +import com.privateinternetaccess.account.model.request.AndroidSignupInformation +import com.privateinternetaccess.account.model.request.IOSSignupInformation +import com.privateinternetaccess.account.model.response.AmazonSubscriptionsInformation +import com.privateinternetaccess.account.model.response.AndroidSubscriptionsInformation +import com.privateinternetaccess.account.model.response.SignUpInformation +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.* +import kotlinx.coroutines.* +import kotlinx.serialization.SerializationException + + +internal class AndroidAccount( + endpointsProvider: IAccountEndpointProvider, + certificate: String?, + userAgentValue: String +) : AndroidAccountAPI, Account(endpointsProvider, certificate, userAgentValue, Platform.ANDROID) { + + // region AndroidAccountAPI + override fun loginWithReceipt( + store: String, + token: String, + productId: String, + applicationPackage: String, + callback: (error: List) -> Unit + ) { + launch { + loginWithReceiptAsync( + store, + token, + productId, + applicationPackage, + endpointsProvider.accountEndpoints(), + callback + ) + } + } + + override fun amazonLoginWithReceipt( + receiptId: String, + userId: String, + store: String, + callback: (error: List) -> Unit + ) { + launch { + amazonLoginWithReceiptAsync(receiptId, userId, store, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun signUp( + information: AndroidSignupInformation, + callback: (details: SignUpInformation?, error: List) -> Unit + ) { + launch { + signUpAsync(information, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun amazonSignUp( + information: AmazonSignupInformation, + callback: (details: SignUpInformation?, error: List) -> Unit + ) { + launch { + amazonSignUpAsync(information, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun subscriptions( + callback: (details: AndroidSubscriptionsInformation?, error: List) -> Unit + ) { + launch { + subscriptionsAsync(endpointsProvider.accountEndpoints(), callback) + } + } + + override fun amazonSubscriptions(callback: (details: AmazonSubscriptionsInformation?, error: List) -> Unit) { + launch { + amazonSubscriptionsAsync(endpointsProvider.accountEndpoints(), callback) + } + } + // endregion + + // region private + private suspend fun loginWithReceiptAsync( + store: String, + token: String, + productId: String, + applicationPackage: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.LOGIN) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.LOGIN.url}")) + continue + } + + var succeeded = false + val receiptRequest = AndroidLoginReceiptRequest( + store = store, + receipt = AndroidLoginReceiptRequest.Receipt( + token = token, + productId = productId, + applicationPackage = applicationPackage + ) + ) + val response = httpClient.postCatching> { + url(url) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(AndroidLoginReceiptRequest.serializer(), receiptRequest)) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + val apiTokenResponse = json.decodeFromString(ApiTokenResponse.serializer(), it.bodyAsText()) + persistence.persistApiTokenResponse(apiTokenResponse) + refreshVpnToken(apiTokenResponse.apiToken, endpoints) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun amazonLoginWithReceiptAsync( + receiptId: String, + userId: String, + store: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.LOGIN) + if (url == null) { + listErrors.add( + AccountRequestError( + 600, + "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.LOGIN.url}" + ) + ) + continue + } + + var succeeded = false + val receiptRequest = AmazonLoginReceiptRequest(receiptId, userId, store) + val response = httpClient.postCatching> { + url(url) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(AmazonLoginReceiptRequest.serializer(), receiptRequest)) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + val apiTokenResponse = json.decodeFromString(ApiTokenResponse.serializer(), it.bodyAsText()) + persistence.persistApiTokenResponse(apiTokenResponse) + refreshVpnToken(apiTokenResponse.apiToken, endpoints) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun signUpAsync( + information: AndroidSignupInformation, + endpoints: List, + callback: (details: SignUpInformation?, error: List) -> Unit + ) { + var signUpInformation: SignUpInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.SIGNUP) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.SIGNUP.url}")) + continue + } + + var succeeded = false + val response = httpClient.postCatching> { + url(url) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(AndroidSignupInformation.serializer(), information)) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + signUpInformation = json.decodeFromString(SignUpInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(signUpInformation, listErrors) + } + } + + private suspend fun amazonSignUpAsync( + information: AmazonSignupInformation, + endpoints: List, + callback: (details: SignUpInformation?, error: List) -> Unit + ) { + var signUpInformation: SignUpInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.SIGNUP_AMAZON) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.SIGNUP.url}")) + continue + } + + var succeeded = false + val response = httpClient.postCatching> { + url(url) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(AmazonSignupInformation.serializer(), information)) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + signUpInformation = json.decodeFromString(SignUpInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(signUpInformation, listErrors) + } + } + + private suspend fun subscriptionsAsync( + endpoints: List, + callback: (details: AndroidSubscriptionsInformation?, error: List) -> Unit + ) { + var subscriptionsInformation: AndroidSubscriptionsInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.ANDROID_SUBSCRIPTIONS) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.ANDROID_SUBSCRIPTIONS.url}")) + continue + } + + var succeeded = false + val response = httpClient.getCatching> { + url(url) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + subscriptionsInformation = json.decodeFromString(AndroidSubscriptionsInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(subscriptionsInformation, listErrors) + } + } + // endregion + + private suspend fun amazonSubscriptionsAsync( + endpoints: List, + callback: (details: AmazonSubscriptionsInformation?, error: List) -> Unit + ) { + var subscriptionsInformation: AmazonSubscriptionsInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.AMAZON_SUBSCRIPTIONS) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.AMAZON_SUBSCRIPTIONS.url}")) + continue + } + + var succeeded = false + val response = httpClient.getCatching> { + url(url) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + subscriptionsInformation = json.decodeFromString(AmazonSubscriptionsInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(subscriptionsInformation, listErrors) + } + } +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/IOSAccount.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/IOSAccount.kt new file mode 100644 index 0000000..9997c37 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/IOSAccount.kt @@ -0,0 +1,545 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals + +import com.privateinternetaccess.account.* +import com.privateinternetaccess.account.Platform +import com.privateinternetaccess.account.internals.model.request.IOSLoginReceiptRequest +import com.privateinternetaccess.account.internals.model.response.ApiTokenResponse +import com.privateinternetaccess.account.internals.model.response.SetEmailResponse +import com.privateinternetaccess.account.internals.utils.AccountUtils +import com.privateinternetaccess.account.internals.utils.NetworkUtils.mapStatusCodeToAccountError +import com.privateinternetaccess.account.model.request.IOSPaymentInformation +import com.privateinternetaccess.account.model.request.IOSSignupInformation +import com.privateinternetaccess.account.model.response.IOSSubscriptionInformation +import com.privateinternetaccess.account.model.response.SignUpInformation +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import io.ktor.utils.io.* +import kotlinx.coroutines.* +import kotlinx.serialization.SerializationException + + +internal class IOSAccount( + endpointsProvider: IAccountEndpointProvider, + certificate: String?, + userAgentValue: String +) : IOSAccountAPI, Account(endpointsProvider, certificate, userAgentValue, Platform.IOS) { + + @InternalAPI + override fun loginWithReceipt( + receiptBase64: String, + callback: (error: List) -> Unit + ) { + launch { + loginWithReceiptAsync(receiptBase64, endpointsProvider.accountEndpoints(), callback) + } + } + + @InternalAPI + override fun setEmail( + username: String, + password: String, + email: String, + resetPassword: Boolean, + callback: (temporaryPassword: String?, error: List) -> Unit + ) { + launch { + setEmailAsync(username, password, email, resetPassword, endpointsProvider.accountEndpoints(), callback) + } + } + + @InternalAPI + override fun payment( + username: String, + password: String, + information: IOSPaymentInformation, + callback: (error: List) -> Unit + ) { + launch { + paymentAsync(username, password, information, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun signUp( + information: IOSSignupInformation, + callback: (details: SignUpInformation?, error: List) -> Unit + ) { + launch { + signUpAsync(information, endpointsProvider.accountEndpoints(), callback) + } + } + + override fun subscriptions( + receipt: String?, + callback: (details: IOSSubscriptionInformation?, error: List) -> Unit + ) { + launch { + subscriptionsAsync(receipt, endpointsProvider.accountEndpoints(), callback) + } + } + // endregion + + // region private + @InternalAPI + private suspend fun loginWithReceiptAsync( + receiptBase64: String, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.LOGIN) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.LOGIN.url}")) + continue + } + + var succeeded = false + val response = httpClient.postCatching> { + url(url) + contentType(ContentType.Application.Json) + body = json.encodeToString(IOSLoginReceiptRequest.serializer(), IOSLoginReceiptRequest(receiptBase64)) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(it.mapStatusCodeToAccountError()) + } else { + it.content.readUTF8Line()?.let { content -> + try { + val apiTokenResponse = json.decodeFromString(ApiTokenResponse.serializer(), content) + persistence.persistApiTokenResponse(apiTokenResponse) + refreshVpnToken(apiTokenResponse.apiToken, endpoints) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } ?: run { + listErrors.add(AccountRequestError(600, "Request response undefined")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + @InternalAPI + private suspend fun setEmailAsync( + username: String, + password: String, + email: String, + resetPassword: Boolean, + endpoints: List, + callback: (temporaryPassword: String?, error: List) -> Unit + ) { + var temporaryPassword: String? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.SET_EMAIL) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.SET_EMAIL.url}")) + continue + } + + var succeeded = false + val auth = "$username:$password".encodeBase64() + val response = httpClient.postCatching> { + url(url) + header("Authorization", "Basic $auth") + parameter("email", email) + parameter("reset_password", resetPassword) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + it.content.readUTF8Line()?.let { content -> + try { + temporaryPassword = json.decodeFromString(SetEmailResponse.serializer(), content).password + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } ?: run { + listErrors.add(AccountRequestError(600, "Request response undefined")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(temporaryPassword, listErrors) + } + } + + @InternalAPI + private suspend fun paymentAsync( + username: String, + password: String, + information: IOSPaymentInformation, + endpoints: List, + callback: (error: List) -> Unit + ) { + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.IOS_PAYMENT) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.IOS_PAYMENT.url}")) + continue + } + + var succeeded = false + val auth = "$username:$password".encodeBase64() + val response = httpClient.postCatching> { + url(url) + contentType(ContentType.Application.Json) + header("Authorization", "Basic $auth") + body = json.encodeToString(IOSPaymentInformation.serializer(), information) + } + + response.first?.let { + succeeded = AccountUtils.isErrorStatusCode(it.status.value).not() + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(listErrors) + } + } + + private suspend fun signUpAsync( + information: IOSSignupInformation, + endpoints: List, + callback: (details: SignUpInformation?, error: List) -> Unit + ) { + var signUpInformation: SignUpInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.SIGNUP) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.SIGNUP.url}")) + continue + } + + var succeeded = false + val response = httpClient.postCatching> { + url(url) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(IOSSignupInformation.serializer(), information)) + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + signUpInformation = json.decodeFromString(SignUpInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(signUpInformation, listErrors) + } + } + + private suspend fun subscriptionsAsync( + receipt: String?, + endpoints: List, + callback: (details: IOSSubscriptionInformation?, error: List) -> Unit + ) { + var subscriptionsInformation: IOSSubscriptionInformation? = null + val listErrors: MutableList = mutableListOf() + if (endpoints.isEmpty()) { + listErrors.add(AccountRequestError(600, "No available endpoints to perform the request")) + } + + refreshTokensIfNeeded(endpoints) + for (endpoint in endpoints) { + if (endpoint.usePinnedCertificate && certificate.isNullOrEmpty()) { + listErrors.add( + AccountRequestError( + 600, + "No available certificate for pinning purposes" + ) + ) + continue + } + + val httpClientConfigResult = if (endpoint.usePinnedCertificate) { + AccountHttpClient.client(certificate, Pair(endpoint.ipOrRootDomain, endpoint.certificateCommonName!!)) + } else { + AccountHttpClient.client() + } + + val httpClient = httpClientConfigResult.first + val httpClientError = httpClientConfigResult.second + if (httpClientError != null) { + listErrors.add(AccountRequestError(600, httpClientError.message)) + continue + } + + if (httpClient == null) { + listErrors.add(AccountRequestError(600, "Invalid http client")) + continue + } + + val url = AccountUtils.prepareRequestUrl(endpoint.ipOrRootDomain, Path.IOS_SUBSCRIPTIONS) + if (url == null) { + listErrors.add(AccountRequestError(600, "Error preparing url ${endpoint.ipOrRootDomain} - ${Path.IOS_SUBSCRIPTIONS.url}")) + continue + } + + var succeeded = false + val response = httpClient.getCatching> { + url(url) + parameter("type", "subscription") + if (receipt != null) { + parameter("receipt", receipt) + } + } + + response.first?.let { + if (AccountUtils.isErrorStatusCode(it.status.value)) { + listErrors.add(AccountRequestError(it.status.value, it.status.description)) + } else { + try { + subscriptionsInformation = + json.decodeFromString(IOSSubscriptionInformation.serializer(), it.bodyAsText()) + succeeded = true + } catch (exception: SerializationException) { + listErrors.add(AccountRequestError(600, "Decode error $exception")) + } + } + } + response.second?.let { + listErrors.add(AccountRequestError(600, it.message)) + } + + // Close the used client explicitly. + // We need to recreate it due to the possibility of pinning among the endpoints list. + httpClient.close() + + // If there were no errors in the request for the current endpoint. No need to try the next endpoint. + if (succeeded) { + listErrors.clear() + break + } + } + + withContext(Dispatchers.Main) { + callback(subscriptionsInformation, listErrors) + } + } + // endregion +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AmazonLoginReceiptRequest.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AmazonLoginReceiptRequest.kt new file mode 100644 index 0000000..3dc233d --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AmazonLoginReceiptRequest.kt @@ -0,0 +1,14 @@ +package com.privateinternetaccess.account.internals.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class AmazonLoginReceiptRequest( + @SerialName("receipt_id") + val receiptId: String, + @SerialName("user_id") + val userId: String, + @SerialName("store") + val store: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AndroidLoginReceiptRequest.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AndroidLoginReceiptRequest.kt new file mode 100644 index 0000000..48b55bd --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/AndroidLoginReceiptRequest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +internal data class AndroidLoginReceiptRequest( + @SerialName("store") + private val store: String, + @SerialName("receipt") + val receipt: Receipt +) { + @Serializable + data class Receipt( + @SerialName("token") + val token: String, + @SerialName("product_id") + val productId: String, + @SerialName("application_package") + val applicationPackage: String + ) +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/DedicatedIPRequest.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/DedicatedIPRequest.kt new file mode 100644 index 0000000..8f0bc1b --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/DedicatedIPRequest.kt @@ -0,0 +1,29 @@ +package com.privateinternetaccess.account.internals.model.request + +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +class DedicatedIPRequest( + @SerialName("tokens") + val tokens: List +) diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/IOSLoginReceiptRequest.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/IOSLoginReceiptRequest.kt new file mode 100644 index 0000000..657a096 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/request/IOSLoginReceiptRequest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +internal data class IOSLoginReceiptRequest( + @SerialName("receipt") + val receipt: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/ApiTokenResponse.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/ApiTokenResponse.kt new file mode 100644 index 0000000..8b87720 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/ApiTokenResponse.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +internal data class ApiTokenResponse( + @SerialName("api_token") + val apiToken: String, + + /** + * ISO 8601 string representation. + */ + @SerialName("expires_at") + val expiresAt: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/SetEmailResponse.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/SetEmailResponse.kt new file mode 100644 index 0000000..7a73130 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/SetEmailResponse.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +internal data class SetEmailResponse( + @SerialName("password") + val password: String = "", + @SerialName("status") + val status: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/VpnTokenResponse.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/VpnTokenResponse.kt new file mode 100644 index 0000000..9bf8eed --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/model/response/VpnTokenResponse.kt @@ -0,0 +1,38 @@ +package com.privateinternetaccess.account.internals.model.response + +/* + * Copyright (c) 2021 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +internal data class VpnTokenResponse( + @SerialName("vpn_secret1") + val vpnUsernameToken: String, + + @SerialName("vpn_secret2") + val vpnPasswordToken: String, + + /** + * ISO 8601 string representation. + */ + @SerialName("expires_at") + val expiresAt: String +) diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/AccountPersistence.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/AccountPersistence.kt new file mode 100644 index 0000000..3810dbc --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/AccountPersistence.kt @@ -0,0 +1,13 @@ +package com.privateinternetaccess.account.internals.persistency + +import com.privateinternetaccess.account.internals.model.response.ApiTokenResponse +import com.privateinternetaccess.account.internals.model.response.VpnTokenResponse + +internal interface AccountPersistence { + fun persistApiTokenResponse(apiToken: ApiTokenResponse) + fun persistVpnTokenResponse(vpnToken: VpnTokenResponse) + fun apiTokenResponse(): ApiTokenResponse? + fun vpnTokenResponse(): VpnTokenResponse? + fun clearApiTokenResponse() + fun clearVpnTokenResponse() +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsPersistence.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsPersistence.kt new file mode 100644 index 0000000..93ab7d4 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsPersistence.kt @@ -0,0 +1,50 @@ +package com.privateinternetaccess.account.internals.persistency.secureSettings + +import com.privateinternetaccess.account.internals.Account +import com.privateinternetaccess.account.internals.model.response.ApiTokenResponse +import com.privateinternetaccess.account.internals.model.response.VpnTokenResponse +import com.privateinternetaccess.account.internals.persistency.AccountPersistence +import com.russhwolf.settings.Settings + +internal object SecureSettingsPersistence : AccountPersistence { + + private val settings: Settings? = SecureSettingsProvider.settings + + override fun persistApiTokenResponse(apiToken: ApiTokenResponse) { + settings?.putString( + Account.API_TOKEN_KEY, + Account.json.encodeToString( + ApiTokenResponse.serializer(), + apiToken + ) + ) + } + + override fun persistVpnTokenResponse(vpnToken: VpnTokenResponse) { + settings?.putString( + Account.VPN_TOKEN_KEY, + Account.json.encodeToString( + VpnTokenResponse.serializer(), + vpnToken + ) + ) + } + + override fun apiTokenResponse(): ApiTokenResponse? = + settings?.getStringOrNull(Account.API_TOKEN_KEY)?.let { + Account.json.decodeFromString(ApiTokenResponse.serializer(), it) + } + + override fun vpnTokenResponse(): VpnTokenResponse? = + settings?.getStringOrNull(Account.VPN_TOKEN_KEY)?.let { + Account.json.decodeFromString(VpnTokenResponse.serializer(), it) + } + + override fun clearApiTokenResponse() { + settings?.remove(Account.API_TOKEN_KEY) + } + + override fun clearVpnTokenResponse() { + settings?.remove(Account.VPN_TOKEN_KEY) + } +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt new file mode 100644 index 0000000..3537ac2 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt @@ -0,0 +1,7 @@ +package com.privateinternetaccess.account.internals.persistency.secureSettings + +import com.russhwolf.settings.Settings + +internal expect object SecureSettingsProvider { + val settings: Settings? +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/AccountUtils.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/AccountUtils.kt new file mode 100644 index 0000000..a664377 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/AccountUtils.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals.utils + +import com.privateinternetaccess.account.internals.Account + + +object AccountUtils { + + private val DOMAIN_REGEX = Regex("^((?!-)[A-Za-z0-9-]{1,63}(? + // Redirect response + return true + in 400..499 -> + // Client error response + return true + in 500..599 -> + // Server error response + return true + } + + if (code >= 600) { + // Unknown error response + return true + } + return false + } + + internal fun prepareRequestUrl(ipOrRootDomain: String, path: Account.Path): String? { + // If it's not recognized as ip or domain. Return. + if (!ipOrRootDomain.matches(DOMAIN_REGEX) && !ipOrRootDomain.matches(IPV4_REGEX)) { + return null + } + + // If it's a domain but there is no subdomain definition. Return. + if (ipOrRootDomain.matches(DOMAIN_REGEX) && !containsSubdomainForPath(path)) { + return null + } + + return when { + // Order matters. The staging check needs to be prior the domain check as staging is technically a domain. + // But, we don't want to apply the subdomain to it and rather use it as it is. + ipOrRootDomain.contains(STAGING_DOMAIN) || + ipOrRootDomain.matches(IPV4_REGEX) -> { + "$SCHEME://$ipOrRootDomain${path.url}" + } + ipOrRootDomain.matches(DOMAIN_REGEX) -> { + val subdomain = Account.SUBDOMAINS.getValue(path) + "$SCHEME://$subdomain.$ipOrRootDomain${path.url}" + } + else -> null + } + } + + // region private + private fun containsSubdomainForPath(path: Account.Path) = + Account.SUBDOMAINS.containsKey(path) + // endregion +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/NetworkUtils.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/NetworkUtils.kt new file mode 100644 index 0000000..2c96f5b --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/internals/utils/NetworkUtils.kt @@ -0,0 +1,21 @@ +package com.privateinternetaccess.account.internals.utils + +import com.privateinternetaccess.account.AccountRequestError +import io.ktor.client.statement.* + +object NetworkUtils { + + internal fun HttpResponse.mapStatusCodeToAccountError(): AccountRequestError = + getRetryAfterHeaderValue()?.let { retryAfterSeconds -> + if (status.value == 429) { + AccountRequestError(status.value, status.description, retryAfterSeconds = retryAfterSeconds) + } + else { + null + } + } ?: AccountRequestError(status.value, status.description) + + private fun HttpResponse.getRetryAfterHeaderValue(): Long? = + headers["Retry-After"].toString().toLongOrNull() +} + diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AmazonSignupInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AmazonSignupInformation.kt new file mode 100644 index 0000000..28966ba --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AmazonSignupInformation.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class AmazonSignupInformation( + @SerialName("userId") + val userId: String, + @SerialName("receiptId") + val receiptId: String, + @SerialName("email") + val email: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AndroidSignupInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AndroidSignupInformation.kt new file mode 100644 index 0000000..1eb8341 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/AndroidSignupInformation.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class AndroidSignupInformation( + @SerialName("store") + internal val store: String, + @SerialName("receipt") + val receipt: Receipt, + @SerialName("marketing") + val marketing: String? = null +) { + @Serializable + data class Receipt( + @SerialName("order_id") + val orderId: String, + @SerialName("token") + val token: String, + @SerialName("product_id") + val sku: String + ) +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSPaymentInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSPaymentInformation.kt new file mode 100644 index 0000000..e33bc73 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSPaymentInformation.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class IOSPaymentInformation( + @SerialName("store") + private val store: String, + @SerialName("receipt") + val receipt: String, + @SerialName("marketing") + val marketing: String, + @SerialName("debug") + val debug: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSSignupInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSSignupInformation.kt new file mode 100644 index 0000000..bc41624 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/request/IOSSignupInformation.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class IOSSignupInformation( + @SerialName("store") + private val store: String, + @SerialName("receipt") + val receipt: String, + @SerialName("email") + val email: String, + @SerialName("marketing") + val marketing: String? = null, + @SerialName("debug") + val debug: String? = null +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AccountInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AccountInformation.kt new file mode 100644 index 0000000..6922298 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AccountInformation.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class AccountInformation( + @SerialName("active") + val active: Boolean, + @SerialName("can_invite") + val canInvite: Boolean, + @SerialName("canceled") + val canceled: Boolean, + @SerialName("days_remaining") + val daysRemaining: Int, + @SerialName("email") + val email: String, + @SerialName("expiration_time") + val expirationTime: Int, + @SerialName("expire_alert") + val expireAlert: Boolean, + @SerialName("expired") + val expired: Boolean, + @SerialName("needs_payment") + val needsPayment: Boolean, + @SerialName("plan") + val plan: String, + @SerialName("product_id") + val productId: String?, + @SerialName("recurring") + val recurring: Boolean, + @SerialName("renew_url") + val renewUrl: String, + @SerialName("renewable") + val renewable: Boolean, + @SerialName("username") + val username: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AmazonSubscriptionInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AmazonSubscriptionInformation.kt new file mode 100644 index 0000000..5679a78 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AmazonSubscriptionInformation.kt @@ -0,0 +1,43 @@ +package com.privateinternetaccess.account.model.response + +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class AmazonSubscriptionsInformation( + @SerialName("available_products") + val availableProducts: List, + @SerialName("status") + val status: String +) { + @Serializable + data class AvailableProduct( + @SerialName("id") + val id: String, + @SerialName("legacy") + val legacy: Boolean, + @SerialName("plan") + val plan: String, + @SerialName("price") + val price: Double + ) +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AndroidSubscriptionsInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AndroidSubscriptionsInformation.kt new file mode 100644 index 0000000..5d7b27a --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/AndroidSubscriptionsInformation.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class AndroidSubscriptionsInformation( + @SerialName("available_products") + val availableProducts: List, + @SerialName("status") + val status: String +) { + @Serializable + data class AvailableProduct( + @SerialName("id") + val id: String, + @SerialName("legacy") + val legacy: Boolean, + @SerialName("plan") + val plan: String, + @SerialName("price") + val price: String + ) +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/ClientStatusInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/ClientStatusInformation.kt new file mode 100644 index 0000000..da5181b --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/ClientStatusInformation.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class ClientStatusInformation( + @SerialName("connected") + val connected: Boolean, + @SerialName("ip") + val ip: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/DedicatedIPInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/DedicatedIPInformation.kt new file mode 100644 index 0000000..56ccb7e --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/DedicatedIPInformation.kt @@ -0,0 +1,36 @@ +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class DedicatedIPInformationResponse( + @SerialName("result") + val result: List +) { + enum class Status { + active, + expired, + invalid, + error + } + + @Serializable + data class DedicatedIPInformation( + @SerialName("id") + val id: String? = null, + @SerialName("ip") + val ip: String? = null, + @SerialName("cn") + val cn: String? = null, + @SerialName("groups") + val groups: List? = null, + @SerialName("dip_expire") + val dip_expire: Long? = null, + @SerialName("dip_token") + val dipToken: String, + @SerialName("status") + val status: Status + ) +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/FeatureFlagsInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/FeatureFlagsInformation.kt new file mode 100644 index 0000000..7c37745 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/FeatureFlagsInformation.kt @@ -0,0 +1,31 @@ +package com.privateinternetaccess.account.model.response + +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class FeatureFlagsInformation( + @SerialName("flags") + val flags: List, + @SerialName("latest_version_piax") + val latestVersionPiax: List +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/IOSSubscriptionInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/IOSSubscriptionInformation.kt new file mode 100644 index 0000000..ec04ebd --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/IOSSubscriptionInformation.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class IOSSubscriptionInformation( + @SerialName("available_products") + val availableProducts: List, + @SerialName("eligible_for_trial") + val eligibleForTrial: Boolean, + @SerialName("receipt") + val receipt: Receipt, + @SerialName("status") + val status: String +) { + @Serializable + data class AvailableProduct( + @SerialName("id") + val id: String, + @SerialName("legacy") + val legacy: Boolean, + @SerialName("plan") + val plan: String, + @SerialName("price") + val price: String + ) + + @Serializable + data class Receipt( + @SerialName("eligible_for_trial") + val eligibleForTrial: Boolean + ) +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/InvitesDetailsInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/InvitesDetailsInformation.kt new file mode 100644 index 0000000..aede3ba --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/InvitesDetailsInformation.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class InvitesDetailsInformation( + @SerialName("invites") + val invites: List, + @SerialName("total_free_days_given") + val totalFreeDaysGiven: Int, + @SerialName("total_invites_rewarded") + val totalInvitesRewarded: Int, + @SerialName("total_invites_sent") + val totalInvitesSent: Int, + @SerialName("unique_referral_link") + val uniqueReferralLink: String +) { + @Serializable + data class Invite( + @SerialName("accepted") + val accepted: Boolean, + @SerialName("grace_period_remaining") + val gracePeriodRemaining: String, + @SerialName("obfuscated_email") + val obfuscatedEmail: String, + @SerialName("rewarded") + val rewarded: Boolean + ) +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/MessageInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/MessageInformation.kt new file mode 100644 index 0000000..645a254 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/MessageInformation.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class MessageInformation( + @SerialName("id") + val id: Long, + @SerialName("link") + val link: Link? = null, + @SerialName("message") + val message: Map = mapOf() +) { + @Serializable + data class Link( + @SerialName("action") + val action: Action, + @SerialName("text") + val text: Map + ) { + @Serializable + data class Action( + @SerialName("settings") + val settings: Map = mapOf(), + @SerialName("uri") + val uri: String? = null, + @SerialName("view") + val view: String? = null, + ) + } +} \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/RedeemInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/RedeemInformation.kt new file mode 100644 index 0000000..291ccfc --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/RedeemInformation.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +class RedeemInformation( + @SerialName("code") + val message: String?, + @SerialName("username") + val username: String, + @SerialName("password") + val password: String +) \ No newline at end of file diff --git a/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/SignUpInformation.kt b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/SignUpInformation.kt new file mode 100644 index 0000000..6ff8c49 --- /dev/null +++ b/account/src/commonMain/kotlin/com/privateinternetaccess/account/model/response/SignUpInformation.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +class SignUpInformation( + @SerialName("status") + val status: String, + @SerialName("username") + val username: String, + @SerialName("password") + val password: String +) \ No newline at end of file diff --git a/account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt b/account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt new file mode 100644 index 0000000..f40ad05 --- /dev/null +++ b/account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/AccountHttpClient.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2020 Private Internet Access, Inc. + * + * This file is part of the Private Internet Access Mobile Client. + * + * The Private Internet Access Mobile Client is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * The Private Internet Access Mobile Client is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with the Private + * Internet Access Mobile Client. If not, see . + */ + +package com.privateinternetaccess.account.internals + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.* +import io.ktor.client.engine.ios.Ios +import io.ktor.client.plugins.* +import io.ktor.client.engine.ios.* +import kotlinx.cinterop.* +import platform.CoreFoundation.* +import platform.Foundation.* +import platform.Security.* + + +internal actual object AccountHttpClient { + + actual fun client( + certificate: String?, + pinnedEndpoint: Pair? + ): Pair { + return Pair(HttpClient(Ios) { + expectSuccess = false + install(HttpTimeout) { + requestTimeoutMillis = Account.REQUEST_TIMEOUT_MS + } + + if (certificate != null && pinnedEndpoint != null) { + engine { + handleChallenge( + AccountCertificatePinner( + certificate, + pinnedEndpoint.first, + pinnedEndpoint.second + ) + ) + } + } + }, null) + } +} + +@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +private class AccountCertificatePinner( + certificate: String, + private val hostname: String, + private val commonName: String +) : ChallengeHandler { + + private val certificateData = NSData.create( + base64EncodedString = + certificate + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replace("\n", ""), + options = NSDataBase64Encoding64CharacterLineLength + ) + + override fun invoke( + session: NSURLSession, + task: NSURLSessionTask, + challenge: NSURLAuthenticationChallenge, + completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit + ) { + if (challenge.protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust) { + challenge.sender?.cancelAuthenticationChallenge(challenge) + completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null) + return + } + + val serverTrust = challenge.protectionSpace.serverTrust + val serverCertificateRef = SecTrustGetCertificateAtIndex(serverTrust, 0) + val certificateDataRef = CFBridgingRetain(certificateData) as CFDataRef + val certificateRef = SecCertificateCreateWithData(null, certificateDataRef) + val policyRef = SecPolicyCreateSSL(true, null) + + memScoped { + var preparationSucceeded = true + val serverCommonNameRef = alloc() + SecCertificateCopyCommonName(serverCertificateRef, serverCommonNameRef.ptr) + val commonNameEvaluationSucceeded = (commonName == CFBridgingRelease(serverCommonNameRef.value)) + val hostNameEvaluationSucceeded = (hostname == challenge.protectionSpace.host) + + val trust = alloc() + val trustCreation = SecTrustCreateWithCertificates(serverCertificateRef, policyRef, trust.ptr) + if (trustCreation != errSecSuccess) { + preparationSucceeded = false + } + + val mutableArrayRef = CFArrayCreateMutable(kCFAllocatorDefault, 1, null) + CFArrayAppendValue(mutableArrayRef, certificateRef) + + val trustAnchor = SecTrustSetAnchorCertificates(trust.value, mutableArrayRef) + if (trustAnchor != errSecSuccess) { + preparationSucceeded = false + } + + val error = alloc() + val certificateEvaluationSucceeded = SecTrustEvaluateWithError(trust.value, error.ptr) + challenge.sender?.useCredential(NSURLCredential.create(serverTrust), challenge) + if (preparationSucceeded && hostNameEvaluationSucceeded && commonNameEvaluationSucceeded && certificateEvaluationSucceeded) { + completionHandler(NSURLSessionAuthChallengeUseCredential, NSURLCredential.create(serverTrust)) + } else { + completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null) + } + + CFRelease(serverCertificateRef) + CFRelease(certificateDataRef) + CFRelease(certificateRef) + CFRelease(policyRef) + CFRelease(mutableArrayRef) + } + } +} \ No newline at end of file diff --git a/account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt b/account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt new file mode 100644 index 0000000..17fb7bd --- /dev/null +++ b/account/src/iosMain/kotlin/com/privateinternetaccess/account/internals/persistency/secureSettings/SecureSettingsProvider.kt @@ -0,0 +1,12 @@ +package com.privateinternetaccess.account.internals.persistency.secureSettings + +import com.russhwolf.settings.KeychainSettings +import com.russhwolf.settings.Settings + +internal actual object SecureSettingsProvider { + + private const val KEYCHAIN_NAME = "account_keychain" + + actual val settings: Settings? + get() = KeychainSettings(KEYCHAIN_NAME) +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2d5213f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("com.android.library").version("7.3.1").apply(false) + kotlin("multiplatform").version("1.9.0").apply(false) + kotlin("plugin.serialization").version("1.9.0").apply(false) +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} diff --git a/create-account-framework.sh b/create-account-framework.sh new file mode 100755 index 0000000..42ead65 --- /dev/null +++ b/create-account-framework.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail + +./gradlew iOSBinaries \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..649dfa4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +#Gradle +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" + +#Kotlin +kotlin.code.style=official + +#Android +android.useAndroidX=true +android.nonTransitiveRClass=true + +#MPP +kotlin.mpp.enableCInteropCommonization=true +kotlin.mpp.androidSourceSetLayoutVersion=2 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..490fda8577df6c95960ba7077c43220e5bb2c0d9 GIT binary patch literal 58694 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..31cca49 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..2fe81a7 --- /dev/null +++ b/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..133f6e5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Account" +include(":account")