Compare commits

..

2 Commits

Author SHA1 Message Date
yp
a9861d18b7 fix: wrong index on xray pages (#2669)
* test crash xray

* fixed save config xray

* reset file

* fixed text port & reset file

* fixed textFieldWithHeaderType.textField
2026-06-01 12:22:54 +08:00
lunardunno
c14138f031 fix: deleting volumes when cleaning the server (#2673)
* Deleting volumes when cleaning the server

* force the remove volumes
2026-06-01 11:54:34 +08:00
37 changed files with 74 additions and 1233 deletions

View File

@@ -854,41 +854,13 @@ jobs:
VERSION=$(grep CMAKE_PROJECT_VERSION:STATIC deploy/build/CMakeCache.txt | cut -d= -f2)
(cd deploy/build/client/android-build && mv AmneziaVPN.apk AmneziaVPN_${VERSION}_android9+_universal.apk)
(cd deploy/build/client/android-build/build/outputs/bundle/ossRelease && mv android-build-oss-release.aab AmneziaVPN_${VERSION}_oss.aab)
(cd deploy/build/client/android-build/build/outputs/bundle/release && mv android-build-release.aab AmneziaVPN_${VERSION}.aab)
for abi in arm64-v8a armeabi-v7a x86 x86_64; do
deploy/build.sh -t android --sign --abi ${abi} --build ./deploy/build/${abi}
(cd deploy/build/${abi}/client/android-build && mv AmneziaVPN.apk AmneziaVPN_${VERSION}_android9+_${abi}.apk)
done
- name: 'Build Play AAB'
env:
QT_INSTALL_DIR: ${{ runner.temp }}
QT_ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore
QT_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
QT_ANDROID_KEYSTORE_STORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
shell: bash
run: |
VERSION=$(grep CMAKE_PROJECT_VERSION:STATIC deploy/build/CMakeCache.txt | cut -d= -f2)
deploy/build.sh -t android --sign --aab --play --build ./deploy/build/play
(cd deploy/build/play/client/android-build/build/outputs/bundle/playRelease && mv android-build-play-release.aab AmneziaVPN_${VERSION}_play.aab)
- name: 'Build Play APK'
env:
QT_INSTALL_DIR: ${{ runner.temp }}
QT_ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore
QT_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
QT_ANDROID_KEYSTORE_STORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
shell: bash
run: |
VERSION=$(grep CMAKE_PROJECT_VERSION:STATIC deploy/build/CMakeCache.txt | cut -d= -f2)
deploy/build.sh -t android --sign --apk --play --build ./deploy/build/play-apk
(cd deploy/build/play-apk/client/android-build && mv AmneziaVPN.apk AmneziaVPN_${VERSION}_play.apk)
- name: 'Upload universal APK'
uses: actions/upload-artifact@v7
with:
@@ -896,24 +868,10 @@ jobs:
archive: false
retention-days: 7
- name: 'Upload OSS AAB'
- name: 'Upload AAB'
uses: actions/upload-artifact@v7
with:
path: deploy/build/client/android-build/build/outputs/bundle/ossRelease/*.aab
archive: false
retention-days: 7
- name: 'Upload Play AAB'
uses: actions/upload-artifact@v7
with:
path: deploy/build/play/client/android-build/build/outputs/bundle/playRelease/*.aab
archive: false
retention-days: 7
- name: 'Upload Play APK'
uses: actions/upload-artifact@v7
with:
path: deploy/build/play-apk/client/android-build/*.apk
path: deploy/build/client/android-build/build/outputs/bundle/release/*.aab
archive: false
retention-days: 7

View File

@@ -1,19 +0,0 @@
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)
}

View File

@@ -1,65 +0,0 @@
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
}
}
}
}

View File

@@ -1,320 +0,0 @@
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

View File

@@ -20,7 +20,6 @@ android {
namespace = "org.amnezia.vpn"
buildFeatures {
buildConfig = true
viewBinding = true
}
@@ -34,54 +33,13 @@ android {
jniLibs.useLegacyPackaging = true
}
val abiList = qtTargetAbiList.split(",")
defaultConfig {
applicationId = "org.amnezia.vpn"
targetSdk = qtTargetSdkVersion.toInt()
// keeps language resources for only the locales specified below
resourceConfigurations += listOf("en", "ru", "b+zh+Hans")
// ndk.abiFilters is only used for single-ABI builds; multi-ABI uses splits below
if (abiList.size == 1) {
ndk.abiFilters += abiList
}
}
signingConfigs {
register("release") {
storeFile = providers.environmentVariable("QT_ANDROID_KEYSTORE_PATH").orNull?.let { file(it) }
storePassword = providers.environmentVariable("QT_ANDROID_KEYSTORE_STORE_PASS").orNull
keyAlias = providers.environmentVariable("QT_ANDROID_KEYSTORE_ALIAS").orNull
keyPassword = providers.environmentVariable("QT_ANDROID_KEYSTORE_STORE_PASS").orNull
}
}
buildTypes {
release {
// exclude coroutine debug resource from release build
packaging {
resources.excludes += "DebugProbesKt.bin"
}
signingConfig = signingConfigs["release"]
}
create("fdroid") {
initWith(getByName("release"))
signingConfig = null
matchingFallbacks += "release"
}
}
flavorDimensions += "billing"
productFlavors {
create("oss") {
dimension = "billing"
}
create("play") {
dimension = "billing"
}
ndk.abiFilters += qtTargetAbiList.split(",")
}
sourceSets {
@@ -93,55 +51,13 @@ android {
assets.setSrcDirs(listOf("assets"))
jniLibs.setSrcDirs(listOf("libs"))
}
getByName("oss") {
java.setSrcDirs(listOf("oss"))
}
getByName("play") {
java.setSrcDirs(listOf("play"))
}
}
splits {
abi {
// splits only make sense for multi-ABI builds; single-ABI uses ndk.abiFilters
isEnable = abiList.size > 1
reset()
include(*abiList.toTypedArray())
isUniversalApk = false
}
}
// fix for Qt Creator to allow deploying the application to a device
// to enable this fix, add the line outputBaseName=android-build to local.properties
if (outputBaseName.isNotEmpty()) {
applicationVariants.all {
outputs.map { it as BaseVariantOutputImpl }
.forEach { output ->
if (output.outputFileName.endsWith(".apk")) {
output.outputFileName = "$outputBaseName-${buildType.name}.apk"
}
}
// Qt cmake expects APK at build/outputs/apk/ (no flavor/buildType subdirectories).
// With product flavors Gradle puts it under build/outputs/apk/{flavor}/{buildType}/.
// Copy after packaging so Qt's zipalign/apksigner step can find it.
val flavorName = productFlavors.firstOrNull()?.name ?: ""
if (flavorName.isNotEmpty()) {
val buildTypeName = buildType.name
val variantName = name.replaceFirstChar { it.uppercase() }
tasks.named("package$variantName") {
doLast {
val srcDir = layout.buildDirectory.dir("outputs/apk/$flavorName/$buildTypeName").get().asFile
//val dstDir = layout.buildDirectory.dir("outputs/apk/$buildTypeName").get().asFile
val dstDir = layout.buildDirectory.dir("outputs/apk").get().asFile
dstDir.mkdirs()
srcDir.listFiles()?.filter { it.name.endsWith(".apk") }?.forEach { apk ->
apk.copyTo(File(dstDir, apk.name), overwrite = true)
}
}
}
buildTypes {
release {
// exclude coroutine debug resource from release build
packaging {
resources.excludes += "DebugProbesKt.bin"
}
}
}
@@ -168,9 +84,4 @@ dependencies {
implementation(libs.google.mlkit)
implementation(libs.androidx.datastore)
implementation(libs.androidx.biometric)
playImplementation(project(":billing"))
}
fun DependencyHandler.playImplementation(dependency: Any): Dependency? =
add("playImplementation", dependency)

View File

@@ -1,7 +1,6 @@
[versions]
agp = "8.6.1"
kotlin = "1.9.24"
android-billing = "7.0.0"
androidx-core = "1.13.1"
androidx-activity = "1.9.1"
androidx-annotation = "1.8.2"
@@ -15,7 +14,6 @@ kotlinx-serialization = "1.6.3"
google-mlkit = "17.3.0"
[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-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }

View File

@@ -1,13 +0,0 @@
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 = ""
}

View File

@@ -1,34 +0,0 @@
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()
}
}

View File

@@ -30,7 +30,6 @@ rootProject.buildFileName = "build.gradle.kts"
include(":qt")
include(":utils")
include(":billing")
include(":protocolApi")
include(":wireguard")
include(":awg")

View File

@@ -55,6 +55,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.amnezia.vpn.protocol.getStatistics
import org.amnezia.vpn.protocol.getStatus
import org.amnezia.vpn.qt.QtAndroidController
@@ -88,7 +89,6 @@ class AmneziaActivity : QtActivity() {
private var notificationStateReceiver: BroadcastReceiver? = null
private lateinit var vpnServiceMessenger: IpcMessenger
private var pfd: ParcelFileDescriptor? = null
private lateinit var billingRepository: BillingRepository
private val actionResultHandlers = mutableMapOf<Int, ActivityResultHandler>()
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
@@ -205,7 +205,6 @@ class AmneziaActivity : QtActivity() {
registerBroadcastReceivers()
intent?.let(::processIntent)
runBlocking { vpnProto = proto.await() }
billingRepository = BillingPaymentRepository(applicationContext)
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -984,9 +983,15 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused")
fun getAppList(): String {
Log.v(TAG, "Get app list")
return blockingCall(Dispatchers.IO) {
AppListProvider.getAppList(packageManager, packageName)
var appList = ""
runBlocking {
mainScope.launch {
withContext(Dispatchers.IO) {
appList = AppListProvider.getAppList(packageManager, packageName)
}
}.join()
}
return appList
}
@Suppress("unused")
@@ -1157,59 +1162,11 @@ class AmneziaActivity : QtActivity() {
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
*/
private fun <T> blockingCall(
context: CoroutineContext = Dispatchers.Default,
context: CoroutineContext = Dispatchers.Main.immediate,
block: suspend () -> T
) = runBlocking {
mainScope.async(context) { block() }.await()

View File

@@ -1,6 +1,5 @@
package org.amnezia.vpn
import android.system.Os
import androidx.camera.camera2.Camera2Config
import androidx.camera.core.CameraSelector
import androidx.camera.core.CameraXConfig
@@ -13,9 +12,6 @@ private const val TAG = "AmneziaApplication"
class AmneziaApplication : QtApplication(), CameraXConfig.Provider {
override fun onCreate() {
if (BuildConfig.DEBUG) {
Os.setenv("QT_ANDROID_DEBUGGER_MAIN_THREAD_SLEEP_MS", "0", true)
}
super.onCreate()
Prefs.init(this)
Log.init(this)

View File

@@ -1,12 +0,0 @@
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
}

View File

@@ -1,14 +0,0 @@
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
}

View File

@@ -1,9 +1,5 @@
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(ANDROID_PLATFORM "android-${APP_ANDROID_MIN_SDK}" CACHE STRING
"The minimum API level supported by the application or library" FORCE)
@@ -57,24 +53,3 @@ file(COPY ${AMNEZIA_LIBXRAY_PATH} DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/androi
find_package(openvpn-pt-android REQUIRED)
set(LIBS ${LIBS} amnezia::openvpn-pt-android)
set_property(TARGET ${PROJECT} APPEND PROPERTY QT_ANDROID_EXTRA_LIBS ${OPENVPN_PT_ANDROID_LIBCK_OVPN_PLUGIN_PATH})
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 APK (assemblePlay${_gradle_suffix})"
DEPENDS ${PROJECT}
)
add_custom_target(android_play_aab
COMMAND ./gradlew bundlePlay${_gradle_suffix} -DexplicitRun=1
WORKING_DIRECTORY "${_android_build_dir}"
COMMENT "Building Android Play AAB (bundlePlay${_gradle_suffix})"
DEPENDS ${PROJECT}
)
endif()

View File

@@ -33,9 +33,6 @@
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include "platforms/ios/ios_controller.h"
#include <AmneziaVPN-Swift.h>
#elif defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#include <QtConcurrent>
#endif
using namespace amnezia;
@@ -330,7 +327,7 @@ ErrorCode SubscriptionController::importTrialFromGateway(const QString &userCoun
return ErrorCode::NoError;
}
ErrorCode SubscriptionController::importServiceFromMarket(const QString &userCountryCode, const QString &serviceType,
ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,
int *duplicateServerIndex)
@@ -789,7 +786,7 @@ ErrorCode SubscriptionController::processAppStorePurchase(const QString &userCou
bool isTestPurchase = IosController::Instance()->isTestFlight();
ProtocolData protocolData = generateProtocolData(serviceProtocol);
return importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
return importServiceFromAppStore(userCountryCode, serviceType, serviceProtocol, protocolData,
originalTransactionId, isTestPurchase, duplicateServerIndex);
#else
Q_UNUSED(userCountryCode);
@@ -800,117 +797,6 @@ ErrorCode SubscriptionController::processAppStorePurchase(const QString &userCou
#endif
}
ErrorCode SubscriptionController::processPlayMarketPurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex)
{
#if defined(Q_OS_ANDROID)
auto androidController = AndroidController::instance();
QString purchaseToken;
bool purchaseOk = false;
QFutureWatcher<QPair<bool, QString>> watcher;
QEventLoop waitLoop;
QObject::connect(&watcher, &QFutureWatcher<QPair<bool, QString>>::finished, &waitLoop, &QEventLoop::quit);
QFuture<QPair<bool, QString>> future = QtConcurrent::run([androidController, productId]() {
QJsonObject plansResult = androidController->getSubscriptionPlans();
int responseCode = plansResult.value("responseCode").toInt(-1);
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();
if (product.value("productId").toString() == productId) {
QJsonArray offers = product.value("offers").toArray();
if (!offers.isEmpty()) {
offerToken = offers.at(0).toObject().value("offerToken").toString();
qInfo() << "[Billing] Found offer token for product:" << productId;
break;
}
}
}
if (offerToken.isEmpty()) {
qWarning() << "[Billing] No offer token found for product:" << productId;
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()) {
return ErrorCode::ApiPurchaseError;
}
// First call: determine if this is a test purchase
GatewayRequestData checkRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
userCountryCode,
"",
serviceType,
serviceProtocol,
QJsonObject() };
QJsonObject checkPayload = checkRequestData.toJsonObject();
checkPayload[apiDefs::key::transactionId] = purchaseToken;
QByteArray checkResponse;
ErrorCode checkError = executeRequest(QString("%1v1/subscriptions"), checkPayload, checkResponse, false);
if (checkError != ErrorCode::NoError) {
qWarning().noquote() << "[Billing] Initial subscriptions check failed:" << static_cast<int>(checkError);
return checkError;
}
QJsonObject checkObject = QJsonDocument::fromJson(checkResponse).object();
bool isTestPurchase = checkObject.value(apiDefs::key::isTestPurchase).toBool(false);
qInfo().noquote() << "[Billing] Purchase isTestPurchase =" << isTestPurchase;
// Second call: import service with correct isTestPurchase flag
ProtocolData protocolData = generateProtocolData(serviceProtocol);
return importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
purchaseToken, isTestPurchase, duplicateServerIndex);
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
Q_UNUSED(productId);
return ErrorCode::ApiPurchaseError;
#endif
}
SubscriptionController::AppStoreRestoreResult SubscriptionController::processAppStoreRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol)
{
@@ -966,7 +852,7 @@ SubscriptionController::AppStoreRestoreResult SubscriptionController::processApp
ProtocolData protocolData = generateProtocolData(serviceProtocol);
int currentDuplicateServerIndex = -1;
ErrorCode errorCode = importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
ErrorCode errorCode = importServiceFromAppStore(userCountryCode, serviceType, serviceProtocol, protocolData,
originalTransactionId, isTestPurchase,
&currentDuplicateServerIndex);
@@ -999,146 +885,14 @@ SubscriptionController::AppStoreRestoreResult SubscriptionController::processApp
#endif
}
SubscriptionController::PlayMarketRestoreResult SubscriptionController::processPlayMarketRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol)
{
PlayMarketRestoreResult result;
#if defined(Q_OS_ANDROID)
auto androidController = AndroidController::instance();
QJsonObject purchasesResult;
{
QFutureWatcher<QJsonObject> queryWatcher;
QEventLoop queryLoop;
QObject::connect(&queryWatcher, &QFutureWatcher<QJsonObject>::finished, &queryLoop, &QEventLoop::quit);
QFuture<QJsonObject> queryFuture = QtConcurrent::run([androidController]() {
return androidController->queryPurchases();
});
queryWatcher.setFuture(queryFuture);
queryLoop.exec();
purchasesResult = queryWatcher.result();
}
int responseCode = purchasesResult.value("responseCode").toInt(-1);
if (responseCode != 0) {
qWarning().noquote() << "[Billing] queryPurchases failed, responseCode =" << responseCode;
result.errorCode = ErrorCode::ApiPurchaseError;
return result;
}
QJsonArray purchases = purchasesResult.value("purchases").toArray();
if (purchases.isEmpty()) {
qInfo().noquote() << "[Billing] Restore completed, but no purchases were found";
result.errorCode = ErrorCode::ApiNoPurchasesToRestore;
return result;
}
QSet<QString> processedTokens;
for (const QJsonValue &purchaseValue : std::as_const(purchases)) {
const QJsonObject purchaseObj = purchaseValue.toObject();
const QString purchaseToken = purchaseObj.value("purchaseToken").toString();
if (purchaseToken.isEmpty()) {
qWarning().noquote() << "[Billing] Skipping purchase without purchaseToken";
continue;
}
if (processedTokens.contains(purchaseToken)) {
result.duplicateCount++;
continue;
}
processedTokens.insert(purchaseToken);
qInfo().noquote() << "[Billing] Restoring subscription with purchaseToken =" << purchaseToken;
{
QFutureWatcher<QJsonObject> ackWatcher;
QEventLoop ackLoop;
QObject::connect(&ackWatcher, &QFutureWatcher<QJsonObject>::finished, &ackLoop, &QEventLoop::quit);
QFuture<QJsonObject> ackFuture = QtConcurrent::run([androidController, purchaseToken]() {
return androidController->acknowledgePurchase(purchaseToken);
});
ackWatcher.setFuture(ackFuture);
ackLoop.exec();
QJsonObject ackResult = ackWatcher.result();
int ackCode = ackResult.value("responseCode").toInt(-1);
if (ackCode != 0) {
qWarning().noquote() << "[Billing] acknowledgePurchase failed, responseCode =" << ackCode;
} else {
qInfo().noquote() << "[Billing] Purchase acknowledged successfully";
}
}
GatewayRequestData checkRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
userCountryCode,
"",
serviceType,
serviceProtocol,
QJsonObject() };
QJsonObject checkPayload = checkRequestData.toJsonObject();
checkPayload[apiDefs::key::transactionId] = purchaseToken;
QByteArray checkResponse;
ErrorCode checkError = executeRequest(QString("%1v1/subscriptions"), checkPayload, checkResponse, false);
if (checkError != ErrorCode::NoError) {
qWarning().noquote() << "[Billing] Initial subscriptions check failed:" << static_cast<int>(checkError);
result.errorCode = checkError;
continue;
}
QJsonObject checkObject = QJsonDocument::fromJson(checkResponse).object();
bool isTestPurchase = checkObject.value(apiDefs::key::isTestPurchase).toBool(false);
qInfo().noquote() << "[Billing] Purchase isTestPurchase =" << isTestPurchase;
ProtocolData protocolData = generateProtocolData(serviceProtocol);
int currentDuplicateServerIndex = -1;
ErrorCode errorCode = importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
purchaseToken, isTestPurchase,
&currentDuplicateServerIndex);
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
result.duplicateConfigAlreadyPresent = true;
if (result.duplicateServerIndex < 0) {
result.duplicateServerIndex = currentDuplicateServerIndex;
}
qInfo().noquote() << "[Billing] Skipping purchase" << purchaseToken
<< "because subscription config with the same vpn_key already exists";
} else if (errorCode != ErrorCode::NoError) {
qWarning().noquote() << "[Billing] Failed to process restored subscription for purchaseToken =" << purchaseToken;
result.errorCode = errorCode;
} else {
result.hasInstalledConfig = true;
}
}
if (!result.hasInstalledConfig) {
result.errorCode = result.duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiNoPurchasesToRestore;
}
return result;
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
result.errorCode = ErrorCode::ApiPurchaseError;
return result;
#endif
}
ErrorCode SubscriptionController::getAccountInfo(const QString &serverId, QJsonObject &accountInfo)
{
auto apiV2Opt = m_serversRepository->apiV2Config(serverId);
if (!apiV2Opt.has_value()) {
auto apiV2 = m_serversRepository->apiV2Config(serverId);
if (!apiV2.has_value()) {
return ErrorCode::InternalError;
}
const ApiV2ServerConfig* apiV2 = &apiV2Opt.value();
bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),

View File

@@ -61,7 +61,7 @@ public:
ErrorCode importTrialFromGateway(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &email);
ErrorCode importServiceFromMarket(const QString &userCountryCode, const QString &serviceType,
ErrorCode importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,
int *duplicateServerIndex = nullptr);
@@ -99,23 +99,10 @@ public:
ErrorCode errorCode = ErrorCode::NoError;
};
struct PlayMarketRestoreResult
{
bool hasInstalledConfig = false;
bool duplicateConfigAlreadyPresent = false;
int duplicateCount = 0;
int duplicateServerIndex = -1;
ErrorCode errorCode = ErrorCode::NoError;
};
ErrorCode processAppStorePurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex = nullptr);
ErrorCode processPlayMarketPurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex = nullptr);
AppStoreRestoreResult processAppStoreRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol);
@@ -124,9 +111,6 @@ public:
const QString &captchaId, const QString &captchaSolution,
CaptchaInfo *retryCaptchaOut = nullptr);
PlayMarketRestoreResult processPlayMarketRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol);
private:
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false);
bool isApiKeyExpired(const QString &serverId) const;

View File

@@ -105,7 +105,6 @@ namespace amnezia
ApiCaptchaInvalidError = 1118,
ApiCaptchaRefreshError = 1119,
ApiRateLimitError = 1120,
ApiNoPurchasesToRestore = 1121,
// QFile errors
OpenError = 1200,
@@ -113,16 +112,7 @@ namespace amnezia
PermissionsError = 1202,
UnspecifiedError = 1203,
FatalError = 1204,
AbortError = 1205,
// Billing errors
BillingCanceled = 1300,
BillingError = 1301,
BillingGooglePlayError = 1302,
BillingUnavailable = 1303,
SubscriptionAlreadyOwned = 1304,
SubscriptionUnavailable = 1305,
BillingNetworkError = 1306,
AbortError = 1205
};
Q_ENUM_NS(ErrorCode)
}

View File

@@ -97,15 +97,6 @@ QString errorString(ErrorCode code) {
case (ErrorCode::ApiCaptchaInvalidError): errorMessage = QObject::tr("CAPTCHA was incorrect. Please try again"); break;
case (ErrorCode::ApiCaptchaRefreshError): errorMessage = QObject::tr("CAPTCHA refreshed. Please try again"); break;
case (ErrorCode::ApiRateLimitError): errorMessage = QObject::tr("Too many requests. Please try again later"); 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
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
@@ -115,15 +106,6 @@ QString errorString(ErrorCode code) {
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;
// 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):
default:
errorMessage = QObject::tr("Internal error"); break;

View File

@@ -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.
// - 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 -------------------------------------------- use the JSON value
// - Else -------------------------------------------->>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> use the JSON value
//
#define __vmess_checker__func(key, values) \
{ \

View File

@@ -328,57 +328,6 @@ void AndroidController::sendTouch(float x, float 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
jclass AndroidController::log;
jmethodID AndroidController::logDebug;

View File

@@ -55,13 +55,6 @@ public:
void requestNotificationPermission();
bool requestAuthentication();
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 void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);

View File

@@ -1,5 +1,6 @@
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker stop;\
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker rm -fv;\
sudo docker images -a --format table | grep amnezia | awk '{print $3, $1 ":" $2}' | xargs sudo docker rmi;\
sudo docker volume ls | grep amnezia | awk '{print $2}' | xargs sudo docker volume rm -f;\
sudo docker network ls | grep amnezia-dns-net | awk '{print $1}' | xargs sudo docker network rm;\
sudo rm -frd /opt/amnezia

View File

@@ -227,36 +227,6 @@ bool SubscriptionUiController::importPremiumFromAppStore(const QString &storePro
return true;
}
bool SubscriptionUiController::importPremiumFromPlayMarket(const QString &storeProductId)
{
#if defined(Q_OS_ANDROID)
QString productId = storeProductId.trimmed();
if (productId.isEmpty()) {
productId = QStringLiteral("premium");
}
int duplicateServerIndex = -1;
ErrorCode errorCode = m_subscriptionController->processPlayMarketPurchase(
m_apiServicesModel->getCountryCode(),
m_apiServicesModel->getSelectedServiceType(),
m_apiServicesModel->getSelectedServiceProtocol(),
productId,
&duplicateServerIndex);
if (errorCode != ErrorCode::NoError) {
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
emit installServerFromApiFinished(tr("This subscription has already been added"), duplicateServerIndex);
return true;
}
emit errorOccurred(errorCode);
return false;
}
emit installServerFromApiFinished(tr("%1 has been added to the app").arg(m_apiServicesModel->getSelectedServiceName()));
#endif
return true;
}
bool SubscriptionUiController::restoreServiceFromAppStore()
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
@@ -311,59 +281,6 @@ bool SubscriptionUiController::restoreServiceFromAppStore()
return true;
}
bool SubscriptionUiController::restoreServiceFromPlayMarket()
{
#if defined(Q_OS_ANDROID)
const QString premiumServiceType = QStringLiteral("amnezia-premium");
if (!fillAvailableServices()) {
qWarning().noquote() << "[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;
}
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;
}
SubscriptionController::PlayMarketRestoreResult result = m_subscriptionController->processPlayMarketRestore(
m_apiServicesModel->getCountryCode(),
m_apiServicesModel->getSelectedServiceType(),
m_apiServicesModel->getSelectedServiceProtocol());
if (!result.hasInstalledConfig) {
if (result.duplicateConfigAlreadyPresent) {
emit installServerFromApiFinished(tr("This subscription has already been added"), result.duplicateServerIndex);
return true;
}
emit errorOccurred(result.errorCode);
return false;
}
emit installServerFromApiFinished(tr("Subscription restored successfully."));
if (result.duplicateCount > 0) {
qInfo().noquote() << "[Billing] Skipped" << result.duplicateCount
<< "duplicate restored purchases for tokens already processed";
}
#endif
return true;
}
bool SubscriptionUiController::importFreeFromGateway()
{
QString userCountryCode = m_apiServicesModel->getCountryCode();

View File

@@ -45,10 +45,8 @@ public slots:
bool fillAvailableServices();
bool importPremiumFromAppStore(const QString &storeProductId);
bool importPremiumFromPlayMarket(const QString &storeProductId);
bool importFreeFromGateway();
bool restoreServiceFromAppStore();
bool restoreServiceFromPlayMarket();
bool importTrialFromGateway(const QString &email);
bool updateServiceFromGateway(const QString &serverId, const QString &newCountryCode, const QString &newCountryName,
bool reloadServiceConfig = false);

View File

@@ -112,7 +112,7 @@ PageType {
return
}
PageController.goToPage(PageEnum.PageSetupWizardInstalling)
InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
InstallController.updateContainer(ServersUiController.processedServerId, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
}
var noButtonFunction = function () {
if (typeof GC !== "undefined" && !GC.isMobile()) {

View File

@@ -279,7 +279,7 @@ PageType {
return
}
PageController.goToPage(PageEnum.PageSetupWizardInstalling)
InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
InstallController.updateContainer(ServersUiController.processedServerId, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
}
var noButtonFunction = function () {
if (typeof GC !== "undefined" && !GC.isMobile()) {

View File

@@ -17,6 +17,10 @@ import "../Components"
PageType {
id: root
enableTimer: false
property bool portDirty: false
function formatTransport(value) {
if (value === "raw") return "RAW (TCP)"
if (value === "xhttp") return "XHTTP"
@@ -39,8 +43,8 @@ PageType {
anchors.right: parent.right
anchors.topMargin: 20 + PageController.safeAreaTopMargin
onFocusChanged: {
if (this.activeFocus) {
onActiveFocusChanged: {
if (backButton.enabled && backButton.activeFocus) {
listView.positionViewAtBeginning()
}
}
@@ -60,8 +64,6 @@ PageType {
delegate: ColumnLayout {
width: listView.width
property alias focusItemId: textFieldWithHeaderType.textField
spacing: 0
Text {
@@ -107,13 +109,32 @@ PageType {
Layout.rightMargin: 16
enabled: listView.enabled
headerText: qsTr("Port")
textField.text: port
Binding {
target: textFieldWithHeaderType.textField
property: "text"
value: port
when: !textFieldWithHeaderType.textField.activeFocus
restoreMode: Binding.RestoreNone
}
textField.maximumLength: 5
textField.validator: IntValidator {
bottom: 1; top: 65535
}
textField.onActiveFocusChanged: {
if (textField.activeFocus && textField.text === "" && port !== "") {
textField.text = port
}
}
textField.onTextChanged: {
root.portDirty = (textField.text !== port)
}
textField.onEditingFinished: {
if (textField.text !== port) port = textField.text
if (textField.text !== port) {
port = textField.text
}
root.portDirty = false
}
checkEmptyText: true
}
@@ -172,9 +193,8 @@ PageType {
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: listView.enabled
&& (XrayConfigModel.hasUnsavedChanges
|| textFieldWithHeaderType.textField.text !== port)
enabled: visible && textFieldWithHeaderType.errorText === ""
&& (XrayConfigModel.hasUnsavedChanges || root.portDirty)
enabled: visible && textFieldWithHeaderType.textField.text !== ""
text: qsTr("Save")
onClicked: function() {
forceActiveFocus()

View File

@@ -742,7 +742,7 @@ PageType {
return
}
PageController.goToPage(PageEnum.PageSetupWizardInstalling)
InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
InstallController.updateContainer(ServersUiController.processedServerId, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
}
var noButtonFunction = function () {
if (typeof GC !== "undefined" && !GC.isMobile()) {

View File

@@ -95,7 +95,7 @@ PageType {
return
}
PageController.goToPage(PageEnum.PageSetupWizardInstalling)
InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
InstallController.updateContainer(ServersUiController.processedServerId, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
}
var noButtonFunction = function () {
if (typeof GC !== "undefined" && !GC.isMobile()) {

View File

@@ -211,7 +211,7 @@ PageType {
return
}
PageController.goToPage(PageEnum.PageSetupWizardInstalling)
InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
InstallController.updateContainer(ServersUiController.processedServerId, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
}
var noButtonFunction = function () {
if (typeof GC !== "undefined" && !GC.isMobile()) {

View File

@@ -208,7 +208,7 @@ PageType {
return
}
PageController.goToPage(PageEnum.PageSetupWizardInstalling)
InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
InstallController.updateContainer(ServersUiController.processedServerId, ServersUiController.processedContainerIndex, ProtocolEnum.Xray)
}
var noButtonFunction = function () {
if (typeof GC !== "undefined" && !GC.isMobile()) {

View File

@@ -187,13 +187,6 @@ PageType {
PageController.showBusyIndicator(false)
return
}
if (Qt.platform.os === "android") {
PageController.showBusyIndicator(true)
var androidStoreId = plan.storeProductId !== undefined ? String(plan.storeProductId) : ""
SubscriptionUiController.importPremiumFromPlayMarket(androidStoreId)
PageController.showBusyIndicator(false)
return
}
if (plan.checkoutUrl) {
Qt.openUrlExternally(plan.checkoutUrl)
PageController.closePage()

View File

@@ -366,14 +366,10 @@ PageType {
property string title: qsTr("Restore purchases")
property string description: qsTr("")
property string imageSource: "qrc:/images/controls/refresh-cw.svg"
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild || Qt.platform.os === "android"
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild
property var handler: function() {
PageController.showBusyIndicator(true)
if (Qt.platform.os === "android") {
SubscriptionUiController.restoreServiceFromPlayMarket()
} else {
SubscriptionUiController.restoreServiceFromAppStore()
}
SubscriptionUiController.restoreServiceFromAppStore()
PageController.showBusyIndicator(false)
}
}

View File

@@ -33,8 +33,6 @@ while [[ $# -gt 0 ]]; do
--abi) abis+=("$2"); shift 2 ;;
--sign) : ${SIGN:=true}; shift ;;
--aab) : ${BUILD_AAB=true}; shift ;;
--apk) : ${BUILD_APK=true}; shift ;;
--play) : ${BUILD_PLAY=true}; shift ;;
--help|-h|?)
echo "Usage: $0 [options]"
echo " Options:"
@@ -47,8 +45,6 @@ while [[ $# -gt 0 ]]; do
echo " --abi - specify Android ABIs for target to build for. all by default"
echo " --sign - whether to sign the resulting files. only appicable to Android"
echo " --aab - whether to build AAB. only applicable to Android"
echo " --apk - whether to build APK. use with --play. only applicable to Android"
echo " --play - build Play flavor (Google Play Billing). use with --aab or --apk. only applicable to Android"
exit 0
;;
*) echo "Unknown arg \"$1\". Use $0 -h to get help"; exit 1 ;;
@@ -205,7 +201,6 @@ args=()
[[ -n "$QT_ANDROID_SIGN_AAB" ]] && args+=("-DQT_ANDROID_SIGN_AAB=$QT_ANDROID_SIGN_AAB")
[[ -n "$QT_ANDROID_ABIS" ]] && args+=("-DQT_ANDROID_ABIS=$QT_ANDROID_ABIS")
[[ -n "$QT_ANDROID_BUILD_ALL_ABIS" ]] && args+=("-DQT_ANDROID_BUILD_ALL_ABIS=$QT_ANDROID_BUILD_ALL_ABIS")
[[ -n "$BUILD_PLAY" ]] && args+=("-DANDROID_BUILD_PLAY=ON")
if [[ -n "$FORCE" ]]; then
run_traced rm -rf "$BUILD_PATH"
@@ -214,17 +209,7 @@ fi
run_traced cmake -S "$SOURCE_PATH" -B "$BUILD_PATH" "${args[@]}"
run_traced cmake --build "$BUILD_PATH" --config "$CMAKE_BUILD_TYPE"
if [[ -n "$BUILD_AAB" ]]; then
if [[ -n "$BUILD_PLAY" ]]; then
run_traced cmake --build "$BUILD_PATH" --config "$CMAKE_BUILD_TYPE" -t "android_play_aab"
else
run_traced cmake --build "$BUILD_PATH" --config "$CMAKE_BUILD_TYPE" -t "aab"
fi
fi
if [[ -n "$BUILD_APK" ]] && [[ -n "$BUILD_PLAY" ]]; then
run_traced cmake --build "$BUILD_PATH" --config "$CMAKE_BUILD_TYPE" -t "android_play_apk"
fi
[[ -n "$BUILD_AAB" ]] && run_traced cmake --build "$BUILD_PATH" --config "$CMAKE_BUILD_TYPE" -t "aab"
if [ -z "$no_installers" ]; then
for installer in $INSTALLERS; do

View File

@@ -50,15 +50,12 @@ class AmneziaLibxray(ConanFile):
def build(self):
self._patch_sources()
if self.settings_build.os == "Windows":
self.run("bash build.sh android")
else:
self.run("./build.sh android")
self.run("./build.sh android")
def package(self):
copy(self, "libxray.aar", src=self.build_folder, dst=os.path.join(self.package_folder, "aar"))
def package_info(self):
self.cpp_info.set_property("cmake_extra_variables", {
"AMNEZIA_LIBXRAY_PATH": Path(self.package_folder, "aar", "libxray.aar").as_posix(),
"AMNEZIA_LIBXRAY_PATH": os.path.join(self.package_folder, "aar", "libxray.aar"),
})

View File

@@ -1,13 +1,11 @@
from conan import ConanFile
from conan.tools.cmake import cmake_layout, CMake, CMakeToolchain
from conan.tools.files import copy, replace_in_file
from conan.tools.env import VirtualBuildEnv, Environment
from conan.errors import ConanInvalidConfiguration
from conan.tools.scm import Git
import os
import platform
from pathlib import Path
class AwgAndroid(ConanFile):
name = "awg-android"
@@ -23,11 +21,6 @@ class AwgAndroid(ConanFile):
def build_requirements(self):
self.tool_requires("cmake/[>=3.4.1 <4]")
if platform.system() == "Windows":
self.tool_requires("ninja/[*]")
self.tool_requires("go/[*]")
if not self.conf.get("tools.microsoft.bash:path", check_type=str):
self.tool_requires("msys2/cci.latest")
def validate(self):
if self.settings.os != "Android":
@@ -42,11 +35,9 @@ class AwgAndroid(ConanFile):
)
def generate(self):
VirtualBuildEnv(self).generate()
tc = CMakeToolchain(self)
tc.variables["GRADLE_USER_HOME"] = Path(os.path.join(self.build_folder, "gradle_user_home")).as_posix()
tc.variables["CMAKE_LIBRARY_OUTPUT_DIRECTORY"] = Path(os.path.join(self.build_folder, "out")).as_posix()
tc.variables["GRADLE_USER_HOME"] = os.path.join(self.build_folder, "gradle_user_home")
tc.variables["CMAKE_LIBRARY_OUTPUT_DIRECTORY"] = os.path.join(self.build_folder, "out")
# not to warn in case of strtok() usage
tc.extra_cflags = ["-Wno-deprecated-declarations"]
tc.generate()
@@ -73,31 +64,6 @@ class AwgAndroid(ConanFile):
'sha256sum -c',
'shasum -a 256 -c'
)
elif platform.system() == 'Windows':
# elf-cleaner uses sys/mman.h (POSIX only) and cannot be built on Windows;
# skip it — DT_FLAGS_1 warnings only affect Android < 6.0
replace_in_file(self,
os.path.join(self.source_folder, "tunnel", "tools", "CMakeLists.txt"),
'# Strip unwanted ELF sections to prevent DT_FLAGS_1 warnings on old Android versions\n'
'file(GLOB ELF_CLEANER_SOURCES elf-cleaner/*.c elf-cleaner/*.cpp)\n'
'add_custom_target(elf-cleaner COMMENT "Building elf-cleaner" VERBATIM COMMAND cc\n'
' -O2 -DPACKAGE_NAME="elf-cleaner" -DPACKAGE_VERSION="" -DCOPYRIGHT=""\n'
' -o "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner" ${ELF_CLEANER_SOURCES}\n'
')\n'
'add_custom_command(TARGET libwg.so POST_BUILD VERBATIM COMMAND "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner"\n'
' --api-level "${ANDROID_NATIVE_API_LEVEL}" "$<TARGET_FILE:libwg.so>")\n'
'add_dependencies(libwg.so elf-cleaner)\n'
'add_custom_command(TARGET libwg-quick.so POST_BUILD VERBATIM COMMAND "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner"\n'
' --api-level "${ANDROID_NATIVE_API_LEVEL}" "$<TARGET_FILE:libwg-quick.so>")\n'
'add_dependencies(libwg-quick.so elf-cleaner)',
'',
)
# patch Makefile: skip Go download, use 'go' already in PATH from tool_requires
replace_in_file(self,
os.path.join(self.source_folder, "tunnel", "tools", "libwg-go", "Makefile"),
'$(DESTDIR)/libwg-go.so: export PATH := $(BUILDDIR)/go-$(GO_VERSION)/bin/:$(PATH)\n$(DESTDIR)/libwg-go.so: $(BUILDDIR)/go-$(GO_VERSION)/.prepared go.mod',
'$(DESTDIR)/libwg-go.so: go.mod',
)
def build(self):
self._patch_sources()
@@ -115,6 +81,6 @@ class AwgAndroid(ConanFile):
self.cpp_info.set_property("cmake_target_name", "amnezia::awg-android")
self.cpp_info.libs = [ "wg-go" ]
self.cpp_info.set_property("cmake_extra_variables", {
"AMNEZIA_ANDROID_LIBWG_PATH": Path(os.path.join(self.package_folder, "bin", "libwg.so")).as_posix(),
"AMNEZIA_ANDROID_LIBWG_QUICK_PATH": Path(os.path.join(self.package_folder, "bin", "libwg-quick.so")).as_posix(),
"AMNEZIA_ANDROID_LIBWG_PATH": os.path.join(self.package_folder, "bin", "libwg.so"),
"AMNEZIA_ANDROID_LIBWG_QUICK_PATH": os.path.join(self.package_folder, "bin", "libwg-quick.so"),
})

View File

@@ -5,7 +5,6 @@ from conan.tools.scm import Git
from conan.errors import ConanInvalidConfiguration
import os
from pathlib import Path
class OpenvpnPtAndroid(ConanFile):
name = "openvpn-pt-android"
@@ -54,5 +53,5 @@ class OpenvpnPtAndroid(ConanFile):
self.cpp_info.set_property("cmake_target_name", "amnezia::openvpn-pt-android")
self.cpp_info.libs = [ "ovpn3", "ovpnutil", "rsapss" ]
self.cpp_info.set_property("cmake_extra_variables", {
"OPENVPN_PT_ANDROID_LIBCK_OVPN_PLUGIN_PATH": Path(self.package_folder, "lib", "libck-ovpn-plugin.so").as_posix()
"OPENVPN_PT_ANDROID_LIBCK_OVPN_PLUGIN_PATH": os.path.join(self.package_folder, "lib", "libck-ovpn-plugin.so")
})