mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-22 02:01:08 +07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c168bc9ed | |||
| 0d7f9381c1 | |||
| fe99cdeb85 | |||
| 4c03463344 | |||
| d4f6add807 | |||
| 545c766732 | |||
| 494e93d4ab | |||
| 4b6ec29761 | |||
| daa44a2672 | |||
| 5e23eed2bc | |||
| 012135aea6 | |||
| 58acf71858 |
@@ -660,15 +660,57 @@ jobs:
|
|||||||
ANDROID_KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
|
ANDROID_KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
|
||||||
ANDROID_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
|
ANDROID_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: ./deploy/build_android.sh --aab --apk all --build-platform ${{ env.ANDROID_BUILD_PLATFORM }}
|
run: ./deploy/build_android.sh --aab --play --apk all --build-platform ${{ env.ANDROID_BUILD_PLATFORM }}
|
||||||
|
|
||||||
|
- name: 'Build OSS AAB (in-app purchase)'
|
||||||
|
env:
|
||||||
|
ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||||
|
QT_HOST_PATH: ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/gcc_64
|
||||||
|
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore
|
||||||
|
ANDROID_KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
|
||||||
|
ANDROID_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
|
||||||
|
shell: bash
|
||||||
|
run: ./deploy/build_android.sh --aab --build-platform ${{ env.ANDROID_BUILD_PLATFORM }}
|
||||||
|
|
||||||
|
- name: 'Upload OSS x86_64 apk'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: AmneziaVPN-android-x86_64
|
||||||
|
path: deploy/build/AmneziaVPN-oss-x86_64-release.apk
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 'Upload OSS x86 apk'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: AmneziaVPN-android-x86
|
||||||
|
path: deploy/build/AmneziaVPN-oss-x86-release.apk
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 'Upload OSS arm64-v8a apk'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: AmneziaVPN-android-arm64-v8a
|
||||||
|
path: deploy/build/AmneziaVPN-oss-arm64-v8a-release.apk
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 'Upload OSS armeabi-v7a apk'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: AmneziaVPN-android-armeabi-v7a
|
||||||
|
path: deploy/build/AmneziaVPN-oss-armeabi-v7a-release.apk
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: 'Rename Android APKs'
|
- name: 'Rename Android APKs'
|
||||||
run: |
|
run: |
|
||||||
cd deploy/build
|
cd deploy/build
|
||||||
mv AmneziaVPN-x86_64-release.apk AmneziaVPN_${VERSION}_android9+_x86_64.apk
|
mv AmneziaVPN-oss-x86_64-release.apk AmneziaVPN_${VERSION}_android9+_x86_64.apk
|
||||||
mv AmneziaVPN-x86-release.apk AmneziaVPN_${VERSION}_android9+_x86.apk
|
mv AmneziaVPN-oss-x86-release.apk AmneziaVPN_${VERSION}_android9+_x86.apk
|
||||||
mv AmneziaVPN-arm64-v8a-release.apk AmneziaVPN_${VERSION}_android9+_arm64-v8a.apk
|
mv AmneziaVPN-oss-arm64-v8a-release.apk AmneziaVPN_${VERSION}_android9+_arm64-v8a.apk
|
||||||
mv AmneziaVPN-armeabi-v7a-release.apk AmneziaVPN_${VERSION}_android9+_armeabi-v7a.apk
|
mv AmneziaVPN-oss-armeabi-v7a-release.apk AmneziaVPN_${VERSION}_android9+_armeabi-v7a.apk
|
||||||
cd ../..
|
cd ../..
|
||||||
|
|
||||||
- name: 'Upload x86_64 apk'
|
- name: 'Upload x86_64 apk'
|
||||||
@@ -703,11 +745,19 @@ jobs:
|
|||||||
compression-level: 0
|
compression-level: 0
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 'Upload aab'
|
- name: 'Upload Play AAB'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: AmneziaVPN-android
|
name: AmneziaVPN-android
|
||||||
path: deploy/build/AmneziaVPN-release.aab
|
path: deploy/build/AmneziaVPN-play-release.aab
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: 'Upload OSS AAB (in-app purchase)'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: AmneziaVPN-android-oss-aab
|
||||||
|
path: deploy/build/AmneziaVPN-oss-release.aab
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d")
|
|||||||
set(RELEASE_DATE "${CURRENT_DATE}")
|
set(RELEASE_DATE "${CURRENT_DATE}")
|
||||||
|
|
||||||
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
|
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
|
||||||
|
|
||||||
set(APP_ANDROID_VERSION_CODE 2107)
|
set(APP_ANDROID_VERSION_CODE 2107)
|
||||||
|
|
||||||
|
|
||||||
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
||||||
set(MZ_PLATFORM_NAME "linux")
|
set(MZ_PLATFORM_NAME "linux")
|
||||||
elseif(${CMAKE_SYSTEM_NAME} STREQUAL "Windows")
|
elseif(${CMAKE_SYSTEM_NAME} STREQUAL "Windows")
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
plugins {
|
||||||
|
id(libs.plugins.android.library.get().pluginId)
|
||||||
|
id(libs.plugins.kotlin.android.get().pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.amnezia.vpn.billing"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(project(":utils"))
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.kotlinx.coroutines)
|
||||||
|
implementation(libs.android.billing)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.BILLING_UNAVAILABLE
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.DEVELOPER_ERROR
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.ERROR
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_NOT_OWNED
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_UNAVAILABLE
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.NETWORK_ERROR
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.SERVICE_DISCONNECTED
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode.USER_CANCELED
|
||||||
|
import com.android.billingclient.api.BillingResult
|
||||||
|
import org.amnezia.vpn.util.ErrorCode
|
||||||
|
|
||||||
|
internal class BillingException(
|
||||||
|
billingResult: BillingResult,
|
||||||
|
retryable: Boolean = false
|
||||||
|
) : Exception(billingResult.toString()) {
|
||||||
|
|
||||||
|
constructor(msg: String) : this(BillingResult.newBuilder()
|
||||||
|
.setResponseCode(DEVELOPER_ERROR)
|
||||||
|
.setDebugMessage(msg)
|
||||||
|
.build())
|
||||||
|
|
||||||
|
val errorCode: Int
|
||||||
|
val isCanceled = billingResult.responseCode == USER_CANCELED
|
||||||
|
val isRetryable = retryable || billingResult.responseCode in setOf(
|
||||||
|
NETWORK_ERROR,
|
||||||
|
SERVICE_DISCONNECTED,
|
||||||
|
SERVICE_UNAVAILABLE,
|
||||||
|
ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
when (billingResult.responseCode) {
|
||||||
|
ERROR -> {
|
||||||
|
errorCode = ErrorCode.BillingGooglePlayError
|
||||||
|
}
|
||||||
|
|
||||||
|
BILLING_UNAVAILABLE, SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE -> {
|
||||||
|
errorCode = ErrorCode.BillingUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
DEVELOPER_ERROR, FEATURE_NOT_SUPPORTED, ITEM_NOT_OWNED -> {
|
||||||
|
errorCode = ErrorCode.BillingError
|
||||||
|
}
|
||||||
|
|
||||||
|
ITEM_ALREADY_OWNED -> {
|
||||||
|
errorCode = ErrorCode.SubscriptionAlreadyOwned
|
||||||
|
}
|
||||||
|
|
||||||
|
ITEM_UNAVAILABLE -> {
|
||||||
|
errorCode = ErrorCode.SubscriptionUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
NETWORK_ERROR -> {
|
||||||
|
errorCode = ErrorCode.BillingNetworkError
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
errorCode = ErrorCode.BillingError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||||
|
import com.android.billingclient.api.BillingClient
|
||||||
|
import com.android.billingclient.api.BillingClient.BillingResponseCode
|
||||||
|
import com.android.billingclient.api.BillingClient.ProductType
|
||||||
|
import com.android.billingclient.api.BillingClientStateListener
|
||||||
|
import com.android.billingclient.api.BillingFlowParams
|
||||||
|
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode
|
||||||
|
import com.android.billingclient.api.BillingResult
|
||||||
|
import com.android.billingclient.api.GetBillingConfigParams
|
||||||
|
import com.android.billingclient.api.PendingPurchasesParams
|
||||||
|
import com.android.billingclient.api.ProductDetails
|
||||||
|
import com.android.billingclient.api.Purchase
|
||||||
|
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||||
|
import com.android.billingclient.api.QueryProductDetailsParams
|
||||||
|
import com.android.billingclient.api.QueryProductDetailsParams.Product
|
||||||
|
import com.android.billingclient.api.QueryPurchasesParams
|
||||||
|
import com.android.billingclient.api.acknowledgePurchase
|
||||||
|
import com.android.billingclient.api.queryProductDetails
|
||||||
|
import com.android.billingclient.api.queryPurchasesAsync
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.amnezia.vpn.util.ErrorCode
|
||||||
|
import org.amnezia.vpn.util.Log
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
private const val TAG = "BillingProvider"
|
||||||
|
private const val PRODUCT_ID = "premium"
|
||||||
|
|
||||||
|
class BillingProvider(context: Context) : AutoCloseable {
|
||||||
|
|
||||||
|
private var billingClient: BillingClient
|
||||||
|
private var subscriptionPurchases = MutableStateFlow<Pair<BillingResult, List<Purchase>?>?>(null)
|
||||||
|
|
||||||
|
private val purchasesUpdatedListeners = PurchasesUpdatedListener { billingResult, purchases ->
|
||||||
|
Log.v(TAG, "Purchases updated: $billingResult")
|
||||||
|
subscriptionPurchases.value = billingResult to purchases
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
billingClient = BillingClient.newBuilder(context)
|
||||||
|
.setListener(purchasesUpdatedListeners)
|
||||||
|
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connect() {
|
||||||
|
if (billingClient.isReady) return
|
||||||
|
|
||||||
|
Log.v(TAG, "Billing client connection")
|
||||||
|
val connection = CompletableDeferred<Unit>()
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
billingClient.startConnection(object : BillingClientStateListener {
|
||||||
|
override fun onBillingSetupFinished(billingResult: BillingResult) {
|
||||||
|
Log.v(TAG, "Billing setup finished: $billingResult")
|
||||||
|
if (billingResult.isOk) {
|
||||||
|
connection.complete(Unit)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Billing setup failed: $billingResult")
|
||||||
|
connection.completeExceptionally(BillingException(billingResult))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBillingServiceDisconnected() {
|
||||||
|
Log.w(TAG, "Billing service disconnected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
connection.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleBillingApiCall(block: suspend () -> JSONObject): JSONObject {
|
||||||
|
val numberAttempts = 3
|
||||||
|
var attemptCount = 0
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (e: BillingException) {
|
||||||
|
if (e.isCanceled) {
|
||||||
|
Log.w(TAG, "Billing canceled")
|
||||||
|
return JSONObject().put("responseCode", ErrorCode.BillingCanceled)
|
||||||
|
} else if (e.isRetryable && attemptCount < numberAttempts) {
|
||||||
|
Log.d(TAG, "Retryable error: $e")
|
||||||
|
++attemptCount
|
||||||
|
delay(1000)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Billing error: $e")
|
||||||
|
return JSONObject().put("responseCode", e.errorCode)
|
||||||
|
}
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
Log.w(TAG, "Billing coroutine canceled")
|
||||||
|
return JSONObject().put("responseCode", ErrorCode.BillingCanceled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getSubscriptionPlans(): JSONObject {
|
||||||
|
Log.v(TAG, "Get subscription plans")
|
||||||
|
|
||||||
|
val productDetailsList = getProductDetails()
|
||||||
|
val resultJson = JSONObject().put("responseCode", ErrorCode.NoError)
|
||||||
|
val productArray = JSONArray().also { resultJson.put("products", it) }
|
||||||
|
productDetailsList?.forEach { productDetails ->
|
||||||
|
val product = JSONObject().also { productArray.put(it) }
|
||||||
|
.put("productId", productDetails.productId)
|
||||||
|
.put("name", productDetails.name)
|
||||||
|
val offers = JSONArray().also { product.put("offers", it) }
|
||||||
|
productDetails.subscriptionOfferDetails?.forEach { offerDetails ->
|
||||||
|
val offer = JSONObject().also { offers.put(it) }
|
||||||
|
.put("basePlanId", offerDetails.basePlanId)
|
||||||
|
.put("offerId", offerDetails.offerId)
|
||||||
|
.put("offerToken", offerDetails.offerToken)
|
||||||
|
val pricingPhases = JSONArray().also { offer.put("pricingPhases", it) }
|
||||||
|
offerDetails.pricingPhases.pricingPhaseList.forEach { phase ->
|
||||||
|
JSONObject().also { pricingPhases.put(it) }
|
||||||
|
.put("billingCycleCount", phase.billingCycleCount)
|
||||||
|
.put("billingPeriod", phase.billingPeriod)
|
||||||
|
.put("formatedPrice", phase.formattedPrice)
|
||||||
|
.put("recurrenceMode", phase.recurrenceMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultJson
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getProductDetails(): List<ProductDetails>? {
|
||||||
|
Log.v(TAG, "Get product details")
|
||||||
|
|
||||||
|
val productDetailsParams = Product.newBuilder()
|
||||||
|
.setProductId(PRODUCT_ID)
|
||||||
|
.setProductType(ProductType.SUBS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
|
||||||
|
.setProductList(listOf(productDetailsParams))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
billingClient.queryProductDetails(queryProductDetailsParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.v(TAG, "Query product details result: ${result.billingResult}")
|
||||||
|
|
||||||
|
if (!result.billingResult.isOk) {
|
||||||
|
Log.e(TAG, "Failed to get product details: ${result.billingResult}")
|
||||||
|
throw BillingException(result.billingResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.productDetailsList
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCustomerCountryCode(): JSONObject {
|
||||||
|
Log.v(TAG, "Get customer country code")
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<String>()
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
billingClient.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build(),
|
||||||
|
{ billingResult, billingConfig ->
|
||||||
|
Log.v(TAG, "Billing config: $billingResult, ${billingConfig?.countryCode}")
|
||||||
|
if (billingResult.isOk) {
|
||||||
|
deferred.complete(billingConfig?.countryCode ?: "")
|
||||||
|
} else {
|
||||||
|
deferred.completeExceptionally(BillingException(billingResult))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val countryCode = deferred.await()
|
||||||
|
|
||||||
|
return JSONObject()
|
||||||
|
.put("responseCode", ErrorCode.NoError)
|
||||||
|
.put("countryCode", countryCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun purchaseSubscription(
|
||||||
|
activity: Activity,
|
||||||
|
offerToken: String,
|
||||||
|
oldPurchaseToken: String? = null
|
||||||
|
): JSONObject {
|
||||||
|
Log.v(TAG, "Purchase subscription")
|
||||||
|
Log.v(TAG, "Offer token: $offerToken")
|
||||||
|
oldPurchaseToken?.let { Log.v(TAG, "Old purchase token: $it") }
|
||||||
|
|
||||||
|
if (offerToken.isBlank()) throw BillingException("offerToken can not be empty")
|
||||||
|
|
||||||
|
val productDetails = getProductDetails()?.let {
|
||||||
|
it.filter { it.productId == PRODUCT_ID }
|
||||||
|
}?.firstOrNull() ?: throw BillingException("Product details not found")
|
||||||
|
|
||||||
|
Log.v(TAG, "Filtered product details:\n$productDetails")
|
||||||
|
|
||||||
|
val productDetail = BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||||
|
.setProductDetails(productDetails)
|
||||||
|
.setOfferToken(offerToken)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val subscriptionUpdateParams = oldPurchaseToken?.let {
|
||||||
|
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
|
||||||
|
.setOldPurchaseToken(oldPurchaseToken)
|
||||||
|
.setSubscriptionReplacementMode(ReplacementMode.WITHOUT_PRORATION)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val billingResult = billingClient.launchBillingFlow(activity, BillingFlowParams.newBuilder()
|
||||||
|
.setProductDetailsParamsList(listOf(productDetail))
|
||||||
|
.apply { subscriptionUpdateParams?.let { setSubscriptionUpdateParams(it) } }
|
||||||
|
.build())
|
||||||
|
|
||||||
|
Log.v(TAG, "Start billing flow result: $billingResult")
|
||||||
|
|
||||||
|
if (billingResult.responseCode == BillingResponseCode.ITEM_ALREADY_OWNED) {
|
||||||
|
Log.w(TAG, "Attempting to purchase already owned product")
|
||||||
|
val purchases = queryPurchases()
|
||||||
|
if (purchases.any { PRODUCT_ID in it.products }) throw BillingException(billingResult)
|
||||||
|
else throw BillingException(billingResult, retryable = true)
|
||||||
|
} else if (billingResult.responseCode == BillingResponseCode.ITEM_NOT_OWNED) {
|
||||||
|
Log.w(TAG, "Attempting to replace not owned product")
|
||||||
|
val purchases = queryPurchases()
|
||||||
|
if (purchases.all { PRODUCT_ID !in it.products }) throw BillingException(billingResult)
|
||||||
|
else throw BillingException(billingResult, retryable = true)
|
||||||
|
} else if (!billingResult.isOk) throw BillingException(billingResult)
|
||||||
|
|
||||||
|
subscriptionPurchases.firstOrNull { it != null }?.let { (billingResult, purchases) ->
|
||||||
|
if (!billingResult.isOk) throw BillingException(billingResult)
|
||||||
|
return JSONObject()
|
||||||
|
.put("responseCode", ErrorCode.NoError)
|
||||||
|
.put("purchases", processPurchases(purchases))
|
||||||
|
} ?: throw BillingException("Purchase failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPurchases(purchases: List<Purchase>?): JSONArray {
|
||||||
|
val purchaseArray = JSONArray()
|
||||||
|
purchases?.forEach { purchase ->
|
||||||
|
/* val purchaseJson = */ JSONObject().also { purchaseArray.put(it) }
|
||||||
|
.put("purchaseToken", purchase.purchaseToken)
|
||||||
|
.put("purchaseTime", purchase.purchaseTime)
|
||||||
|
.put("purchaseState", purchase.purchaseState)
|
||||||
|
.put("isAcknowledged", purchase.isAcknowledged)
|
||||||
|
.put("isAutoRenewing", purchase.isAutoRenewing)
|
||||||
|
.put("orderId", purchase.orderId)
|
||||||
|
// .put("productIds", JSONArray(purchase.products))
|
||||||
|
|
||||||
|
/* purchase.pendingPurchaseUpdate?.let { purchaseUpdate ->
|
||||||
|
JSONObject()
|
||||||
|
.put("purchaseToken", purchaseUpdate.purchaseToken)
|
||||||
|
// .put("productIds", JSONArray(purchaseUpdate.products))
|
||||||
|
}.also { purchaseJson.put("pendingPurchaseUpdate", it) } */
|
||||||
|
}
|
||||||
|
return purchaseArray
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun acknowledge(purchaseToken: String): JSONObject {
|
||||||
|
Log.v(TAG, "Acknowledge purchase: $purchaseToken")
|
||||||
|
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
billingClient.acknowledgePurchase(
|
||||||
|
AcknowledgePurchaseParams.newBuilder()
|
||||||
|
.setPurchaseToken(purchaseToken)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.v(TAG, "Acknowledge purchase result: $result")
|
||||||
|
|
||||||
|
if (result.responseCode == BillingResponseCode.ITEM_NOT_OWNED) {
|
||||||
|
Log.w(TAG, "Attempting to acknowledge not owned product")
|
||||||
|
val purchases = queryPurchases()
|
||||||
|
if (purchases.all { PRODUCT_ID !in it.products }) throw BillingException(result)
|
||||||
|
else throw BillingException(result, retryable = true)
|
||||||
|
} else if (!result.isOk && result.responseCode != BillingResponseCode.ITEM_ALREADY_OWNED) {
|
||||||
|
throw BillingException(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSONObject().put("responseCode", ErrorCode.NoError)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPurchases(): JSONObject {
|
||||||
|
Log.v(TAG, "Get purchases")
|
||||||
|
val purchases = queryPurchases()
|
||||||
|
return JSONObject()
|
||||||
|
.put("responseCode", ErrorCode.NoError)
|
||||||
|
.put("purchases", processPurchases(purchases))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryPurchases(): List<Purchase> {
|
||||||
|
Log.v(TAG, "Query purchases")
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
billingClient.queryPurchasesAsync(
|
||||||
|
QueryPurchasesParams.newBuilder().setProductType(ProductType.SUBS).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.v(TAG, "Query purchases result: ${result.billingResult}")
|
||||||
|
if (!result.billingResult.isOk) throw BillingException(result.billingResult)
|
||||||
|
return result.purchasesList
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
Log.v(TAG, "Close billing client connection")
|
||||||
|
billingClient.endConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
suspend fun withBillingProvider(context: Context, block: suspend BillingProvider.() -> JSONObject): String =
|
||||||
|
BillingProvider(context).use { bp ->
|
||||||
|
bp.handleBillingApiCall {
|
||||||
|
bp.connect()
|
||||||
|
bp.block()
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val BillingResult.isOk: Boolean
|
||||||
|
get() = responseCode == BillingResponseCode.OK
|
||||||
@@ -20,6 +20,7 @@ android {
|
|||||||
namespace = "org.amnezia.vpn"
|
namespace = "org.amnezia.vpn"
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,17 +42,6 @@ android {
|
|||||||
resourceConfigurations += listOf("en", "ru", "b+zh+Hans")
|
resourceConfigurations += listOf("en", "ru", "b+zh+Hans")
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
getByName("main") {
|
|
||||||
manifest.srcFile("AndroidManifest.xml")
|
|
||||||
java.setSrcDirs(listOf("src"))
|
|
||||||
res.setSrcDirs(listOf("res"))
|
|
||||||
// androyddeployqt creates the folders below
|
|
||||||
assets.setSrcDirs(listOf("assets"))
|
|
||||||
jniLibs.setSrcDirs(listOf("libs"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
register("release") {
|
register("release") {
|
||||||
storeFile = providers.environmentVariable("ANDROID_KEYSTORE_PATH").orNull?.let { file(it) }
|
storeFile = providers.environmentVariable("ANDROID_KEYSTORE_PATH").orNull?.let { file(it) }
|
||||||
@@ -77,6 +67,36 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flavorDimensions += "billing"
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("oss") {
|
||||||
|
dimension = "billing"
|
||||||
|
}
|
||||||
|
create("play") {
|
||||||
|
dimension = "billing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
manifest.srcFile("AndroidManifest.xml")
|
||||||
|
java.setSrcDirs(listOf("src"))
|
||||||
|
res.setSrcDirs(listOf("res"))
|
||||||
|
// androyddeployqt creates the folders below
|
||||||
|
assets.setSrcDirs(listOf("assets"))
|
||||||
|
jniLibs.setSrcDirs(listOf("libs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("oss") {
|
||||||
|
java.setSrcDirs(listOf("oss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("play") {
|
||||||
|
java.setSrcDirs(listOf("play"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
splits {
|
splits {
|
||||||
abi {
|
abi {
|
||||||
isEnable = true
|
isEnable = true
|
||||||
@@ -122,4 +142,9 @@ dependencies {
|
|||||||
implementation(libs.google.mlkit)
|
implementation(libs.google.mlkit)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.androidx.biometric)
|
implementation(libs.androidx.biometric)
|
||||||
|
|
||||||
|
playImplementation(project(":billing"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun DependencyHandler.playImplementation(dependency: Any): Dependency? =
|
||||||
|
add("playImplementation", dependency)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.5.2"
|
agp = "8.5.2"
|
||||||
kotlin = "1.9.24"
|
kotlin = "1.9.24"
|
||||||
|
android-billing = "7.0.0"
|
||||||
androidx-core = "1.13.1"
|
androidx-core = "1.13.1"
|
||||||
androidx-activity = "1.9.1"
|
androidx-activity = "1.9.1"
|
||||||
androidx-annotation = "1.8.2"
|
androidx-annotation = "1.8.2"
|
||||||
@@ -14,6 +15,7 @@ kotlinx-serialization = "1.6.3"
|
|||||||
google-mlkit = "17.3.0"
|
google-mlkit = "17.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
android-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "android-billing" }
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||||
androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
|
androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
|
||||||
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
|
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.amnezia.vpn
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
class BillingPaymentRepository(@Suppress("UNUSED_PARAMETER") context: Context) : BillingRepository {
|
||||||
|
override suspend fun getCountryCode(): String = ""
|
||||||
|
override suspend fun getSubscriptionPlans(): String = ""
|
||||||
|
override suspend fun purchaseSubscription(activity: Activity, offerToken: String): String = ""
|
||||||
|
override suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String = ""
|
||||||
|
override suspend fun acknowledge(purchaseToken: String): String = ""
|
||||||
|
override suspend fun queryPurchases(): String = ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package org.amnezia.vpn
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import BillingProvider.Companion.withBillingProvider
|
||||||
|
|
||||||
|
class BillingPaymentRepository(private val context: Context) : BillingRepository {
|
||||||
|
|
||||||
|
override suspend fun getCountryCode(): String = withBillingProvider(context) {
|
||||||
|
getCustomerCountryCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSubscriptionPlans(): String = withBillingProvider(context) {
|
||||||
|
getSubscriptionPlans()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun purchaseSubscription(activity: Activity, offerToken: String): String =
|
||||||
|
withBillingProvider(context) {
|
||||||
|
purchaseSubscription(activity, offerToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String =
|
||||||
|
withBillingProvider(context) {
|
||||||
|
purchaseSubscription(activity, offerToken, oldPurchaseToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun acknowledge(purchaseToken: String): String = withBillingProvider(context) {
|
||||||
|
acknowledge(purchaseToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun queryPurchases(): String = withBillingProvider(context) {
|
||||||
|
getPurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ rootProject.buildFileName = "build.gradle.kts"
|
|||||||
|
|
||||||
include(":qt")
|
include(":qt")
|
||||||
include(":utils")
|
include(":utils")
|
||||||
|
include(":billing")
|
||||||
include(":protocolApi")
|
include(":protocolApi")
|
||||||
include(":wireguard")
|
include(":wireguard")
|
||||||
include(":awg")
|
include(":awg")
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import kotlinx.coroutines.async
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.amnezia.vpn.protocol.getStatistics
|
import org.amnezia.vpn.protocol.getStatistics
|
||||||
import org.amnezia.vpn.protocol.getStatus
|
import org.amnezia.vpn.protocol.getStatus
|
||||||
import org.amnezia.vpn.qt.QtAndroidController
|
import org.amnezia.vpn.qt.QtAndroidController
|
||||||
@@ -87,6 +86,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
private var notificationStateReceiver: BroadcastReceiver? = null
|
private var notificationStateReceiver: BroadcastReceiver? = null
|
||||||
private lateinit var vpnServiceMessenger: IpcMessenger
|
private lateinit var vpnServiceMessenger: IpcMessenger
|
||||||
private var pfd: ParcelFileDescriptor? = null
|
private var pfd: ParcelFileDescriptor? = null
|
||||||
|
private lateinit var billingRepository: BillingRepository
|
||||||
|
|
||||||
private val actionResultHandlers = mutableMapOf<Int, ActivityResultHandler>()
|
private val actionResultHandlers = mutableMapOf<Int, ActivityResultHandler>()
|
||||||
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
|
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
|
||||||
@@ -199,6 +199,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
registerBroadcastReceivers()
|
registerBroadcastReceivers()
|
||||||
intent?.let(::processIntent)
|
intent?.let(::processIntent)
|
||||||
runBlocking { vpnProto = proto.await() }
|
runBlocking { vpnProto = proto.await() }
|
||||||
|
billingRepository = BillingPaymentRepository(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadLibs() {
|
private fun loadLibs() {
|
||||||
@@ -932,15 +933,9 @@ class AmneziaActivity : QtActivity() {
|
|||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun getAppList(): String {
|
fun getAppList(): String {
|
||||||
Log.v(TAG, "Get app list")
|
Log.v(TAG, "Get app list")
|
||||||
var appList = ""
|
return blockingCall(Dispatchers.IO) {
|
||||||
runBlocking {
|
AppListProvider.getAppList(packageManager, packageName)
|
||||||
mainScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
appList = AppListProvider.getAppList(packageManager, packageName)
|
|
||||||
}
|
|
||||||
}.join()
|
|
||||||
}
|
}
|
||||||
return appList
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
@@ -1111,11 +1106,59 @@ class AmneziaActivity : QtActivity() {
|
|||||||
return super.dispatchTrackballEvent(ev)
|
return super.dispatchTrackballEvent(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun isPlay(): Boolean = BuildConfig.FLAVOR == "play"
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun isTestPurchaseEnvironment(): Boolean {
|
||||||
|
if (BuildConfig.DEBUG) return true
|
||||||
|
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||||
|
return (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun getCountryCode(): String {
|
||||||
|
Log.v(TAG, "Get country code")
|
||||||
|
return blockingCall { billingRepository.getCountryCode() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun getSubscriptionPlans(): String {
|
||||||
|
Log.v(TAG, "Get subscription plans")
|
||||||
|
return blockingCall { billingRepository.getSubscriptionPlans() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun purchaseSubscription(offerToken: String): String {
|
||||||
|
Log.v(TAG, "Purchase subscription")
|
||||||
|
return blockingCall { billingRepository.purchaseSubscription(this@AmneziaActivity, offerToken) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun upgradeSubscription(offerToken: String, oldPurchaseToken: String): String {
|
||||||
|
Log.v(TAG, "Upgrade subscription")
|
||||||
|
return blockingCall {
|
||||||
|
billingRepository.upgradeSubscription(this@AmneziaActivity, offerToken, oldPurchaseToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun acknowledgePurchase(purchaseToken: String): String {
|
||||||
|
Log.v(TAG, "Acknowledge purchase")
|
||||||
|
return blockingCall { billingRepository.acknowledge(purchaseToken) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun queryPurchases(): String {
|
||||||
|
Log.v(TAG, "Query purchases")
|
||||||
|
return blockingCall { billingRepository.queryPurchases() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utils methods
|
* Utils methods
|
||||||
*/
|
*/
|
||||||
private fun <T> blockingCall(
|
private fun <T> blockingCall(
|
||||||
context: CoroutineContext = Dispatchers.Main.immediate,
|
context: CoroutineContext = Dispatchers.Default,
|
||||||
block: suspend () -> T
|
block: suspend () -> T
|
||||||
) = runBlocking {
|
) = runBlocking {
|
||||||
mainScope.async(context) { block() }.await()
|
mainScope.async(context) { block() }.await()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.amnezia.vpn
|
package org.amnezia.vpn
|
||||||
|
|
||||||
|
import android.system.Os
|
||||||
import androidx.camera.camera2.Camera2Config
|
import androidx.camera.camera2.Camera2Config
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.CameraXConfig
|
import androidx.camera.core.CameraXConfig
|
||||||
@@ -12,6 +13,9 @@ private const val TAG = "AmneziaApplication"
|
|||||||
class AmneziaApplication : QtApplication(), CameraXConfig.Provider {
|
class AmneziaApplication : QtApplication(), CameraXConfig.Provider {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Os.setenv("QT_ANDROID_DEBUGGER_MAIN_THREAD_SLEEP_MS", "0", true)
|
||||||
|
}
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Prefs.init(this)
|
Prefs.init(this)
|
||||||
Log.init(this)
|
Log.init(this)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.amnezia.vpn
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
|
||||||
|
interface BillingRepository {
|
||||||
|
suspend fun getCountryCode(): String
|
||||||
|
suspend fun getSubscriptionPlans(): String
|
||||||
|
suspend fun purchaseSubscription(activity: Activity, offerToken: String): String
|
||||||
|
suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String
|
||||||
|
suspend fun acknowledge(purchaseToken: String): String
|
||||||
|
suspend fun queryPurchases(): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.amnezia.vpn.util
|
||||||
|
|
||||||
|
// keep synchronized with client/core/defs.h error_code_ns::ErrorCode
|
||||||
|
object ErrorCode {
|
||||||
|
const val NoError = 0
|
||||||
|
|
||||||
|
const val BillingCanceled = 1300
|
||||||
|
const val BillingError = 1301
|
||||||
|
const val BillingGooglePlayError = 1302
|
||||||
|
const val BillingUnavailable = 1303
|
||||||
|
const val SubscriptionAlreadyOwned = 1304
|
||||||
|
const val SubscriptionUnavailable = 1305
|
||||||
|
const val BillingNetworkError = 1306
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
message("Client android ${CMAKE_ANDROID_ARCH_ABI} build")
|
message("Client android ${CMAKE_ANDROID_ARCH_ABI} build")
|
||||||
|
|
||||||
|
# Option to build Play variant (with Google Play Billing) instead of OSS
|
||||||
|
# When ON, adds target android_play_apk: cmake --build . --target android_play_apk
|
||||||
|
option(ANDROID_BUILD_PLAY "Add android_play_apk target for Google Play Billing build" OFF)
|
||||||
|
|
||||||
set(APP_ANDROID_MIN_SDK 28)
|
set(APP_ANDROID_MIN_SDK 28)
|
||||||
set(ANDROID_PLATFORM "android-${APP_ANDROID_MIN_SDK}" CACHE STRING
|
set(ANDROID_PLATFORM "android-${APP_ANDROID_MIN_SDK}" CACHE STRING
|
||||||
"The minimum API level supported by the application or library" FORCE)
|
"The minimum API level supported by the application or library" FORCE)
|
||||||
@@ -57,3 +61,22 @@ endforeach()
|
|||||||
|
|
||||||
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/3rd-prebuilt/3rd-prebuilt/xray/android/libxray.aar
|
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/3rd-prebuilt/3rd-prebuilt/xray/android/libxray.aar
|
||||||
DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/android/xray/libXray)
|
DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/android/xray/libXray)
|
||||||
|
|
||||||
|
# Custom target to build Play variant (with Google Play Billing)
|
||||||
|
# Enable with: cmake -DANDROID_BUILD_PLAY=ON ...
|
||||||
|
# Then run: cmake --build <build_dir> --target android_play_apk
|
||||||
|
# Note: Do a normal build first so androiddeployqt creates the android-build folder
|
||||||
|
if(ANDROID_BUILD_PLAY)
|
||||||
|
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||||
|
set(_gradle_suffix "Debug")
|
||||||
|
else()
|
||||||
|
set(_gradle_suffix "Release")
|
||||||
|
endif()
|
||||||
|
set(_android_build_dir "${CMAKE_CURRENT_BINARY_DIR}/android-build-${PROJECT}")
|
||||||
|
add_custom_target(android_play_apk
|
||||||
|
COMMAND ./gradlew assemblePlay${_gradle_suffix} -DexplicitRun=1
|
||||||
|
WORKING_DIRECTORY "${_android_build_dir}"
|
||||||
|
COMMENT "Building Android Play variant (assemblePlay${_gradle_suffix})"
|
||||||
|
DEPENDS ${PROJECT}
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|||||||
+11
-1
@@ -123,6 +123,7 @@ namespace amnezia
|
|||||||
ApiUpdateRequestError = 1111,
|
ApiUpdateRequestError = 1111,
|
||||||
ApiSubscriptionExpiredError = 1112,
|
ApiSubscriptionExpiredError = 1112,
|
||||||
ApiPurchaseError = 1113,
|
ApiPurchaseError = 1113,
|
||||||
|
ApiNoPurchasesToRestore = 1114,
|
||||||
|
|
||||||
// QFile errors
|
// QFile errors
|
||||||
OpenError = 1200,
|
OpenError = 1200,
|
||||||
@@ -130,7 +131,16 @@ namespace amnezia
|
|||||||
PermissionsError = 1202,
|
PermissionsError = 1202,
|
||||||
UnspecifiedError = 1203,
|
UnspecifiedError = 1203,
|
||||||
FatalError = 1204,
|
FatalError = 1204,
|
||||||
AbortError = 1205
|
AbortError = 1205,
|
||||||
|
|
||||||
|
// Billing errors
|
||||||
|
BillingCanceled = 1300,
|
||||||
|
BillingError = 1301,
|
||||||
|
BillingGooglePlayError = 1302,
|
||||||
|
BillingUnavailable = 1303,
|
||||||
|
SubscriptionAlreadyOwned = 1304,
|
||||||
|
SubscriptionUnavailable = 1305,
|
||||||
|
BillingNetworkError = 1306,
|
||||||
};
|
};
|
||||||
Q_ENUM_NS(ErrorCode)
|
Q_ENUM_NS(ErrorCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ QString errorString(ErrorCode code) {
|
|||||||
case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break;
|
case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break;
|
||||||
case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break;
|
case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break;
|
||||||
case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break;
|
case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break;
|
||||||
|
case (ErrorCode::ApiNoPurchasesToRestore):
|
||||||
|
#if defined(Q_OS_ANDROID)
|
||||||
|
errorMessage = QObject::tr("No purchases to restore. If you have an active subscription, make sure you're signed in with the same Google account used for the purchase.");
|
||||||
|
#elif defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
|
errorMessage = QObject::tr("No purchases to restore. If you have an active subscription, make sure you're signed in with the same Apple ID used for the purchase.");
|
||||||
|
#else
|
||||||
|
errorMessage = QObject::tr("No purchases to restore. If you have an active subscription, make sure you're signed in with the same account used for the purchase.");
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
|
|
||||||
// QFile errors
|
// QFile errors
|
||||||
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
||||||
@@ -89,6 +98,15 @@ QString errorString(ErrorCode code) {
|
|||||||
case(ErrorCode::FatalError): errorMessage = QObject::tr("QFile error: A fatal error occurred"); break;
|
case(ErrorCode::FatalError): errorMessage = QObject::tr("QFile error: A fatal error occurred"); break;
|
||||||
case(ErrorCode::AbortError): errorMessage = QObject::tr("QFile error: The operation was aborted"); break;
|
case(ErrorCode::AbortError): errorMessage = QObject::tr("QFile error: The operation was aborted"); break;
|
||||||
|
|
||||||
|
// Billing errors
|
||||||
|
case(ErrorCode::BillingCanceled): errorMessage = QObject::tr("Transaction was canceled by the user"); break;
|
||||||
|
case(ErrorCode::BillingError): errorMessage = QObject::tr("Billing error"); break;
|
||||||
|
case(ErrorCode::BillingGooglePlayError): errorMessage = QObject::tr("Internal Google Play error, please try again later"); break;
|
||||||
|
case(ErrorCode::BillingUnavailable): errorMessage = QObject::tr("Billing is unavailable, please try again later"); break;
|
||||||
|
case(ErrorCode::SubscriptionAlreadyOwned): errorMessage = QObject::tr("You already own this subscription"); break;
|
||||||
|
case(ErrorCode::SubscriptionUnavailable): errorMessage = QObject::tr("The requested subscription is not available for purchase"); break;
|
||||||
|
case(ErrorCode::BillingNetworkError): errorMessage = QObject::tr("A network error occurred during the operation, please check the Internet connection"); break;
|
||||||
|
|
||||||
case(ErrorCode::InternalError):
|
case(ErrorCode::InternalError):
|
||||||
default:
|
default:
|
||||||
errorMessage = QObject::tr("Internal error"); break;
|
errorMessage = QObject::tr("Internal error"); break;
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ QJsonObject Deserialize(const QString &vmessStr, QString *alias, QString *errMes
|
|||||||
// - It can be empty, if so, if the key is not in the JSON, or the value is empty, report an error.
|
// - It can be empty, if so, if the key is not in the JSON, or the value is empty, report an error.
|
||||||
// - Else if it contains one thing. if the key is not in the JSON, or the value is empty, use that one.
|
// - Else if it contains one thing. if the key is not in the JSON, or the value is empty, use that one.
|
||||||
// - Else if it contains many things, when the key IS in the JSON but not within the THINGS, use the first in the THINGS
|
// - Else if it contains many things, when the key IS in the JSON but not within the THINGS, use the first in the THINGS
|
||||||
// - Else -------------------------------------------->>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> use the JSON value
|
// - Else -------------------------------------------- use the JSON value
|
||||||
//
|
//
|
||||||
#define __vmess_checker__func(key, values) \
|
#define __vmess_checker__func(key, values) \
|
||||||
{ \
|
{ \
|
||||||
|
|||||||
@@ -326,6 +326,57 @@ void AndroidController::sendTouch(float x, float y)
|
|||||||
callActivityMethod("sendTouch", "(FF)V", x, y);
|
callActivityMethod("sendTouch", "(FF)V", x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool AndroidController::isPlay()
|
||||||
|
{
|
||||||
|
return callActivityMethod<jboolean>("isPlay", "()Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AndroidController::isTestPurchaseEnvironment()
|
||||||
|
{
|
||||||
|
return callActivityMethod<jboolean>("isTestPurchaseEnvironment", "()Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject AndroidController::getSubscriptionPlans()
|
||||||
|
{
|
||||||
|
QJniObject subscriptionPlans = callActivityMethod<jstring>("getSubscriptionPlans", "()Ljava/lang/String;");
|
||||||
|
QJsonObject json = QJsonDocument::fromJson(subscriptionPlans.toString().toUtf8()).object();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject AndroidController::purchaseSubscription(const QString &offerToken)
|
||||||
|
{
|
||||||
|
QJniObject result = callActivityMethod<jstring, jstring>("purchaseSubscription", "(Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
QJniObject::fromString(offerToken).object<jstring>());
|
||||||
|
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject AndroidController::upgradeSubscription(const QString &offerToken, const QString &oldPurchaseToken)
|
||||||
|
{
|
||||||
|
QJniObject result = callActivityMethod<jstring, jstring, jstring>("upgradeSubscription",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
QJniObject::fromString(offerToken).object<jstring>(),
|
||||||
|
QJniObject::fromString(oldPurchaseToken).object<jstring>());
|
||||||
|
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject AndroidController::acknowledgePurchase(const QString &purchaseToken)
|
||||||
|
{
|
||||||
|
QJniObject result = callActivityMethod<jstring, jstring>("acknowledgePurchase", "(Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
QJniObject::fromString(purchaseToken).object<jstring>());
|
||||||
|
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject AndroidController::queryPurchases()
|
||||||
|
{
|
||||||
|
QJniObject result = callActivityMethod<jstring>("queryPurchases", "()Ljava/lang/String;");
|
||||||
|
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
// Moving log processing to the Android side
|
// Moving log processing to the Android side
|
||||||
jclass AndroidController::log;
|
jclass AndroidController::log;
|
||||||
jmethodID AndroidController::logDebug;
|
jmethodID AndroidController::logDebug;
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ public:
|
|||||||
void requestNotificationPermission();
|
void requestNotificationPermission();
|
||||||
bool requestAuthentication();
|
bool requestAuthentication();
|
||||||
void sendTouch(float x, float y);
|
void sendTouch(float x, float y);
|
||||||
|
bool isPlay();
|
||||||
|
bool isTestPurchaseEnvironment();
|
||||||
|
QJsonObject getSubscriptionPlans();
|
||||||
|
QJsonObject purchaseSubscription(const QString &offerToken);
|
||||||
|
QJsonObject upgradeSubscription(const QString &offerToken, const QString &oldPurchaseToken);
|
||||||
|
QJsonObject acknowledgePurchase(const QString &purchaseToken);
|
||||||
|
QJsonObject queryPurchases();
|
||||||
|
|
||||||
static bool initLogging();
|
static bool initLogging();
|
||||||
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);
|
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);
|
||||||
|
|||||||
@@ -11,9 +11,14 @@
|
|||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
|
#include <QFutureWatcher>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
#include "platforms/ios/ios_controller.h"
|
#include "platforms/ios/ios_controller.h"
|
||||||
|
#ifdef Q_OS_ANDROID
|
||||||
|
#include "platforms/android/android_controller.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -370,6 +375,7 @@ bool ApiConfigsController::fillAvailableServices()
|
|||||||
|
|
||||||
QByteArray responseBody;
|
QByteArray responseBody;
|
||||||
ErrorCode errorCode = executeRequest(QString("%1v1/services"), apiPayload, responseBody);
|
ErrorCode errorCode = executeRequest(QString("%1v1/services"), apiPayload, responseBody);
|
||||||
|
qDebug().noquote() << "[Billing] gateway response v1/services responseBody:" << responseBody;
|
||||||
if (errorCode == ErrorCode::NoError) {
|
if (errorCode == ErrorCode::NoError) {
|
||||||
if (!responseBody.contains("services")) {
|
if (!responseBody.contains("services")) {
|
||||||
errorCode = ErrorCode::ApiServicesMissingError;
|
errorCode = ErrorCode::ApiServicesMissingError;
|
||||||
@@ -425,6 +431,97 @@ bool ApiConfigsController::fillAvailableServices()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#elif defined(Q_OS_ANDROID)
|
||||||
|
// Get price from Google Play Billing
|
||||||
|
auto androidController = AndroidController::instance();
|
||||||
|
QJsonObject plansResult = androidController->getSubscriptionPlans();
|
||||||
|
int responseCode = plansResult.value("responseCode").toInt(-1);
|
||||||
|
qDebug().noquote() << "[Billing] getSubscriptionPlans plansResult:" << QJsonDocument(plansResult).toJson(QJsonDocument::Compact);
|
||||||
|
qDebug() << "[Billing] getSubscriptionPlans responseCode:" << responseCode;
|
||||||
|
|
||||||
|
if (responseCode == 0) {
|
||||||
|
QJsonArray products = plansResult.value("products").toArray();
|
||||||
|
QString formattedPrice;
|
||||||
|
int billingPeriodDays = 180;
|
||||||
|
for (const QJsonValue &productValue : products) {
|
||||||
|
QJsonObject product = productValue.toObject();
|
||||||
|
const QString productId = product.value("productId").toString();
|
||||||
|
const bool isPremium = (productId == "premium") || productId.contains("premium");
|
||||||
|
if (isPremium) {
|
||||||
|
QJsonArray offers = product.value("offers").toArray();
|
||||||
|
if (!offers.isEmpty()) {
|
||||||
|
QJsonObject firstOffer = offers.at(0).toObject();
|
||||||
|
QJsonArray pricingPhases = firstOffer.value("pricingPhases").toArray();
|
||||||
|
if (!pricingPhases.isEmpty()) {
|
||||||
|
QJsonObject pricingPhase = pricingPhases.at(0).toObject();
|
||||||
|
formattedPrice = pricingPhase.value("formatedPrice").toString();
|
||||||
|
if (formattedPrice.isEmpty()) {
|
||||||
|
formattedPrice = pricingPhase.value("formattedPrice").toString();
|
||||||
|
}
|
||||||
|
QString billingPeriod = pricingPhase.value("billingPeriod").toString();
|
||||||
|
if (billingPeriod.contains("Y")) {
|
||||||
|
int idx = billingPeriod.indexOf("Y");
|
||||||
|
int years = billingPeriod.mid(1, idx - 1).toInt();
|
||||||
|
if (years > 0) billingPeriodDays = years * 365;
|
||||||
|
} else if (billingPeriod.contains("M")) {
|
||||||
|
int idx = billingPeriod.indexOf("M");
|
||||||
|
int months = billingPeriod.mid(1, idx - 1).toInt();
|
||||||
|
if (months > 0) billingPeriodDays = months * 30;
|
||||||
|
} else if (billingPeriod.contains("D")) {
|
||||||
|
int idx = billingPeriod.indexOf("D");
|
||||||
|
billingPeriodDays = billingPeriod.mid(1, idx - 1).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!formattedPrice.isEmpty()) {
|
||||||
|
QJsonArray services = data.value("services").toArray();
|
||||||
|
bool premiumFound = false;
|
||||||
|
for (int i = 0; i < services.size(); ++i) {
|
||||||
|
QJsonObject service = services[i].toObject();
|
||||||
|
if (service.value(configKey::serviceType).toString() == serviceType::amneziaPremium) {
|
||||||
|
QJsonObject serviceInfo = service.value(configKey::serviceInfo).toObject();
|
||||||
|
serviceInfo["price"] = formattedPrice;
|
||||||
|
service[configKey::serviceInfo] = serviceInfo;
|
||||||
|
services[i] = service;
|
||||||
|
data["services"] = services;
|
||||||
|
premiumFound = true;
|
||||||
|
qInfo() << "[Billing] Updated premium service price in data:" << formattedPrice;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* if (!premiumFound) {
|
||||||
|
// Gateway did not return premium; add it from billing data
|
||||||
|
QString region = data.value(configKey::userCountryCode).toString();
|
||||||
|
QJsonObject serviceInfo;
|
||||||
|
serviceInfo["name"] = tr("Amnezia Premium");
|
||||||
|
serviceInfo["price"] = formattedPrice;
|
||||||
|
serviceInfo["region"] = region;
|
||||||
|
serviceInfo["speed"] = "200";
|
||||||
|
serviceInfo["timelimit"] = QString::number(billingPeriodDays);
|
||||||
|
QJsonObject serviceDescription;
|
||||||
|
serviceDescription["card_description"] = tr("Amnezia Premium is classic VPN for seamless work, downloading large files, and watching videos.");
|
||||||
|
serviceDescription["description"] = serviceDescription["card_description"];
|
||||||
|
serviceDescription["features"] = "";
|
||||||
|
QJsonObject premiumService;
|
||||||
|
premiumService[configKey::serviceType] = serviceType::amneziaPremium;
|
||||||
|
premiumService[configKey::serviceProtocol] = "amnezia-premium";
|
||||||
|
premiumService[configKey::serviceInfo] = serviceInfo;
|
||||||
|
premiumService["service_description"] = serviceDescription;
|
||||||
|
premiumService["available_countries"] = QJsonArray();
|
||||||
|
premiumService["is_available"] = true;
|
||||||
|
premiumService["store_endpoint"] = "";
|
||||||
|
premiumService["subscription"] = QJsonObject();
|
||||||
|
services.prepend(premiumService);
|
||||||
|
data["services"] = services;
|
||||||
|
qInfo() << "[Billing] Added premium service from billing (gateway did not return it)";
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Billing] Failed to fetch product price, responseCode:" << responseCode;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
m_apiServicesModel->updateModel(data);
|
m_apiServicesModel->updateModel(data);
|
||||||
@@ -436,25 +533,19 @@ bool ApiConfigsController::fillAvailableServices()
|
|||||||
|
|
||||||
bool ApiConfigsController::importService()
|
bool ApiConfigsController::importService()
|
||||||
{
|
{
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
|
||||||
bool isIosOrMacOsNe = true;
|
|
||||||
#else
|
|
||||||
bool isIosOrMacOsNe = false;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
|
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
|
||||||
if (isIosOrMacOsNe) {
|
#if defined(Q_OS_IOS) || defined(MACOS_NE) || defined(Q_OS_ANDROID)
|
||||||
importSerivceFromAppStore();
|
importServiceFromPaymentMarket();
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
importServiceFromGateway();
|
|
||||||
return true;
|
return true;
|
||||||
|
#else
|
||||||
|
return false; // premium only via App Store / Play
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
return false;
|
importServiceFromGateway();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::importSerivceFromAppStore()
|
bool ApiConfigsController::importServiceFromPaymentMarket()
|
||||||
{
|
{
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
bool purchaseOk = false;
|
bool purchaseOk = false;
|
||||||
@@ -511,12 +602,116 @@ bool ApiConfigsController::importSerivceFromAppStore()
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||||
|
#elif defined(Q_OS_ANDROID)
|
||||||
|
auto androidController = AndroidController::instance();
|
||||||
|
QString purchaseToken;
|
||||||
|
bool purchaseOk = false;
|
||||||
|
|
||||||
|
QFutureWatcher<QPair<bool, QString>> watcher;
|
||||||
|
QEventLoop waitLoop;
|
||||||
|
connect(&watcher, &QFutureWatcher<QPair<bool, QString>>::finished, &waitLoop, &QEventLoop::quit);
|
||||||
|
|
||||||
|
QFuture<QPair<bool, QString>> future = QtConcurrent::run([androidController]() {
|
||||||
|
QJsonObject plansResult = androidController->getSubscriptionPlans();
|
||||||
|
int responseCode = plansResult.value("responseCode").toInt(-1);
|
||||||
|
qDebug().noquote() << "[Billing] importService getSubscriptionPlans plansResult:" << QJsonDocument(plansResult).toJson(QJsonDocument::Compact);
|
||||||
|
qDebug() << "[Billing] importService getSubscriptionPlans responseCode:" << responseCode;
|
||||||
|
if (responseCode != 0) {
|
||||||
|
qWarning() << "[Billing] Failed to get subscription plans, responseCode:" << responseCode;
|
||||||
|
return qMakePair(false, QString());
|
||||||
|
}
|
||||||
|
QJsonArray products = plansResult.value("products").toArray();
|
||||||
|
QString offerToken;
|
||||||
|
for (const QJsonValue &productValue : products) {
|
||||||
|
QJsonObject product = productValue.toObject();
|
||||||
|
const QString productId = product.value("productId").toString();
|
||||||
|
const bool isPremium = (productId == "premium") || productId.contains("premium");
|
||||||
|
if (isPremium) {
|
||||||
|
QJsonArray offers = product.value("offers").toArray();
|
||||||
|
if (!offers.isEmpty()) {
|
||||||
|
QJsonObject firstOffer = offers.at(0).toObject();
|
||||||
|
offerToken = firstOffer.value("offerToken").toString();
|
||||||
|
qInfo() << "[Billing] Found offer token:" << offerToken;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (offerToken.isEmpty()) {
|
||||||
|
qWarning() << "[Billing] No offer token found for premium subscription";
|
||||||
|
return qMakePair(false, QString());
|
||||||
|
}
|
||||||
|
QJsonObject purchaseResult = androidController->purchaseSubscription(offerToken);
|
||||||
|
responseCode = purchaseResult.value("responseCode").toInt(-1);
|
||||||
|
if (responseCode != 0) {
|
||||||
|
qWarning() << "[Billing] Purchase failed, responseCode:" << responseCode;
|
||||||
|
return qMakePair(false, QString());
|
||||||
|
}
|
||||||
|
QJsonArray purchases = purchaseResult.value("purchases").toArray();
|
||||||
|
if (purchases.isEmpty()) {
|
||||||
|
qWarning() << "[Billing] Purchase succeeded but no purchases returned";
|
||||||
|
return qMakePair(false, QString());
|
||||||
|
}
|
||||||
|
QJsonObject purchase = purchases.at(0).toObject();
|
||||||
|
QString token = purchase.value("purchaseToken").toString();
|
||||||
|
bool isAcknowledged = purchase.value("isAcknowledged").toBool();
|
||||||
|
qInfo() << "[Billing] Purchase success. purchaseToken:" << token << "isAcknowledged:" << isAcknowledged;
|
||||||
|
if (!isAcknowledged) {
|
||||||
|
QJsonObject ackResult = androidController->acknowledgePurchase(token);
|
||||||
|
if (ackResult.value("responseCode").toInt(-1) != 0) {
|
||||||
|
qWarning() << "[Billing] Acknowledge failed";
|
||||||
|
} else {
|
||||||
|
qInfo() << "[Billing] Purchase acknowledged successfully";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return qMakePair(true, token);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.setFuture(future);
|
||||||
|
waitLoop.exec();
|
||||||
|
|
||||||
|
purchaseOk = watcher.result().first;
|
||||||
|
purchaseToken = watcher.result().second;
|
||||||
|
|
||||||
|
if (!purchaseOk || purchaseToken.isEmpty()) {
|
||||||
|
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||||
|
QString(APP_VERSION),
|
||||||
|
m_settings->getAppLanguage().name().split("_").first(),
|
||||||
|
m_settings->getInstallationUuid(true),
|
||||||
|
m_apiServicesModel->getCountryCode(),
|
||||||
|
"",
|
||||||
|
m_apiServicesModel->getSelectedServiceType(),
|
||||||
|
m_apiServicesModel->getSelectedServiceProtocol(),
|
||||||
|
QJsonObject() };
|
||||||
|
|
||||||
|
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||||
|
apiPayload[apiDefs::key::transactionId] = purchaseToken;
|
||||||
|
bool isTestPurchase = m_settings->isDevGatewayEnv(false) || androidController->isTestPurchaseEnvironment();
|
||||||
|
|
||||||
|
ErrorCode errorCode;
|
||||||
|
QByteArray responseBody;
|
||||||
|
errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
|
||||||
|
if (errorCode != ErrorCode::NoError) {
|
||||||
|
emit errorOccurred(errorCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorCode = importServiceFromBilling(responseBody, isTestPurchase);
|
||||||
|
if (errorCode != ErrorCode::NoError) {
|
||||||
|
emit errorOccurred(errorCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||||
#endif
|
#endif
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::restoreSerivceFromAppStore()
|
bool ApiConfigsController::restoreServiceFromPaymentMarket()
|
||||||
{
|
{
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
||||||
@@ -639,6 +834,131 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
|
|||||||
qInfo().noquote() << "[IAP] Skipped" << duplicateCount
|
qInfo().noquote() << "[IAP] Skipped" << duplicateCount
|
||||||
<< "duplicate restored transactions for original transaction IDs already processed";
|
<< "duplicate restored transactions for original transaction IDs already processed";
|
||||||
}
|
}
|
||||||
|
#elif defined(Q_OS_ANDROID)
|
||||||
|
// Android Google Play Billing restore implementation
|
||||||
|
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
||||||
|
|
||||||
|
if (!fillAvailableServices()) {
|
||||||
|
qWarning() << "[Billing] Unable to fetch services list before restore";
|
||||||
|
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_apiServicesModel->rowCount() <= 0) {
|
||||||
|
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have a valid premium selection for gateway requests
|
||||||
|
bool premiumSelected = false;
|
||||||
|
for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) {
|
||||||
|
m_apiServicesModel->setServiceIndex(i);
|
||||||
|
if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) {
|
||||||
|
premiumSelected = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!premiumSelected) {
|
||||||
|
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto androidController = AndroidController::instance();
|
||||||
|
|
||||||
|
// Query existing purchases
|
||||||
|
QJsonObject purchasesResult = androidController->queryPurchases();
|
||||||
|
int responseCode = purchasesResult.value("responseCode").toInt(-1);
|
||||||
|
|
||||||
|
if (responseCode != 0) {
|
||||||
|
qWarning() << "[Billing] Failed to query purchases, responseCode:" << responseCode;
|
||||||
|
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray purchases = purchasesResult.value("purchases").toArray();
|
||||||
|
|
||||||
|
if (purchases.isEmpty()) {
|
||||||
|
qInfo() << "[Billing] No purchases found to restore";
|
||||||
|
emit errorOccurred(ErrorCode::ApiNoPurchasesToRestore);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasInstalledConfig = false;
|
||||||
|
bool duplicateConfigAlreadyPresent = false;
|
||||||
|
QSet<QString> processedTokens;
|
||||||
|
|
||||||
|
for (const QJsonValue &purchaseValue : purchases) {
|
||||||
|
QJsonObject purchase = purchaseValue.toObject();
|
||||||
|
QString purchaseToken = purchase.value("purchaseToken").toString();
|
||||||
|
bool isAcknowledged = purchase.value("isAcknowledged").toBool();
|
||||||
|
|
||||||
|
if (purchaseToken.isEmpty()) {
|
||||||
|
qWarning() << "[Billing] Skipping purchase without token";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedTokens.contains(purchaseToken)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processedTokens.insert(purchaseToken);
|
||||||
|
|
||||||
|
qInfo() << "[Billing] Restoring purchase. purchaseToken:" << purchaseToken
|
||||||
|
<< "isAcknowledged:" << isAcknowledged;
|
||||||
|
|
||||||
|
// Acknowledge purchase if needed
|
||||||
|
if (!isAcknowledged) {
|
||||||
|
QJsonObject ackResult = androidController->acknowledgePurchase(purchaseToken);
|
||||||
|
int ackResponseCode = ackResult.value("responseCode").toInt(-1);
|
||||||
|
if (ackResponseCode != 0) {
|
||||||
|
qWarning() << "[Billing] Acknowledge failed, responseCode:" << ackResponseCode;
|
||||||
|
} else {
|
||||||
|
qInfo() << "[Billing] Purchase acknowledged successfully";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send purchase token to gateway
|
||||||
|
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||||
|
QString(APP_VERSION),
|
||||||
|
m_settings->getAppLanguage().name().split("_").first(),
|
||||||
|
m_settings->getInstallationUuid(true),
|
||||||
|
m_apiServicesModel->getCountryCode(),
|
||||||
|
"",
|
||||||
|
m_apiServicesModel->getSelectedServiceType(),
|
||||||
|
m_apiServicesModel->getSelectedServiceProtocol(),
|
||||||
|
QJsonObject() };
|
||||||
|
|
||||||
|
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||||
|
apiPayload[apiDefs::key::transactionId] = purchaseToken;
|
||||||
|
bool isTestPurchase = m_settings->isDevGatewayEnv(false) || androidController->isTestPurchaseEnvironment();
|
||||||
|
|
||||||
|
QByteArray responseBody;
|
||||||
|
ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
|
||||||
|
if (errorCode != ErrorCode::NoError) {
|
||||||
|
qWarning() << "[Billing] Failed to restore purchase" << purchaseToken
|
||||||
|
<< "errorCode =" << static_cast<int>(errorCode);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorCode installError = importServiceFromBilling(responseBody, isTestPurchase);
|
||||||
|
if (installError == ErrorCode::ApiConfigAlreadyAdded) {
|
||||||
|
duplicateConfigAlreadyPresent = true;
|
||||||
|
qInfo() << "[Billing] Skipping restored purchase" << purchaseToken
|
||||||
|
<< "because subscription config with the same vpn_key already exists";
|
||||||
|
} else if (installError != ErrorCode::NoError) {
|
||||||
|
qWarning() << "[Billing] Failed to process restored subscription response for purchase" << purchaseToken;
|
||||||
|
} else {
|
||||||
|
hasInstalledConfig = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasInstalledConfig) {
|
||||||
|
const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError;
|
||||||
|
emit errorOccurred(restoreError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit installServerFromApiFinished(tr("Subscription restored successfully."));
|
||||||
#endif
|
#endif
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -944,16 +1264,16 @@ QString ApiConfigsController::getVpnKey()
|
|||||||
|
|
||||||
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase)
|
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase)
|
||||||
{
|
{
|
||||||
#ifdef Q_OS_IOS
|
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID)
|
||||||
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
|
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
|
||||||
QString key = responseObject.value(QStringLiteral("key")).toString();
|
QString key = responseObject.value(QStringLiteral("key")).toString();
|
||||||
if (key.isEmpty()) {
|
if (key.isEmpty()) {
|
||||||
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
|
qWarning().noquote() << "[IAP/Billing] Subscription response does not contain a key field";
|
||||||
return ErrorCode::ApiPurchaseError;
|
return ErrorCode::ApiPurchaseError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_serversModel->hasServerWithVpnKey(key)) {
|
if (m_serversModel->hasServerWithVpnKey(key)) {
|
||||||
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
|
qInfo().noquote() << "[IAP/Billing] Subscription config with the same vpn_key already exists";
|
||||||
return ErrorCode::ApiConfigAlreadyAdded;
|
return ErrorCode::ApiConfigAlreadyAdded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,7 +1287,7 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (configString.isEmpty()) {
|
if (configString.isEmpty()) {
|
||||||
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
|
qWarning().noquote() << "[IAP/Billing] Subscription response config payload is empty";
|
||||||
return ErrorCode::ApiPurchaseError;
|
return ErrorCode::ApiPurchaseError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ public slots:
|
|||||||
|
|
||||||
bool fillAvailableServices();
|
bool fillAvailableServices();
|
||||||
bool importService();
|
bool importService();
|
||||||
bool importSerivceFromAppStore();
|
bool importServiceFromPaymentMarket();
|
||||||
bool restoreSerivceFromAppStore();
|
bool restoreServiceFromPaymentMarket();
|
||||||
bool importServiceFromGateway();
|
bool importServiceFromGateway();
|
||||||
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
||||||
bool reloadServiceConfig = false);
|
bool reloadServiceConfig = false);
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
|
|||||||
}
|
}
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
return tr("%1 $").arg(price);
|
return tr("%1 $").arg(price);
|
||||||
|
#elif defined(Q_OS_ANDROID)
|
||||||
|
return price;
|
||||||
#else
|
#else
|
||||||
return tr("%1 $/month").arg(price);
|
return tr("%1 $/month").arg(price);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,226 +1,237 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Dialogs
|
import QtQuick.Dialogs
|
||||||
|
|
||||||
import PageEnum 1.0
|
import PageEnum 1.0
|
||||||
import Style 1.0
|
import Style 1.0
|
||||||
|
|
||||||
import "./"
|
import "./"
|
||||||
import "../Controls2"
|
import "../Controls2"
|
||||||
import "../Controls2/TextTypes"
|
import "../Controls2/TextTypes"
|
||||||
import "../Config"
|
import "../Config"
|
||||||
import "../Components"
|
import "../Components"
|
||||||
|
|
||||||
PageType {
|
PageType {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
BackButtonType {
|
BackButtonType {
|
||||||
id: backButton
|
id: backButton
|
||||||
|
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
||||||
|
|
||||||
onFocusChanged: {
|
onFocusChanged: {
|
||||||
if (this.activeFocus) {
|
if (this.activeFocus) {
|
||||||
listView.positionViewAtBeginning()
|
listView.positionViewAtBeginning()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ListViewType {
|
ListViewType {
|
||||||
id: listView
|
id: listView
|
||||||
|
|
||||||
anchors.top: backButton.bottom
|
anchors.top: backButton.bottom
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
|
|
||||||
header: ColumnLayout {
|
header: ColumnLayout {
|
||||||
width: listView.width
|
width: listView.width
|
||||||
|
|
||||||
BaseHeaderType {
|
BaseHeaderType {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: 8
|
Layout.topMargin: 8
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
Layout.bottomMargin: 32
|
Layout.bottomMargin: 32
|
||||||
|
|
||||||
headerText: ApiServicesModel.getSelectedServiceData("name")
|
headerText: ApiServicesModel.getSelectedServiceData("name")
|
||||||
descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription")
|
descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model: inputFields
|
model: inputFields
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
delegate: ColumnLayout {
|
delegate: ColumnLayout {
|
||||||
width: listView.width
|
width: listView.width
|
||||||
|
|
||||||
LabelWithImageType {
|
LabelWithImageType {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.margins: 16
|
Layout.margins: 16
|
||||||
|
|
||||||
imageSource: imagePath
|
imageSource: imagePath
|
||||||
leftText: lText
|
leftText: lText
|
||||||
rightText: rText
|
rightText: rText
|
||||||
|
|
||||||
visible: isVisible
|
visible: isVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
footer: ColumnLayout {
|
footer: ColumnLayout {
|
||||||
width: listView.width
|
width: listView.width
|
||||||
|
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
ParagraphTextType {
|
ParagraphTextType {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
onLinkActivated: function(link) {
|
onLinkActivated: function(link) {
|
||||||
Qt.openUrlExternally(link)
|
Qt.openUrlExternally(link)
|
||||||
}
|
}
|
||||||
textFormat: Text.RichText
|
textFormat: Text.RichText
|
||||||
text: {
|
text: {
|
||||||
var text = ApiServicesModel.getSelectedServiceData("features")
|
var text = ApiServicesModel.getSelectedServiceData("features")
|
||||||
return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway
|
return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.NoButton
|
acceptedButtons: Qt.NoButton
|
||||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ParagraphTextType {
|
ParagraphTextType {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: 16
|
Layout.topMargin: 16
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
|
|
||||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
visible: ((Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium") ||
|
||||||
|
(Qt.platform.os === "android" && ApiServicesModel.getSelectedServiceType() === "amnezia-premium")
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
textFormat: Text.PlainText
|
horizontalAlignment: Text.AlignHCenter
|
||||||
color: AmneziaStyle.color.mutedGray
|
textFormat: Text.PlainText
|
||||||
font.pixelSize: 12
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 12
|
||||||
text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.")
|
|
||||||
}
|
text: {
|
||||||
|
if (Qt.platform.os === "ios" || IsMacOsNeBuild) {
|
||||||
BasicButtonType {
|
return qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.")
|
||||||
id: continueButton
|
} else if (Qt.platform.os === "android") {
|
||||||
|
return qsTr("Charged to your Google Play account at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Google Play settings.")
|
||||||
Layout.fillWidth: true
|
}
|
||||||
Layout.topMargin: 32
|
return ""
|
||||||
Layout.bottomMargin: 16
|
}
|
||||||
Layout.leftMargin: 16
|
}
|
||||||
Layout.rightMargin: 16
|
|
||||||
|
BasicButtonType {
|
||||||
text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : qsTr("Connect")
|
id: continueButton
|
||||||
|
|
||||||
clickedFunc: function() {
|
Layout.fillWidth: true
|
||||||
PageController.showBusyIndicator(true)
|
Layout.topMargin: 32
|
||||||
var result = ApiConfigsController.importService()
|
Layout.bottomMargin: 16
|
||||||
PageController.showBusyIndicator(false)
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
if (!result) {
|
|
||||||
var endpoint = ApiServicesModel.getStoreEndpoint()
|
text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : qsTr("Connect")
|
||||||
Qt.openUrlExternally(endpoint)
|
|
||||||
PageController.closePage()
|
clickedFunc: function() {
|
||||||
PageController.closePage()
|
PageController.showBusyIndicator(true)
|
||||||
}
|
var result = ApiConfigsController.importService()
|
||||||
}
|
PageController.showBusyIndicator(false)
|
||||||
}
|
|
||||||
|
if (!result) {
|
||||||
ParagraphTextType {
|
var endpoint = ApiServicesModel.getStoreEndpoint()
|
||||||
Layout.fillWidth: true
|
Qt.openUrlExternally(endpoint)
|
||||||
Layout.topMargin: 16
|
PageController.closePage()
|
||||||
Layout.leftMargin: 16
|
PageController.closePage()
|
||||||
Layout.rightMargin: 16
|
}
|
||||||
Layout.bottomMargin: 32
|
}
|
||||||
|
}
|
||||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
|
||||||
|
ParagraphTextType {
|
||||||
horizontalAlignment: Text.AlignHCenter
|
Layout.fillWidth: true
|
||||||
textFormat: Text.RichText
|
Layout.topMargin: 16
|
||||||
color: AmneziaStyle.color.mutedGray
|
Layout.leftMargin: 16
|
||||||
font.pixelSize: 12
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 32
|
||||||
text: {
|
|
||||||
var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
visible: ((Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium") ||
|
||||||
var privacyUrl = LanguageModel.getCurrentSiteUrl("policy")
|
(Qt.platform.os === "android" && ApiServicesModel.getSelectedServiceType() === "amnezia-premium")
|
||||||
return qsTr("By continuing, you agree to the <a href=\"%1\" style=\"color: #FBB26A;\">Terms of Use</a> and <a href=\"%2\" style=\"color: #FBB26A;\">Privacy Policy</a>").arg(termsUrl).arg(privacyUrl)
|
|
||||||
}
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
textFormat: Text.RichText
|
||||||
onLinkActivated: function(link) {
|
color: AmneziaStyle.color.mutedGray
|
||||||
Qt.openUrlExternally(link)
|
font.pixelSize: 12
|
||||||
}
|
|
||||||
|
text: {
|
||||||
MouseArea {
|
var termsUrl = Qt.platform.os === "ios" || IsMacOsNeBuild ?
|
||||||
anchors.fill: parent
|
"https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" :
|
||||||
acceptedButtons: Qt.NoButton
|
"https://play.google.com/intl/en_us/about/play-terms/"
|
||||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
var privacyUrl = LanguageModel.getCurrentSiteUrl("policy")
|
||||||
}
|
return qsTr("By continuing, you agree to the <a href=\"%1\" style=\"color: #FBB26A;\">Terms of Use</a> and <a href=\"%2\" style=\"color: #FBB26A;\">Privacy Policy</a>").arg(termsUrl).arg(privacyUrl)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
onLinkActivated: function(link) {
|
||||||
|
Qt.openUrlExternally(link)
|
||||||
property list<QtObject> inputFields: [
|
}
|
||||||
region,
|
|
||||||
price,
|
MouseArea {
|
||||||
timeLimit,
|
anchors.fill: parent
|
||||||
speed,
|
acceptedButtons: Qt.NoButton
|
||||||
features
|
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
]
|
}
|
||||||
|
}
|
||||||
QtObject {
|
}
|
||||||
id: region
|
}
|
||||||
|
|
||||||
readonly property string imagePath: "qrc:/images/controls/map-pin.svg"
|
property list<QtObject> inputFields: [
|
||||||
readonly property string lText: qsTr("For the region")
|
region,
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("region")
|
price,
|
||||||
property bool isVisible: true
|
timeLimit,
|
||||||
}
|
speed,
|
||||||
|
features
|
||||||
QtObject {
|
]
|
||||||
id: price
|
|
||||||
|
QtObject {
|
||||||
readonly property string imagePath: "qrc:/images/controls/tag.svg"
|
id: region
|
||||||
readonly property string lText: qsTr("Price")
|
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("price")
|
readonly property string imagePath: "qrc:/images/controls/map-pin.svg"
|
||||||
property bool isVisible: true
|
readonly property string lText: qsTr("For the region")
|
||||||
}
|
readonly property string rText: ApiServicesModel.getSelectedServiceData("region")
|
||||||
|
property bool isVisible: true
|
||||||
QtObject {
|
}
|
||||||
id: timeLimit
|
|
||||||
|
QtObject {
|
||||||
readonly property string imagePath: "qrc:/images/controls/history.svg"
|
id: price
|
||||||
readonly property string lText: qsTr("Work period")
|
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit")
|
readonly property string imagePath: "qrc:/images/controls/tag.svg"
|
||||||
property bool isVisible: rText !== ""
|
readonly property string lText: qsTr("Price")
|
||||||
}
|
readonly property string rText: ApiServicesModel.getSelectedServiceData("price")
|
||||||
|
property bool isVisible: true
|
||||||
QtObject {
|
}
|
||||||
id: speed
|
|
||||||
|
QtObject {
|
||||||
readonly property string imagePath: "qrc:/images/controls/gauge.svg"
|
id: timeLimit
|
||||||
readonly property string lText: qsTr("Speed")
|
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("speed")
|
readonly property string imagePath: "qrc:/images/controls/history.svg"
|
||||||
property bool isVisible: true
|
readonly property string lText: qsTr("Work period")
|
||||||
}
|
readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit")
|
||||||
|
property bool isVisible: rText !== ""
|
||||||
QtObject {
|
}
|
||||||
id: features
|
|
||||||
|
QtObject {
|
||||||
readonly property string imagePath: "qrc:/images/controls/info.svg"
|
id: speed
|
||||||
readonly property string lText: qsTr("Features")
|
|
||||||
readonly property string rText: ""
|
readonly property string imagePath: "qrc:/images/controls/gauge.svg"
|
||||||
property bool isVisible: true
|
readonly property string lText: qsTr("Speed")
|
||||||
}
|
readonly property string rText: ApiServicesModel.getSelectedServiceData("speed")
|
||||||
}
|
property bool isVisible: true
|
||||||
|
}
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: features
|
||||||
|
|
||||||
|
readonly property string imagePath: "qrc:/images/controls/info.svg"
|
||||||
|
readonly property string lText: qsTr("Features")
|
||||||
|
readonly property string rText: ""
|
||||||
|
property bool isVisible: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -358,10 +358,10 @@ PageType {
|
|||||||
property string title: qsTr("Restore purchases")
|
property string title: qsTr("Restore purchases")
|
||||||
property string description: qsTr("")
|
property string description: qsTr("")
|
||||||
property string imageSource: "qrc:/images/controls/refresh-cw.svg"
|
property string imageSource: "qrc:/images/controls/refresh-cw.svg"
|
||||||
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild
|
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild || Qt.platform.os === "android"
|
||||||
property var handler: function() {
|
property var handler: function() {
|
||||||
PageController.showBusyIndicator(true)
|
PageController.showBusyIndicator(true)
|
||||||
ApiConfigsController.restoreSerivceFromAppStore()
|
ApiConfigsController.restoreServiceFromPaymentMarket()
|
||||||
PageController.showBusyIndicator(false)
|
PageController.showBusyIndicator(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-6
@@ -23,6 +23,7 @@ Options:
|
|||||||
By default, the latest available platform is used
|
By default, the latest available platform is used
|
||||||
-m, --move Move the build result to the root of the build directory
|
-m, --move Move the build result to the root of the build directory
|
||||||
-f, --fdroid Build for F-Droid
|
-f, --fdroid Build for F-Droid
|
||||||
|
-p, --play Build AAB for Google Play
|
||||||
-h, --help Display this help
|
-h, --help Display this help
|
||||||
|
|
||||||
EOT
|
EOT
|
||||||
@@ -30,7 +31,7 @@ EOT
|
|||||||
|
|
||||||
BUILD_TYPE="release"
|
BUILD_TYPE="release"
|
||||||
|
|
||||||
opts=$(getopt -l debug,aab,apk:,build-platform:,move,fdroid,help -o "dua:b:mfh" -- "$@")
|
opts=$(getopt -l debug,aab,apk:,build-platform:,move,fdroid,play,help -o "dua:b:mfph" -- "$@")
|
||||||
eval set -- "$opts"
|
eval set -- "$opts"
|
||||||
while true; do
|
while true; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -40,6 +41,7 @@ while true; do
|
|||||||
-b | --build-platform) ANDROID_BUILD_PLATFORM=$2; shift 2;;
|
-b | --build-platform) ANDROID_BUILD_PLATFORM=$2; shift 2;;
|
||||||
-m | --move) MOVE_RESULT=1; shift;;
|
-m | --move) MOVE_RESULT=1; shift;;
|
||||||
-f | --fdroid) FDROID=1; shift;;
|
-f | --fdroid) FDROID=1; shift;;
|
||||||
|
-p | --play) PLAY=1; shift;;
|
||||||
-h | --help) usage; exit 0;;
|
-h | --help) usage; exit 0;;
|
||||||
--) shift; break;;
|
--) shift; break;;
|
||||||
esac
|
esac
|
||||||
@@ -149,11 +151,17 @@ if [ -v FDROID ]; then
|
|||||||
BUILD_TYPE="fdroid"
|
BUILD_TYPE="fdroid"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -v PLAY ]; then
|
||||||
|
AAB_FLAVOR="play"
|
||||||
|
else
|
||||||
|
AAB_FLAVOR="oss"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -v AAB ]; then
|
if [ -v AAB ]; then
|
||||||
gradle_opts+=(bundle"${BUILD_TYPE^}")
|
gradle_opts+=(bundle"${AAB_FLAVOR^}${BUILD_TYPE^}")
|
||||||
fi
|
fi
|
||||||
if [ -v ABIS ]; then
|
if [ -v ABIS ]; then
|
||||||
gradle_opts+=(assemble"${BUILD_TYPE^}")
|
gradle_opts+=(assembleOss"${BUILD_TYPE^}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
$OUT_APP_DIR/android-build/gradlew \
|
$OUT_APP_DIR/android-build/gradlew \
|
||||||
@@ -164,7 +172,7 @@ $OUT_APP_DIR/android-build/gradlew \
|
|||||||
if [[ -v CI || -v MOVE_RESULT ]]; then
|
if [[ -v CI || -v MOVE_RESULT ]]; then
|
||||||
echo "Moving APK/AAB..."
|
echo "Moving APK/AAB..."
|
||||||
if [ -v AAB ]; then
|
if [ -v AAB ]; then
|
||||||
mv -u $OUT_APP_DIR/android-build/build/outputs/bundle/$BUILD_TYPE/AmneziaVPN-$BUILD_TYPE.aab \
|
mv -u $OUT_APP_DIR/android-build/build/outputs/bundle/$AAB_FLAVOR"${BUILD_TYPE^}"/AmneziaVPN-$AAB_FLAVOR-$BUILD_TYPE.aab \
|
||||||
$PROJECT_DIR/deploy/build/
|
$PROJECT_DIR/deploy/build/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -181,8 +189,8 @@ if [[ -v CI || -v MOVE_RESULT ]]; then
|
|||||||
IFS=';' read -r -a abi_array <<< "$ABIS"
|
IFS=';' read -r -a abi_array <<< "$ABIS"
|
||||||
for ABI in "${abi_array[@]}"
|
for ABI in "${abi_array[@]}"
|
||||||
do
|
do
|
||||||
mv -u $OUT_APP_DIR/android-build/build/outputs/apk/$BUILD_TYPE/AmneziaVPN-$ABI-$suffix.apk \
|
mv -u $OUT_APP_DIR/android-build/build/outputs/apk/oss/$BUILD_TYPE/AmneziaVPN-oss-$ABI-$suffix.apk \
|
||||||
$PROJECT_DIR/deploy/build/
|
$PROJECT_DIR/deploy/build/
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
Reference in New Issue
Block a user