diff --git a/.gitignore b/.gitignore
index 50b66e1e8..4af5d6730 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,7 @@ app/src/headless/assets
/tools
/.kotlin/sessions
app/google-services.json
+/app/release
app/fdroidFull/release
app/playFull/release
app/websiteFull/release
diff --git a/app/build.gradle b/app/build.gradle
index 1d492f7ea..6f3eba039 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -4,18 +4,6 @@ plugins {
id 'kotlin-android'
}
-// ensure consistent JVM version for Java and Kotlin compilation
-// kotl.in/gradle/jvm/toolchain
-java {
- toolchain {
- languageVersion = JavaLanguageVersion.of(17)
- }
-}
-
-kotlin {
- jvmToolchain(17)
-}
-
// apply Google Services and Firebase Crashlytics plugins conditionally
// strategy: For command-line builds, check task names. For IDE, always apply plugins
// but they'll only process play/website variants (Firebase deps are scoped to those variants)
@@ -79,6 +67,13 @@ try {
keystoreProperties['storePassword'] = ''
}
+// ref: developer.android.com/build/jdks#target-compat
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
android {
compileSdkVersion(36)
// https://developer.android.com/studio/build/configure-app-module
@@ -155,7 +150,7 @@ android {
// the setting to true
minifyEnabled true
shrinkResources true
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
ndk {
// Use SYMBOL_TABLE to reduce symbol file size significantly
debugSymbolLevel 'SYMBOL_TABLE'
@@ -289,7 +284,7 @@ dependencies {
fullImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20'
fullImplementation 'androidx.appcompat:appcompat:1.7.1'
- fullImplementation 'androidx.core:core-ktx:1.16.0'
+ fullImplementation 'androidx.core:core-ktx:1.17.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
fullImplementation 'androidx.constraintlayout:constraintlayout:2.2.1'
fullImplementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
@@ -358,7 +353,7 @@ dependencies {
playImplementation firestackDependency()
// Work manager
- implementation('androidx.work:work-runtime-ktx:2.10.5') {
+ implementation('androidx.work:work-runtime-ktx:2.11.0') {
modules {
module("com.google.guava:listenablefuture") {
replacedBy("com.google.guava:guava", "listenablefuture is part of guava")
@@ -378,20 +373,20 @@ dependencies {
testImplementation 'org.robolectric:robolectric:4.16'
testImplementation 'androidx.test:core:1.7.0'
testImplementation 'androidx.test.ext:junit:1.3.0'
- testImplementation 'org.mockito:mockito-core:5.20.0'
+ testImplementation 'org.mockito:mockito-core:5.21.0'
// Added test dependencies for comprehensive testing
- testImplementation 'io.mockk:mockk:1.14.6'
- testImplementation 'io.mockk:mockk-android:1.14.6'
+ testImplementation 'io.mockk:mockk:1.14.7'
+ testImplementation 'io.mockk:mockk-android:1.14.7'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
testImplementation 'io.insert-koin:koin-test:4.1.1'
testImplementation 'io.insert-koin:koin-test-junit4:4.1.1'
- androidTestImplementation 'io.mockk:mockk-android:1.14.6'
+ androidTestImplementation 'io.mockk:mockk-android:1.14.7'
leakCanaryImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
- fullImplementation 'androidx.navigation:navigation-fragment-ktx:2.9.5'
- fullImplementation 'androidx.navigation:navigation-ui-ktx:2.9.5'
+ fullImplementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
+ fullImplementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
fullImplementation 'androidx.biometric:biometric:1.1.0'
@@ -410,23 +405,23 @@ dependencies {
// for confetti animation
fullImplementation 'nl.dionsegijn:konfetti-xml:2.0.5'
// for in-app purchases
- //playImplementation 'com.android.billingclient:billing:8.0.0'
- //websiteImplementation 'com.android.billingclient:billing:8.0.0'
+ playImplementation 'com.android.billingclient:billing:8.3.0'
+ websiteImplementation 'com.android.billingclient:billing:8.3.0'
// for stripe payment gateway
//websiteImplementation 'com.stripe:stripe-android:21.21.0'
//fdroidImplementation 'com.stripe:stripe-android:21.21.0'
- lintChecks 'com.android.security.lint:lint:1.0.3'
+ lintChecks 'com.android.security.lint:lint:1.0.4'
// battery optimization permission helper
implementation 'com.waseemsabir:betterypermissionhelper:1.0.3'
// Firebase dependencies for error reporting (website and play variants only)
- websiteImplementation platform('com.google.firebase:firebase-bom:34.6.0')
+ websiteImplementation platform('com.google.firebase:firebase-bom:34.7.0')
websiteImplementation 'com.google.firebase:firebase-crashlytics'
websiteImplementation 'com.google.firebase:firebase-crashlytics-ndk'
- playImplementation platform('com.google.firebase:firebase-bom:34.6.0')
+ playImplementation platform('com.google.firebase:firebase-bom:34.7.0')
playImplementation 'com.google.firebase:firebase-crashlytics'
playImplementation 'com.google.firebase:firebase-crashlytics-ndk'
}
diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml
index e7593124c..68d10eb64 100644
--- a/app/src/full/AndroidManifest.xml
+++ b/app/src/full/AndroidManifest.xml
@@ -170,14 +170,15 @@
android:finishOnTaskLaunch="true" />
-
+
diff --git a/app/src/full/java/com/celzero/bravedns/ServiceModuleProvider.kt b/app/src/full/java/com/celzero/bravedns/ServiceModuleProvider.kt
index 955bc3e5a..170aa8a6f 100644
--- a/app/src/full/java/com/celzero/bravedns/ServiceModuleProvider.kt
+++ b/app/src/full/java/com/celzero/bravedns/ServiceModuleProvider.kt
@@ -23,6 +23,8 @@ import com.celzero.bravedns.scheduler.ScheduleManager
import com.celzero.bravedns.scheduler.WorkScheduler
import com.celzero.bravedns.service.AppUpdater
import com.celzero.bravedns.service.ServiceModule
+import com.celzero.bravedns.subscription.StateMachineDatabaseSyncService
+import com.celzero.bravedns.subscription.SubscriptionStateMachineV2
import com.celzero.bravedns.util.Constants
import com.celzero.bravedns.util.OrbotHelper
import com.celzero.bravedns.viewmodel.ViewModelModule
@@ -47,13 +49,13 @@ private val appDownloadManagerModule = module {
private val workerModule = module { single { WorkScheduler(androidContext()) } }
private val schedulerModule = module { single { ScheduleManager(androidContext()) } }
-/*
+
private val stateMachine = module {
single { SubscriptionStateMachineV2() }
single { StateMachineDatabaseSyncService() }
-}*/
+}
-//private val stateMachineModules = listOf(stateMachine)
+private val stateMachineModules = listOf(stateMachine)
val AppModules: List by lazy {
mutableListOf().apply {
@@ -62,7 +64,7 @@ val AppModules: List by lazy {
addAll(ViewModelModule.modules)
addAll(DataModule.modules)
addAll(ServiceModule.modules)
- //addAll(stateMachineModules)
+ addAll(stateMachineModules)
addAll(updaterModules)
add(schedulerModule)
add(workerModule)
diff --git a/app/src/full/java/com/celzero/bravedns/adapter/GenericHopAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/GenericHopAdapter.kt
new file mode 100644
index 000000000..83043a2ff
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/adapter/GenericHopAdapter.kt
@@ -0,0 +1,527 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.adapter
+
+import Logger
+import Logger.LOG_TAG_UI
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.RecyclerView
+import com.celzero.bravedns.R
+import com.celzero.bravedns.databinding.ListItemWgHopBinding
+import com.celzero.bravedns.service.ProxyManager
+import com.celzero.bravedns.service.VpnController
+import com.celzero.bravedns.service.WireguardManager
+import com.celzero.bravedns.util.UIUtils
+import com.celzero.bravedns.util.UIUtils.fetchColor
+import com.celzero.bravedns.util.Utilities
+import com.celzero.bravedns.wireguard.WgHopManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Generic adapter for hopping between different proxy types
+ * Supports both WireGuard configs and RPN proxies through HopItem sealed class
+ */
+class GenericHopAdapter(
+ private val context: Context,
+ private val lifecycleOwner: LifecycleOwner,
+ private val srcId: Int,
+ private val hopItems: List,
+ private var selectedId: Int,
+ private val onHopChanged: ((Int) -> Unit)? = null
+) : RecyclerView.Adapter() {
+
+ companion object {
+ private const val TAG = "GenericHopAdapter"
+ }
+
+ private var isAttached = false
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HopViewHolder {
+ val itemBinding =
+ ListItemWgHopBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return HopViewHolder(itemBinding)
+ }
+
+ override fun getItemCount(): Int = hopItems.size
+
+ override fun onBindViewHolder(holder: HopViewHolder, position: Int) {
+ if (position < 0 || position >= itemCount) {
+ Logger.w(LOG_TAG_UI, "$TAG; Invalid position $position for itemCount $itemCount")
+ return
+ }
+ holder.update(hopItems[position])
+ }
+
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ super.onAttachedToRecyclerView(recyclerView)
+ isAttached = true
+ }
+
+ override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ super.onDetachedFromRecyclerView(recyclerView)
+ isAttached = false
+ }
+
+ inner class HopViewHolder(private val b: ListItemWgHopBinding) :
+ RecyclerView.ViewHolder(b.root) {
+
+ fun update(item: HopItem) {
+ when (item) {
+ is HopItem.WireGuardHop -> updateWireGuardHop(item)
+ is HopItem.RpnProxyHop -> updateRpnProxyHop(item)
+ }
+ }
+
+ private fun updateWireGuardHop(item: HopItem.WireGuardHop) {
+ val config = item.config
+ // Verify config exists in manager
+ if (WireguardManager.getConfigFilesById(config.getId()) == null) return
+
+ b.wgHopListNameTv.text = "${config.getName()} (${config.getId()})"
+ b.wgHopListCheckbox.isChecked = config.getId() == selectedId
+ setCardStroke(config.getId() == selectedId, item.active)
+ showWgChips(item)
+ updateWgStatusUi(item)
+ setupWgClickListeners(item)
+ }
+
+ private fun updateRpnProxyHop(item: HopItem.RpnProxyHop) {
+ val countryConfig = item.countryConfig
+
+ b.wgHopListNameTv.text = countryConfig.cc
+ b.wgHopListCheckbox.isChecked = countryConfig.cc.hashCode() == selectedId
+ setCardStroke(countryConfig.cc.hashCode() == selectedId, item.active)
+ showRpnChips(item)
+ updateRpnStatusUi(item)
+ setupRpnClickListeners(item)
+ }
+
+ private fun showWgChips(item: HopItem.WireGuardHop) {
+ io {
+ val config = item.config
+ val id = ProxyManager.ID_WG_BASE + config.getId()
+ val pair = VpnController.getSupportedIpVersion(id)
+ val isSplitTunnel = if (config.getPeers()?.isNotEmpty() == true) {
+ VpnController.isSplitTunnelProxy(id, pair)
+ } else {
+ false
+ }
+ uiCtx {
+ updateWgPropertiesChip(config)
+ updateAmzChip(config)
+ updateProtocolChip(pair)
+ updateSplitTunnelChip(isSplitTunnel)
+ updateHopSrcChip(config)
+ updateHoppingChip(config)
+ }
+ }
+ }
+
+ private fun showRpnChips(item: HopItem.RpnProxyHop) {
+ io {
+ val countryConfig = item.countryConfig
+ uiCtx {
+ updateRpnPropertiesChip(countryConfig)
+ // Hide WG-specific chips
+ b.chipAmnezia.visibility = View.GONE
+ b.chipIpv4.visibility = View.GONE
+ b.chipIpv6.visibility = View.GONE
+ b.chipSplitTunnel.visibility = View.GONE
+ b.chipHopSrc.visibility = View.GONE
+ b.chipHopping.visibility = View.GONE
+ }
+ }
+ }
+
+ private fun updateWgPropertiesChip(config: com.celzero.bravedns.wireguard.Config) {
+ val mapping = WireguardManager.getConfigFilesById(config.getId()) ?: return
+ if (!mapping.isCatchAll && !mapping.useOnlyOnMetered && !mapping.ssidEnabled) {
+ b.chipProperties.visibility = View.GONE
+ return
+ }
+ b.chipProperties.text = ""
+ if (mapping.isCatchAll) {
+ b.chipProperties.visibility = View.VISIBLE
+ b.chipProperties.text = context.getString(R.string.symbol_lightening)
+ }
+ if (mapping.useOnlyOnMetered) {
+ b.chipProperties.visibility = View.VISIBLE
+ b.chipProperties.text = context.getString(
+ R.string.two_argument_space,
+ b.chipProperties.text.toString(),
+ context.getString(R.string.symbol_mobile)
+ )
+ }
+ if (mapping.ssidEnabled) {
+ b.chipProperties.visibility = View.VISIBLE
+ b.chipProperties.text = context.getString(
+ R.string.two_argument_space,
+ b.chipProperties.text.toString(),
+ context.getString(R.string.symbol_id)
+ )
+ }
+
+ val visible = if (b.chipProperties.text.isNotEmpty()) View.VISIBLE else View.GONE
+ b.chipProperties.visibility = visible
+ }
+
+ private fun updateRpnPropertiesChip(countryConfig: com.celzero.bravedns.database.CountryConfig) {
+ // Use the countryConfig directly since it already has all the properties
+ if (!countryConfig.catchAll && !countryConfig.lockdown && !countryConfig.mobileOnly && !countryConfig.ssidBased) {
+ b.chipProperties.visibility = View.GONE
+ return
+ }
+ b.chipProperties.text = ""
+ if (countryConfig.catchAll) {
+ b.chipProperties.visibility = View.VISIBLE
+ b.chipProperties.text = context.getString(R.string.symbol_lightening)
+ }
+ if (countryConfig.lockdown) {
+ b.chipProperties.visibility = View.VISIBLE
+ b.chipProperties.text = context.getString(
+ R.string.two_argument_space,
+ b.chipProperties.text.toString(),
+ context.getString(R.string.symbol_lockdown)
+ )
+ }
+ if (countryConfig.mobileOnly) {
+ b.chipProperties.visibility = View.VISIBLE
+ b.chipProperties.text = context.getString(
+ R.string.two_argument_space,
+ b.chipProperties.text.toString(),
+ context.getString(R.string.symbol_mobile)
+ )
+ }
+ if (countryConfig.ssidBased) {
+ b.chipProperties.visibility = View.VISIBLE
+ b.chipProperties.text = context.getString(
+ R.string.two_argument_space,
+ b.chipProperties.text.toString(),
+ context.getString(R.string.symbol_id)
+ )
+ }
+
+ val visible = if (b.chipProperties.text.isNotEmpty()) View.VISIBLE else View.GONE
+ b.chipProperties.visibility = visible
+ }
+
+ private fun updateAmzChip(config: com.celzero.bravedns.wireguard.Config) {
+ config.getInterface()?.let {
+ if (it.isAmnezia()) {
+ b.chipGroup.visibility = View.VISIBLE
+ b.chipAmnezia.visibility = View.VISIBLE
+ } else {
+ b.chipAmnezia.visibility = View.GONE
+ }
+ }
+ }
+
+ private fun updateProtocolChip(pair: Pair?) {
+ if (pair == null) return
+
+ if (!pair.first && !pair.second) {
+ b.chipIpv4.visibility = View.GONE
+ b.chipIpv6.visibility = View.GONE
+ return
+ }
+ b.chipGroup.visibility = View.VISIBLE
+ b.chipIpv4.visibility = View.GONE
+ b.chipIpv6.visibility = View.GONE
+ if (pair.first) {
+ b.chipIpv4.visibility = View.VISIBLE
+ b.chipIpv4.text = context.getString(R.string.settings_ip_text_ipv4)
+ } else {
+ b.chipIpv4.visibility = View.GONE
+ }
+ if (pair.second) {
+ b.chipIpv6.visibility = View.VISIBLE
+ b.chipIpv6.text = context.getString(R.string.settings_ip_text_ipv6)
+ } else {
+ b.chipIpv6.visibility = View.GONE
+ }
+ }
+
+ private fun updateSplitTunnelChip(isSplitTunnel: Boolean) {
+ if (isSplitTunnel) {
+ b.chipGroup.visibility = View.VISIBLE
+ b.chipSplitTunnel.visibility = View.VISIBLE
+ } else {
+ b.chipSplitTunnel.visibility = View.GONE
+ }
+ }
+
+ private fun updateHopSrcChip(config: com.celzero.bravedns.wireguard.Config) {
+ val id = ProxyManager.ID_WG_BASE + config.getId()
+ val hop = WgHopManager.getMapBySrc(id)
+ if (hop.isNotEmpty()) {
+ b.chipGroup.visibility = View.VISIBLE
+ b.chipHopSrc.visibility = View.VISIBLE
+ } else {
+ b.chipHopSrc.visibility = View.GONE
+ }
+ }
+
+ private fun updateHoppingChip(config: com.celzero.bravedns.wireguard.Config) {
+ val id = ProxyManager.ID_WG_BASE + config.getId()
+ val hop = WgHopManager.isAlreadyHop(id)
+ if (hop) {
+ b.chipGroup.visibility = View.VISIBLE
+ b.chipHopping.visibility = View.VISIBLE
+ } else {
+ b.chipHopping.visibility = View.GONE
+ }
+ }
+
+ private fun updateWgStatusUi(item: HopItem.WireGuardHop) {
+ io {
+ val config = item.config
+ val map = WireguardManager.getConfigFilesById(config.getId())
+ if (map == null) {
+ uiCtx {
+ b.wgHopListDescTv.text = context.getString(R.string.config_invalid_desc)
+ }
+ return@io
+ }
+ if (selectedId == config.getId()) {
+ val srcConfig = WireguardManager.getConfigById(srcId)
+ if (srcConfig == null) {
+ Logger.i(LOG_TAG_UI, "$TAG; source config($srcId) not found to hop")
+ uiCtx {
+ b.wgHopListDescTv.text = context.getString(R.string.lbl_inactive)
+ }
+ return@io
+ }
+ val src = ProxyManager.ID_WG_BASE + srcConfig.getId()
+ val hop = ProxyManager.ID_WG_BASE + config.getId()
+ val statusPair = VpnController.hopStatus(src, hop)
+ uiCtx {
+ val id = statusPair.first
+ if (statusPair.first != null) {
+ val txt = UIUtils.getProxyStatusStringRes(id)
+ b.wgHopListDescTv.text = context.getString(txt)
+ } else {
+ b.wgHopListDescTv.text = statusPair.second
+ }
+ }
+ return@io
+ }
+ if (map.isActive) {
+ uiCtx {
+ b.wgHopListDescTv.text = context.getString(R.string.lbl_active)
+ }
+ return@io
+ } else {
+ uiCtx {
+ b.wgHopListDescTv.text = context.getString(R.string.lbl_inactive)
+ }
+ }
+ }
+ }
+
+ private fun updateRpnStatusUi(item: HopItem.RpnProxyHop) {
+ io {
+ uiCtx {
+ if (item.active) {
+ b.wgHopListDescTv.text = context.getString(R.string.lbl_active)
+ } else {
+ b.wgHopListDescTv.text = context.getString(R.string.lbl_inactive)
+ }
+ }
+ }
+ }
+
+ private fun setupWgClickListeners(item: HopItem.WireGuardHop) {
+ b.wgHopListCard.setOnClickListener {
+ io { handleWgHop(item, !b.wgHopListCheckbox.isChecked) }
+ }
+
+ b.wgHopListCheckbox.setOnClickListener {
+ io { handleWgHop(item, b.wgHopListCheckbox.isChecked) }
+ }
+ }
+
+ private fun setupRpnClickListeners(item: HopItem.RpnProxyHop) {
+ b.wgHopListCard.setOnClickListener {
+ io { handleRpnHop(item, !b.wgHopListCheckbox.isChecked) }
+ }
+
+ b.wgHopListCheckbox.setOnClickListener {
+ io { handleRpnHop(item, b.wgHopListCheckbox.isChecked) }
+ }
+ }
+
+ private suspend fun handleWgHop(item: HopItem.WireGuardHop, isChecked: Boolean) {
+ val config = item.config
+ val srcConfig = WireguardManager.getConfigById(srcId)
+ val mapping = WireguardManager.getConfigFilesById(config.getId())
+ if (srcConfig == null || mapping == null) {
+ Logger.i(LOG_TAG_UI, "$TAG; source config($srcId) not found to hop")
+ uiCtx {
+ if (!isAttached) return@uiCtx
+ Utilities.showToastUiCentered(context, context.getString(R.string.config_invalid_desc), Toast.LENGTH_LONG)
+ }
+ return
+ }
+
+ if (mapping.useOnlyOnMetered || mapping.ssidEnabled) {
+ uiCtx {
+ if (!isAttached) return@uiCtx
+ Utilities.showToastUiCentered(
+ context,
+ context.getString(R.string.hop_error_toast_msg_3),
+ Toast.LENGTH_LONG
+ )
+ }
+ return
+ }
+ uiCtx {
+ showProgressIndicator()
+ }
+ Logger.d(LOG_TAG_UI, "$TAG; init, hop: ${srcConfig.getId()} -> ${config.getId()}, isChecked? $isChecked")
+ val src = ProxyManager.ID_WG_BASE + srcConfig.getId()
+ val hop = ProxyManager.ID_WG_BASE + config.getId()
+ val currMap = WgHopManager.getMapBySrc(src)
+ if (currMap.isNotEmpty()) {
+ var res = false
+ currMap.forEach {
+ if (it.hop != hop && it.hop.isNotEmpty()) {
+ val id = it.hop.substring(ProxyManager.ID_WG_BASE.length).toIntOrNull() ?: return@forEach
+ res = WgHopManager.removeHop(srcConfig.getId(), id).first
+ }
+ }
+ if (res) {
+ selectedId = -1
+ uiCtx {
+ if (!isAttached) return@uiCtx
+ notifyDataSetChanged()
+ }
+ }
+ }
+ delay(2000)
+ if (isChecked) {
+ val hopTestRes = VpnController.testHop(src, hop)
+ if (!hopTestRes.first) {
+ uiCtx {
+ if (!isAttached) return@uiCtx
+
+ dismissProgressIndicator()
+ b.wgHopListCheckbox.isChecked = false
+ Utilities.showToastUiCentered(
+ context,
+ hopTestRes.second ?: context.getString(R.string.unknown_error),
+ Toast.LENGTH_LONG
+ )
+ }
+ return
+ }
+ }
+
+ val res = if (!isChecked) {
+ selectedId = -1
+ WgHopManager.removeHop(srcConfig.getId(), config.getId())
+ } else {
+ selectedId = config.getId()
+ WgHopManager.hop(srcConfig.getId(), config.getId())
+ }
+ uiCtx {
+ if (!isAttached) return@uiCtx
+
+ dismissProgressIndicator()
+ Utilities.showToastUiCentered(context, res.second, Toast.LENGTH_LONG)
+ if (!res.first) {
+ b.wgHopListCheckbox.isChecked = false
+ setCardStroke(isSelected = false, isActive = false)
+ } else {
+ b.wgHopListCheckbox.isChecked = true
+ setCardStroke(isSelected = true, item.active)
+ onHopChanged?.invoke(config.getId())
+ }
+ notifyDataSetChanged()
+ }
+ }
+
+ private suspend fun handleRpnHop(item: HopItem.RpnProxyHop, isChecked: Boolean) {
+ val countryConfig = item.countryConfig
+ uiCtx {
+ showProgressIndicator()
+ }
+ Logger.d(LOG_TAG_UI, "$TAG; RPN hop: ${countryConfig.cc}, isChecked? $isChecked")
+
+ // TODO: Implement RPN hop logic when hop manager is ready
+ delay(1000)
+
+ uiCtx {
+ if (!isAttached) return@uiCtx
+
+ dismissProgressIndicator()
+ Utilities.showToastUiCentered(
+ context,
+ "RPN hopping not yet implemented",
+ Toast.LENGTH_SHORT
+ )
+ b.wgHopListCheckbox.isChecked = false
+ }
+ }
+
+ fun showProgressIndicator() {
+ if (!isAttached) return
+
+ b.wgHopListCheckbox.isEnabled = false
+ b.wgHopListProgress.visibility = View.VISIBLE
+ b.wgHopListCard.isEnabled = false
+ }
+
+ fun dismissProgressIndicator() {
+ if (!isAttached) return
+
+ b.wgHopListCheckbox.isEnabled = true
+ b.wgHopListProgress.visibility = View.GONE
+ b.wgHopListCard.isEnabled = true
+ }
+
+ private fun setCardStroke(isSelected: Boolean, isActive: Boolean) {
+ val strokeColor = if (isSelected && isActive) {
+ b.wgHopListCard.strokeWidth = 2
+ fetchColor(context, R.attr.chipTextPositive)
+ } else if (isSelected) { // selected but not active
+ b.wgHopListCard.strokeWidth = 2
+ fetchColor(context, R.attr.chipTextNegative)
+ } else {
+ b.wgHopListCard.strokeWidth = 0
+ fetchColor(context, R.attr.chipTextNegative)
+ }
+ b.wgHopListCard.strokeColor = strokeColor
+ }
+
+ private fun io(f: suspend () -> Unit) {
+ lifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { f() }
+ }
+
+ private suspend fun uiCtx(f: suspend () -> Unit) {
+ withContext(Dispatchers.Main) { f() }
+ }
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/adapter/HopItem.kt b/app/src/full/java/com/celzero/bravedns/adapter/HopItem.kt
new file mode 100644
index 000000000..0c1aa6bdd
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/adapter/HopItem.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.adapter
+
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.wireguard.Config
+
+/**
+ * Sealed class representing different types of hop items
+ * Used by GenericHopAdapter to support both WireGuard and RPN hopping
+ */
+sealed class HopItem {
+ abstract fun getId(): Int
+ abstract fun getName(): String
+ abstract fun isActive(): Boolean
+
+ /**
+ * WireGuard configuration hop item
+ */
+ data class WireGuardHop(val config: Config, val active: Boolean) : HopItem() {
+ override fun getId(): Int = config.getId()
+ override fun getName(): String = config.getName()
+ override fun isActive(): Boolean = active
+ }
+
+ /**
+ * RPN proxy hop item (country-based)
+ */
+ data class RpnProxyHop(val countryConfig: CountryConfig, val active: Boolean) : HopItem() {
+ override fun getId(): Int = countryConfig.cc.hashCode()
+ override fun getName(): String = countryConfig.cc
+ override fun isActive(): Boolean = active
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RpnWinProxiesAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RpnWinProxiesAdapter.kt
deleted file mode 100644
index 498798a86..000000000
--- a/app/src/full/java/com/celzero/bravedns/adapter/RpnWinProxiesAdapter.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * Copyright 2025 RethinkDNS and its authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *//*
-
-package com.celzero.bravedns.adapter
-
-import android.content.Context
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.RecyclerView
-import com.celzero.bravedns.R
-import com.celzero.bravedns.databinding.ListItemRpnCountriesBinding
-import com.celzero.bravedns.databinding.ListItemRpnWinProxyBinding
-import com.celzero.bravedns.databinding.ListItemWgHopBinding
-import com.celzero.bravedns.rpnproxy.RpnProxyManager
-import com.celzero.bravedns.util.UIUtils.fetchColor
-import com.celzero.bravedns.util.Utilities.getFlag
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-class RpnWinProxiesAdapter(
- private val context: Context,
- private val servers: List,
- private var selectedIds: List
-) : RecyclerView.Adapter() {
-
- companion object {
- private const val TAG = "RpnWinAdapter"
- }
-
- private var isAttached = false
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RpnWinServersViewHolder {
- val itemBinding =
- ListItemRpnWinProxyBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return RpnWinServersViewHolder(itemBinding)
- }
-
- override fun getItemCount(): Int {
- return servers.size
- }
-
- override fun onBindViewHolder(holder: RpnWinServersViewHolder, position: Int) {
- holder.update(servers[position])
- }
-
- override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
- super.onAttachedToRecyclerView(recyclerView)
- isAttached = true
- }
-
- override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
- super.onDetachedFromRecyclerView(recyclerView)
- isAttached = false
- }
-
- inner class RpnWinServersViewHolder(private val b: ListItemRpnWinProxyBinding) :
- RecyclerView.ViewHolder(b.root) {
-
- fun update(server: RpnProxyManager.RpnWinServer) {
- b.rwpCcTv.text = server.countryCode
- val selected = selectedIds.contains(server.countryCode)
- b.rwpCheckbox.isChecked = selectedIds.contains(server.countryCode)
- setCardStroke(selected, true)
- b.rwpNamesTv.text = server.names
- b.rwpAddrTv.text = server.address
- b.rwpFlag.text = getFlag(server.countryCode)
- }
-
- private fun setCardStroke(isSelected: Boolean, isActive: Boolean) {
- val strokeColor = if (isSelected && isActive) {
- b.rwpCard.strokeWidth = 2
- fetchColor(context, R.attr.chipTextPositive)
- } else if (isSelected) { // selected but not active
- b.rwpCard.strokeWidth = 2
- fetchColor(context, R.attr.chipTextNegative)
- } else {
- b.rwpCard.strokeWidth = 0
- fetchColor(context, R.attr.chipTextNegative)
- }
- b.rwpCard.strokeColor = strokeColor
- }
- }
-
- private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
- }
-
- private fun io(f: suspend () -> Unit) {
- (context as LifecycleOwner).lifecycleScope.launch { withContext(Dispatchers.IO) { f() } }
- }
-}
-*/
diff --git a/app/src/full/java/com/celzero/bravedns/adapter/ServerWgPeersAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/ServerWgPeersAdapter.kt
new file mode 100644
index 000000000..0fb94c2a1
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/adapter/ServerWgPeersAdapter.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.adapter
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.celzero.bravedns.R
+import com.celzero.bravedns.databinding.ListItemServerWgPeersBinding
+import com.celzero.bravedns.util.UIUtils
+import com.celzero.bravedns.util.Utilities.tos
+import com.celzero.bravedns.wireguard.Peer
+
+/**
+ * Adapter for displaying server WireGuard peers with expandable details
+ * - No delete functionality
+ * - Expandable/collapsible peer items
+ * - Persistent keepalive shown and can be edited inline
+ */
+class ServerWgPeersAdapter(
+ val context: Context,
+ private var peers: MutableList,
+ private val onPeerExpanded: (Int, Boolean) -> Unit
+) : RecyclerView.Adapter() {
+
+ private val expandedPositions = mutableSetOf()
+
+ override fun onBindViewHolder(holder: ServerWgPeersViewHolder, position: Int) {
+ val peer: Peer = peers[position]
+ holder.update(peer, position)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServerWgPeersViewHolder {
+ val itemBinding =
+ ListItemServerWgPeersBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return ServerWgPeersViewHolder(itemBinding)
+ }
+
+ override fun getItemCount(): Int {
+ return peers.size
+ }
+
+ fun updatePeers(newPeers: MutableList) {
+ peers = newPeers
+ notifyDataSetChanged()
+ }
+
+ inner class ServerWgPeersViewHolder(private val b: ListItemServerWgPeersBinding) :
+ RecyclerView.ViewHolder(b.root) {
+
+ fun update(wgPeer: Peer, position: Int) {
+ val isExpanded = expandedPositions.contains(position)
+
+ // Always show public key
+ b.publicKeyText.text = wgPeer.getPublicKey().base64().tos()
+
+ // Toggle expanded/collapsed state
+ if (isExpanded) {
+ b.expandedDetailsLayout.visibility = View.VISIBLE
+ b.expandIcon.rotation = 180f
+
+ // Show endpoint
+ if (wgPeer.getEndpoint().isPresent) {
+ b.endpointText.text = wgPeer.getEndpoint().get().toString()
+ b.endpointLabel.visibility = View.VISIBLE
+ b.endpointText.visibility = View.VISIBLE
+ } else {
+ b.endpointLabel.visibility = View.GONE
+ b.endpointText.visibility = View.GONE
+ }
+
+ // Show allowed IPs
+ if (wgPeer.getAllowedIps().isNotEmpty()) {
+ b.allowedIpsText.text = wgPeer.getAllowedIps().joinToString { it.toString() }
+ b.allowedIpsLabel.visibility = View.VISIBLE
+ b.allowedIpsText.visibility = View.VISIBLE
+ } else {
+ b.allowedIpsLabel.visibility = View.GONE
+ b.allowedIpsText.visibility = View.GONE
+ }
+
+ // Show persistent keepalive
+ if (wgPeer.persistentKeepalive.isPresent) {
+ b.persistentKeepaliveText.text =
+ UIUtils.getDurationInHumanReadableFormat(
+ context,
+ wgPeer.persistentKeepalive.get()
+ )
+ b.persistentKeepaliveLabel.visibility = View.VISIBLE
+ b.persistentKeepaliveText.visibility = View.VISIBLE
+ } else {
+ b.persistentKeepaliveLabel.visibility = View.VISIBLE
+ b.persistentKeepaliveText.visibility = View.VISIBLE
+ b.persistentKeepaliveText.text = context.getString(R.string.lbl_not_set)
+ }
+ } else {
+ b.expandedDetailsLayout.visibility = View.GONE
+ b.expandIcon.rotation = 0f
+ }
+
+ // Click to expand/collapse
+ b.root.setOnClickListener {
+ toggleExpanded(position)
+ onPeerExpanded(position, !isExpanded)
+ }
+ }
+
+ private fun toggleExpanded(position: Int) {
+ if (expandedPositions.contains(position)) {
+ expandedPositions.remove(position)
+ } else {
+ expandedPositions.add(position)
+ }
+ notifyItemChanged(position)
+ }
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt
index 014a2a5be..90d822635 100644
--- a/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt
+++ b/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt
@@ -40,6 +40,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+/**
+ * Adapter for WireGuard configuration hopping
+ *
+ * NOTE: For new implementations, consider using GenericHopAdapter which supports
+ * both WireGuard configs and RPN proxies through the HopItem sealed interface.
+ * This adapter is kept for backwards compatibility.
+ */
class WgHopAdapter(
private val context: Context,
private val srcId: Int,
@@ -65,6 +72,10 @@ class WgHopAdapter(
}
override fun onBindViewHolder(holder: HopViewHolder, position: Int) {
+ if (position < 0 || position >= itemCount) {
+ Logger.w(LOG_TAG_UI, "$TAG; Invalid position $position for itemCount $itemCount")
+ return
+ }
holder.update(hopables[position])
}
diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt
index 687435b94..97fe068f4 100644
--- a/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt
+++ b/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt
@@ -38,6 +38,9 @@ import com.celzero.bravedns.database.ProxyApplicationMapping
import com.celzero.bravedns.databinding.ListItemWgIncludeAppsBinding
import com.celzero.bravedns.service.FirewallManager
import com.celzero.bravedns.service.ProxyManager
+import com.celzero.bravedns.service.ProxyManager.addProxyToApp
+import com.celzero.bravedns.service.ProxyManager.removeProxyFromApp
+import com.celzero.bravedns.service.ProxyManager.updateProxyIdForApp
import com.celzero.bravedns.util.UIUtils
import com.celzero.bravedns.util.Utilities.getDefaultIcon
import com.celzero.bravedns.util.Utilities.getIcon
@@ -49,6 +52,7 @@ import kotlinx.coroutines.withContext
class WgIncludeAppsAdapter(
private val context: Context,
+ private val lifecycleOwner: LifecycleOwner,
private val proxyId: String,
private val proxyName: String
) :
@@ -58,19 +62,18 @@ class WgIncludeAppsAdapter(
private val packageManager: PackageManager = context.packageManager
companion object {
- private const val ALPHA_FULL = 1f
- private const val ALPHA_DISABLED = 0.4f
private val DIFF_CALLBACK =
object : DiffUtil.ItemCallback() {
- // based on the apps package info and excluded status
+ // Unique identifier should be based on uid and packageName only
+ // since the same app can appear in multiple proxy mappings
override fun areItemsTheSame(
oldConnection: ProxyApplicationMapping,
newConnection: ProxyApplicationMapping
): Boolean {
return (oldConnection.proxyId == newConnection.proxyId &&
- oldConnection.uid == newConnection.uid)
+ oldConnection.uid == newConnection.uid)
}
// return false, when there is difference in excluded status
@@ -78,7 +81,12 @@ class WgIncludeAppsAdapter(
oldConnection: ProxyApplicationMapping,
newConnection: ProxyApplicationMapping
): Boolean {
- return oldConnection == newConnection
+ return (oldConnection.uid == newConnection.uid &&
+ oldConnection.packageName == newConnection.packageName &&
+ oldConnection.appName == newConnection.appName &&
+ oldConnection.proxyId == newConnection.proxyId &&
+ oldConnection.proxyName == newConnection.proxyName &&
+ oldConnection.isActive == newConnection.isActive)
}
}
}
@@ -91,6 +99,11 @@ class WgIncludeAppsAdapter(
override fun onBindViewHolder(holder: IncludedAppInfoViewHolder, position: Int) {
val apps: ProxyApplicationMapping = getItem(position) ?: return
+ // Double-check position validity to prevent IndexOutOfBoundsException
+ if (position < 0 || position >= itemCount) {
+ Logger.w(LOG_TAG_PROXY, "Invalid position $position for itemCount $itemCount")
+ return
+ }
holder.update(apps)
}
@@ -107,35 +120,43 @@ class WgIncludeAppsAdapter(
val itemProxyName = mapping.proxyName
io {
+ // all proxies assigned to this uid and package
+ val proxyIdsForApp =
+ ProxyManager.getProxyIdsForApp(mapping.uid, mapping.packageName)
+ val isIncludedInCurrent = proxyIdsForApp.contains(proxyId)
val isProxyExcluded = FirewallManager.isAppExcludedFromProxy(itemUid)
val hasInternetPerm = mapping.hasInternetPermission(packageManager)
val iconDrawable = getIcon(context, itemPackageName, itemAppName)
uiCtx {
+ // Update UI synchronously on the main thread
+ // enable/disable UI based on exclusion
// is still valid and bound to the same item
if (bindingAdapterPosition == RecyclerView.NO_POSITION) {
- Logger.w(LOG_TAG_PROXY, "ViewHolder recycled, skipping UI update for uid: $itemUid")
+ Logger.w(
+ LOG_TAG_PROXY,
+ "ViewHolder recycled, skipping UI update for uid: $itemUid"
+ )
return@uiCtx
}
// double-check
val currentItem = getItem(bindingAdapterPosition)
if (currentItem?.uid != itemUid) {
- Logger.w(LOG_TAG_PROXY, "ViewHolder rebound to different item, skipping update for uid: $itemUid")
+ Logger.w(
+ LOG_TAG_PROXY,
+ "ViewHolder rebound to different item, skipping update for uid: $itemUid"
+ )
return@uiCtx
}
if (isProxyExcluded) {
b.wgIncludeAppListContainer.isEnabled = false
b.wgIncludeAppListCheckbox.isChecked = false
- // do not allow to click on the card
b.wgIncludeCard.isClickable = false
b.wgIncludeCard.isFocusable = false
b.wgIncludeAppListCheckbox.isClickable = false
b.wgIncludeAppListCheckbox.isFocusable = false
- b.wgIncludeAppAppDescTv.visibility = View.VISIBLE
- b.wgIncludeAppAppDescTv.text =
- context.getString(R.string.exclude_apps_from_proxy)
} else {
b.wgIncludeAppListContainer.isEnabled = true
b.wgIncludeCard.isClickable = true
@@ -146,42 +167,12 @@ class WgIncludeAppsAdapter(
b.wgIncludeAppListApkLabelTv.text = itemAppName
- if (itemProxyId == "") {
- b.wgIncludeAppAppDescTv.text = ""
- b.wgIncludeAppAppDescTv.visibility = View.GONE
- b.wgIncludeAppListCheckbox.isChecked = false
- setCardBackground(false)
- } else if (itemProxyId != proxyId) {
- if (!isProxyExcluded) {
- b.wgIncludeAppAppDescTv.text =
- context.getString(
- R.string.wireguard_apps_proxy_map_desc,
- itemProxyName
- )
- } else {
- b.wgIncludeAppAppDescTv.text =
- context.getString(R.string.exclude_apps_from_proxy)
- }
- b.wgIncludeAppAppDescTv.visibility = View.VISIBLE
- b.wgIncludeAppListCheckbox.isChecked = false
- setCardBackground(false)
- } else {
- b.wgIncludeAppListCheckbox.isChecked =
- itemProxyId == proxyId && !isProxyExcluded
- setCardBackground(itemProxyId == proxyId && !isProxyExcluded)
- }
+ // checkbox state purely based on membership in this proxyId
+ b.wgIncludeAppListCheckbox.isChecked = isIncludedInCurrent && !isProxyExcluded
+ setCardBackground(isIncludedInCurrent && !isProxyExcluded)
- val isIncluded = itemProxyId == proxyId && itemProxyId != ""
- displayIcon(iconDrawable)
- // set the alpha based on internet permission
- if (hasInternetPerm) {
- b.wgIncludeAppListApkLabelTv.alpha = ALPHA_FULL
- b.wgIncludeAppListApkIconIv.alpha = ALPHA_FULL
- } else {
- b.wgIncludeAppListApkLabelTv.alpha = ALPHA_DISABLED
- b.wgIncludeAppListApkIconIv.alpha = ALPHA_DISABLED
- }
- setupClickListeners(mapping, isIncluded)
+ // description text logic: show only other proxies (exclude current proxyId)
+ setupClickListeners(mapping, isIncludedInCurrent && !isProxyExcluded)
}
}
}
@@ -197,9 +188,11 @@ class WgIncludeAppsAdapter(
b.wgIncludeAppListCheckbox.setOnCheckedChangeListener(null)
b.wgIncludeAppListCheckbox.setOnClickListener {
- val isAdded = mapping.proxyId == proxyId
- Logger.i(LOG_TAG_PROXY, "wgIncludeAppListCheckbox - ${mapping.appName}, $isAdded")
- updateInterfaceDetails(mapping, !isAdded)
+ Logger.i(
+ LOG_TAG_PROXY,
+ "wgIncludeAppListCheckbox - ${mapping.appName}, $isIncluded"
+ )
+ updateInterfaceDetails(mapping, !isIncluded)
}
}
@@ -224,6 +217,7 @@ class WgIncludeAppsAdapter(
private fun updateInterfaceDetails(mapping: ProxyApplicationMapping, include: Boolean) {
io {
+ // apps that share this packageName but may have multiple uids (multi-user)
val appUidList = FirewallManager.getAppNamesByUid(mapping.uid)
if (FirewallManager.isAppExcludedFromProxy(mapping.uid)) {
uiCtx {
@@ -248,12 +242,12 @@ class WgIncludeAppsAdapter(
private fun updateProxyIdForApp(mapping: ProxyApplicationMapping, include: Boolean) {
io {
if (include) {
- ProxyManager.updateProxyIdForApp(mapping.uid, proxyId, proxyName)
- Logger.i(LOG_TAG_PROXY, "Included apps: ${mapping.uid}, $proxyId, $proxyName")
+ addProxyToApp(mapping.uid, mapping.packageName, proxyId, proxyName)
+ Logger.i(LOG_TAG_PROXY, "Included app: ${mapping.uid}, $proxyId, $proxyName")
} else {
- ProxyManager.setNoProxyForApp(mapping.uid)
+ removeProxyFromApp(mapping.uid, mapping.packageName, proxyId)
uiCtx { b.wgIncludeAppListCheckbox.isChecked = false }
- Logger.i(LOG_TAG_PROXY, "Removed apps: ${mapping.uid}, $proxyId, $proxyName")
+ Logger.i(LOG_TAG_PROXY, "Removed app: ${mapping.uid}, $proxyId, $proxyName")
}
}
}
@@ -285,15 +279,34 @@ class WgIncludeAppsAdapter(
arrayAdapter.addAll(packageList)
builderSingle.setCancelable(false)
+ // show list just for information, we operate on all uids for this package
builderSingle.setItems(packageList.toTypedArray(), null)
builderSingle
.setPositiveButton(positiveTxt) { _: DialogInterface, _: Int ->
- updateProxyIdForApp(mapping, included)
+ // apply change to all UIDs that share this package name
+ io {
+ val packageNames: List =
+ FirewallManager.getPackageNamesByUid(mapping.uid)
+ packageNames.forEach { pkgName: String ->
+ val appInfo = FirewallManager.getAppInfoByPackage(pkgName)
+ if (appInfo != null) {
+ if (included) {
+ addProxyToApp(
+ appInfo.uid,
+ appInfo.packageName,
+ proxyId,
+ proxyName
+ )
+ } else {
+ removeProxyFromApp(appInfo.uid, appInfo.packageName, proxyId)
+ }
+ }
+ }
+ }
}
- .setNeutralButton(context.getString(R.string.ctbs_dialog_negative_btn)) {
- _: DialogInterface,
- _: Int ->
+ .setNeutralButton(context.getString(R.string.ctbs_dialog_negative_btn)) { _: DialogInterface,
+ _: Int ->
}
val alertDialog: AlertDialog = builderSingle.show()
diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/ITcpProxy.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/ITcpProxy.kt
index d36e3cc8c..157b33766 100644
--- a/app/src/full/java/com/celzero/bravedns/customdownloader/ITcpProxy.kt
+++ b/app/src/full/java/com/celzero/bravedns/customdownloader/ITcpProxy.kt
@@ -47,10 +47,11 @@ interface ITcpProxy {
suspend fun isRethinkPlusAvailable(refId: String): Boolean
- @GET("/g/{refId}/{purchaseToken}")
+ @GET("/g/{refId}/{purchaseToken}/{appVersion}")
suspend fun checkForPaymentAcknowledgement(
@Path("refId") refId: String,
- @Path("purchaseToken") purchaseToken: String
+ @Path("purchaseToken") purchaseToken: String,
+ @Path("appVersion") appVersion: String
): Response?
/*
@@ -61,8 +62,9 @@ interface ITcpProxy {
* response: {"message":"canceled subscription","purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
* {"error":"cannot revoke, subscription canceled or expired","expired":false,"canceled":true,"cancelCtx":{"userInitiatedCancellation":{"cancelSurveyResult":null,"cancelTime":"2025-07-10T13:21:24.743Z"},"systemInitiatedCancellation":null,"developerInitiatedCancellation":null,"replacementCancellation":null},"purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
*/
- @POST("/g/stop")
+ @POST("/g/stop/{appVersion}")
suspend fun cancelSubscription(
+ @Path("appVersion") appVersion: String,
@Query("cid") accountId: String,
@Query("purchaseToken") purchaseToken: String,
@Query("test") test: Boolean = false
@@ -77,20 +79,31 @@ interface ITcpProxy {
* response: {"message":"canceled subscription","purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
* {"error":"cannot revoke, subscription canceled or expired","expired":false,"canceled":true,"cancelCtx":{"userInitiatedCancellation":{"cancelSurveyResult":null,"cancelTime":"2025-07-10T13:21:24.743Z"},"systemInitiatedCancellation":null,"developerInitiatedCancellation":null,"replacementCancellation":null},"purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
*/
- @POST("/g/refund")
+ @POST("/g/refund/{appVersion}")
suspend fun revokeSubscription(
+ @Path("appVersion") appVersion: String,
@Query("cid") accountId: String,
@Query("purchaseToken") purchaseToken: String,
@Query("test") test: Boolean = false
): Response?
- @GET("/g/ent")
+ @GET("/g/ent/{appVersion}")
suspend fun queryEntitlement(
+ @Path("appVersion") appVersion: String,
@Query("cid") accountId: String,
@Query("test") test: Boolean = false
): Response?
+
+ @POST("/g/ack/{appVersion}")
+ suspend fun acknowledgePurchase(
+ @Path("appVersion") appVersion: String,
+ @Query("cid") accountId: String,
+ @Query("purchaseToken") purchaseToken: String,
+ @Query("force") force: Boolean = false
+ ): Response?
+
/*@GET("/warp/renew")
@Streaming
suspend fun renewWarpConfig(
diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt b/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt
index 93079fa1f..c3ea4c028 100644
--- a/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt
+++ b/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt
@@ -92,7 +92,7 @@ class PaymentWorker(val context: Context, workerParameters: WorkerParameters) :
.build()
val retrofitInterface = retrofit.create(ITcpProxy::class.java)
// TODO: get refId from EncryptedFile
- val response = retrofitInterface.checkForPaymentAcknowledgement(referenceId, purchaseToken)
+ val response = retrofitInterface.checkForPaymentAcknowledgement(referenceId, purchaseToken, persistentState.appVersion.toString())
Logger.d(
Logger.LOG_IAB,
"getPaymentStatusFromServer: ${response?.headers()}, ${response?.message()}, ${response?.raw()?.request?.url}"
diff --git a/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt b/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt
index fdf02ee7e..f3415c4e6 100644
--- a/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt
+++ b/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt
@@ -35,7 +35,7 @@ object ProxyManager : KoinComponent {
const val ID_S5_BASE = "S5"
const val ID_HTTP_BASE = "HTTP"
const val ID_NONE = "SYSTEM" // no proxy
- const val ID_RPN_WIN = "RPN-WIN" // rpn win proxy
+ const val ID_RPN_WIN = "RPNWIN" // rpn win proxy
const val TCP_PROXY_NAME = "Rethink-Proxy"
const val ORBOT_PROXY_NAME = "Orbot"
@@ -129,16 +129,22 @@ object ProxyManager : KoinComponent {
}
suspend fun setProxyIdForAllApps(proxyId: String, proxyName: String) {
- // ID_NONE or empty proxy-id is not allowed; see removeProxyForAllApps()
if (!isValidProxyPrefix(proxyId)) {
Logger.e(LOG_TAG_PROXY, "Invalid proxy id: $proxyId")
return
}
- val m = pamSet.map { ProxyAppMapTuple(it.uid, it.packageName, proxyId) }
- pamSet.clear()
- pamSet.addAll(m)
- db.updateProxyForAllApps(proxyId, proxyName)
- Logger.i(LOG_TAG_PROXY, "added all apps to proxy: $proxyId")
+ // add this proxy to every app that does not already have it
+ val toAdd = trackedApps()
+ toAdd.forEach { app ->
+ val tuple = ProxyAppMapTuple(app.uid, app.packageName, proxyId)
+ if (!pamSet.contains(tuple)) {
+ pamSet.add(tuple)
+ val appName = FirewallManager.getAppInfoByPackage(app.packageName)?.appName ?: ""
+ val pam = ProxyApplicationMapping(app.uid, app.packageName, appName, proxyName, true, proxyId)
+ db.insert(pam)
+ }
+ }
+ Logger.i(LOG_TAG_PROXY, "added proxy $proxyId to all apps")
}
suspend fun updateProxyNameForProxyId(proxyId: String, proxyName: String) {
@@ -147,17 +153,37 @@ object ProxyManager : KoinComponent {
}
suspend fun setProxyIdForUnselectedApps(proxyId: String, proxyName: String) {
- // ID_NONE or empty proxy-id is not allowed
if (!isValidProxyPrefix(proxyId)) {
Logger.e(LOG_TAG_PROXY, "Invalid proxy id: $proxyId")
return
}
- val m = pamSet.filter { it.proxyId == "" }.toSet()
- val n = m.map { ProxyAppMapTuple(it.uid, it.packageName, proxyId) }
- pamSet.removeAll(m)
- pamSet.addAll(n)
- db.updateProxyForUnselectedApps(proxyId, proxyName)
- Logger.i(LOG_TAG_PROXY, "added unselected apps to interface: $proxyId")
+ // add this proxy only to apps that do not yet have it
+ val toAdd = trackedApps()
+ toAdd.forEach { app ->
+ val existing = pamSet.any { it.uid == app.uid && it.packageName == app.packageName && it.proxyId == proxyId }
+ if (!existing) {
+ pamSet.add(ProxyAppMapTuple(app.uid, app.packageName, proxyId))
+ val appName = FirewallManager.getAppInfoByPackage(app.packageName)?.appName ?: ""
+ val pam = ProxyApplicationMapping(app.uid, app.packageName, appName, proxyName, true, proxyId)
+ db.insert(pam)
+ }
+ }
+ Logger.i(LOG_TAG_PROXY, "added proxy $proxyId to unselected apps")
+ }
+
+ suspend fun setNoProxyForAllAppsForProxy(proxyId: String) {
+ // remove only this proxyId from every app
+ val toRemove = pamSet.filter { it.proxyId == proxyId }.toSet()
+ if (toRemove.isEmpty()) return
+ pamSet.removeAll(toRemove)
+ // delete only the rows for this proxy from DB
+ db.removeAllAppsForProxy(proxyId)
+ Logger.i(LOG_TAG_PROXY, "removed proxy $proxyId from all apps")
+ }
+
+ suspend fun removeProxyId(proxyId: String) {
+ // alias to removing this proxy mapping from all apps
+ setNoProxyForAllAppsForProxy(proxyId)
}
suspend fun getAllSelectedApps(): Set {
@@ -186,26 +212,6 @@ object ProxyManager : KoinComponent {
}
}
- suspend fun setNoProxyForAllApps() {
- val noProxy = ""
- Logger.i(LOG_TAG_PROXY, "Removing all apps from proxy")
- val m = pamSet.filter { it.proxyId != noProxy }.toSet()
- val n = m.map { ProxyAppMapTuple(it.uid, it.packageName, noProxy) }
- pamSet.removeAll(m)
- pamSet.addAll(n)
- db.updateProxyForAllApps(noProxy, noProxy)
- }
-
- suspend fun removeProxyId(proxyId: String) {
- Logger.i(LOG_TAG_PROXY, "Removing all apps from proxy with id: $proxyId")
- val noProxy = ""
- val m = pamSet.filter { it.proxyId == proxyId }.toSet()
- val n = m.map { ProxyAppMapTuple(it.uid, it.packageName, noProxy) }
- pamSet.removeAll(m)
- pamSet.addAll(n)
- db.removeAllAppsForProxy(proxyId)
- }
-
suspend fun deleteApps(m: Collection) {
m.forEach { deleteApp(it.uid, it.packageName) }
}
@@ -402,4 +408,36 @@ object ProxyManager : KoinComponent {
return sb.toString()
}
+
+ fun getProxyIdsForApp(uid: Int): Set {
+ return pamSet.filter { it.uid == uid && it.proxyId.isNotEmpty() }
+ .map { it.proxyId }
+ .toSet()
+ }
+
+ fun getProxyIdsForApp(uid: Int, packageName: String): Set {
+ return pamSet.filter { it.uid == uid && it.packageName == packageName && it.proxyId.isNotEmpty() }
+ .map { it.proxyId }
+ .toSet()
+ }
+
+ suspend fun addProxyToApp(uid: Int, packageName: String, proxyId: String, proxyName: String) {
+ if (!isValidProxyPrefix(proxyId)) {
+ Logger.e(LOG_TAG_PROXY, "cannot add invalid proxy id: $proxyId")
+ return
+ }
+ val tuple = ProxyAppMapTuple(uid, packageName, proxyId)
+ if (pamSet.contains(tuple)) return
+ pamSet.add(tuple)
+ val appName = FirewallManager.getAppInfoByPackage(packageName)?.appName ?: ""
+ val pam = ProxyApplicationMapping(uid, packageName, appName, proxyName ?: "", true, proxyId)
+ db.insert(pam)
+ }
+
+ suspend fun removeProxyFromApp(uid: Int, packageName: String, proxyId: String) {
+ val toRemove = pamSet.filter { it.uid == uid && it.packageName == packageName && it.proxyId == proxyId }
+ if (toRemove.isEmpty()) return
+ pamSet.removeAll(toRemove.toSet())
+ db.deleteMapping(uid, packageName, proxyId)
+ }
}
diff --git a/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt b/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt
index d6acfa228..d9fa66968 100644
--- a/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt
+++ b/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt
@@ -19,6 +19,7 @@ import Logger
import Logger.LOG_TAG_PROXY
import android.content.Context
import android.text.format.DateUtils
+import com.celzero.firestack.backend.Backend
import com.celzero.bravedns.backup.BackupHelper.Companion.TEMP_WG_DIR
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.data.SsidItem
@@ -34,7 +35,6 @@ import com.celzero.bravedns.wireguard.Config
import com.celzero.bravedns.wireguard.Peer
import com.celzero.bravedns.wireguard.WgHopManager
import com.celzero.bravedns.wireguard.WgInterface
-import com.celzero.firestack.backend.Backend
import com.celzero.firestack.backend.RouterStats
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -398,6 +398,7 @@ object WireguardManager : KoinComponent {
val lockdown = persistentState.wgGlobalLockdown
val block = Backend.Block
val proxyIds: MutableList = mutableListOf()
+
if (oneWireGuardEnabled()) {
val id = getOneWireGuardProxyId()
if (id == null || id == INVALID_CONF_ID) {
@@ -465,26 +466,33 @@ object WireguardManager : KoinComponent {
if (dcProxyPair.first.isNotEmpty()) proxyIds.add(dcProxyPair.first) // domain-app specific
*/
- // check for app specific config
- val ac = ProxyManager.getProxyIdForApp(uid)
- // app-specific config can be empty, if the app is not configured
- // app-specific config id
- val acid = if (ac == ID_NONE) "" else ac // ignore id string if it is ID_NONE
- val appProxyPair = canUseConfig(acid, "app($uid)", usesMobileNw, ssid)
- if (!appProxyPair.second) {
- if (appProxyPair.first == block) {
- proxyIds.clear()
- proxyIds.add(block)
- } else {
- proxyIds.add(appProxyPair.first)
+ // --- App-specific WireGuard configs (multi-proxy aware) ---
+ // collect all proxy-ids for this uid and keep only WireGuard ones (wgX)
+ val allProxyIdsForApp = ProxyManager.getProxyIdsForApp(uid)
+ val wgProxyIdsForApp = allProxyIdsForApp.filter { it.startsWith(ID_WG_BASE) }
+
+ // app-specific configs may be empty if the app is not configured
+ if (wgProxyIdsForApp.isNotEmpty()) {
+ for (pid in wgProxyIdsForApp) {
+ val appProxyPair = canUseConfig(pid, "app($uid)", usesMobileNw, ssid)
+ if (!appProxyPair.second) {
+ // lockdown or block; honor it and stop further processing
+ proxyIds.clear()
+ if (appProxyPair.first == block) {
+ proxyIds.add(block)
+ } else if (appProxyPair.first.isNotEmpty()) {
+ proxyIds.add(appProxyPair.first)
+ }
+ Logger.i(LOG_TAG_PROXY, "lockdown wg for app($uid) => return $proxyIds")
+ return proxyIds
+ }
+ if (appProxyPair.first.isNotEmpty()) {
+ // add eligible app-specific config in the order we see them
+ proxyIds.add(appProxyPair.first)
+ }
}
- Logger.i(LOG_TAG_PROXY, "lockdown wg for app($uid) => return $proxyIds")
- return proxyIds
}
- // add the app specific config to the list
- if (appProxyPair.first.isNotEmpty()) proxyIds.add(appProxyPair.first)
-
/* TODO: commenting the code as v055o doesn't use universal ip and domain rules
// check for universal ip config
val uipc = IpRulesManager.hasProxy(UID_EVERYBODY, ip, port)
@@ -524,7 +532,9 @@ object WireguardManager : KoinComponent {
// if catch-all config is enabled, then add the config id to the list
val cac = mappings.filter { it.isActive && it.isCatchAll }
cac.forEach {
- if ((checkEligibilityBasedOnNw(it.id, usesMobileNw) && checkEligibilityBasedOnSsid(it.id, ssid)) && !proxyIds.contains(ID_WG_BASE + it.id)) {
+ if ((checkEligibilityBasedOnNw(it.id, usesMobileNw) || checkEligibilityBasedOnSsid(it.id, ssid)) &&
+ !proxyIds.contains(ID_WG_BASE + it.id)
+ ) {
proxyIds.add(ID_WG_BASE + it.id)
Logger.i(
LOG_TAG_PROXY,
@@ -534,7 +544,10 @@ object WireguardManager : KoinComponent {
}
if (proxyIds.isEmpty()) {
- Logger.i(LOG_TAG_PROXY, "no proxy ids found for $uid, $ip, $port, $domain; returning $default")
+ Logger.i(
+ LOG_TAG_PROXY,
+ "no proxy ids found for $uid, $ip, $port, $domain; returning $default"
+ )
return listOf(default)
}
diff --git a/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt
index fe00aeb74..61e9c05c8 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt
@@ -20,6 +20,7 @@ import Logger.LOG_TAG_APP_UPDATE
import Logger.LOG_TAG_BACKUP_RESTORE
import Logger.LOG_TAG_DOWNLOAD
import Logger.LOG_TAG_UI
+import android.app.ActivityManager
import android.app.UiModeManager
import android.content.ActivityNotFoundException
import android.content.Context
@@ -146,6 +147,7 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) {
setupNavigationItemSelectedListener()
+
// handle intent receiver for backup/restore
handleIntent()
@@ -755,15 +757,13 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) {
)
}
- /*private fun updateRethinkPlusHighlight() {
- val btmNavView = findViewById(R.id.nav_view)
- val rethinkPlusItem = btmNavView.menu.findItem(R.id.rethinkPlus)
- rethinkPlusItem.setIcon(R.drawable.ic_rethink_plus_sparkle)
- btmNavView.removeBadge(R.id.rethinkPlus)
- }*/
private fun setupNavigationItemSelectedListener() {
- val btmNavView = findViewById(R.id.nav_view)
+ val btmNavView = findViewById(R.id.nav_view) ?: run {
+ Logger.w(LOG_TAG_UI, "setupNavigationItemSelectedListener: BottomNavigationView not found")
+ return
+ }
+
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.fragment_container) as? NavHostFragment
val navController = navHostFragment?.navController
@@ -773,31 +773,18 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) {
when (item.itemId) {
R.id.rethinkPlus -> {
- showToastUiCentered(this, "Coming soon!", Toast.LENGTH_SHORT)
+ // Navigate to rethinkPlus fragment regardless of subscription status
+ // The fragment itself will handle redirecting to dashboard if needed
+ if (navController?.currentDestination?.id != R.id.rethinkPlus) {
+ navController?.navigate(
+ R.id.rethinkPlus,
+ null,
+ NavOptions.Builder().setPopUpTo(homeId, false).build()
+ )
+ }
+ // safe-check before marking checked
+ btmNavView.menu.findItem(R.id.rethinkPlus)?.isChecked = true
true
- /*if (RpnProxyManager.hasValidSubscription()) {
- // Navigate to rethinkPlusDashboardFragment
- if (navController?.currentDestination?.id != R.id.rethinkPlusDashboardFragment) {
- navController?.navigate(
- R.id.rethinkPlusDashboardFragment,
- null,
- NavOptions.Builder().setPopUpTo(homeId, false).build()
- )
- }
- btmNavView.menu.findItem(R.id.rethinkPlus)?.isChecked = true
- true
- } else {
- // Navigate to rethinkPlus fragment
- if (navController?.currentDestination?.id != R.id.rethinkPlus) {
- navController?.navigate(
- R.id.rethinkPlus,
- null,
- NavOptions.Builder().setPopUpTo(homeId, false).build()
- )
- }
- btmNavView.menu.findItem(R.id.rethinkPlus)?.isChecked = true
- true
- }*/
}
homeId -> {
@@ -827,12 +814,6 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) {
}
}
}
-
- // Optionally sync the bottom nav highlight with nav changes
- /*navController?.addOnDestinationChangedListener { _, destination, _ ->
- // Update Rethink Plus badge or icon here if needed
- updateRethinkPlusHighlight()
- }*/
}
private fun io(f: suspend () -> Unit) {
diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/PingTestActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/PingTestActivity.kt
index 687eb8fd0..c52e587cc 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/activity/PingTestActivity.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/activity/PingTestActivity.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.ui.activity
-/*
+
import Logger
import android.content.Context
import android.content.res.Configuration
@@ -262,4 +262,3 @@ class PingTestActivity: AppCompatActivity(R.layout.activity_ping_test) {
lifecycleScope.launch(Dispatchers.IO) { f() }
}
}
-*/
diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/RpnAvailabilityCheckActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/RpnAvailabilityCheckActivity.kt
index 3d17af78e..fa8c57b7c 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/activity/RpnAvailabilityCheckActivity.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/activity/RpnAvailabilityCheckActivity.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.ui.activity
-/*
+
import Logger
import android.content.Context
@@ -130,4 +130,3 @@ class RpnAvailabilityCheckActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.Main) { f() }
}
}
-*/
diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/ServerWgConfigDetailActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/ServerWgConfigDetailActivity.kt
new file mode 100644
index 000000000..6abd52d3b
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/activity/ServerWgConfigDetailActivity.kt
@@ -0,0 +1,598 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.activity
+
+import Logger.LOG_TAG_UI
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Bundle
+import android.view.View
+import android.widget.ArrayAdapter
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import by.kirich1409.viewbindingdelegate.viewBinding
+import com.celzero.bravedns.R
+import com.celzero.bravedns.adapter.ServerWgPeersAdapter
+import com.celzero.bravedns.adapter.WgIncludeAppsAdapter
+import com.celzero.bravedns.databinding.ActivityServerWgDetailBinding
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.service.CountryConfigManager
+import com.celzero.bravedns.service.PersistentState
+import com.celzero.bravedns.service.ProxyManager
+import com.celzero.bravedns.service.ProxyManager.ID_RPN_WIN
+import com.celzero.bravedns.service.VpnController
+import com.celzero.bravedns.service.WireguardManager
+import com.celzero.bravedns.ui.dialog.WgIncludeAppsDialog
+import com.celzero.bravedns.util.Themes
+import com.celzero.bravedns.util.Utilities
+import com.celzero.bravedns.util.Utilities.isAtleastQ
+import com.celzero.bravedns.util.Utilities.tos
+import com.celzero.bravedns.util.handleFrostEffectIfNeeded
+import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel
+import com.celzero.firestack.backend.Proxy
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import kotlin.getValue
+import kotlin.math.abs
+
+/**
+ * Activity for viewing and editing server-provided WireGuard configurations
+ * Allows editing MTU, listen port (from dropdown), and peer persistent keepalive
+ * No delete or full edit capabilities
+ */
+class ServerWgConfigDetailActivity : AppCompatActivity(R.layout.activity_server_wg_detail) {
+ private val b by viewBinding(ActivityServerWgDetailBinding::bind)
+ private val persistentState by inject()
+ private val mappingViewModel: ProxyAppsMappingViewModel by viewModel()
+
+ private var serverWgPeersAdapter: ServerWgPeersAdapter? = null
+ private var layoutManager: LinearLayoutManager? = null
+
+ private var configId: Int = WireguardManager.INVALID_CONF_ID
+ private var countryCode: String = ""
+ private var proxy: Proxy? = null
+ private var countryConfig: CountryConfig? = null
+
+ // Available listen ports
+ private val availableListenPorts = listOf(80, 443, 8080, 9110)
+
+ // SSID permission callback for country configs
+ private val ssidPermissionCallback = object : com.celzero.bravedns.util.SsidPermissionManager.PermissionCallback {
+ override fun onPermissionsGranted() {
+ Logger.vv(LOG_TAG_UI, "ssid-callback permissions granted for country: $countryCode")
+ lifecycleScope.launch {
+ refreshSsidSection()
+ }
+ }
+
+ override fun onPermissionsDenied() {
+ Logger.vv(LOG_TAG_UI, "ssid-callback permissions denied for country: $countryCode")
+ lifecycleScope.launch {
+ // Reset the switch since permissions are required
+ b.ssidCheck.isChecked = false
+ refreshSsidSection()
+ }
+ }
+
+ override fun onPermissionsRationale() {
+ Logger.vv(LOG_TAG_UI, "ssid-callback permissions rationale for country: $countryCode")
+ showSsidPermissionExplanationDialog()
+ }
+ }
+
+ companion object {
+ const val INTENT_EXTRA_SERVER_ID = "SERVER_WG_CONFIG_ID"
+ const val INTENT_EXTRA_FROM_SERVER_SELECTION = "FROM_SERVER_SELECTION"
+ const val INTENT_EXTRA_COUNTRY_CODE = "COUNTRY_CODE"
+ }
+
+ private fun Context.isDarkThemeOn(): Boolean {
+ return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
+ Configuration.UI_MODE_NIGHT_YES
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ theme.applyStyle(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme), true)
+ super.onCreate(savedInstanceState)
+
+ handleFrostEffectIfNeeded(persistentState.theme)
+
+ if (isAtleastQ()) {
+ val controller = WindowInsetsControllerCompat(window, window.decorView)
+ controller.isAppearanceLightNavigationBars = false
+ window.isNavigationBarContrastEnforced = false
+ }
+
+ configId = intent.getIntExtra(INTENT_EXTRA_SERVER_ID, WireguardManager.INVALID_CONF_ID)
+ countryCode = intent.getStringExtra(INTENT_EXTRA_COUNTRY_CODE) ?: ""
+
+ // Setup toolbar
+ setSupportActionBar(b.toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.setDisplayShowHomeEnabled(true)
+
+ // Set toolbar title
+ b.collapsingToolbar.title = getString(R.string.lbl_server_config)
+
+ // Handle back button
+ b.toolbar.setNavigationOnClickListener {
+ onBackPressedDispatcher.onBackPressed()
+ }
+
+ // Setup smooth collapsing animation for hero content
+ setupCollapsingAnimation()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ init()
+ setupClickListeners()
+ }
+
+ private fun init() {
+ // For now, show a message that server configs are loaded from dummy data
+ // In production, this would load actual server-provided WireGuard configs
+ Utilities.showToastUiCentered(
+ this,
+ "Server configuration view (using dummy data for demo)",
+ Toast.LENGTH_LONG
+ )
+
+ // Load config if ID is valid
+ io {
+ proxy = VpnController.getWinByKey(countryCode)
+ val appCount = ProxyManager.getAppsCountForProxy(proxy?.id().tos() ?: "")
+ uiCtx {
+ if (countryCode.isEmpty() || proxy == null) {
+ showInvalidConfigDialog()
+ return@uiCtx
+ }
+ b.appsLabel.text = "Apps($appCount)"
+ prefillConfig(proxy)
+ setupListenPortSpinner()
+ }
+ }
+ }
+
+ private fun showInvalidConfigDialog() {
+ val builder = MaterialAlertDialogBuilder(this, R.style.App_Dialog_NoDim)
+ builder.setTitle(getString(R.string.lbl_wireguard))
+ builder.setMessage(getString(R.string.config_invalid_desc))
+ builder.setCancelable(false)
+ builder.setPositiveButton(getString(R.string.fapps_info_dialog_positive_btn)) { _, _ ->
+ finish()
+ }
+ val dialog = builder.create()
+ dialog.show()
+ }
+
+ private fun prefillConfig(proxy: Proxy?) {
+ if (proxy == null) return
+
+ b.configNameText.visibility = View.VISIBLE
+ b.configNameText.text = proxy.addr.tos()
+ b.configIdText.text =
+ getString(R.string.single_argument_parenthesis, proxy.id().toString())
+
+ b.statusText.text = getString(R.string.lbl_server_config_readonly)
+
+ val router = proxy.router()
+
+ // Pre-fill MTU (read-only)
+ b.mtuText.text = router.mtu().toString()
+ b.mtuText.visibility = View.VISIBLE
+
+ // Load switch states
+ loadConfigSettings()
+
+ // setPeersAdapter()
+ }
+
+ private fun loadConfigSettings() {
+ val cc = countryCode
+ if (cc.isEmpty()) {
+ // Hide settings cards if no country code
+ b.otherSettingsCard.visibility = View.GONE
+ b.mobileSsidSettingsCard.visibility = View.GONE
+ return
+ }
+
+ // Load from CountryConfigManager
+ lifecycleScope.launch(Dispatchers.IO) {
+ val config = CountryConfigManager.getConfig(cc)
+ withContext(Dispatchers.Main) {
+ if (config != null) {
+ b.catchAllCheck.isChecked = config.catchAll
+ b.useMobileCheck.isChecked = config.mobileOnly
+ b.ssidCheck.isChecked = config.ssidBased
+ b.otherSettingsCard.visibility = View.VISIBLE
+ b.mobileSsidSettingsCard.visibility = View.VISIBLE
+ } else {
+ // Create default config if it doesn't exist
+ // should not happen normally
+ CountryConfigManager.upsertConfig(
+ CountryConfig(
+ id = cc, // or "WIN-$cc" if you prefer
+ cc = cc,
+ name = cc, // you can replace with a nicer label later
+ address = "",
+ city = "",
+ key = cc, // can be adjusted once you have a proper key
+ load = 0,
+ link = 0,
+ count = 0,
+ isActive = true,
+ catchAll = false,
+ lockdown = false,
+ mobileOnly = false,
+ ssidBased = false,
+ priority = 0,
+ lastModified = System.currentTimeMillis()
+ )
+ )
+ b.otherSettingsCard.visibility = View.VISIBLE
+ b.mobileSsidSettingsCard.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
+
+ private fun setupListenPortSpinner() {
+ val adapter = ArrayAdapter(
+ this,
+ android.R.layout.simple_spinner_item,
+ availableListenPorts.map { it.toString() }
+ )
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ b.listenPortSpinner.adapter = adapter
+
+ // Set current listen port if present
+ /*if (wgInterface?.listenPort?.isPresent == true) {
+ val currentPort = wgInterface?.listenPort?.get()
+ val position = availableListenPorts.indexOf(currentPort)
+ if (position >= 0) {
+ b.listenPortSpinner.setSelection(position)
+ }
+ }*/
+
+ // Make read-only for server configs
+ b.listenPortSpinner.isEnabled = false
+ }
+
+ private fun setupClickListeners() {
+ // Applications button
+ b.applicationsBtn.setOnClickListener {
+ openAppsDialog()
+ }
+
+ // Hop button
+ b.hopBtn.setOnClickListener {
+ openHopDialog()
+ }
+
+ // Logs button
+ b.logsBtn.setOnClickListener {
+ openLogsDialog()
+ }
+
+ val cc = countryCode
+ if (cc == null) {
+ // If no country code, hide the settings cards
+ b.otherSettingsCard.visibility = View.GONE
+ b.mobileSsidSettingsCard.visibility = View.GONE
+ return
+ }
+
+ // Catch all mode toggle - uses CountryConfigManager
+ b.catchAllCheck.setOnCheckedChangeListener { _, isChecked ->
+ lifecycleScope.launch(Dispatchers.IO) {
+ CountryConfigManager.updateCatchAll(cc, isChecked)
+ withContext(Dispatchers.Main) {
+ Utilities.showToastUiCentered(
+ this@ServerWgConfigDetailActivity,
+ if (isChecked) "Catch all mode enabled" else "Catch all mode disabled",
+ Toast.LENGTH_SHORT
+ )
+ }
+ }
+ }
+
+ // Mobile data only toggle - uses CountryConfigManager
+ b.useMobileCheck.setOnCheckedChangeListener { _, isChecked ->
+ lifecycleScope.launch(Dispatchers.IO) {
+ CountryConfigManager.updateMobileOnly(cc, isChecked)
+ withContext(Dispatchers.Main) {
+ Utilities.showToastUiCentered(
+ this@ServerWgConfigDetailActivity,
+ if (isChecked) "Mobile data only enabled" else "Mobile data only disabled",
+ Toast.LENGTH_SHORT
+ )
+ }
+ }
+ }
+
+ // Setup SSID section with premium dialog and permission handling
+ setupSsidSection(cc)
+
+ // Layout click listeners for better UX
+ b.catchAllRl.setOnClickListener { b.catchAllCheck.performClick() }
+ b.useMobileRl.setOnClickListener { b.useMobileCheck.performClick() }
+ b.ssidFilterRl.setOnClickListener { b.ssidCheck.performClick() }
+ }
+
+ /*private fun setPeersAdapter() {
+ layoutManager = LinearLayoutManager(this)
+ b.peersList.layoutManager = layoutManager
+ serverWgPeersAdapter = ServerWgPeersAdapter(this, peers) { position, isExpanded ->
+ // Handle peer expansion if needed
+ }
+ b.peersList.adapter = serverWgPeersAdapter
+ }*/
+
+ private fun openHopDialog() {
+ Utilities.showToastUiCentered(
+ this,
+ "Configure Hops - Coming Soon",
+ Toast.LENGTH_SHORT
+ )
+ // TODO: Implement WgHopActivity navigation
+ // Similar to WgConfigDetailActivity implementation
+ }
+
+ private fun openLogsDialog() {
+ Utilities.showToastUiCentered(
+ this,
+ "View Logs - Coming Soon",
+ Toast.LENGTH_SHORT
+ )
+ // TODO: Implement WgLogActivity navigation
+ // Similar to WgConfigDetailActivity implementation
+ }
+
+ private fun openAppsDialog() {
+ if (countryCode.isEmpty() || proxy == null) {
+ Logger.e(LOG_TAG_UI, "win-openAppsDialog: countryCode is null")
+ return
+ }
+
+ val proxyId = proxy?.id().tos() ?: (ID_RPN_WIN + countryCode)
+ val proxyName = countryCode
+ val appsAdapter = WgIncludeAppsAdapter(this, this, proxyId, proxyName)
+ mappingViewModel.apps.observe(this) { appsAdapter.submitData(lifecycle, it) }
+ var themeId = Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)
+ if (Themes.isFrostTheme(themeId)) {
+ themeId = R.style.App_Dialog_NoDim
+ }
+ val includeAppsDialog =
+ WgIncludeAppsDialog(this, appsAdapter, mappingViewModel, themeId, proxyId, proxyName)
+ includeAppsDialog.setCanceledOnTouchOutside(false)
+ includeAppsDialog.show()
+ }
+
+ private fun setupCollapsingAnimation() {
+ b.appBar.addOnOffsetChangedListener { appBarLayout, verticalOffset ->
+ val totalScrollRange = appBarLayout.totalScrollRange
+ val percentage = abs(verticalOffset).toFloat() / totalScrollRange.toFloat()
+
+ // Fade out hero content as toolbar collapses
+ b.configNameText.alpha = 1f - percentage
+ b.statusText.alpha = 1f - percentage
+ b.configIdText.alpha = 1f - percentage
+
+ // Scale down hero content slightly for premium effect
+ val scale = 1f - (percentage * 0.1f) // Scale from 1.0 to 0.9
+ b.configNameText.scaleX = scale
+ b.configNameText.scaleY = scale
+ }
+ }
+
+ // ===== SSID Section Implementation =====
+
+ private fun setupSsidSection(cc: String) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ countryConfig = com.celzero.bravedns.rpnproxy.RpnProxyManager.getCountryConfig(cc)
+ withContext(Dispatchers.Main) {
+ setupSsidSectionUI(countryConfig)
+ }
+ }
+ }
+
+ private fun setupSsidSectionUI(config: CountryConfig?) {
+ val sw = b.ssidCheck
+
+ if (config == null) {
+ sw.isEnabled = false
+ Logger.w(LOG_TAG_UI, "setupSsidSection: config is null for $countryCode")
+ return
+ }
+
+ // Check if device supports required features
+ if (!com.celzero.bravedns.util.SsidPermissionManager.isDeviceSupported(this)) {
+ sw.isEnabled = false
+ b.ssidFilterRl.visibility = View.GONE
+ Logger.w(LOG_TAG_UI, "setupSsidSection: device not supported for SSID feature")
+ return
+ }
+
+ // Always keep the switch enabled
+ sw.isEnabled = true
+ b.ssidFilterRl.visibility = View.VISIBLE
+
+ // Check permissions and location services
+ val hasPermissions = com.celzero.bravedns.util.SsidPermissionManager.hasRequiredPermissions(this)
+ val isLocationEnabled = com.celzero.bravedns.util.SsidPermissionManager.isLocationEnabled(this)
+
+ val enabled = config.ssidBased
+ val ssidItems = com.celzero.bravedns.data.SsidItem.parseStorageList(config.ssids)
+ sw.isChecked = enabled
+
+ Logger.d(LOG_TAG_UI, "SSID for $countryCode - permissions: $hasPermissions, location: $isLocationEnabled, ssidBased: $enabled, items: ${ssidItems.size}")
+
+ sw.setOnCheckedChangeListener { _, isChecked ->
+ // Check current permissions and location status dynamically
+ val currentHasPermissions = com.celzero.bravedns.util.SsidPermissionManager.hasRequiredPermissions(this)
+ val currentLocationEnabled = com.celzero.bravedns.util.SsidPermissionManager.isLocationEnabled(this)
+
+ // Check permissions before enabling SSID feature
+ if (isChecked && !currentHasPermissions) {
+ com.celzero.bravedns.util.SsidPermissionManager.checkAndRequestPermissions(this, ssidPermissionCallback)
+ Logger.d(LOG_TAG_UI, "SSID permissions not granted, requesting...")
+ return@setOnCheckedChangeListener
+ }
+
+ // Check if location services are enabled
+ if (isChecked && !currentLocationEnabled) {
+ showLocationEnableDialog()
+ Logger.d(LOG_TAG_UI, "Location services not enabled, prompting user...")
+ return@setOnCheckedChangeListener
+ }
+
+ // If we reach here, either we're disabling or we have all required permissions
+ lifecycleScope.launch(Dispatchers.IO) {
+ com.celzero.bravedns.rpnproxy.RpnProxyManager.updateSsidBased(countryCode, isChecked)
+ withContext(Dispatchers.Main) {
+ if (isChecked) {
+ openSsidDialog()
+ }
+ }
+ }
+
+ Logger.i(LOG_TAG_UI, "SSID feature ${if (isChecked) "enabled" else "disabled"} for country: $countryCode")
+ }
+ }
+
+ private fun refreshSsidSection() {
+ lifecycleScope.launch(Dispatchers.IO) {
+ countryConfig = com.celzero.bravedns.rpnproxy.RpnProxyManager.getCountryConfig(countryCode)
+ withContext(Dispatchers.Main) {
+ setupSsidSectionUI(countryConfig)
+ }
+ }
+ }
+
+ private fun openSsidDialog() {
+ if (countryCode.isEmpty() || countryConfig == null) {
+ Logger.e(LOG_TAG_UI, "openSsidDialog: countryCode or config is null")
+ return
+ }
+
+ val currentSsids = countryConfig?.ssids ?: ""
+ val countryName = countryConfig?.countryName ?: countryCode
+
+ var themeId = Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)
+ if (Themes.isFrostTheme(themeId)) {
+ themeId = R.style.App_Dialog_NoDim
+ }
+
+ val ssidDialog = com.celzero.bravedns.ui.dialog.CountrySsidDialog(
+ this,
+ themeId,
+ countryCode,
+ countryName,
+ currentSsids
+ ) { newSsids ->
+ // Save callback - update the SSID configuration
+ lifecycleScope.launch(Dispatchers.IO) {
+ com.celzero.bravedns.rpnproxy.RpnProxyManager.updateSsids(countryCode, newSsids)
+ withContext(Dispatchers.Main) {
+ refreshSsidSection()
+ Utilities.showToastUiCentered(
+ this@ServerWgConfigDetailActivity,
+ "SSID settings saved for $countryName",
+ Toast.LENGTH_SHORT
+ )
+ }
+ }
+ }
+
+ ssidDialog.setCanceledOnTouchOutside(false)
+ ssidDialog.show()
+ ssidDialog.setOnDismissListener {
+ // Refresh SSID section after dialog dismisses
+ refreshSsidSection()
+ }
+ }
+
+ private fun showLocationEnableDialog() {
+ val builder = MaterialAlertDialogBuilder(this, R.style.App_Dialog_NoDim)
+ builder.setTitle(getString(R.string.ssid_location_error))
+ builder.setMessage(getString(R.string.location_enable_explanation, getString(R.string.lbl_ssids)))
+ builder.setCancelable(true)
+ builder.setPositiveButton(getString(R.string.ssid_location_error_action)) { dialog, _ ->
+ com.celzero.bravedns.util.SsidPermissionManager.requestLocationEnable(this)
+ dialog.dismiss()
+ Logger.vv(LOG_TAG_UI, "Prompted user to enable location services for country: $countryCode")
+ }
+ builder.setNegativeButton(getString(R.string.lbl_cancel)) { _, _ ->
+ // Reset the SSID switch since location is required
+ b.ssidCheck.isChecked = false
+ lifecycleScope.launch(Dispatchers.IO) {
+ com.celzero.bravedns.rpnproxy.RpnProxyManager.updateSsidBased(countryCode, false)
+ }
+ }
+ builder.create().show()
+ }
+
+ private fun showSsidPermissionExplanationDialog() {
+ val builder = MaterialAlertDialogBuilder(this, R.style.App_Dialog_NoDim)
+ builder.setTitle(getString(R.string.ssid_permission_error_action))
+ builder.setMessage(getString(R.string.ssid_permission_explanation, getString(R.string.lbl_ssids)))
+ builder.setCancelable(true)
+ builder.setPositiveButton(getString(R.string.ssid_permission_error_action)) { dialog, _ ->
+ com.celzero.bravedns.util.SsidPermissionManager.requestSsidPermissions(this)
+ dialog.dismiss()
+ Logger.vv(LOG_TAG_UI, "Showing SSID permission rationale dialog for country: $countryCode")
+ }
+ builder.setNegativeButton(getString(R.string.lbl_cancel)) { _, _ ->
+ // Reset the SSID switch since permissions are required
+ b.ssidCheck.isChecked = false
+ lifecycleScope.launch(Dispatchers.IO) {
+ com.celzero.bravedns.rpnproxy.RpnProxyManager.updateSsidBased(countryCode, false)
+ }
+ }
+ builder.create().show()
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ com.celzero.bravedns.util.SsidPermissionManager.handlePermissionResult(
+ requestCode,
+ permissions,
+ grantResults,
+ ssidPermissionCallback
+ )
+ }
+
+ private fun io(f: suspend () -> Unit) {
+ lifecycleScope.launch(Dispatchers.IO) { f() }
+ }
+
+ private suspend fun uiCtx(f: () -> Unit) {
+ withContext(Dispatchers.Main) { f() }
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt
index 8834b41ef..e4250b5a6 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt
@@ -163,7 +163,7 @@ class TcpProxyMainActivity : AppCompatActivity(R.layout.activity_tcp_proxy) {
private fun openAppsDialog() {
val proxyId = ProxyManager.ID_TCP_BASE
val proxyName = ProxyManager.TCP_PROXY_NAME
- val appsAdapter = WgIncludeAppsAdapter(this, proxyId, proxyName)
+ val appsAdapter = WgIncludeAppsAdapter(this, this, proxyId, proxyName)
mappingViewModel.apps.observe(this) { appsAdapter.submitData(lifecycle, it) }
var themeId = Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)
if (Themes.isFrostTheme(themeId)) {
diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt
index 0b1492113..84272e45d 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt
@@ -741,7 +741,7 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) {
private fun openAppsDialog(proxyName: String) {
val proxyId = ID_WG_BASE + configId
- val appsAdapter = WgIncludeAppsAdapter(this, proxyId, proxyName)
+ val appsAdapter = WgIncludeAppsAdapter(this, this, proxyId, proxyName)
mappingViewModel.apps.observe(this) { appsAdapter.submitData(lifecycle, it) }
var themeId = Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)
if (Themes.isFrostTheme(themeId)) {
diff --git a/app/src/full/java/com/celzero/bravedns/ui/adapter/CountryServerAdapter.kt b/app/src/full/java/com/celzero/bravedns/ui/adapter/CountryServerAdapter.kt
new file mode 100644
index 000000000..adf3221f1
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/adapter/CountryServerAdapter.kt
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2026 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.adapter
+
+import Logger
+import Logger.LOG_TAG_UI
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.databinding.ListItemCountryCardBinding
+
+/**
+ * Adapter showing servers grouped by country using list_item_country_card.
+ * Each country row can be expanded to reveal its city servers.
+ */
+class CountryServerAdapter(
+ private var countries: List,
+ private val listener: CitySelectionListener
+) : RecyclerView.Adapter() {
+
+ interface CitySelectionListener {
+ fun onCitySelected(server: CountryConfig, isSelected: Boolean)
+ }
+
+ data class CityItem(val server: CountryConfig)
+
+ data class CountryItem(
+ val countryCode: String,
+ val countryName: String,
+ val flagEmoji: String,
+ val cities: List
+ )
+
+ // track which countries are expanded by country code
+ private val expandedCountries = mutableSetOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CountryViewHolder {
+ val binding = ListItemCountryCardBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return CountryViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: CountryViewHolder, position: Int) {
+ val country = countries[position]
+ Logger.v(LOG_TAG_UI, "CountryServerAdapter.bind: ${country.countryName} with ${country.cities.size} cities")
+ holder.bind(country, expandedCountries.contains(country.countryCode))
+ }
+
+ override fun getItemCount(): Int = countries.size
+
+ fun updateCountries(newCountries: List) {
+ countries = newCountries
+ // retain expansion where possible
+ val newCodes = newCountries.map { it.countryCode }.toSet()
+ expandedCountries.retainAll(newCodes)
+ notifyDataSetChanged()
+ }
+
+ inner class CountryViewHolder(
+ private val binding: ListItemCountryCardBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ private var cityAdapter: CityServerAdapter? = null
+
+ fun bind(item: CountryItem, isExpanded: Boolean) {
+ binding.apply {
+ tvCountryFlag.text = item.flagEmoji
+ tvCountryName.text = item.countryName
+ tvServerCount.text =
+ itemView.context.resources.getQuantityString(
+ com.celzero.bravedns.R.plurals.server_count,
+ item.cities.size,
+ item.cities.size
+ )
+
+ if (cityAdapter == null) {
+ cityAdapter = CityServerAdapter(listener)
+ rvServers.layoutManager = LinearLayoutManager(itemView.context)
+ rvServers.adapter = cityAdapter
+ rvServers.isNestedScrollingEnabled = false
+ }
+ cityAdapter?.submitList(item.cities)
+
+ // expand / collapse state
+ rvServers.visibility = if (isExpanded) View.VISIBLE else View.GONE
+ ivExpandArrow.rotation = if (isExpanded) 180f else 0f
+
+ layoutCountryHeader.setOnClickListener {
+ val code = item.countryCode
+ val nowExpanded: Boolean
+ if (expandedCountries.contains(code)) {
+ expandedCountries.remove(code)
+ nowExpanded = false
+ } else {
+ expandedCountries.add(code)
+ nowExpanded = true
+ }
+ rvServers.visibility = if (nowExpanded) View.VISIBLE else View.GONE
+ ivExpandArrow.animate().rotation(if (nowExpanded) 180f else 0f).start()
+ }
+ }
+ }
+ }
+
+ /**
+ * Child adapter for city rows under each country card.
+ */
+ private class CityServerAdapter(
+ private val listener: CitySelectionListener
+ ) : RecyclerView.Adapter() {
+
+ private val items = mutableListOf()
+
+ fun submitList(newItems: List) {
+ items.clear()
+ items.addAll(newItems)
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CityViewHolder {
+ val binding = com.celzero.bravedns.databinding.ItemServerBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return CityViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: CityViewHolder, position: Int) {
+ holder.bind(items[position].server)
+ }
+
+ override fun getItemCount(): Int = items.size
+
+ inner class CityViewHolder(
+ private val binding: com.celzero.bravedns.databinding.ItemServerBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(server: CountryConfig) {
+ binding.apply {
+ // Display city name
+ tvCityName.text = server.serverLocation
+
+ // Display server metrics (latency and load)
+ tvServerMetric.text = buildString {
+ if (server.link > 0) {
+ append("${server.link}ms")
+ }
+ if (server.load > 0) {
+ if (isNotEmpty()) append(" • ")
+ append("${server.load}%")
+ }
+ }.ifEmpty { "—" }
+
+ // Selection state
+ checkboxServer.isChecked = server.isActive
+
+ // Click handling
+ root.setOnClickListener {
+ val newState = !server.isActive
+ listener.onCitySelected(server, newState)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/full/java/com/celzero/bravedns/ui/adapter/VpnServerAdapter.kt b/app/src/full/java/com/celzero/bravedns/ui/adapter/VpnServerAdapter.kt
new file mode 100644
index 000000000..43aca7e3d
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/adapter/VpnServerAdapter.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.adapter
+
+import Logger
+import Logger.LOG_TAG_UI
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.celzero.bravedns.R
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.databinding.ListItemVpnServerBinding
+import com.celzero.bravedns.ui.activity.ServerWgConfigDetailActivity
+import com.celzero.bravedns.util.UIUtils.fetchColor
+import kotlin.collections.toList
+
+/**
+ * RecyclerView adapter for displaying VPN server list
+ * with Material Design cards and expandable/collapsible functionality
+ */
+class VpnServerAdapter(
+ private var servers: List,
+ private val listener: ServerSelectionListener
+) : RecyclerView.Adapter() {
+
+ interface ServerSelectionListener {
+ fun onServerSelected(server: CountryConfig, isSelected: Boolean)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServerViewHolder {
+ val binding = ListItemVpnServerBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return ServerViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ServerViewHolder, position: Int) {
+ Logger.v(LOG_TAG_UI, "VpnServerAdapter.onBindViewHolder: position=$position, server=${servers[position].countryName}")
+ holder.bind(servers[position])
+ // Add subtle entrance animation
+ holder.itemView.alpha = 0f
+ holder.itemView.translationY = 50f
+ holder.itemView.animate()
+ .alpha(1f)
+ .translationY(0f)
+ .setDuration(300)
+ .setStartDelay((position * 50).toLong())
+ .start()
+ }
+
+ override fun getItemCount(): Int {
+ val count = servers.size
+ Logger.v(LOG_TAG_UI, "VpnServerAdapter.getItemCount: returning $count")
+ return count
+ }
+
+ fun updateServers(newServers: List) {
+ val oldSize = servers.size
+ servers = newServers.toList() // Create a new copy to ensure list is not shared
+ Logger.v(LOG_TAG_UI, "VpnServerAdapter.updateServers: oldSize=$oldSize, newSize=${servers.size}, servers=${servers.map { it.countryName }}")
+ notifyDataSetChanged()
+ }
+
+ inner class ServerViewHolder(
+ private val binding: ListItemVpnServerBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ private val context: Context = binding.root.context
+
+ fun bind(server: CountryConfig) {
+ binding.apply {
+ Logger.v(LOG_TAG_UI, "VpnServerAdapter.bind: server=${server.countryName}, selected=${server.isActive}, position=$bindingAdapterPosition")
+ Logger.v(LOG_TAG_UI, "Binding server: ${server.name}, location: ${server.serverLocation}, latency: ${server.link}ms, load: ${server.load}%, selected: ${server.isActive}, cc: ${server.cc}, addr: ${server.address}, key: ${server.key}, active: ${server.isActive}")
+
+ // Check if this is the AUTO server
+ val isAutoServer = server.id == "AUTO"
+
+ if (isAutoServer) {
+ // Special styling for AUTO server
+ tvFlag.text = "🌐"
+ tvCountryName.text = "AUTO"
+ tvServerLocation.text = "Automatic server selection"
+ latencyBadge.text = "Auto"
+ latencyBadge.setTextColor(fetchColor(context, R.attr.accentGood))
+
+ // AUTO is always selected and cannot be manually toggled
+ checkboxSelect.isChecked = true
+ checkboxSelect.isEnabled = false
+ checkboxSelect.alpha = 0.6f
+
+ // Disable info icon for AUTO
+ infoIcon.isEnabled = false
+ infoIcon.alpha = 0.3f
+ } else {
+ // Normal server display
+ tvFlag.text = server.flagEmoji
+ tvCountryName.text = server.countryName
+ tvServerLocation.text = root.context.getString(R.string.server_location_format, server.serverLocation, server.cc)
+ checkboxSelect.isChecked = server.isActive
+ latencyBadge.text = server.getBadgeText()
+ setLatencyColor(server)
+
+ // Re-enable controls for normal servers
+ checkboxSelect.isEnabled = true
+ checkboxSelect.alpha = 1f
+ infoIcon.isEnabled = true
+ infoIcon.alpha = 1f
+ }
+
+ premiumBadgeMini.visibility = View.GONE // premium badge not used for RPN
+
+ checkboxSelect.setOnClickListener {
+ if (!isAutoServer) {
+ val newState = !server.isActive
+ listener.onServerSelected(server, newState)
+ }
+ }
+
+ infoIcon.setOnClickListener {
+ if (!isAutoServer) {
+ openServerDetail(server)
+ }
+ }
+
+ serverCard.setOnClickListener {
+ if (!isAutoServer) {
+ checkboxSelect.performClick()
+ }
+ }
+ }
+ }
+
+ private fun setLatencyColor(server: CountryConfig) {
+ val colorAttr = when (server.getQualityLevel()) {
+ CountryConfig.ServerQuality.EXCELLENT -> R.attr.chipTextPositive
+ CountryConfig.ServerQuality.GOOD -> R.attr.accentGood
+ CountryConfig.ServerQuality.FAIR -> R.attr.chipTextNeutral
+ CountryConfig.ServerQuality.POOR -> R.attr.chipTextNegative
+ }
+ binding.latencyBadge.setTextColor(fetchColor(context, colorAttr))
+ }
+
+ private fun openServerDetail(server: CountryConfig) {
+ val intent = android.content.Intent(context, ServerWgConfigDetailActivity::class.java)
+ intent.putExtra(ServerWgConfigDetailActivity.INTENT_EXTRA_SERVER_ID, server.id.hashCode())
+ intent.putExtra(ServerWgConfigDetailActivity.INTENT_EXTRA_FROM_SERVER_SELECTION, true)
+ intent.putExtra(ServerWgConfigDetailActivity.INTENT_EXTRA_COUNTRY_CODE, server.key)
+ context.startActivity(intent)
+ }
+ }
+}
diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt
index 03c5804eb..89f23c901 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt
@@ -413,6 +413,7 @@ class OrbotBottomSheet : BottomSheetDialogFragment() {
val appsAdapter =
WgIncludeAppsAdapter(
requireContext(),
+ this,
ProxyManager.ID_ORBOT_BASE,
ProxyManager.ORBOT_PROXY_NAME
)
diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/PurchaseProcessingBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/PurchaseProcessingBottomSheet.kt
new file mode 100644
index 000000000..275490340
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/PurchaseProcessingBottomSheet.kt
@@ -0,0 +1,152 @@
+package com.celzero.bravedns.ui.bottomsheet
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import com.celzero.bravedns.R
+import com.celzero.bravedns.databinding.BottomsheetPurchaseProcessingBinding
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import java.io.Serializable
+
+/**
+ * Bottom sheet for displaying purchase processing states
+ * Provides visual feedback during subscription purchase, activation, and completion
+ */
+class PurchaseProcessingBottomSheet : BottomSheetDialogFragment() {
+
+ private var _binding: BottomsheetPurchaseProcessingBinding? = null
+ private val binding get() = _binding!!
+
+ private var currentState: ProcessingState = ProcessingState.Processing
+
+ companion object {
+ private const val TAG = "PurchaseProcessingBS"
+ private const val ARG_STATE = "state"
+ private const val ARG_MESSAGE = "message"
+
+ fun newInstance(state: ProcessingState, message: String? = null): PurchaseProcessingBottomSheet {
+ return PurchaseProcessingBottomSheet().apply {
+ arguments = Bundle().apply {
+ putSerializable(ARG_STATE, state)
+ message?.let { putString(ARG_MESSAGE, it) }
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.BottomSheetDialogTheme)
+ isCancelable = false
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = BottomsheetPurchaseProcessingBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val state = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+ arguments?.getSerializable(ARG_STATE, ProcessingState::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ arguments?.getSerializable(ARG_STATE) as? ProcessingState
+ } ?: ProcessingState.Processing
+
+ val message = arguments?.getString(ARG_MESSAGE)
+
+ updateState(state, message)
+ }
+
+ /**
+ * Update the bottom sheet state
+ */
+ fun updateState(state: ProcessingState, message: String? = null) {
+ currentState = state
+
+ when (state) {
+ ProcessingState.Processing -> showProcessing(message)
+ ProcessingState.PendingVerification -> showPendingVerification()
+ ProcessingState.Success -> showSuccess(message)
+ ProcessingState.Error -> showError(message)
+ }
+ }
+
+ private fun showProcessing(message: String?) {
+ binding.progressIndicator.isVisible = true
+ binding.statusIcon.isVisible = false
+ binding.titleText.text = getString(R.string.processing_purchase)
+ binding.messageText.text = message ?: getString(R.string.please_wait_processing)
+ binding.actionButton.isVisible = false
+ }
+
+ private fun showPendingVerification() {
+ binding.progressIndicator.isVisible = true
+ binding.statusIcon.isVisible = false
+ binding.titleText.text = getString(R.string.verifying_purchase)
+ binding.messageText.text = getString(R.string.verifying_with_play_store)
+ binding.actionButton.isVisible = false
+ }
+
+ private fun showSuccess(message: String?) {
+ binding.progressIndicator.isVisible = false
+ binding.statusIcon.isVisible = true
+ binding.statusIcon.setImageResource(R.drawable.ic_check_circle)
+ binding.titleText.text = getString(R.string.purchase_successful)
+ binding.messageText.text = message ?: getString(R.string.subscription_activated)
+
+ binding.actionButton.isVisible = true
+ binding.actionButton.text = getString(R.string.continue_text)
+ binding.actionButton.setOnClickListener {
+ dismiss()
+ }
+ }
+
+ private fun showError(message: String?) {
+ binding.progressIndicator.isVisible = false
+ binding.statusIcon.isVisible = true
+ binding.statusIcon.setImageResource(androidx.biometric.R.drawable.fingerprint_dialog_error)
+ binding.titleText.text = getString(R.string.purchase_failed)
+ binding.messageText.text = message ?: getString(R.string.something_went_wrong)
+
+ binding.actionButton.isVisible = true
+ binding.actionButton.text = getString(R.string.close)
+ binding.actionButton.setOnClickListener {
+ dismiss()
+ }
+ }
+
+ override fun dismiss() {
+ // Check if fragment is in valid state before dismissing
+ if (isAdded && !isStateSaved) {
+ super.dismiss()
+ }
+ }
+
+ override fun dismissAllowingStateLoss() {
+ // Safe dismiss that works even after onSaveInstanceState
+ if (isAdded) {
+ super.dismissAllowingStateLoss()
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ enum class ProcessingState : Serializable {
+ Processing,
+ PendingVerification,
+ Success,
+ Error
+ }
+}
\ No newline at end of file
diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ServerRemovalNotificationBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ServerRemovalNotificationBottomSheet.kt
new file mode 100644
index 000000000..3c6239cc0
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ServerRemovalNotificationBottomSheet.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.bottomsheet
+
+import Logger
+import Logger.LOG_TAG_UI
+import android.animation.Animator
+import android.animation.ObjectAnimator
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.AccelerateDecelerateInterpolator
+import com.celzero.bravedns.R
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.databinding.BottomsheetServerRemovalNotificationBinding
+import com.celzero.bravedns.databinding.ItemRemovedServerBinding
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+
+/**
+ * Premium bottom sheet notification for informing users about removed server locations
+ * Features:
+ * - Elegant Material Design 3 UI
+ * - Smooth animations
+ * - List of removed servers with details
+ * - Professional notification experience
+ */
+class ServerRemovalNotificationBottomSheet : BottomSheetDialogFragment() {
+
+ private var _binding: BottomsheetServerRemovalNotificationBinding? = null
+ private val binding get() = _binding!!
+
+ private var removedServers: List = emptyList()
+ private var onDismissCallback: (() -> Unit)? = null
+
+ companion object {
+ private const val TAG = "ServerRemovalNotificationBS"
+ private const val ARG_REMOVED_SERVERS = "removed_servers"
+
+ fun newInstance(removedServers: List): ServerRemovalNotificationBottomSheet {
+ return ServerRemovalNotificationBottomSheet().apply {
+ arguments = Bundle().apply {
+ putParcelableArrayList(ARG_REMOVED_SERVERS, ArrayList(removedServers) as ArrayList)
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.BottomSheetDialogTheme)
+ isCancelable = true
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = BottomsheetServerRemovalNotificationBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // Extract removed servers from arguments
+ @Suppress("UNCHECKED_CAST", "DEPRECATION")
+ removedServers = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+ arguments?.getParcelableArrayList(ARG_REMOVED_SERVERS, CountryConfig::class.java) as? List ?: emptyList()
+ } else {
+ arguments?.getParcelableArrayList(ARG_REMOVED_SERVERS) ?: emptyList()
+ }
+
+ Logger.i(LOG_TAG_UI, "$TAG: showing notification for ${removedServers.size} removed servers")
+
+ setupUI()
+ animateEntry()
+ }
+
+ private fun setupUI() {
+ // Update title and subtitle based on count
+ if (removedServers.size == 1) {
+ binding.notificationTitle.text = getString(R.string.server_removal_title)
+ binding.notificationSubtitle.text = getString(R.string.server_removal_subtitle)
+ } else {
+ binding.notificationTitle.text = getString(R.string.server_removal_title).replace("Location", "Locations")
+ binding.notificationSubtitle.text = "${removedServers.size} locations are no longer available"
+ }
+
+ // Populate removed servers list
+ binding.removedServersContainer.removeAllViews()
+ removedServers.forEachIndexed { index, server ->
+ val itemBinding = ItemRemovedServerBinding.inflate(
+ layoutInflater,
+ binding.removedServersContainer,
+ false
+ )
+
+ // Set server details
+ itemBinding.serverFlag.text = server.flagEmoji
+ itemBinding.serverName.text = server.countryName
+ itemBinding.serverLocation.text = server.serverLocation
+
+ // Add to container
+ binding.removedServersContainer.addView(itemBinding.root)
+
+ // Animate item entry with stagger
+ itemBinding.root.alpha = 0f
+ itemBinding.root.translationY = 20f
+ itemBinding.root.animate()
+ .alpha(1f)
+ .translationY(0f)
+ .setStartDelay((index * 80L))
+ .setDuration(400)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+ }
+
+ // Setup action button
+ binding.btnUnderstand.setOnClickListener {
+ animateExitAndDismiss()
+ }
+ }
+
+ private fun animateEntry() {
+ // Animate icon
+ binding.iconContainer.scaleX = 0f
+ binding.iconContainer.scaleY = 0f
+ binding.iconContainer.animate()
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(500)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+
+ // Rotate icon slightly for attention
+ ObjectAnimator.ofFloat(binding.notificationIcon, "rotation", 0f, 360f).apply {
+ duration = 800
+ interpolator = AccelerateDecelerateInterpolator()
+ start()
+ }
+
+ // Animate title
+ binding.notificationTitle.alpha = 0f
+ binding.notificationTitle.translationX = -30f
+ binding.notificationTitle.animate()
+ .alpha(1f)
+ .translationX(0f)
+ .setStartDelay(200)
+ .setDuration(500)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+
+ // Animate subtitle
+ binding.notificationSubtitle.alpha = 0f
+ binding.notificationSubtitle.translationX = -30f
+ binding.notificationSubtitle.animate()
+ .alpha(1f)
+ .translationX(0f)
+ .setStartDelay(300)
+ .setDuration(500)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+
+ // Animate button
+ binding.btnUnderstand.alpha = 0f
+ binding.btnUnderstand.scaleX = 0.9f
+ binding.btnUnderstand.scaleY = 0.9f
+ binding.btnUnderstand.animate()
+ .alpha(1f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setStartDelay(600)
+ .setDuration(400)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+ }
+
+ private fun animateExitAndDismiss() {
+ // Disable button to prevent double tap
+ binding.btnUnderstand.isEnabled = false
+
+ // Animate card out
+ binding.notificationCard.animate()
+ .alpha(0f)
+ .scaleX(0.95f)
+ .scaleY(0.95f)
+ .translationY(50f)
+ .setDuration(300)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .withEndAction {
+ dismissAllowingStateLoss()
+ }
+ .start()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ onDismissCallback?.invoke()
+ }
+
+ /**
+ * Set a callback to be invoked when the bottom sheet is dismissed
+ */
+ fun setOnDismissCallback(callback: () -> Unit) {
+ onDismissCallback = callback
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/CountrySsidDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/CountrySsidDialog.kt
new file mode 100644
index 000000000..5893b1eeb
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/CountrySsidDialog.kt
@@ -0,0 +1,319 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.dialog
+
+import Logger
+import Logger.LOG_TAG_UI
+import android.animation.ObjectAnimator
+import android.app.Activity
+import android.app.Dialog
+import android.os.Bundle
+import android.view.Gravity
+import android.view.Window
+import android.view.WindowManager
+import android.view.animation.AccelerateDecelerateInterpolator
+import android.view.inputmethod.EditorInfo
+import android.widget.Toast
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.widget.addTextChangedListener
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.celzero.bravedns.R
+import com.celzero.bravedns.adapter.SsidAdapter
+import com.celzero.bravedns.data.SsidItem
+import com.celzero.bravedns.databinding.DialogCountrySsidPremiumBinding
+import com.celzero.bravedns.util.UIUtils
+import com.celzero.bravedns.util.Utilities
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+/**
+ * Premium SSID Dialog for Country-based VPN configurations
+ * Features modern Material 3 design with smooth animations
+ */
+class CountrySsidDialog(
+ private val activity: Activity,
+ private val themeId: Int,
+ private val countryCode: String,
+ private val countryName: String,
+ private val currentSsids: String,
+ private val onSave: (String) -> Unit
+) : Dialog(activity, themeId) {
+
+ private lateinit var b: DialogCountrySsidPremiumBinding
+ private lateinit var ssidAdapter: SsidAdapter
+ private val ssidItems = mutableListOf()
+
+ companion object {
+ private const val TAG = "CountrySsidDialog"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ requestWindowFeature(Window.FEATURE_NO_TITLE)
+
+ b = DialogCountrySsidPremiumBinding.inflate(layoutInflater)
+ setContentView(b.root)
+ setCancelable(false)
+ setupDialog()
+ setupRecyclerView()
+ loadCurrentSsids()
+ setupClickListeners()
+ animateEntry()
+ }
+
+ private fun setupDialog() {
+ window?.setLayout(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT
+ )
+
+ window?.setGravity(Gravity.CENTER)
+ window?.setBackgroundDrawableResource(android.R.color.transparent)
+
+ ViewCompat.setOnApplyWindowInsetsListener(b.root) { view, insets ->
+ val sysInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.setPadding(0, sysInsets.top, 0, sysInsets.bottom)
+ insets
+ }
+
+ // Set title with country name
+ b.dialogTitle.text = context.getString(R.string.country_ssid_dialog_title, countryName)
+
+ // Set description
+ b.descriptionTextView.text = getDescTxt()
+
+ // Set input hint
+ b.ssidTextInputLayout.hint = context.getString(R.string.wg_ssid_input_hint, context.getString(R.string.lbl_ssids))
+
+ // Set radio button text
+ b.radioNotEqual.text = context.getString(R.string.notification_action_pause_vpn).lowercase()
+ .replaceFirstChar { it.uppercase() }
+
+ // Set initial state of add button to disabled
+ b.addSsidBtn.isEnabled = false
+ b.addSsidBtn.isClickable = false
+ b.addSsidBtn.setTextColor(UIUtils.fetchColor(context, R.attr.primaryLightColorText))
+
+ // Listeners to update description text when radio buttons change
+ b.ssidConditionRadioGroup.setOnCheckedChangeListener { _, _ ->
+ updateDescriptionText()
+ }
+
+ b.ssidMatchTypeRadioGroup.setOnCheckedChangeListener { _, _ ->
+ updateDescriptionText()
+ }
+ }
+
+ private fun animateEntry() {
+ // Animate dialog card
+ b.dialogCard.alpha = 0f
+ b.dialogCard.scaleX = 0.9f
+ b.dialogCard.scaleY = 0.9f
+ b.dialogCard.translationY = 50f
+
+ b.dialogCard.animate()
+ .alpha(1f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .translationY(0f)
+ .setDuration(400)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+
+ // Animate icon
+ b.dialogIcon.scaleX = 0f
+ b.dialogIcon.scaleY = 0f
+ b.dialogIcon.animate()
+ .scaleX(1f)
+ .scaleY(1f)
+ .setStartDelay(100)
+ .setDuration(500)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+
+ // Rotate icon
+ ObjectAnimator.ofFloat(b.dialogIcon, "rotation", 0f, 360f).apply {
+ duration = 800
+ startDelay = 100
+ interpolator = AccelerateDecelerateInterpolator()
+ start()
+ }
+ }
+
+ private fun getDescTxt(): String {
+ val isEqual = b.radioEqual.isChecked
+ val isExact = b.radioExact.isChecked
+
+ val pauseTxt = context.getString(R.string.notification_action_pause_vpn).lowercase()
+ .replaceFirstChar { it.uppercase() }
+ val connectTxt = context.getString(R.string.lbl_connect).lowercase()
+ .replaceFirstChar { it.uppercase() }
+ val firstArg = if (isEqual) connectTxt else pauseTxt
+ val secArg = context.getString(R.string.lbl_ssid)
+
+ val exactMatchTxt = context.getString(R.string.wg_ssid_type_exact).lowercase()
+ val partialMatchTxt = context.getString(R.string.wg_ssid_type_wildcard).lowercase()
+ val thirdArg = if (isExact) exactMatchTxt else partialMatchTxt
+
+ return context.getString(R.string.country_ssid_dialog_description, firstArg, countryName, secArg, thirdArg)
+ }
+
+ private fun updateDescriptionText() {
+ b.descriptionTextView.text = getDescTxt()
+ }
+
+ private fun setupRecyclerView() {
+ ssidAdapter = SsidAdapter(ssidItems) { ssidItem ->
+ showDeleteConfirmation(ssidItem)
+ }
+
+ b.ssidRecyclerView.apply {
+ layoutManager = LinearLayoutManager(activity)
+ adapter = ssidAdapter
+ }
+ }
+
+ private fun loadCurrentSsids() {
+ val parsedSsids = SsidItem.parseStorageList(currentSsids)
+ ssidItems.clear()
+ ssidItems.addAll(parsedSsids)
+ ssidAdapter.notifyDataSetChanged()
+
+ updateEmptyState()
+ }
+
+ private fun setupClickListeners() {
+ b.addSsidBtn.setOnClickListener {
+ addSsid()
+ }
+
+ b.ssidEditText.setOnEditorActionListener { _, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ addSsid()
+ true
+ } else {
+ false
+ }
+ }
+
+ b.ssidEditText.addTextChangedListener { text ->
+ val isNotEmpty = !text.isNullOrBlank()
+
+ // Enable or disable button based on text
+ b.addSsidBtn.isEnabled = isNotEmpty
+ b.addSsidBtn.isClickable = isNotEmpty
+
+ if (isNotEmpty) {
+ b.addSsidBtn.setTextColor(UIUtils.fetchColor(context, R.attr.accentGood))
+ } else {
+ b.addSsidBtn.setTextColor(UIUtils.fetchColor(context, R.attr.primaryLightColorText))
+ }
+ }
+
+ b.cancelBtn.setOnClickListener {
+ dismiss()
+ }
+
+ b.saveBtn.setOnClickListener {
+ saveSsids()
+ }
+ }
+
+ private fun addSsid() {
+ val ssidName = b.ssidEditText.text.toString().trim()
+ if (ssidName.isEmpty()) {
+ Utilities.showToastUiCentered(
+ context,
+ context.getString(R.string.wg_ssid_empty_error),
+ Toast.LENGTH_SHORT
+ )
+ return
+ }
+
+ // Check for duplicate
+ if (ssidItems.any { it.name.equals(ssidName, ignoreCase = true) }) {
+ Utilities.showToastUiCentered(
+ context,
+ context.getString(R.string.wg_ssid_duplicate_error),
+ Toast.LENGTH_SHORT
+ )
+ return
+ }
+
+ val isEqual = b.radioEqual.isChecked
+ val isExact = b.radioExact.isChecked
+
+ val ssidType = when {
+ isEqual && isExact -> SsidItem.SsidType.EQUAL_EXACT
+ isEqual && !isExact -> SsidItem.SsidType.EQUAL_WILDCARD
+ !isEqual && isExact -> SsidItem.SsidType.NOTEQUAL_EXACT
+ else -> SsidItem.SsidType.NOTEQUAL_WILDCARD
+ }
+
+ val newItem = SsidItem(ssidName, ssidType)
+ ssidItems.add(newItem)
+ ssidAdapter.notifyItemInserted(ssidItems.size - 1)
+
+ // Clear input
+ b.ssidEditText.text?.clear()
+
+ // Scroll to new item with animation
+ b.ssidRecyclerView.smoothScrollToPosition(ssidItems.size - 1)
+
+ updateEmptyState()
+
+ Logger.i(LOG_TAG_UI, "$TAG: added SSID: $ssidName, type: ${ssidType.id} for country: $countryCode")
+ }
+
+ private fun showDeleteConfirmation(ssidItem: SsidItem) {
+ val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim)
+ builder.setTitle(context.getString(R.string.wg_ssid_delete_title))
+ builder.setMessage(context.getString(R.string.wg_ssid_delete_message, ssidItem.name))
+ builder.setCancelable(true)
+ builder.setPositiveButton(context.getString(R.string.lbl_delete)) { dialog, _ ->
+ val position = ssidItems.indexOf(ssidItem)
+ if (position != -1) {
+ ssidItems.removeAt(position)
+ ssidAdapter.notifyItemRemoved(position)
+ updateEmptyState()
+ Logger.i(LOG_TAG_UI, "$TAG: deleted SSID: ${ssidItem.name} for country: $countryCode")
+ }
+ dialog.dismiss()
+ }
+ builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { dialog, _ ->
+ dialog.dismiss()
+ }
+ builder.create().show()
+ }
+
+ private fun updateEmptyState() {
+ if (ssidItems.isEmpty()) {
+ b.emptyStateView.visibility = android.view.View.VISIBLE
+ b.ssidRecyclerView.visibility = android.view.View.GONE
+ } else {
+ b.emptyStateView.visibility = android.view.View.GONE
+ b.ssidRecyclerView.visibility = android.view.View.VISIBLE
+ }
+ }
+
+ private fun saveSsids() {
+ val ssidsString = SsidItem.toStorageList(ssidItems)
+ Logger.i(LOG_TAG_UI, "$TAG: saving ${ssidItems.size} SSIDs for country: $countryCode")
+ onSave(ssidsString)
+ dismiss()
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/GenericHopDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/GenericHopDialog.kt
new file mode 100644
index 000000000..e12796b6b
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/GenericHopDialog.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.dialog
+
+import android.app.Activity
+import android.app.Dialog
+import android.os.Bundle
+import android.view.Window
+import android.view.WindowManager
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.celzero.bravedns.adapter.GenericHopAdapter
+import com.celzero.bravedns.adapter.HopItem
+import com.celzero.bravedns.databinding.DialogWgHopBinding
+
+/**
+ * Generic dialog for hopping between different proxy types
+ * Supports both WireGuard configs and RPN proxies through HopItem sealed class
+ */
+open class GenericHopDialog(
+ private val activity: Activity,
+ themeID: Int,
+ private val srcId: Int,
+ private val hopItems: List,
+ private val selectedId: Int,
+ private val onHopChanged: ((Int) -> Unit)? = null
+) : Dialog(activity, themeID) {
+
+ private lateinit var b: DialogWgHopBinding
+ private var mLayoutManager: RecyclerView.LayoutManager? = null
+ private lateinit var adapter: GenericHopAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ requestWindowFeature(Window.FEATURE_NO_TITLE)
+ b = DialogWgHopBinding.inflate(layoutInflater)
+ setContentView(b.root)
+ initView()
+ }
+
+ private fun initView() {
+ window?.setLayout(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT
+ )
+
+ mLayoutManager = LinearLayoutManager(activity)
+
+ adapter = GenericHopAdapter(
+ activity,
+ activity as LifecycleOwner,
+ srcId,
+ hopItems,
+ selectedId,
+ onHopChanged
+ )
+
+ b.wgHopRecyclerView.layoutManager = mLayoutManager
+ b.wgHopRecyclerView.adapter = adapter
+
+ b.wgHopDialogOkButton.setOnClickListener {
+ this.dismiss()
+ }
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/RpnProxyHopDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/RpnProxyHopDialog.kt
new file mode 100644
index 000000000..6625c24c4
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/RpnProxyHopDialog.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.dialog
+
+import android.app.Activity
+import com.celzero.bravedns.adapter.HopItem
+import com.celzero.bravedns.database.CountryConfig
+
+/**
+ * Dialog for RPN proxy country-based hopping
+ * Extends GenericHopDialog to reuse common hop logic
+ */
+class RpnProxyHopDialog(
+ activity: Activity,
+ themeID: Int,
+ srcCountryCode: String,
+ availableCountries: List,
+ selectedCountryCode: String?,
+ onHopChanged: ((Int) -> Unit)? = null
+) : GenericHopDialog(
+ activity,
+ themeID,
+ srcCountryCode.hashCode(),
+ availableCountries.map { countryConfig ->
+ HopItem.RpnProxyHop(countryConfig, countryConfig.isActive)
+ },
+ selectedCountryCode?.hashCode() ?: -1,
+ onHopChanged
+) {
+ companion object {
+ /**
+ * Create RPN proxy hop dialog
+ */
+ fun create(
+ activity: Activity,
+ themeID: Int,
+ srcCountryCode: String,
+ availableCountries: List,
+ currentlySelectedCountryCode: String? = null,
+ onHopChanged: ((Int) -> Unit)? = null
+ ): RpnProxyHopDialog {
+ return RpnProxyHopDialog(
+ activity,
+ themeID,
+ srcCountryCode,
+ availableCountries,
+ currentlySelectedCountryCode,
+ onHopChanged
+ )
+ }
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/ServerWgPeerEditDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/ServerWgPeerEditDialog.kt
new file mode 100644
index 000000000..dfd0a721c
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/ServerWgPeerEditDialog.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.dialog
+
+import android.app.Activity
+import android.app.Dialog
+import android.os.Bundle
+import android.view.Window
+import com.celzero.bravedns.R
+import com.celzero.bravedns.databinding.DialogServerWgPeerEditBinding
+import com.celzero.bravedns.wireguard.Peer
+
+/**
+ * Dialog for viewing server WireGuard peer's persistent keepalive
+ * Read-only view for server configurations
+ */
+class ServerWgPeerEditDialog(
+ private val activity: Activity,
+ themeId: Int,
+ @Suppress("UNUSED_PARAMETER") configId: Int,
+ private val peer: Peer
+) : Dialog(activity, themeId) {
+
+ private lateinit var b: DialogServerWgPeerEditBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ requestWindowFeature(Window.FEATURE_NO_TITLE)
+ b = DialogServerWgPeerEditBinding.inflate(layoutInflater)
+ setContentView(b.root)
+ initView()
+ setupClickListeners()
+ }
+
+ private fun initView() {
+ // Pre-fill persistent keepalive if present
+ if (peer.persistentKeepalive.isPresent) {
+ b.persistentKeepaliveEditText.setText(peer.persistentKeepalive.get().toString())
+ }
+ // Make it read-only for server configs
+ b.persistentKeepaliveEditText.isEnabled = false
+ }
+
+ private fun setupClickListeners() {
+ b.dialogCancelBtn.text = activity.getString(R.string.lbl_dismiss)
+ b.dialogCancelBtn.setOnClickListener { dismiss() }
+
+ // Remove save button for server configs
+ b.dialogSaveBtn.visibility = android.view.View.GONE
+ }
+}
+
diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/SubscriptionAnimDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/SubscriptionAnimDialog.kt
index b3ff8a8c0..15e0960a3 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/dialog/SubscriptionAnimDialog.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/SubscriptionAnimDialog.kt
@@ -49,6 +49,17 @@ class SubscriptionAnimDialog : DialogFragment() {
private const val POSITION_Y_BOTTOM = 1.0
}
+
+ private val autoDismissRunnable = Runnable {
+ if (isAdded && !isStateSaved) {
+ // safe to dismiss normally
+ dismiss()
+ } else if (isAdded) {
+ // if state is already saved, allow state loss to avoid IllegalStateException
+ dismissAllowingStateLoss()
+ }
+ }
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
@@ -61,9 +72,14 @@ class SubscriptionAnimDialog : DialogFragment() {
dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
dialog?.setCancelable(true)
b.konfettiView.start(festive())
- b.konfettiView.postDelayed({
- dismiss()
- }, DIALOG_DISPLAY_DURATION_MS)
+ // post delayed auto-dismiss safely
+ b.konfettiView.postDelayed(autoDismissRunnable, DIALOG_DISPLAY_DURATION_MS)
+ }
+
+ override fun onDestroyView() {
+ // cancel pending auto-dismiss runnable to avoid running after view/state is gone
+ b.konfettiView.removeCallbacks(autoDismissRunnable)
+ super.onDestroyView()
}
private fun festive(): List {
diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgHopDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgHopDialog.kt
index 6b225fd4f..1e2b72161 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgHopDialog.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgHopDialog.kt
@@ -15,9 +15,9 @@
*/
package com.celzero.bravedns.ui.dialog
-import Logger
-import Logger.LOG_TAG_UI
import android.app.Activity
+import com.celzero.bravedns.adapter.HopItem
+import com.celzero.bravedns.service.WireguardManager
import android.app.Dialog
import android.os.Bundle
import android.view.Window
@@ -28,73 +28,49 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.celzero.bravedns.adapter.WgHopAdapter
import com.celzero.bravedns.databinding.DialogWgHopBinding
import com.celzero.bravedns.wireguard.Config
-import org.koin.core.component.KoinComponent
+/**
+ * Dialog for WireGuard configuration hopping
+ * Now extends GenericHopDialog to reuse common hop logic
+ */
class WgHopDialog(
- private var activity: Activity,
+ activity: Activity,
themeID: Int,
- private val srcId: Int,
- private val hopables: List,
- private val selectedId: Int
-) : Dialog(activity, themeID), KoinComponent {
-
- private lateinit var b: DialogWgHopBinding
- private lateinit var animation: Animation
- private lateinit var adapter: WgHopAdapter
-
+ srcId: Int,
+ configs: List,
+ selectedId: Int,
+ onHopChanged: ((Int) -> Unit)? = null
+) : GenericHopDialog(
+ activity,
+ themeID,
+ srcId,
+ configs.map { config ->
+ val mapping = WireguardManager.getConfigFilesById(config.getId())
+ HopItem.WireGuardHop(config, mapping?.isActive ?: false)
+ },
+ selectedId,
+ onHopChanged
+) {
companion object {
- private const val ANIMATION_DURATION = 750L
- private const val ANIMATION_REPEAT_COUNT = -1
- private const val ANIMATION_PIVOT_VALUE = 0.5f
- private const val ANIMATION_START_DEGREE = 0.0f
- private const val ANIMATION_END_DEGREE = 360.0f
-
- private const val TAG = "HopDlg"
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- requestWindowFeature(Window.FEATURE_NO_TITLE)
- b = DialogWgHopBinding.inflate(layoutInflater)
- setContentView(b.root)
- setCancelable(false)
- addAnimation()
- init()
- setupClickListeners()
- }
-
- private fun addAnimation() {
- animation =
- RotateAnimation(
- ANIMATION_START_DEGREE,
- ANIMATION_END_DEGREE,
- Animation.RELATIVE_TO_SELF,
- ANIMATION_PIVOT_VALUE,
- Animation.RELATIVE_TO_SELF,
- ANIMATION_PIVOT_VALUE
+ /**
+ * Create WireGuard hop dialog
+ */
+ fun create(
+ activity: Activity,
+ themeID: Int,
+ srcConfigId: Int,
+ availableConfigs: List,
+ currentlySelectedConfigId: Int = -1,
+ onHopChanged: ((Int) -> Unit)? = null
+ ): WgHopDialog {
+ return WgHopDialog(
+ activity,
+ themeID,
+ srcConfigId,
+ availableConfigs,
+ currentlySelectedConfigId,
+ onHopChanged
)
- animation.repeatCount = ANIMATION_REPEAT_COUNT
- animation.duration = ANIMATION_DURATION
- }
-
- private fun init() {
- window?.setLayout(
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.MATCH_PARENT
- )
- Logger.v(LOG_TAG_UI, "$TAG; init called")
-
- val layoutManager = LinearLayoutManager(activity)
- b.wgHopRecyclerView.layoutManager = layoutManager
- adapter = WgHopAdapter(activity, srcId, hopables, selectedId)
- b.wgHopRecyclerView.adapter = adapter
- }
-
- private fun setupClickListeners() {
- b.wgHopDialogOkButton.setOnClickListener {
- Logger.d(LOG_TAG_UI, "$TAG; dismiss hop dialog")
- dismiss()
}
}
}
diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt
index b169252a6..5eec78a1b 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt
@@ -44,6 +44,7 @@ import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -127,9 +128,11 @@ class WgIncludeAppsDialog(
}
private fun observeApps() {
- viewModel.getAppCountById(proxyId).observe(activity as LifecycleOwner) {
+ // observe DB-backed count so heading stays in sync as mappings change
+ viewModel.getAppCountById(proxyId).observe(activity as LifecycleOwner) { count ->
+ val safeCount = count ?: 0
b.wgIncludeAppDialogHeading.text =
- activity.getString(R.string.add_remove_apps, it.toString())
+ activity.getString(R.string.add_remove_apps, safeCount.toString())
}
}
@@ -142,6 +145,7 @@ class WgIncludeAppsDialog(
activity.getString(TopLevelFilter.ALL_APPS.getLabelId()),
true
)
+
val selected =
makeFirewallChip(
TopLevelFilter.SELECTED_APPS.id,
@@ -270,14 +274,17 @@ class WgIncludeAppsDialog(
if (toAdd) context.getString(R.string.lbl_include)
else context.getString(R.string.exclude)
) { _, _ ->
- // add all if the list is empty or remove all if the list is full
io {
if (toAdd) {
Logger.i(LOG_TAG_PROXY, "Adding all apps to proxy $proxyId, $proxyName")
ProxyManager.setProxyIdForAllApps(proxyId, proxyName)
} else {
Logger.i(LOG_TAG_PROXY, "Removing all apps from proxy $proxyId, $proxyName")
- ProxyManager.setNoProxyForAllApps()
+ ProxyManager.setNoProxyForAllAppsForProxy(proxyId)
+ }
+ // re-apply current filter to force Paging source reload and UI refresh
+ withContext(Dispatchers.Main) {
+ viewModel.setFilter(searchText, filterType, proxyId)
}
}
}
@@ -298,6 +305,10 @@ class WgIncludeAppsDialog(
io {
Logger.i(LOG_TAG_PROXY, "Adding remaining apps to proxy $proxyId, $proxyName")
ProxyManager.setProxyIdForUnselectedApps(proxyId, proxyName)
+ // refresh paging / adapter after bulk add
+ withContext(Dispatchers.Main) {
+ viewModel.setFilter(searchText, filterType, proxyId)
+ }
}
}
diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt
index 57730170c..f0bd4397a 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt
@@ -25,6 +25,7 @@ import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
+import android.net.Uri
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings
diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/ManageSubscriptionFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/ManageSubscriptionFragment.kt
index 8eb3c6378..080db82b1 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/fragment/ManageSubscriptionFragment.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/ManageSubscriptionFragment.kt
@@ -15,16 +15,37 @@
*/
package com.celzero.bravedns.ui.fragment
+import Logger.LOG_TAG_UI
+import android.graphics.Paint
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.appcompat.widget.AppCompatTextView
import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
import by.kirich1409.viewbindingdelegate.viewBinding
+import com.android.billingclient.api.BillingClient
import com.celzero.bravedns.R
+import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG
+import com.celzero.bravedns.database.SubscriptionStatus
import com.celzero.bravedns.databinding.FragmentManageSubscriptionBinding
+import com.celzero.bravedns.iab.InAppBillingHandler
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
+import com.celzero.bravedns.ui.activity.FragmentHostActivity
+import com.celzero.bravedns.util.Constants
+import com.celzero.bravedns.util.UIUtils.openUrl
+import com.celzero.bravedns.util.Utilities
+import com.celzero.bravedns.util.Utilities.showToastUiCentered
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscription) {
private val b by viewBinding(FragmentManageSubscriptionBinding::bind)
// You can fetch these details dynamically, e.g. from your backend or Play Billing Library
- /*private val appIconRes = R.drawable.ic_launcher_foreground
+ private val appIconRes = R.drawable.ic_launcher_foreground
private val benefits = listOf(
"Unlimited skips",
"Offline download",
@@ -159,7 +180,7 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
val linkWithSubs = link.replace("$1", productId)
val linkWithSubsAndPackage = linkWithSubs.replace("$2", requireContext().packageName)
openUrl(requireContext(), linkWithSubsAndPackage)
- InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS))
+ InAppBillingHandler.fetchPurchases(listOf(BillingClient.ProductType.SUBS, BillingClient.ProductType.INAPP))
}
private fun showDialogConfirmCancelOrRevoke(isCancel: Boolean) {
@@ -213,7 +234,7 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
)
return@io
}
- *//*val curr = subsDb.getCurrentSubscription()
+ /*val curr = subsDb.getCurrentSubscription()
if (curr == null) {
Logger.w(LOG_TAG_UI, "$TAG cancel subscription clicked but no active subscription found")
showToastUiCentered(requireContext(), "No active subscription found", Toast.LENGTH_SHORT)
@@ -223,7 +244,7 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
Logger.w(LOG_TAG_UI, "$TAG cancel subscription clicked but subscription is not active")
showToastUiCentered(requireContext(), "Subscription is not active", Toast.LENGTH_SHORT)
return@io
- }*//*
+ }*/
val curr = RpnProxyManager.getCurrentSubscription()
if (curr == null) {
Logger.w(
@@ -279,14 +300,14 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
initView()
}
Logger.i(LOG_TAG_UI, "$TAG cancel subscription request sent, success? ${res.first}, msg: ${res.second}")
- InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS))
+ InAppBillingHandler.fetchPurchases(listOf(BillingClient.ProductType.SUBS, BillingClient.ProductType.INAPP))
checkForGracePeriod()
}
}
private fun revokeSubscription() {
io {
- *//*val curr = subsDb.getCurrentSubscription()
+ /*val curr = subsDb.getCurrentSubscription()
if (curr == null) {
Logger.w(LOG_TAG_UI, "$TAG revoke subscription clicked but no active subscription found")
showToastUiCentered(requireContext(), "No active subscription found", Toast.LENGTH_SHORT)
@@ -296,7 +317,7 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
Logger.w(LOG_TAG_UI, "$TAG revoke subscription clicked but subscription is not active")
showToastUiCentered(requireContext(), "Subscription is not active", Toast.LENGTH_SHORT)
return@io
- }*//*
+ }*/
val state = RpnProxyManager.getSubscriptionState()
if (!state.hasValidSubscription) {
Logger.w(
@@ -310,7 +331,7 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
)
return@io
}
- *//*val curr = subsDb.getCurrentSubscription()
+ /*val curr = subsDb.getCurrentSubscription()
if (curr == null) {
Logger.w(LOG_TAG_UI, "$TAG cancel subscription clicked but no active subscription found")
showToastUiCentered(requireContext(), "No active subscription found", Toast.LENGTH_SHORT)
@@ -320,7 +341,7 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
Logger.w(LOG_TAG_UI, "$TAG cancel subscription clicked but subscription is not active")
showToastUiCentered(requireContext(), "Subscription is not active", Toast.LENGTH_SHORT)
return@io
- }*//*
+ }*/
val curr = RpnProxyManager.getCurrentSubscription()
if (curr == null) {
Logger.w(
@@ -376,7 +397,7 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
initView()
}
Logger.i(LOG_TAG_UI, "$TAG revoke subscription request sent, success: ${res.first}, msg: ${res.second}")
- InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS))
+ InAppBillingHandler.fetchPurchases(listOf(BillingClient.ProductType.SUBS, BillingClient.ProductType.INAPP))
}
}
@@ -396,5 +417,5 @@ class ManageSubscriptionFragment : Fragment(R.layout.fragment_manage_subscriptio
private fun io(f: suspend () -> Unit) {
lifecycleScope.launch(Dispatchers.IO) { f() }
- }*/
+ }
}
diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkPlusDashboardFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkPlusDashboardFragment.kt
index 13fbc56f0..ebee7f2ba 100644
--- a/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkPlusDashboardFragment.kt
+++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkPlusDashboardFragment.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.ui.fragment
-/*
+
import Logger
import Logger.LOG_TAG_UI
import android.content.ClipData
@@ -59,7 +59,6 @@ import com.celzero.bravedns.ui.activity.PingTestActivity
import com.celzero.bravedns.ui.activity.RpnWinProxyDetailsActivity
import com.celzero.bravedns.ui.dialog.SubscriptionAnimDialog
import com.celzero.bravedns.ui.dialog.WgIncludeAppsDialog
-import com.celzero.bravedns.ui.location.LocationSelectorActivity
import com.celzero.bravedns.util.Constants
import com.celzero.bravedns.util.Themes
import com.celzero.bravedns.util.UIUtils
@@ -79,7 +78,7 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File
import java.util.concurrent.TimeUnit
-import kotlin.getValue
+import kotlin.jvm.java
class RethinkPlusDashboardFragment : Fragment(R.layout.activity_rethink_plus_dashboard) {
private val b by viewBinding(ActivityRethinkPlusDashboardBinding::bind)
@@ -406,7 +405,7 @@ class RethinkPlusDashboardFragment : Fragment(R.layout.activity_rethink_plus_das
val themeId = Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)
val proxyId = id.toString()
val proxyName = "Win-US"
- val appsAdapter = WgIncludeAppsAdapter(requireContext(), proxyId, proxyName)
+ val appsAdapter = WgIncludeAppsAdapter(requireContext(), this, proxyId, proxyName)
mappingViewModel.apps.observe(viewLifecycleOwner) {
appsAdapter.submitData(
lifecycle,
@@ -713,9 +712,6 @@ class RethinkPlusDashboardFragment : Fragment(R.layout.activity_rethink_plus_das
}
return@io
}*/
-
- val intent = Intent(requireContext(), LocationSelectorActivity::class.java)
- startActivity(intent)
}
private fun showInfoDialog(type: RpnProxyManager.RpnType,prop: RpnProxyManager.RpnProps) {
@@ -931,4 +927,3 @@ class RethinkPlusDashboardFragment : Fragment(R.layout.activity_rethink_plus_das
lifecycleScope.launch(Dispatchers.Main) { f() }
}
}
-*/
\ No newline at end of file
diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/ServerSelectionFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/ServerSelectionFragment.kt
new file mode 100644
index 000000000..8dfbaf99e
--- /dev/null
+++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/ServerSelectionFragment.kt
@@ -0,0 +1,719 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.ui.fragment
+
+import Logger
+import Logger.LOG_TAG_UI
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import android.view.animation.AccelerateDecelerateInterpolator
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import by.kirich1409.viewbindingdelegate.viewBinding
+import com.celzero.bravedns.R
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.databinding.FragmentServerSelectionBinding
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
+import com.celzero.bravedns.service.VpnController
+import com.celzero.bravedns.ui.adapter.CountryServerAdapter
+import com.celzero.bravedns.ui.adapter.VpnServerAdapter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.math.abs
+
+/**
+ * Fragment for selecting VPN servers from a list
+ * Features:
+ * - Search/filter servers
+ * - Select/deselect servers
+ * - Display server information (latency, speeds, features)
+ * - Material Design 3 UI with dark/light mode support
+ */
+class ServerSelectionFragment : Fragment(R.layout.fragment_server_selection),
+ VpnServerAdapter.ServerSelectionListener,
+ CountryServerAdapter.CitySelectionListener {
+
+ private val b by viewBinding(FragmentServerSelectionBinding::bind)
+ private lateinit var serverAdapter: CountryServerAdapter
+ private lateinit var selectedAdapter: VpnServerAdapter
+ private val allServers = mutableListOf()
+ private val unselectedServers = mutableListOf()
+ private val selectedServers = mutableListOf()
+ private var selectedServerIds = mutableSetOf()
+
+ private val fragmentScope = CoroutineScope(Dispatchers.Main + Job())
+ private var statusUpdateJob: Job? = null
+ private var isWinRegistered = false
+ private var autoServer: CountryConfig? = null
+
+ companion object {
+ private const val MAX_SELECTIONS = 5
+ const val EXTRA_SELECTED_SERVERS = "selected_servers"
+ private const val TAG = "ServerSelectionFragment"
+ private const val AUTO_SERVER_ID = "AUTO"
+ private const val AUTO_COUNTRY_CODE = "AUTO"
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupToolbar()
+ setupRecyclerViews()
+ setupSearchBar()
+ setupHeaderUI()
+
+ // load servers asynchronously from RpnProxyManager
+ fragmentScope.launch(Dispatchers.IO) {
+ // Check if WIN is registered
+ isWinRegistered = VpnController.isWinRegistered()
+ Logger.v(LOG_TAG_UI, "$TAG; WIN registered: $isWinRegistered")
+
+ val servers = RpnProxyManager.getWinServers()
+ Logger.v(LOG_TAG_UI, "$TAG; fetched ${servers.size} servers from RPN")
+
+ withContext(Dispatchers.Main) {
+ if (isAdded) {
+ initServers(servers)
+ }
+ }
+ }
+
+ animateHeaderEntry()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Refresh servers from API to ensure we have the latest list
+ // This will also detect and notify about removed selected servers
+ fragmentScope.launch(Dispatchers.IO) {
+ val (refreshedServers, removedServers) = RpnProxyManager.refreshWinServers()
+
+ if (removedServers.isNotEmpty()) {
+ Logger.w(LOG_TAG_UI, "$TAG.onResume: ${removedServers.size} selected servers were removed from the list")
+
+ withContext(Dispatchers.Main) {
+ if (isAdded && !requireActivity().isFinishing) {
+ // Show premium notification bottom sheet
+ val removedNames = removedServers.joinToString(", ") { it.countryName }
+ Logger.i(LOG_TAG_UI, "$TAG.onResume: showing premium notification for removed servers: $removedNames")
+
+ try {
+ val bottomSheet = com.celzero.bravedns.ui.bottomsheet.ServerRemovalNotificationBottomSheet.newInstance(removedServers)
+ bottomSheet.setOnDismissCallback {
+ // Update the UI with refreshed server list after user acknowledges
+ if (isAdded && refreshedServers.isNotEmpty()) {
+ initServers(refreshedServers)
+ }
+ }
+ bottomSheet.show(parentFragmentManager, "ServerRemovalNotification")
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_UI, "$TAG.onResume: error showing bottom sheet: ${e.message}", e)
+ // Fallback: update UI directly if bottom sheet fails
+ if (refreshedServers.isNotEmpty()) {
+ initServers(refreshedServers)
+ }
+ }
+ }
+ }
+ } else if (refreshedServers.isNotEmpty()) {
+ // Even if no servers were removed, update the cache with latest data
+ Logger.v(LOG_TAG_UI, "$TAG.onResume: refreshed ${refreshedServers.size} servers, no selected servers removed")
+ }
+ }
+ }
+
+ private fun initServers(servers: List) {
+ Logger.v(LOG_TAG_UI, "$TAG.initServers: received ${servers.size} servers, WIN registered: $isWinRegistered")
+ allServers.clear()
+ allServers.addAll(servers)
+
+ // Check if there are no servers available
+ if (servers.isEmpty()) {
+ showErrorState()
+ Logger.w(LOG_TAG_UI, "$TAG.initServers: no servers available to display")
+ return
+ }
+
+ // Hide error state if it was showing
+ hideErrorState()
+
+ // Create AUTO server if WIN is registered
+ if (isWinRegistered) {
+ autoServer = createAutoServer()
+ }
+
+ // initial split based on isActive
+ selectedServers.clear()
+
+ // If WIN is registered and no servers are selected, AUTO is active
+ if (isWinRegistered && allServers.none { it.isActive }) {
+ autoServer?.let {
+ it.isActive = true
+ selectedServers.add(it)
+ selectedServerIds = mutableSetOf(AUTO_SERVER_ID)
+ }
+ } else {
+ // Add manually selected servers
+ selectedServers.addAll(allServers.filter { it.isActive })
+ selectedServerIds = selectedServers.mapTo(mutableSetOf()) { it.id }
+ }
+
+ unselectedServers.clear()
+ unselectedServers.addAll(allServers.filter { !it.isActive })
+
+ Logger.v(LOG_TAG_UI, "$TAG.initServers: selected=${selectedServers.size}, unselected=${unselectedServers.size}, AUTO active=${autoServer?.isActive}")
+
+ selectedAdapter.updateServers(selectedServers)
+ serverAdapter.updateCountries(buildCountries(unselectedServers))
+
+ updateSelectedSectionVisibility()
+ updateSelectionCount()
+
+ // Force RecyclerView to request layout after data update
+ b.rvServers.post {
+ b.rvServers.requestLayout()
+ Logger.v(LOG_TAG_UI, "$TAG.initServers: forced rvServers layout, adapter.itemCount=${serverAdapter.itemCount}")
+ }
+ b.rvSelectedServers.post {
+ b.rvSelectedServers.requestLayout()
+ Logger.v(LOG_TAG_UI, "$TAG.initServers: forced rvSelectedServers layout, adapter.itemCount=${selectedAdapter.itemCount}")
+ }
+
+ Logger.v(LOG_TAG_UI, "$TAG.initServers: completed")
+ }
+
+ private fun showErrorState() {
+ if (!isAdded) return
+
+ // Hide all normal UI elements
+ b.rvServers.isVisible = false
+ b.searchCard.isVisible = false
+ b.selectedServersCard.isVisible = false
+ b.emptySelectionCard.isVisible = false
+
+ // Show error state with animation
+ b.errorStateContainer.visibility = View.VISIBLE
+ b.errorStateContainer.alpha = 0f
+ b.errorStateContainer.translationY = 40f
+
+ b.errorStateContainer.animate()
+ .alpha(1f)
+ .translationY(0f)
+ .setDuration(500)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+
+ // Animate illustration
+ b.errorIllustration.alpha = 0f
+ b.errorIllustration.scaleX = 0.8f
+ b.errorIllustration.scaleY = 0.8f
+ b.errorIllustration.animate()
+ .alpha(0.4f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setStartDelay(200)
+ .setDuration(600)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+
+ // Setup retry button
+ b.errorRetryBtn.setOnClickListener {
+ retryLoadingServers()
+ }
+
+ Logger.e(LOG_TAG_UI, "$TAG.showErrorState: No servers available, showing premium error UI")
+ }
+
+ private fun hideErrorState() {
+ if (!isAdded) return
+
+ // Animate out error state
+ if (b.errorStateContainer.isVisible) {
+ b.errorStateContainer.animate()
+ .alpha(0f)
+ .translationY(-40f)
+ .setDuration(300)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .withEndAction {
+ if (isAdded) {
+ b.errorStateContainer.visibility = View.GONE
+ }
+ }
+ .start()
+ }
+
+ // Show normal UI
+ b.rvServers.isVisible = true
+ b.searchCard.isVisible = true
+ }
+
+ private fun retryLoadingServers() {
+ if (!isAdded) return
+
+ Logger.v(LOG_TAG_UI, "$TAG.retryLoadingServers: user requested retry")
+
+ // Show loading state on button
+ b.errorRetryBtn.isEnabled = false
+ b.errorRetryBtn.text = getString(R.string.loading)
+
+ // Reload servers
+ fragmentScope.launch(Dispatchers.IO) {
+ delay(500) // Brief delay for better UX
+
+ isWinRegistered = VpnController.isWinRegistered()
+ val servers = RpnProxyManager.getWinServers()
+ Logger.v(LOG_TAG_UI, "$TAG.retryLoadingServers: fetched ${servers.size} servers")
+
+ withContext(Dispatchers.Main) {
+ if (isAdded) {
+ b.errorRetryBtn.isEnabled = true
+ b.errorRetryBtn.text = getString(R.string.server_selection_retry)
+ initServers(servers)
+ }
+ }
+ }
+ }
+
+ private fun createAutoServer(): CountryConfig {
+ return CountryConfig(
+ id = AUTO_SERVER_ID,
+ cc = AUTO_COUNTRY_CODE,
+ name = "AUTO",
+ address = "",
+ city = "Automatic",
+ key = "auto",
+ load = 0,
+ link = 0,
+ count = 1,
+ isActive = true
+ )
+ }
+
+ private fun setupToolbar() {
+ val activity = requireActivity() as AppCompatActivity
+ activity.setSupportActionBar(b.toolbar)
+
+ activity.supportActionBar?.apply {
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowTitleEnabled(false)
+ }
+
+ b.toolbar.setNavigationOnClickListener {
+ requireActivity().onBackPressedDispatcher.onBackPressed()
+ }
+
+ // Show title only when AppBar is collapsed
+ var isCollapsed = false
+ b.appBarLayout.addOnOffsetChangedListener { appBarLayout, verticalOffset ->
+ val totalScrollRange = appBarLayout.totalScrollRange
+ val scrollPercentage = abs(verticalOffset).toFloat() / totalScrollRange.toFloat()
+
+ // Show title when scrolled more than 80%
+ if (scrollPercentage > 0.8f && !isCollapsed) {
+ isCollapsed = true
+ b.collapsingToolbar.title = getString(R.string.server_selection_title)
+ } else if (scrollPercentage <= 0.8f && isCollapsed) {
+ isCollapsed = false
+ b.collapsingToolbar.title = ""
+ }
+ }
+
+ // Initially hide the title (expanded state)
+ b.collapsingToolbar.title = ""
+ }
+
+ private fun setupHeaderUI() {
+ // Initial status update
+ updateVpnStatus()
+
+ // Periodically update VPN status (every 3 seconds)
+ statusUpdateJob = fragmentScope.launch {
+ while (true) {
+ delay(3000)
+ if (isAdded) {
+ updateVpnStatus()
+ }
+ }
+ }
+ }
+
+ private fun updateVpnStatus() {
+ if (!isAdded) return
+
+ val isConnected = VpnController.state().on
+ updateConnectionStatus(isConnected)
+
+ // Get current server info from selected servers
+ if (selectedServers.isNotEmpty()) {
+ val currentServer = selectedServers.first()
+ // Check if it's AUTO server
+ if (currentServer.id == AUTO_SERVER_ID) {
+ updateCurrentLocation(
+ countryFlag = "🌐",
+ countryName = "AUTO",
+ locationName = "Automatic server selection"
+ )
+ } else {
+ updateCurrentLocation(
+ countryFlag = currentServer.flagEmoji,
+ countryName = currentServer.countryName,
+ locationName = currentServer.serverLocation
+ )
+ }
+ } else {
+ // Default fallback when no server is selected
+ if (isWinRegistered) {
+ // If WIN is registered, show AUTO as active
+ updateCurrentLocation(
+ countryFlag = "🌐",
+ countryName = "AUTO",
+ locationName = "Automatic server selection"
+ )
+ } else {
+ updateCurrentLocation(
+ countryFlag = "🌐",
+ countryName = "Not Connected",
+ locationName = "Select a server to connect"
+ )
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ // Cancel all animations to prevent callbacks from accessing destroyed view binding
+ try {
+ b.statusIndicator.animate().cancel()
+ b.statusCard.animate().cancel()
+ b.searchCard.animate().cancel()
+ b.searchClearBtn.animate().cancel()
+ } catch (_: Exception) {
+ // View binding might already be destroyed, ignore
+ }
+
+ statusUpdateJob?.cancel()
+ super.onDestroyView()
+ }
+
+ private fun updateConnectionStatus(isConnected: Boolean) {
+ if (!isAdded) return
+
+ if (isConnected) {
+ b.tvConnectionStatus.text = getString(R.string.vpn_status_connected)
+ b.tvConnectionStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.accentGood))
+ b.statusIndicator.backgroundTintList = ContextCompat.getColorStateList(requireContext(), R.color.accentGood)
+
+ // Pulse animation for connected indicator
+ b.statusIndicator.animate()
+ .scaleX(1.3f)
+ .scaleY(1.3f)
+ .setDuration(500)
+ .withEndAction {
+ if (isAdded) {
+ b.statusIndicator.animate()
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(500)
+ .start()
+ }
+ }
+ .start()
+ } else {
+ b.tvConnectionStatus.text = getString(R.string.vpn_status_disconnected)
+ b.tvConnectionStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.accentBad))
+ b.statusIndicator.backgroundTintList = ContextCompat.getColorStateList(requireContext(), R.color.accentBad)
+ }
+ }
+
+ private fun updateCurrentLocation(countryFlag: String, countryName: String, locationName: String) {
+ if (!isAdded) return
+
+ b.tvCurrentCountryFlag.text = countryFlag
+ b.tvCurrentCountry.text = countryName
+ b.tvCurrentLocation.text = locationName
+ }
+
+ private fun animateHeaderEntry() {
+ if (!isAdded) return
+
+ // Animate the status card with a subtle scale and fade in
+ b.statusCard.alpha = 0f
+ b.statusCard.scaleX = 0.9f
+ b.statusCard.scaleY = 0.9f
+ b.statusCard.translationZ = 2f
+
+ b.statusCard.animate()
+ .alpha(1f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(400)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+ }
+
+ private fun setupRecyclerViews() {
+ Logger.v(LOG_TAG_UI, "$TAG.setupRecyclerViews: initializing adapters")
+
+ b.rvServers.layoutManager = LinearLayoutManager(requireContext())
+ // main list grouped by country, expanding into city rows
+ serverAdapter = CountryServerAdapter(buildCountries(unselectedServers), this)
+ b.rvServers.adapter = serverAdapter
+ Logger.v(LOG_TAG_UI, "$TAG.setupRecyclerViews: serverAdapter created and set, itemCount=${serverAdapter.itemCount}")
+
+ // Add smooth item animations
+ b.rvServers.itemAnimator?.apply {
+ changeDuration = 300
+ moveDuration = 300
+ addDuration = 300
+ removeDuration = 300
+ }
+
+ b.rvSelectedServers.layoutManager = LinearLayoutManager(requireContext())
+ selectedAdapter = VpnServerAdapter(selectedServers, this)
+ b.rvSelectedServers.adapter = selectedAdapter
+ Logger.v(LOG_TAG_UI, "$TAG.setupRecyclerViews: selectedAdapter created and set, itemCount=${selectedAdapter.itemCount}")
+
+ b.rvSelectedServers.itemAnimator?.apply {
+ changeDuration = 300
+ moveDuration = 300
+ addDuration = 300
+ removeDuration = 300
+ }
+ }
+
+ private fun setupSearchBar() {
+ b.searchBar.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ filterServers(s.toString())
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+
+ // Clear button functionality with animation
+ b.searchClearBtn.setOnClickListener {
+ b.searchBar.text?.clear()
+ animateSearchClearButton(false)
+ }
+
+ // Smooth scroll to search when focused
+ b.searchBar.setOnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ b.searchCard.animate()
+ .scaleX(1.02f)
+ .scaleY(1.02f)
+ .setDuration(150)
+ .start()
+ } else {
+ b.searchCard.animate()
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(150)
+ .start()
+ }
+ }
+ }
+
+ private fun animateSearchClearButton(show: Boolean) {
+ if (!isAdded) return
+
+ if (show && b.searchClearBtn.visibility != View.VISIBLE) {
+ b.searchClearBtn.visibility = View.VISIBLE
+ b.searchClearBtn.alpha = 0f
+ b.searchClearBtn.scaleX = 0.5f
+ b.searchClearBtn.scaleY = 0.5f
+ b.searchClearBtn.animate()
+ .alpha(1f)
+ .scaleX(1f)
+ .scaleY(1f)
+ .setDuration(200)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .start()
+ } else if (!show && b.searchClearBtn.isVisible) {
+ b.searchClearBtn.animate()
+ .alpha(0f)
+ .scaleX(0.5f)
+ .scaleY(0.5f)
+ .setDuration(200)
+ .setInterpolator(AccelerateDecelerateInterpolator())
+ .withEndAction {
+ if (isAdded) {
+ b.searchClearBtn.visibility = View.GONE
+ }
+ }
+ .start()
+ }
+ }
+
+ private fun updateSelectedSectionVisibility() {
+ if (!isAdded) return
+ val hasSelection = selectedServers.isNotEmpty()
+ b.selectedServersCard.isVisible = hasSelection
+ b.rvSelectedServers.isVisible = hasSelection
+ b.emptySelectionCard.isVisible = !hasSelection
+ }
+
+ private fun updateSelectionCount() {
+ if (!isAdded) return
+ val total = selectedServers.size
+ b.tvSelectedCount.text =
+ if (total == 0) getString(R.string.server_selection_none_selected)
+ else getString(
+ R.string.server_selection_selected_count,
+ resources.getQuantityString(R.plurals.server_selection_count, total, total),
+ resources.getQuantityString(R.plurals.server_count, MAX_SELECTIONS, MAX_SELECTIONS)
+ )
+ }
+
+ private fun buildCountries(servers: List): List {
+ if (servers.isEmpty()) return emptyList()
+
+ val grouped = servers.groupBy { it.cc }
+ return grouped.map { (cc, serverList) ->
+ val sample = serverList.first()
+ CountryServerAdapter.CountryItem(
+ countryCode = cc,
+ countryName = sample.countryName,
+ flagEmoji = sample.flagEmoji,
+ cities = serverList.map { CountryServerAdapter.CityItem(it) }
+ )
+ }.sortedBy { it.countryName.lowercase() }
+ }
+
+ private fun filterServers(query: String) {
+ val searchText = query.trim().lowercase()
+ Logger.v(LOG_TAG_UI, "$TAG.filterServers: query='$searchText'")
+
+ if (searchText.isEmpty()) {
+ serverAdapter.updateCountries(buildCountries(unselectedServers))
+ animateSearchClearButton(false)
+ return
+ }
+
+ val filtered = unselectedServers.filter { server ->
+ server.countryName.lowercase().contains(searchText) ||
+ server.serverLocation.lowercase().contains(searchText) ||
+ server.cc.lowercase().contains(searchText)
+ }
+ serverAdapter.updateCountries(buildCountries(filtered))
+ animateSearchClearButton(true)
+ }
+
+ override fun onServerSelected(server: CountryConfig, isSelected: Boolean) {
+ Logger.v(LOG_TAG_UI, "$TAG.onServerSelected: server=${server.countryName}, city=${server.serverLocation}, isSelected=$isSelected")
+ if (isSelected) {
+ if (selectedServers.size >= MAX_SELECTIONS) {
+ // Fallback simple message since server_selection_limit_reached does not exist
+ Toast.makeText(
+ requireContext(),
+ "You can select up to $MAX_SELECTIONS servers.",
+ Toast.LENGTH_SHORT
+ ).show()
+ return
+ }
+ if (selectedServerIds.contains(server.id)) {
+ Toast.makeText(
+ requireContext(),
+ "Already selected ${server.countryName}",
+ Toast.LENGTH_SHORT
+ ).show()
+ return
+ }
+ io {
+ val key = server.key
+ Logger.v(LOG_TAG_UI, "$TAG add rpn${server.countryName}, key=$key")
+ val res = RpnProxyManager.enableWinServer(key)
+ uiCtx {
+ if (!res.first) {
+ Toast.makeText(
+ requireContext(),
+ "Failed to add ${server.countryName}: ${res.second}",
+ Toast.LENGTH_SHORT
+ ).show()
+ return@uiCtx
+ }
+ server.isActive = true
+ selectedServers.add(server)
+ selectedServerIds.add(server.id)
+ unselectedServers.removeAll { it.id == server.id }
+ selectedAdapter.updateServers(selectedServers)
+ serverAdapter.updateCountries(buildCountries(unselectedServers))
+
+ updateSelectedSectionVisibility()
+ updateSelectionCount()
+ }
+ }
+ } else {
+ server.isActive = false
+ selectedServers.removeAll { it.id == server.id }
+ selectedServerIds.remove(server.id)
+ if (unselectedServers.any { it.id == server.id }) {
+ Toast.makeText(
+ requireContext(),
+ "${server.countryName} is already unselected",
+ Toast.LENGTH_SHORT
+ ).show()
+ return
+ }
+ io {
+ val key = server.key
+ Logger.v(LOG_TAG_UI, "$TAG.onServerSelected: removing WireGuard proxy for server=${server.countryName}, key=$key")
+ val res = RpnProxyManager.disableWinServer(key)
+ uiCtx {
+ if (!res.first) {
+ Toast.makeText(
+ requireContext(),
+ "Failed to remove ${server.countryName}: ${res.second}",
+ Toast.LENGTH_SHORT
+ ).show()
+ return@uiCtx
+ }
+ unselectedServers.add(server)
+ selectedAdapter.updateServers(selectedServers)
+ serverAdapter.updateCountries(buildCountries(unselectedServers))
+
+ updateSelectedSectionVisibility()
+ updateSelectionCount()
+ }
+ }
+ }
+ }
+
+ // delegate from country/city adapter to existing selection logic
+ override fun onCitySelected(server: CountryConfig, isSelected: Boolean) {
+ onServerSelected(server, isSelected)
+ }
+
+ private fun io(f: suspend () -> Unit) {
+ lifecycleScope.launch(Dispatchers.IO) { f() }
+ }
+
+ private suspend fun uiCtx(f: suspend () -> Unit) {
+ withContext(Dispatchers.Main) { f() }
+ }
+}
diff --git a/app/src/full/java/com/celzero/bravedns/ui/location/Country.kt b/app/src/full/java/com/celzero/bravedns/ui/location/Country.kt
deleted file mode 100644
index 1af2eed23..000000000
--- a/app/src/full/java/com/celzero/bravedns/ui/location/Country.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright 2025 RethinkDNS and its authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.celzero.bravedns.ui.location
-
-/*data class Country(
- val id: String,
- val name: String,
- val flagResource: String,
- val servers: List,
- var isExpanded: Boolean = false
-) {
- val serverCount: Int
- get() = servers.size
-
- val selectedServerCount: Int
- get() = servers.count { it.isSelected }
-}
-*/
\ No newline at end of file
diff --git a/app/src/full/java/com/celzero/bravedns/ui/location/CountryAdapter.kt b/app/src/full/java/com/celzero/bravedns/ui/location/CountryAdapter.kt
deleted file mode 100644
index e229b2bc2..000000000
--- a/app/src/full/java/com/celzero/bravedns/ui/location/CountryAdapter.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * Copyright 2025 RethinkDNS and its authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.celzero.bravedns.ui.location
-/*
-import android.animation.Animator
-import android.animation.AnimatorListenerAdapter
-import android.animation.ObjectAnimator
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.view.animation.AccelerateDecelerateInterpolator
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import com.celzero.bravedns.databinding.ItemCountryCardBinding
-
-class CountryAdapter(
- private val countries: List,
- private val listener: OnServerSelectionChangeListener
-) : RecyclerView.Adapter() {
-
- interface OnServerSelectionChangeListener {
- fun onServerSelectionChanged(server: ServerLocation, isSelected: Boolean)
- }
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CountryViewHolder {
- val binding = ItemCountryCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return CountryViewHolder(binding)
- }
-
- override fun onBindViewHolder(holder: CountryViewHolder, position: Int) {
- val country = countries[position]
- holder.bind(country)
- }
-
- override fun getItemCount(): Int = countries.size
-
- inner class CountryViewHolder(private val binding: ItemCountryCardBinding) :
- RecyclerView.ViewHolder(binding.root) {
-
- init {
- binding.rvServers.layoutManager = LinearLayoutManager(binding.root.context)
-
- binding.layoutCountryHeader.setOnClickListener {
- val position = bindingAdapterPosition
- if (position != RecyclerView.NO_POSITION) {
- val country = countries[position]
- toggleExpansion(country)
- }
- }
- }
-
- fun bind(country: Country) {
- binding.tvCountryName.text = country.name
- binding.tvServerCount.text = "${country.serverCount} servers available"
-
- // Set flag placeholder
- binding.ivCountryFlag.setImageResource(android.R.drawable.ic_menu_mapmode)
-
- // Setup server adapter
- val serverAdapter = ServerAdapter(country.servers, object : ServerAdapter.OnServerSelectionChangeListener {
- override fun onServerSelectionChanged(server: ServerLocation, isSelected: Boolean) {
- listener.onServerSelectionChanged(server, isSelected)
- }
- })
- binding.rvServers.adapter = serverAdapter
-
- // Set expansion state
- binding.rvServers.visibility = if (country.isExpanded) View.VISIBLE else View.GONE
- binding.ivExpandArrow.rotation = if (country.isExpanded) 180f else 0f
- }
-
- private fun toggleExpansion(country: Country) {
- country.isExpanded = !country.isExpanded
-
- // Animate arrow rotation
- val arrowRotation = ObjectAnimator.ofFloat(
- binding.ivExpandArrow,
- "rotation",
- if (country.isExpanded) 0f else 180f,
- if (country.isExpanded) 180f else 0f
- ).apply {
- duration = 300
- interpolator = AccelerateDecelerateInterpolator()
- }
- arrowRotation.start()
-
- // Animate server list visibility
- if (country.isExpanded) {
- binding.rvServers.visibility = View.VISIBLE
- binding.rvServers.alpha = 0f
- ObjectAnimator.ofFloat(binding.rvServers, "alpha", 0f, 1f).apply {
- duration = 250
- startDelay = 50
- }.start()
- } else {
- ObjectAnimator.ofFloat(binding.rvServers, "alpha", 1f, 0f).apply {
- duration = 200
- addListener(object : AnimatorListenerAdapter() {
- override fun onAnimationEnd(animation: Animator) {
- binding.rvServers.visibility = View.GONE
- }
- })
- }.start()
- }
- }
- }
-}
-*/
\ No newline at end of file
diff --git a/app/src/full/java/com/celzero/bravedns/ui/location/LocationSelectorActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/location/LocationSelectorActivity.kt
deleted file mode 100644
index 8c28391f8..000000000
--- a/app/src/full/java/com/celzero/bravedns/ui/location/LocationSelectorActivity.kt
+++ /dev/null
@@ -1,192 +0,0 @@
-/*
- * Copyright 2025 RethinkDNS and its authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.celzero.bravedns.ui.location
-/*
-import android.animation.ObjectAnimator
-import android.animation.ValueAnimator
-import android.content.Context
-import android.content.res.Configuration
-import android.content.res.Configuration.UI_MODE_NIGHT_YES
-import android.os.Bundle
-import android.widget.Toast
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.WindowInsetsControllerCompat
-import androidx.recyclerview.widget.LinearLayoutManager
-import by.kirich1409.viewbindingdelegate.viewBinding
-import com.celzero.bravedns.R
-import com.celzero.bravedns.databinding.ActivityLocationSelectorBinding
-import com.celzero.bravedns.service.PersistentState
-import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme
-import com.celzero.bravedns.util.Utilities.isAtleastQ
-import androidx.core.graphics.drawable.toDrawable
-import org.koin.android.ext.android.inject
-import kotlin.getValue
-
-class LocationSelectorActivity : AppCompatActivity(R.layout.activity_location_selector), CountryAdapter.OnServerSelectionChangeListener {
-
- private val b by viewBinding(ActivityLocationSelectorBinding::bind)
- private val persistentState by inject()
- private lateinit var countryAdapter: CountryAdapter
- private val countries = mutableListOf()
- private var currentSelectionCount = 0
-
- companion object {
- private const val MAX_SELECTIONS = 5
- }
-
- private fun Context.isDarkThemeOn(): Boolean {
- return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
- UI_MODE_NIGHT_YES
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- theme.applyStyle(getCurrentTheme(isDarkThemeOn(), persistentState.theme), true)
- super.onCreate(savedInstanceState)
-
-
- handleFrostEffectIfNeeded(persistentState.theme)
-
- if (isAtleastQ()) {
- val controller = WindowInsetsControllerCompat(window, window.decorView)
- controller.isAppearanceLightNavigationBars = false
- window.isNavigationBarContrastEnforced = false
- }
-
- setupRecyclerView()
- loadSampleData()
- }
-
- override fun onSupportNavigateUp(): Boolean {
- onBackPressedDispatcher.onBackPressed()
- return true
- }
-
- private fun setupRecyclerView() {
- b.rvCountries.layoutManager = LinearLayoutManager(this)
- countryAdapter = CountryAdapter(countries, this)
- b.rvCountries.adapter = countryAdapter
- }
-
- override fun onServerSelectionChanged(server: ServerLocation, isSelected: Boolean) {
- if (isSelected && currentSelectionCount >= MAX_SELECTIONS) {
- Toast.makeText(this, "Maximum 5 servers can be selected", Toast.LENGTH_SHORT).show()
- return
- }
-
- if (isSelected && currentSelectionCount == 0) {
- Toast.makeText(this, "At least one server should be selected", Toast.LENGTH_SHORT).show()
- return
- }
-
- server.isSelected = isSelected
-
- val previousCount = currentSelectionCount
- currentSelectionCount = getTotalSelectedServers()
-
- animateCountChange(previousCount, currentSelectionCount)
-
- // Find and update specific items instead of full refresh
- countries.forEachIndexed { index, country ->
- if (country.servers.contains(server)) {
- countryAdapter.notifyItemChanged(index)
- return@forEachIndexed
- }
- }
- }
-
- private fun getTotalSelectedServers(): Int {
- return countries.sumOf { country ->
- country.servers.count { it.isSelected }
- }
- }
-
- private fun animateCountChange(fromCount: Int, toCount: Int) {
- // Scale animation for the count text
- val scaleX = ObjectAnimator.ofFloat(b.tvSelectedCount, "scaleX", 1.0f, 1.2f, 1.0f)
- val scaleY = ObjectAnimator.ofFloat(b.tvSelectedCount, "scaleY", 1.0f, 1.2f, 1.0f)
- scaleX.duration = 400
- scaleY.duration = 400
-
- // Animate the number change
- val countAnimator = ValueAnimator.ofInt(fromCount, toCount).apply {
- duration = 300
- addUpdateListener { animation ->
- val animatedValue = animation.animatedValue as Int
- val text = "$animatedValue server${if (animatedValue != 1) "s" else ""} selected"
- b.tvSelectedCount.text = text
- }
- }
-
- scaleX.start()
- scaleY.start()
- countAnimator.start()
- }
-
- private fun loadSampleData() {
- countries.clear()
- //val winProxyServers = RpnProxyManager.getWinServers()
- // Sample data for United States
- val usServers = listOf(
- ServerLocation("us-ny", "New York", "25ms"),
- ServerLocation("us-ca", "California", "35ms"),
- ServerLocation("us-tx", "Texas", "40ms"),
- ServerLocation("us-fl", "Florida", "45ms")
- )
- countries.add(Country("us", "United States", "flag_us", usServers))
-
- // Sample data for United Kingdom
- val ukServers = listOf(
- ServerLocation("uk-london", "London", "15ms"),
- ServerLocation("uk-manchester", "Manchester", "20ms"),
- ServerLocation("uk-edinburgh", "Edinburgh", "22ms")
- )
- countries.add(Country("uk", "United Kingdom", "flag_uk", ukServers))
-
- // Sample data for Germany
- val deServers = listOf(
- ServerLocation("de-berlin", "Berlin", "18ms"),
- ServerLocation("de-munich", "Munich", "20ms"),
- ServerLocation("de-frankfurt", "Frankfurt", "16ms"),
- ServerLocation("de-hamburg", "Hamburg", "19ms")
- )
- countries.add(Country("de", "Germany", "flag_de", deServers))
-
- // Sample data for Japan
- val jpServers = listOf(
- ServerLocation("jp-tokyo", "Tokyo", "12ms"),
- ServerLocation("jp-osaka", "Osaka", "15ms")
- )
- countries.add(Country("jp", "Japan", "flag_jp", jpServers))
-
- // Sample data for Australia
- val auServers = listOf(
- ServerLocation("au-sydney", "Sydney", "30ms"),
- ServerLocation("au-melbourne", "Melbourne", "32ms"),
- ServerLocation("au-perth", "Perth", "45ms")
- )
- countries.add(Country("au", "Australia", "flag_au", auServers))
-
- // Sample data for Canada
- val caServers = listOf(
- ServerLocation("ca-toronto", "Toronto", "28ms"),
- ServerLocation("ca-vancouver", "Vancouver", "35ms")
- )
- countries.add(Country("ca", "Canada", "flag_ca", caServers))
-
- countryAdapter.notifyItemRangeInserted(0, countries.size)
- }
-}
-*/
\ No newline at end of file
diff --git a/app/src/full/java/com/celzero/bravedns/ui/location/ServerAdapter.kt b/app/src/full/java/com/celzero/bravedns/ui/location/ServerAdapter.kt
deleted file mode 100644
index 5ef0bd0cf..000000000
--- a/app/src/full/java/com/celzero/bravedns/ui/location/ServerAdapter.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright 2025 RethinkDNS and its authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.celzero.bravedns.ui.location
-/*
-import android.animation.ObjectAnimator
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import com.celzero.bravedns.databinding.ItemServerBinding
-
-class ServerAdapter(
- private val servers: List,
- private val listener: OnServerSelectionChangeListener
-) : RecyclerView.Adapter() {
-
- interface OnServerSelectionChangeListener {
- fun onServerSelectionChanged(server: ServerLocation, isSelected: Boolean)
- }
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServerViewHolder {
- val binding = ItemServerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return ServerViewHolder(binding)
- }
-
- override fun onBindViewHolder(holder: ServerViewHolder, position: Int) {
- val server = servers[position]
- holder.bind(server)
- }
-
- override fun getItemCount(): Int = servers.size
-
- inner class ServerViewHolder(private val binding: ItemServerBinding) :
- RecyclerView.ViewHolder(binding.root) {
-
- init {
- binding.root.setOnClickListener {
- val position = bindingAdapterPosition
- if (position != RecyclerView.NO_POSITION) {
- val server = servers[position]
- val newState = !server.isSelected
- listener.onServerSelectionChanged(server, newState)
- }
- }
- }
-
- fun bind(server: ServerLocation) {
- binding.tvServerLocation.text = server.name
- binding.tvServerLatency.text = server.latency
-
- // Animate checkbox change
- if (binding.cbServerSelected.isChecked != server.isSelected) {
- val scaleX = ObjectAnimator.ofFloat(binding.cbServerSelected, "scaleX", 0.8f, 1.0f)
- val scaleY = ObjectAnimator.ofFloat(binding.cbServerSelected, "scaleY", 0.8f, 1.0f)
- scaleX.duration = 150
- scaleY.duration = 150
- scaleX.start()
- scaleY.start()
- }
-
- binding.cbServerSelected.isChecked = server.isSelected
- }
- }
-}
-*/
\ No newline at end of file
diff --git a/app/src/full/java/com/celzero/bravedns/ui/location/ServerLocation.kt b/app/src/full/java/com/celzero/bravedns/ui/location/ServerLocation.kt
deleted file mode 100644
index ed12922cc..000000000
--- a/app/src/full/java/com/celzero/bravedns/ui/location/ServerLocation.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright 2020 RethinkDNS and its authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.celzero.bravedns.ui.location
-/*
-data class ServerLocation(
- val id: String,
- val name: String,
- val latency: String,
- var isSelected: Boolean = false
-)*/
diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/ProxyAppsMappingViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/ProxyAppsMappingViewModel.kt
index 064cb3f4c..7b7a1c00d 100644
--- a/app/src/full/java/com/celzero/bravedns/viewmodel/ProxyAppsMappingViewModel.kt
+++ b/app/src/full/java/com/celzero/bravedns/viewmodel/ProxyAppsMappingViewModel.kt
@@ -25,6 +25,7 @@ import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.liveData
import com.celzero.bravedns.database.ProxyApplicationMappingDAO
+import com.celzero.bravedns.service.ProxyManager
import com.celzero.bravedns.ui.dialog.WgIncludeAppsDialog
import com.celzero.bravedns.util.Constants.Companion.LIVEDATA_PAGE_SIZE
@@ -44,19 +45,24 @@ class ProxyAppsMappingViewModel(private val mappingDAO: ProxyApplicationMappingD
var apps =
filteredList.switchMap { searchTxt ->
Pager(PagingConfig(LIVEDATA_PAGE_SIZE)) {
- when (filterType) {
- WgIncludeAppsDialog.TopLevelFilter.ALL_APPS ->
- mappingDAO.getAllAppsMapping(searchTxt)
- WgIncludeAppsDialog.TopLevelFilter.SELECTED_APPS ->
- mappingDAO.getSelectedAppsMapping(searchTxt, proxyId)
- WgIncludeAppsDialog.TopLevelFilter.UNSELECTED_APPS ->
- mappingDAO.getUnSelectedAppsMapping(searchTxt, proxyId)
- }
+ when (filterType) {
+ WgIncludeAppsDialog.TopLevelFilter.ALL_APPS ->
+ mappingDAO.getAllAppsMapping(searchTxt)
+ WgIncludeAppsDialog.TopLevelFilter.SELECTED_APPS ->
+ mappingDAO.getSelectedAppsMapping(searchTxt, proxyId)
+ WgIncludeAppsDialog.TopLevelFilter.UNSELECTED_APPS ->
+ mappingDAO.getUnSelectedAppsMapping(searchTxt, proxyId)
}
+ }
.liveData
.cachedIn(viewModelScope)
}
+ // helper to decide if an app is selected for a given proxyId using ProxyManager cache
+ fun isAppSelectedForProxy(uid: Int, proxyId: String): Boolean {
+ return ProxyManager.getProxyIdsForApp(uid).contains(proxyId)
+ }
+
fun setFilter(filter: String, type: WgIncludeAppsDialog.TopLevelFilter, pid: String) {
filterType = type
this.proxyId = pid
diff --git a/app/src/full/res/navigation/app_navigation.xml b/app/src/full/res/navigation/app_navigation.xml
index 53ae16940..701d4bb7e 100644
--- a/app/src/full/res/navigation/app_navigation.xml
+++ b/app/src/full/res/navigation/app_navigation.xml
@@ -15,9 +15,9 @@
android:id="@+id/rethinkPlus"
android:name="com.celzero.bravedns.ui.fragment.RethinkPlusFragment"
android:label="RethinkPlusFragment">
-
+ app:destination="@id/rethinkPlusDashboardFragment" />
-
+ android:name="com.celzero.bravedns.ui.fragment.ServerSelectionFragment"
+ android:label="RethinkPlusDashboardFragment" />
diff --git a/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt b/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
index ce7acc08d..15dae565d 100644
--- a/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
@@ -51,9 +51,10 @@ import com.celzero.bravedns.util.Constants
RpnProxy::class,
WgHopMap::class,
SubscriptionStatus::class,
- SubscriptionStateHistory::class
+ SubscriptionStateHistory::class,
+ CountryConfig::class
],
- version = 27,
+ version = 31,
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -101,6 +102,10 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_24_25)
.addMigrations(MIGRATION_25_26)
.addMigrations(MIGRATION_26_27)
+ .addMigrations(MIGRATION_27_28)
+ .addMigrations(MIGRATION_28_29)
+ .addMigrations(MIGRATION_29_30)
+ .addMigrations(MIGRATION_30_31)
.build()
private val roomCallback: Callback =
@@ -1087,22 +1092,203 @@ abstract class AppDatabase : RoomDatabase() {
// insert new columns with default values (modifiedTs)
db.execSQL("ALTER TABLE WgConfigFiles ADD COLUMN modifiedTs INTEGER NOT NULL DEFAULT 0")
Logger.i(LOG_TAG_APP_DB, "MIGRATION_26_27: removed isLockdown column")
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS CountryConfig (
+ cc TEXT PRIMARY KEY NOT NULL,
+ catchAll INTEGER NOT NULL DEFAULT 0,
+ lockdown INTEGER NOT NULL DEFAULT 0,
+ mobileOnly INTEGER NOT NULL DEFAULT 0,
+ ssidBased INTEGER NOT NULL DEFAULT 0,
+ lastModified INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ priority INTEGER NOT NULL DEFAULT 0
+ )
+ """.trimIndent()
+ )
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_26_27: created CountryConfig table")
+ }
+ }
+
+ private val MIGRATION_27_28: Migration =
+ object : Migration(27, 28) {
+ override fun migrate(db: SupportSQLiteDatabase) {
// Add modifiedTs column to AppInfo table to track when firewall/proxy rules change
try {
db.execSQL("ALTER TABLE AppInfo ADD COLUMN modifiedTs INTEGER NOT NULL DEFAULT 0")
// Backfill all existing rows with 0 (already done by DEFAULT 0)
- Logger.i(LOG_TAG_APP_DB, "MIGRATION_26_27: added modifiedTs column to AppInfo")
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_27_28: added modifiedTs column to AppInfo")
} catch (e: Exception) {
- Logger.e(LOG_TAG_APP_DB, "MIGRATION_26_27: modifiedTs column already exists, ignore", e)
+ Logger.e(LOG_TAG_APP_DB, "MIGRATION_27_28: modifiedTs column already exists, ignore", e)
}
+ // Legacy migration kept for users upgrading stepwise; RpnWinServers
+ // will be dropped in MIGRATION_28_29 when we introduce unified CountryConfig.
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS RpnWinServers (
+ id TEXT PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+ countryCode TEXT NOT NULL,
+ address TEXT NOT NULL,
+ city TEXT NOT NULL,
+ key TEXT NOT NULL,
+ load INTEGER NOT NULL,
+ link INTEGER NOT NULL,
+ count INTEGER NOT NULL,
+ isActive INTEGER NOT NULL,
+ lastUpdated INTEGER NOT NULL
+ )
+ """.trimIndent()
+ )
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_RpnWinServers_countryCode ON RpnWinServers(countryCode)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_RpnWinServers_isActive ON RpnWinServers(isActive)")
+ Logger.i(
+ LOG_TAG_APP_DB,
+ "MIGRATION_27_28: created RpnWinServers table with indexes"
+ )
+ }
+ }
+
+ private val MIGRATION_28_29: Migration =
+ object : Migration(28, 29) {
+ override fun migrate(db: SupportSQLiteDatabase) {
// Add tempAllowEnabled and tempAllowExpiryTime columns to AppInfo table for temporary allow feature
try {
db.execSQL("ALTER TABLE AppInfo ADD COLUMN tempAllowEnabled INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE AppInfo ADD COLUMN tempAllowExpiryTime INTEGER NOT NULL DEFAULT 0")
- Logger.i(LOG_TAG_APP_DB, "MIGRATION_26_27: added tempAllowEnabled and tempAllowExpiryTime columns to AppInfo")
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_28_29: added tempAllowEnabled and tempAllowExpiryTime columns to AppInfo")
} catch (e: Exception) {
- Logger.e(LOG_TAG_APP_DB, "MIGRATION_26_27: temp allow columns already exist, ignore", e)
+ Logger.e(LOG_TAG_APP_DB, "MIGRATION_28_29: temp allow columns already exist, ignore", e)
+ }
+ try {
+ db.execSQL("DROP TABLE IF EXISTS RpnWinServers")
+ db.execSQL("DROP INDEX IF EXISTS index_RpnWinServers_countryCode")
+ db.execSQL("DROP INDEX IF EXISTS index_RpnWinServers_isActive")
+ } catch (_: Exception) {
+ // Table or indexes may not exist on some upgrade paths; ignore.
}
+
+ // Drop any old CountryConfig if schema doesn’t match; recreate clean.
+ try {
+ db.execSQL("DROP TABLE IF EXISTS CountryConfig")
+ } catch (_: Exception) {
+ // Ignore
+ }
+
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS CountryConfig (
+ id TEXT NOT NULL PRIMARY KEY,
+ cc TEXT NOT NULL,
+ name TEXT NOT NULL,
+ address TEXT NOT NULL,
+ city TEXT NOT NULL,
+ key TEXT NOT NULL,
+ load INTEGER NOT NULL,
+ link INTEGER NOT NULL,
+ count INTEGER NOT NULL,
+ isActive INTEGER NOT NULL,
+ catchAll INTEGER NOT NULL DEFAULT 0,
+ lockdown INTEGER NOT NULL DEFAULT 0,
+ mobileOnly INTEGER NOT NULL DEFAULT 0,
+ ssidBased INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ priority INTEGER NOT NULL DEFAULT 0,
+ lastModified INTEGER NOT NULL DEFAULT 0
+ )
+ """.trimIndent()
+ )
+
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_CountryConfig_cc ON CountryConfig(cc)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_CountryConfig_isActive ON CountryConfig(isActive)")
+
+ Logger.i(
+ LOG_TAG_APP_DB,
+ "MIGRATION_28_29: dropped RpnWinServers and recreated CountryConfig table"
+ )
+ }
+ }
+
+ // v30: Add ssids column to CountryConfig (ssidBased already exists)
+ private val MIGRATION_29_30: Migration =
+ object : Migration(29, 30) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Add ssids column (defaults to empty string) - ssidBased already exists from v29
+ if (!doesColumnExistInTable(db, "CountryConfig", "ssids")) {
+ db.execSQL("ALTER TABLE CountryConfig ADD COLUMN ssids TEXT NOT NULL DEFAULT ''")
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_29_30: added ssids column to CountryConfig")
+ }
+
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_29_30: SSID configuration support complete (ssidBased + ssids)")
+ }
+ }
+
+ // v31: Remove redundant 'enabled' column from CountryConfig (use isActive instead)
+ private val MIGRATION_30_31: Migration =
+ object : Migration(30, 31) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // SQLite doesn't support DROP COLUMN directly, so we need to recreate the table
+ // First, create a temporary table with the new schema (without 'enabled')
+ db.execSQL("""
+ CREATE TABLE CountryConfig_temp (
+ id TEXT PRIMARY KEY NOT NULL,
+ cc TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ address TEXT NOT NULL DEFAULT '',
+ city TEXT NOT NULL DEFAULT '',
+ key TEXT NOT NULL DEFAULT '',
+ load INTEGER NOT NULL DEFAULT 0,
+ link INTEGER NOT NULL DEFAULT 0,
+ count INTEGER NOT NULL DEFAULT 0,
+ isActive INTEGER NOT NULL DEFAULT 1,
+ catchAll INTEGER NOT NULL DEFAULT 0,
+ lockdown INTEGER NOT NULL DEFAULT 0,
+ mobileOnly INTEGER NOT NULL DEFAULT 0,
+ ssidBased INTEGER NOT NULL DEFAULT 0,
+ priority INTEGER NOT NULL DEFAULT 0,
+ ssids TEXT NOT NULL DEFAULT '',
+ lastModified INTEGER NOT NULL DEFAULT 0
+ )
+ """.trimIndent())
+
+ // Copy data from old table to new table
+ // If 'enabled' column exists, merge it with isActive (enabled takes precedence if different)
+ // Otherwise, just copy isActive as-is
+ if (doesColumnExistInTable(db, "CountryConfig", "enabled") || doesColumnExistInTable(db, "CountryConfig", "isSelected")) {
+ db.execSQL("""
+ INSERT INTO CountryConfig_temp
+ (id, cc, name, address, city, key, load, link, count, isActive,
+ catchAll, lockdown, mobileOnly, ssidBased, priority, ssids, lastModified)
+ SELECT
+ id, cc, name, address, city, key, load, link, count,
+ CASE WHEN enabled = 0 THEN 0 ELSE isActive END,
+ catchAll, lockdown, mobileOnly, ssidBased, priority, ssids, lastModified
+ FROM CountryConfig
+ """.trimIndent())
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_30_31: merged 'enabled' column into 'isActive'")
+ } else {
+ db.execSQL("""
+ INSERT INTO CountryConfig_temp
+ (id, cc, name, address, city, key, load, link, count, isActive,
+ catchAll, lockdown, mobileOnly, ssidBased, priority, ssids, lastModified)
+ SELECT
+ id, cc, name, address, city, key, load, link, count, isActive,
+ catchAll, lockdown, mobileOnly, ssidBased, priority, ssids, lastModified
+ FROM CountryConfig
+ """.trimIndent())
+ }
+
+ // Drop the old table
+ db.execSQL("DROP TABLE CountryConfig")
+
+ // Rename the temporary table to the original name
+ db.execSQL("ALTER TABLE CountryConfig_temp RENAME TO CountryConfig")
+
+ // Recreate indices
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_CountryConfig_cc ON CountryConfig(cc)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_CountryConfig_isActive ON CountryConfig(isActive)")
+
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_30_31: removed redundant 'enabled' column from CountryConfig")
}
}
@@ -1175,10 +1361,14 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun subscriptionStateHistoryDao(): SubscriptionStateHistoryDao
+ abstract fun countryConfigDAO(): CountryConfigDAO
+
fun appInfoRepository() = AppInfoRepository(appInfoDAO())
fun dohEndpointRepository() = DoHEndpointRepository(dohEndpointsDAO())
+ fun countryConfigRepository() = CountryConfigRepository(countryConfigDAO())
+
fun dnsCryptEndpointRepository() = DnsCryptEndpointRepository(dnsCryptEndpointDAO())
fun dnsCryptRelayEndpointRepository() =
diff --git a/app/src/main/java/com/celzero/bravedns/database/CountryConfig.kt b/app/src/main/java/com/celzero/bravedns/database/CountryConfig.kt
new file mode 100644
index 000000000..e154415d9
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/database/CountryConfig.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.database
+
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import java.util.Locale
+
+@Entity(
+ tableName = "CountryConfig",
+ indices = [
+ Index(value = ["cc"], unique = false),
+ Index(value = ["isActive"], unique = false)
+ ]
+)
+data class CountryConfig(
+ @PrimaryKey
+ val id: String, // Unique server id: "cc-city-key" (or similar)
+
+ val cc: String, // Country code for config-level operations (e.g., "US")
+
+ // Server / location properties (previously RpnWinServer)
+ val name: String = "", // Aggregated location names for a country
+ val address: String = "", // Comma-separated server addresses
+ val city: String = "", // Primary city
+ val key: String = "", // Server key identifier
+ val load: Int = 0, // Server load (0..100, lower is better)
+ val link: Int = 0, // Link metric (latency ms)
+ val count: Int = 0, // Number of endpoints available
+ var isActive: Boolean = true, // Whether server is currently active
+
+ // Country-level configuration flags
+ var catchAll: Boolean = false, // Use this country for all connections
+ var lockdown: Boolean = false, // Force connection through this country only
+ var mobileOnly: Boolean = false,// Use only on mobile data
+ var ssidBased: Boolean = false, // SSID-based connection enabled (use with ssids field)
+
+ val priority: Int = 0, // Priority for selection (higher = preferred)
+
+ // SSID configuration (JSON string of SSID items, see SsidItem.kt)
+ val ssids: String = "", // JSON array of SsidItem when ssidBased is true
+
+ val lastModified: Long = System.currentTimeMillis() // Last update timestamp
+) : Parcelable {
+
+ constructor(parcel: Parcel) : this(
+ parcel.readString()!!,
+ parcel.readString()!!,
+ parcel.readString()!!,
+ parcel.readString()!!,
+ parcel.readString()!!,
+ parcel.readString()!!,
+ parcel.readInt(),
+ parcel.readInt(),
+ parcel.readInt(),
+ parcel.readByte() != 0.toByte(),
+ parcel.readByte() != 0.toByte(),
+ parcel.readByte() != 0.toByte(),
+ parcel.readByte() != 0.toByte(),
+ parcel.readByte() != 0.toByte(),
+ parcel.readInt(),
+ parcel.readString() ?: "",
+ parcel.readLong()
+ )
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeString(id)
+ parcel.writeString(cc)
+ parcel.writeString(name)
+ parcel.writeString(address)
+ parcel.writeString(city)
+ parcel.writeString(key)
+ parcel.writeInt(load)
+ parcel.writeInt(link)
+ parcel.writeInt(count)
+ parcel.writeByte(if (isActive) 1 else 0)
+ parcel.writeByte(if (catchAll) 1 else 0)
+ parcel.writeByte(if (lockdown) 1 else 0)
+ parcel.writeByte(if (mobileOnly) 1 else 0)
+ parcel.writeByte(if (ssidBased) 1 else 0)
+ parcel.writeInt(priority)
+ parcel.writeString(ssids)
+ parcel.writeLong(lastModified)
+ }
+
+ override fun describeContents(): Int = 0
+
+ companion object {
+ const val TAG = "CountryConfig"
+
+ @JvmField
+ val CREATOR = object : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel): CountryConfig {
+ return CountryConfig(parcel)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+
+
+ // ---- RpnWinServer-style helpers ----
+
+ val countryName: String by lazy { countryDisplayName(cc) }
+ val flagEmoji: String by lazy { flagEmojiFor(cc) }
+ val serverLocation: String by lazy { city.ifBlank { name } }
+
+ fun getBadgeText(): String {
+ return if (link > 0) "${link}ms" else "${load}%"
+ }
+
+ enum class ServerQuality { EXCELLENT, GOOD, FAIR, POOR }
+
+ fun getQualityLevel(): ServerQuality {
+ return if (link > 0) {
+ when {
+ link < 50 -> ServerQuality.EXCELLENT
+ link < 100 -> ServerQuality.GOOD
+ link < 200 -> ServerQuality.FAIR
+ else -> ServerQuality.POOR
+ }
+ } else {
+ when {
+ load < 30 -> ServerQuality.EXCELLENT
+ load < 60 -> ServerQuality.GOOD
+ load < 80 -> ServerQuality.FAIR
+ else -> ServerQuality.POOR
+ }
+ }
+ }
+
+ private fun flagEmojiFor(cc: String): String {
+ if (cc.length != 2) return "\uD83C\uDF10" // globe fallback
+ val base = 0x1F1E6 - 'A'.code
+ val first = Character.toChars(base + cc[0].uppercaseChar().code)
+ val second = Character.toChars(base + cc[1].uppercaseChar().code)
+ return String(first) + String(second)
+ }
+
+ private fun countryDisplayName(cc: String): String {
+ return try { Locale("", cc).displayCountry.ifBlank { cc } } catch (t: Throwable) { cc }
+ }
+
+ // ---- CountryConfig-style helpers ----
+
+ /**
+ * Check if this country can be used for the current connection
+ */
+ fun canBeUsed(
+ isMobileData: Boolean,
+ currentSsid: String?,
+ preferredCcs: List
+ ): Boolean {
+ if (!isActive) return false
+
+ // If catchAll is enabled, always use
+ if (catchAll) return true
+
+ // If lockdown, must explicitly match
+ if (lockdown) return preferredCcs.contains(cc)
+
+ // Check mobile-only restriction
+ if (mobileOnly && !isMobileData) return false
+
+ // Check SSID-based routing (would need SSID mapping - future enhancement)
+ if (ssidBased && currentSsid == null) return false
+
+ return true
+ }
+
+ /**
+ * Check if any exclusion rule applies
+ */
+ fun hasExclusionRules(): Boolean {
+ return lockdown || mobileOnly || ssidBased
+ }
+
+ /**
+ * Check if this is a priority country
+ */
+ fun isPriority(): Boolean {
+ return catchAll || lockdown || priority > 0
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/database/CountryConfigDAO.kt b/app/src/main/java/com/celzero/bravedns/database/CountryConfigDAO.kt
new file mode 100644
index 000000000..8396ffd2b
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/database/CountryConfigDAO.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.database
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface CountryConfigDAO {
+
+ @Query("SELECT * FROM CountryConfig WHERE cc = :cc")
+ suspend fun getConfig(cc: String): CountryConfig?
+
+ @Query("SELECT * FROM CountryConfig WHERE cc = :cc")
+ fun getConfigFlow(cc: String): Flow
+
+ @Query("SELECT * FROM CountryConfig")
+ suspend fun getAllConfigs(): List
+
+ @Query("SELECT * FROM CountryConfig")
+ fun getAllConfigsFlow(): Flow>
+
+ @Query("SELECT * FROM CountryConfig WHERE isActive = 1")
+ suspend fun getEnabledConfigs(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE isActive = 1")
+ fun getEnabledConfigsFlow(): Flow>
+
+ @Query("SELECT * FROM CountryConfig WHERE catchAll = 1 AND isActive = 1")
+ suspend fun getCatchAllConfigs(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE catchAll = 1 AND isActive = 1")
+ fun getCatchAllConfigsFlow(): Flow>
+
+ @Query("SELECT * FROM CountryConfig WHERE lockdown = 1 AND isActive = 1")
+ suspend fun getLockdownConfigs(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE lockdown = 1 AND isActive = 1")
+ fun getLockdownConfigsFlow(): Flow>
+
+ @Query("SELECT * FROM CountryConfig WHERE mobileOnly = 1 AND isActive = 1")
+ suspend fun getMobileOnlyConfigs(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE mobileOnly = 1 AND isActive = 1")
+ fun getMobileOnlyConfigsFlow(): Flow>
+
+ @Query("SELECT * FROM CountryConfig WHERE ssidBased = 1 AND isActive = 1")
+ suspend fun getSsidBasedConfigs(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE ssidBased = 1 AND isActive = 1")
+ fun getSsidBasedConfigsFlow(): Flow>
+
+ @Query("SELECT cc FROM CountryConfig WHERE catchAll = 1 AND isActive = 1")
+ suspend fun getCatchAllCountryCodes(): List
+
+ @Query("SELECT cc FROM CountryConfig WHERE lockdown = 1 AND isActive = 1")
+ suspend fun getLockdownCountryCodes(): List
+
+ @Query("SELECT cc FROM CountryConfig WHERE mobileOnly = 1 AND isActive = 1")
+ suspend fun getMobileOnlyCountryCodes(): List
+
+ @Query("SELECT cc FROM CountryConfig WHERE ssidBased = 1 AND isActive = 1")
+ suspend fun getSsidBasedCountryCodes(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE isActive = 1 ORDER BY priority DESC, lastModified DESC")
+ suspend fun getConfigsByPriority(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE isActive = 1 ORDER BY priority DESC, lastModified DESC")
+ fun getConfigsByPriorityFlow(): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(config: CountryConfig)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(configs: List)
+
+ @Update
+ suspend fun update(config: CountryConfig)
+
+ @Delete
+ suspend fun delete(config: CountryConfig)
+
+ @Query("DELETE FROM CountryConfig WHERE cc = :cc")
+ suspend fun deleteByCountryCode(cc: String)
+
+ @Query("DELETE FROM CountryConfig")
+ suspend fun deleteAll()
+
+ @Query("UPDATE CountryConfig SET catchAll = :value, lastModified = :timestamp WHERE cc = :cc")
+ suspend fun updateCatchAll(cc: String, value: Boolean, timestamp: Long = System.currentTimeMillis())
+
+ @Query("UPDATE CountryConfig SET lockdown = :value, lastModified = :timestamp WHERE cc = :cc")
+ suspend fun updateLockdown(cc: String, value: Boolean, timestamp: Long = System.currentTimeMillis())
+
+ @Query("UPDATE CountryConfig SET mobileOnly = :value, lastModified = :timestamp WHERE cc = :cc")
+ suspend fun updateMobileOnly(cc: String, value: Boolean, timestamp: Long = System.currentTimeMillis())
+
+ @Query("UPDATE CountryConfig SET ssidBased = :value, lastModified = :timestamp WHERE cc = :cc")
+ suspend fun updateSsidBased(cc: String, value: Boolean, timestamp: Long = System.currentTimeMillis())
+
+ @Query("UPDATE CountryConfig SET isActive = :value, lastModified = :timestamp WHERE cc = :cc")
+ suspend fun updateEnabled(cc: String, value: Boolean, timestamp: Long = System.currentTimeMillis())
+
+ @Query("UPDATE CountryConfig SET priority = :priority, lastModified = :timestamp WHERE cc = :cc")
+ suspend fun updatePriority(cc: String, priority: Int, timestamp: Long = System.currentTimeMillis())
+
+ @Query("UPDATE CountryConfig SET catchAll = 0 WHERE catchAll = 1")
+ suspend fun clearAllCatchAll()
+
+ @Query("UPDATE CountryConfig SET lockdown = 0 WHERE lockdown = 1")
+ suspend fun clearAllLockdown()
+
+ @Query("SELECT COUNT(*) FROM CountryConfig")
+ suspend fun getCount(): Int
+
+ @Query("SELECT COUNT(*) FROM CountryConfig WHERE isActive = 1")
+ suspend fun getEnabledCount(): Int
+
+ @Query("SELECT COUNT(*) FROM CountryConfig WHERE catchAll = 1 AND isActive = 1")
+ suspend fun getCatchAllCount(): Int
+
+ @Query("SELECT COUNT(*) FROM CountryConfig WHERE lockdown = 1 AND isActive = 1")
+ suspend fun getLockdownCount(): Int
+
+ @Query("SELECT EXISTS(SELECT 1 FROM CountryConfig WHERE cc = :cc)")
+ suspend fun exists(cc: String): Boolean
+
+ @Query("SELECT EXISTS(SELECT 1 FROM CountryConfig WHERE cc = :cc AND isActive = 1)")
+ suspend fun isEnabled(cc: String): Boolean
+
+ @Query("SELECT * FROM CountryConfig WHERE id = :id")
+ suspend fun getById(id: String): CountryConfig?
+
+ @Query("UPDATE CountryConfig SET ssids = :ssids, lastModified = :timestamp WHERE cc = :cc")
+ suspend fun updateSsids(cc: String, ssids: String, timestamp: Long = System.currentTimeMillis())
+
+ @Query("SELECT * FROM CountryConfig WHERE ssidBased = 1 AND isActive = 1")
+ suspend fun getSsidEnabledConfigs(): List
+
+ @Query("SELECT * FROM CountryConfig WHERE ssidBased = 1 AND isActive = 1")
+ fun getSsidEnabledConfigsFlow(): Flow>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/celzero/bravedns/database/CountryConfigRepository.kt b/app/src/main/java/com/celzero/bravedns/database/CountryConfigRepository.kt
new file mode 100644
index 000000000..dad6cce6b
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/database/CountryConfigRepository.kt
@@ -0,0 +1,295 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.database
+
+import Logger
+import Logger.LOG_TAG_PROXY
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+
+class CountryConfigRepository(private val countryConfigDAO: CountryConfigDAO) {
+
+ companion object {
+ private const val TAG = "CountryConfigRepo"
+ }
+
+ suspend fun getConfig(cc: String): CountryConfig? {
+ return countryConfigDAO.getConfig(cc)
+ }
+
+ fun getConfigFlow(cc: String): Flow {
+ return countryConfigDAO.getConfigFlow(cc)
+ }
+
+ suspend fun getAllConfigs(): List {
+ return countryConfigDAO.getAllConfigs()
+ }
+
+ fun getAllConfigsFlow(): Flow> {
+ return countryConfigDAO.getAllConfigsFlow()
+ }
+
+ suspend fun getEnabledConfigs(): List {
+ return countryConfigDAO.getEnabledConfigs()
+ }
+
+ fun getEnabledConfigsFlow(): Flow> {
+ return countryConfigDAO.getEnabledConfigsFlow()
+ }
+
+ suspend fun getConfigsByPriority(): List {
+ return countryConfigDAO.getConfigsByPriority()
+ }
+
+ fun getConfigsByPriorityFlow(): Flow> {
+ return countryConfigDAO.getConfigsByPriorityFlow()
+ }
+
+ // Property-specific queries
+
+ suspend fun getCatchAllConfigs(): List {
+ return countryConfigDAO.getCatchAllConfigs()
+ }
+
+ fun getCatchAllConfigsFlow(): Flow> {
+ return countryConfigDAO.getCatchAllConfigsFlow()
+ }
+
+ suspend fun getLockdownConfigs(): List {
+ return countryConfigDAO.getLockdownConfigs()
+ }
+
+ fun getLockdownConfigsFlow(): Flow> {
+ return countryConfigDAO.getLockdownConfigsFlow()
+ }
+
+ suspend fun getMobileOnlyConfigs(): List {
+ return countryConfigDAO.getMobileOnlyConfigs()
+ }
+
+ fun getMobileOnlyConfigsFlow(): Flow> {
+ return countryConfigDAO.getMobileOnlyConfigsFlow()
+ }
+
+ suspend fun getSsidBasedConfigs(): List {
+ return countryConfigDAO.getSsidBasedConfigs()
+ }
+
+ fun getSsidBasedConfigsFlow(): Flow> {
+ return countryConfigDAO.getSsidBasedConfigsFlow()
+ }
+
+ suspend fun getCatchAllCountryCodes(): List {
+ return countryConfigDAO.getCatchAllCountryCodes()
+ }
+
+ suspend fun getLockdownCountryCodes(): List {
+ return countryConfigDAO.getLockdownCountryCodes()
+ }
+
+ suspend fun getMobileOnlyCountryCodes(): List {
+ return countryConfigDAO.getMobileOnlyCountryCodes()
+ }
+
+ suspend fun getSsidBasedCountryCodes(): List {
+ return countryConfigDAO.getSsidBasedCountryCodes()
+ }
+
+ // WRITE operations
+
+ @Transaction
+ suspend fun insert(config: CountryConfig) {
+ countryConfigDAO.insert(config)
+ }
+
+ @Transaction
+ suspend fun insertAll(configs: List) {
+ countryConfigDAO.insertAll(configs)
+ }
+
+ @Transaction
+ suspend fun update(config: CountryConfig) {
+ countryConfigDAO.update(config)
+ }
+
+ @Transaction
+ suspend fun delete(config: CountryConfig) {
+ countryConfigDAO.delete(config)
+ }
+
+ suspend fun deleteByCountryCode(cc: String) {
+ countryConfigDAO.deleteByCountryCode(cc)
+ }
+
+ suspend fun deleteAll() {
+ countryConfigDAO.deleteAll()
+ }
+
+ // Property updates
+
+ suspend fun updateCatchAll(cc: String, value: Boolean) {
+ countryConfigDAO.updateCatchAll(cc, value)
+ }
+
+ suspend fun updateLockdown(cc: String, value: Boolean) {
+ countryConfigDAO.updateLockdown(cc, value)
+ }
+
+ suspend fun updateMobileOnly(cc: String, value: Boolean) {
+ countryConfigDAO.updateMobileOnly(cc, value)
+ }
+
+ suspend fun updateSsidBased(cc: String, value: Boolean) {
+ countryConfigDAO.updateSsidBased(cc, value)
+ }
+
+ suspend fun updateEnabled(cc: String, value: Boolean) {
+ countryConfigDAO.updateEnabled(cc, value)
+ }
+
+ suspend fun updatePriority(cc: String, priority: Int) {
+ countryConfigDAO.updatePriority(cc, priority)
+ }
+
+ // Batch operations
+
+ suspend fun clearAllCatchAll() {
+ countryConfigDAO.clearAllCatchAll()
+ }
+
+ suspend fun clearAllLockdown() {
+ countryConfigDAO.clearAllLockdown()
+ }
+
+ // Utility methods
+
+ suspend fun exists(cc: String): Boolean {
+ return countryConfigDAO.exists(cc)
+ }
+
+ suspend fun isEnabled(cc: String): Boolean {
+ return countryConfigDAO.isEnabled(cc)
+ }
+
+ suspend fun getCount(): Int {
+ return countryConfigDAO.getCount()
+ }
+
+ suspend fun getEnabledCount(): Int {
+ return countryConfigDAO.getEnabledCount()
+ }
+
+ suspend fun getCatchAllCount(): Int {
+ return countryConfigDAO.getCatchAllCount()
+ }
+
+ suspend fun getLockdownCount(): Int {
+ return countryConfigDAO.getLockdownCount()
+ }
+
+ // ===== Server-specific operations (merged from RpnWinServerRepository) =====
+
+ suspend fun getServerById(serverId: String): CountryConfig? {
+ return countryConfigDAO.getAllConfigs().firstOrNull { it.id == serverId }
+ }
+
+ suspend fun getServersByCountryCode(cc: String): List {
+ return countryConfigDAO.getAllConfigs().filter { it.cc == cc }
+ }
+
+ suspend fun getActiveServers(): List {
+ return countryConfigDAO.getEnabledConfigs()
+ }
+
+ suspend fun getServerCount(): Int {
+ return countryConfigDAO.getCount()
+ }
+
+ suspend fun upsertServer(server: CountryConfig) {
+ countryConfigDAO.insert(server)
+ Logger.d(LOG_TAG_PROXY, "$TAG.upsertServer: ${server.id}")
+ }
+
+ suspend fun upsertServers(servers: List) {
+ countryConfigDAO.insertAll(servers)
+ Logger.d(LOG_TAG_PROXY, "$TAG.upsertServers: inserted/updated ${servers.size} servers")
+ }
+
+ suspend fun deleteServer(serverId: String) {
+ countryConfigDAO.getAllConfigs().firstOrNull { it.id == serverId }?.let {
+ countryConfigDAO.delete(it)
+ Logger.d(LOG_TAG_PROXY, "$TAG.deleteServer: $serverId")
+ }
+ }
+
+ suspend fun deleteServers(serverIds: List) {
+ val all = countryConfigDAO.getAllConfigs()
+ val toDelete = all.filter { serverIds.contains(it.id) }
+ toDelete.forEach { countryConfigDAO.delete(it) }
+ Logger.d(LOG_TAG_PROXY, "$TAG.deleteServers: deleted ${toDelete.size} servers")
+ }
+
+ suspend fun deleteAllServers() {
+ countryConfigDAO.deleteAll()
+ Logger.d(LOG_TAG_PROXY, "$TAG.deleteAllServers: all servers deleted")
+ }
+
+ suspend fun updateServerMetrics(serverId: String, load: Int, link: Int) {
+ val all = countryConfigDAO.getAllConfigs()
+ val curr = all.firstOrNull { it.id == serverId } ?: return
+ val updated = curr.copy(load = load, link = link, lastModified = System.currentTimeMillis())
+ countryConfigDAO.insert(updated)
+ Logger.v(LOG_TAG_PROXY, "$TAG.updateServerMetrics: $serverId, load=$load, link=$link")
+ }
+
+ suspend fun syncServers(newServers: List): Int {
+ try {
+ Logger.i(LOG_TAG_PROXY, "$TAG.syncServers: syncing ${newServers.size} servers from API")
+ val existingServers = countryConfigDAO.getAllConfigs()
+ val existingIds = existingServers.map { it.id }.toSet()
+ val newIds = newServers.map { it.id }.toSet()
+ val idsToRemove = existingIds - newIds
+ if (idsToRemove.isNotEmpty()) {
+ val toDelete = existingServers.filter { idsToRemove.contains(it.id) }
+ toDelete.forEach { countryConfigDAO.delete(it) }
+ Logger.i(LOG_TAG_PROXY, "$TAG.syncServers: removing ${idsToRemove.size} obsolete servers")
+ }
+ countryConfigDAO.insertAll(newServers)
+ val addedCount = (newIds - existingIds).size
+ val updatedCount = newIds.intersect(existingIds).size
+ Logger.i(LOG_TAG_PROXY, "$TAG.syncServers: completed - added=$addedCount, updated=$updatedCount, removed=${idsToRemove.size}")
+ return newServers.size
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG.syncServers: error syncing servers: ${e.message}", e)
+ throw e
+ }
+ }
+
+ // ===== SSID-related operations =====
+
+ suspend fun updateSsids(cc: String, ssids: String) {
+ countryConfigDAO.updateSsids(cc, ssids)
+ Logger.d(LOG_TAG_PROXY, "$TAG.updateSsids: $cc, ssids length=${ssids.length}")
+ }
+
+ suspend fun getSsidEnabledConfigs(): List {
+ return countryConfigDAO.getSsidEnabledConfigs()
+ }
+
+ fun getSsidEnabledConfigsFlow(): Flow> {
+ return countryConfigDAO.getSsidEnabledConfigsFlow()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/celzero/bravedns/database/CustomIpDao.kt b/app/src/main/java/com/celzero/bravedns/database/CustomIpDao.kt
index 4d65c7f2c..561a7a05d 100644
--- a/app/src/main/java/com/celzero/bravedns/database/CustomIpDao.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/CustomIpDao.kt
@@ -23,6 +23,7 @@ import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction
import androidx.room.Update
import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY
@@ -98,7 +99,7 @@ interface CustomIpDao {
)
fun getAppWiseCustomIp(query: String, uid: Int): PagingSource
- @androidx.room.RewriteQueriesToDropUnusedColumns
+ @RewriteQueriesToDropUnusedColumns
@Query(
"SELECT * FROM (SELECT *, (SELECT COUNT(*) FROM CustomIp ci2 WHERE ci2.uid = ci1.uid AND ci2.rowid <= ci1.rowid) row_num FROM CustomIp ci1 WHERE ipAddress LIKE :query AND isActive = 1 AND uid != $UID_EVERYBODY) WHERE row_num <= 5 ORDER BY uid, row_num"
)
diff --git a/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt b/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt
index a49a2f78a..b2770c80d 100644
--- a/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt
@@ -47,6 +47,7 @@ object DatabaseModule {
single { get().wgHopMapDao() }
single { get().subscriptionStatusDao() }
single { get().subscriptionStateHistoryDao()}
+ single { get().countryConfigDAO() }
single { get().connectionTrackerDAO() }
single { get().dnsLogDAO() }
@@ -82,6 +83,7 @@ object DatabaseModule {
single { get().wgHopMapRepository() }
single { get().subscriptionStatusRepository() }
single { get().subscriptionStateHistoryDao() }
+ single { get().countryConfigRepository() }
single { get().rethinkConnectionLogRepository() }
single { get().connectionTrackerRepository() }
diff --git a/app/src/main/java/com/celzero/bravedns/database/ProxyAppMappingRepository.kt b/app/src/main/java/com/celzero/bravedns/database/ProxyAppMappingRepository.kt
index 404fde34e..8671eb26b 100644
--- a/app/src/main/java/com/celzero/bravedns/database/ProxyAppMappingRepository.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/ProxyAppMappingRepository.kt
@@ -82,4 +82,20 @@ class ProxyAppMappingRepository(
// catch the exception to avoid crash
}
}
-}
+
+ suspend fun getProxiesForApp(uid: Int, packageName: String): List {
+ return proxyApplicationMappingDAO.getProxiesForApp(uid, packageName)
+ }
+
+ suspend fun getProxyIdsForApp(uid: Int, packageName: String): List {
+ return proxyApplicationMappingDAO.getProxyIdsForApp(uid, packageName)
+ }
+
+ suspend fun getAppsForProxy(proxyId: String): List {
+ return proxyApplicationMappingDAO.getAppsForProxy(proxyId)
+ }
+
+ suspend fun deleteMapping(uid: Int, packageName: String, proxyId: String) {
+ proxyApplicationMappingDAO.deleteMapping(uid, packageName, proxyId)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/celzero/bravedns/database/ProxyApplicationMappingDAO.kt b/app/src/main/java/com/celzero/bravedns/database/ProxyApplicationMappingDAO.kt
index 5a6ec8757..fba908a15 100644
--- a/app/src/main/java/com/celzero/bravedns/database/ProxyApplicationMappingDAO.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/ProxyApplicationMappingDAO.kt
@@ -48,27 +48,15 @@ interface ProxyApplicationMappingDAO {
@Query("select * from ProxyApplicationMapping")
fun getWgAppMapping(): List
- // query to get apps for pager adapter
- @Query(
- "select * from ProxyApplicationMapping where appName like :appName order by lower(appName)"
- )
+ // query to get apps for pager adapter: distinct apps ordered by name
+ @Query("SELECT * FROM ProxyApplicationMapping WHERE rowid IN ( SELECT MIN(rowid) FROM ProxyApplicationMapping WHERE appName LIKE :appName GROUP BY uid, packageName)ORDER BY lower(appName)")
fun getAllAppsMapping(appName: String): PagingSource
- @Query(
- "select * from ProxyApplicationMapping where appName like :appName and proxyId = :proxyId order by lower(appName)"
- )
- fun getSelectedAppsMapping(
- appName: String,
- proxyId: String
- ): PagingSource
+ @Query("SELECT * FROM ProxyApplicationMapping WHERE rowid IN (SELECT MIN(rowid) FROM ProxyApplicationMapping WHERE appName LIKE :appName AND proxyId = :proxyId GROUP BY uid, packageName) ORDER BY lower(appName)")
+ fun getSelectedAppsMapping(appName: String, proxyId: String): PagingSource
- @Query(
- "select * from ProxyApplicationMapping where appName like :appName and proxyId != :proxyId order by lower(appName)"
- )
- fun getUnSelectedAppsMapping(
- appName: String,
- proxyId: String
- ): PagingSource
+ @Query("SELECT * FROM ProxyApplicationMapping WHERE rowid IN ( SELECT MIN(rowid) FROM ProxyApplicationMapping WHERE appName LIKE :appName AND proxyId != :proxyId GROUP BY uid, packageName) ORDER BY lower(appName)")
+ fun getUnSelectedAppsMapping(appName: String, proxyId: String): PagingSource
@Query("select count(packageName) from ProxyApplicationMapping where proxyId = :id")
fun getAppCountById(id: String): Int
@@ -76,6 +64,13 @@ interface ProxyApplicationMappingDAO {
@Query("select count(packageName) from ProxyApplicationMapping where proxyId = :id")
fun getAppCountByIdLiveData(id: String): LiveData
+ @Query("select count(packageName) from ProxyApplicationMapping where proxyId = :id")
+ fun getSelectedAppsCountLiveData(id: String): LiveData
+
+ // unselected: apps in mapping table that are not using this proxyId
+ @Query("select count(packageName) from ProxyApplicationMapping where proxyId != :id")
+ fun getUnselectedAppsCountLiveData(id: String): LiveData
+
@Query(
"update ProxyApplicationMapping set proxyId = :cfgId, proxyName = :cfgName where uid = :uid"
)
@@ -103,4 +98,16 @@ interface ProxyApplicationMappingDAO {
@Query("update ProxyApplicationMapping set uid = :newUid where uid = :oldUid")
fun tombstoneApp(oldUid: Int, newUid: Int)
+
+ @Query("select * from ProxyApplicationMapping where uid = :uid and packageName = :packageName")
+ fun getProxiesForApp(uid: Int, packageName: String): List
+
+ @Query("select proxyId from ProxyApplicationMapping where uid = :uid and packageName = :packageName")
+ fun getProxyIdsForApp(uid: Int, packageName: String): List
+
+ @Query("select * from ProxyApplicationMapping where proxyId = :proxyId")
+ fun getAppsForProxy(proxyId: String): List
+
+ @Query("delete from ProxyApplicationMapping where uid = :uid and packageName = :packageName and proxyId = :proxyId")
+ fun deleteMapping(uid: Int, packageName: String, proxyId: String)
}
diff --git a/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt b/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt
index 12cd91642..0a77125ba 100644
--- a/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt
@@ -65,7 +65,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
-import kotlin.math.log
+import kotlin.jvm.java
import kotlin.random.Random
class RefreshDatabase
diff --git a/app/src/main/java/com/celzero/bravedns/database/RpnProxy.kt b/app/src/main/java/com/celzero/bravedns/database/RpnProxy.kt
index 7db53f297..f6abfeb57 100644
--- a/app/src/main/java/com/celzero/bravedns/database/RpnProxy.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/RpnProxy.kt
@@ -77,4 +77,4 @@ class RpnProxy {
this.latency = latency
this.lastRefreshTime = lastRefreshTime
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt b/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt
index 4f3f2ce67..cb4ec5702 100644
--- a/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt
@@ -36,6 +36,7 @@ import com.celzero.bravedns.data.AppConfig.Companion.DOH_INDEX
import com.celzero.bravedns.data.AppConfig.Companion.DOT_INDEX
import com.celzero.bravedns.data.AppConfig.Companion.FALLBACK_DNS_IF_NET_DNS_EMPTY
import com.celzero.bravedns.data.AppConfig.TunnelOptions
+import com.celzero.bravedns.database.CountryConfig
import com.celzero.bravedns.database.ConnectionTrackerRepository
import com.celzero.bravedns.database.DnsCryptRelayEndpoint
import com.celzero.bravedns.database.EventSource
@@ -43,6 +44,7 @@ import com.celzero.bravedns.database.EventType
import com.celzero.bravedns.database.ProxyEndpoint
import com.celzero.bravedns.database.Severity
import com.celzero.bravedns.net.doh.Transaction
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
import com.celzero.bravedns.service.BraveVPNService
import com.celzero.bravedns.service.BraveVPNService.Companion.FIRESTACK_MUST_DUP_TUNFD
import com.celzero.bravedns.service.BraveVPNService.Companion.NW_ENGINE_NOTIFICATION_ID
@@ -82,8 +84,10 @@ import com.celzero.firestack.backend.DNSResolver
import com.celzero.firestack.backend.DNSTransport
import com.celzero.firestack.backend.NetStat
import com.celzero.firestack.backend.Proxies
+import com.celzero.firestack.backend.Proxy
import com.celzero.firestack.backend.RDNS
import com.celzero.firestack.backend.RouterStats
+import com.celzero.firestack.backend.RpnProxy
import com.celzero.firestack.intra.Controller
import com.celzero.firestack.intra.DefaultDNS
import com.celzero.firestack.intra.Intra
@@ -171,7 +175,7 @@ class GoVpnAdapter : KoinComponent {
setDialStrategy()
setTransparency()
undelegatedDomains()
- setExperimentalSettings()
+ setExperimentalWireGuardSettings()
setAutoDialsParallel()
setAutoMode()
registerSeProxyIfNeeded()
@@ -1998,7 +2002,6 @@ class GoVpnAdapter : KoinComponent {
)
rdns?.rdnsLocal?.stamp = stamp.togs()
Logger.i(LOG_TAG_VPN, "$TAG local brave dns object is set with stamp: $stamp")
- logEvent(Severity.LOW, "set local blocklist", "Local blocklist set with stamp: $stamp")
} catch (ex: Exception) {
// Set local blocklist enabled to false and reset the timestamp
// if there is a failure creating bravedns
@@ -2242,7 +2245,7 @@ class GoVpnAdapter : KoinComponent {
}
}
- /*suspend fun getRpnProps(rpnType: RpnProxyManager.RpnType): Pair {
+ suspend fun getRpnProps(rpnType: RpnProxyManager.RpnType): Pair {
try {
var errMsg: String? = ""
val rpn: RpnProxy? = try {
@@ -2279,7 +2282,7 @@ class GoVpnAdapter : KoinComponent {
Logger.w(LOG_TAG_PROXY, "$TAG err rpn props($rpnType): ${e.message}")
return Pair(null, e.message)
}
- }*/
+ }
suspend fun testRpnProxy(proxyId: String): Boolean {
if (!tunnel.isConnected) {
@@ -2350,6 +2353,21 @@ class GoVpnAdapter : KoinComponent {
}
}
+ suspend fun getWinByKey(key: String): Proxy? {
+ if (!tunnel.isConnected) {
+ Logger.i(LOG_TAG_PROXY, "$TAG no tunnel, skip get win(rpn) by key")
+ return null
+ }
+ return try {
+ val win = tunnel.proxies.rpn().win().get(key.togs())
+ Logger.i(LOG_TAG_PROXY, "$TAG no win(rpn) found by key: $key")
+ win
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG err get win(rpn) by key: ${e.message}", e)
+ null
+ }
+ }
+
suspend fun updateWin(): ByteArray? {
if (!tunnel.isConnected) {
Logger.i(LOG_TAG_PROXY, "$TAG no tunnel, skip update win(rpn)")
@@ -2365,12 +2383,12 @@ class GoVpnAdapter : KoinComponent {
}
}
- /*suspend fun addNewWinServer(server: RpnProxyManager.RpnWinServer): Pair {
+ suspend fun addNewWinServer(key: String): Pair {
if (!tunnel.isConnected) {
Logger.i(LOG_TAG_PROXY, "$TAG no tunnel, skip add new win(rpn) server")
return Pair(false, "No tunnel connected")
}
- if (server.countryCode.isEmpty()) {
+ if (key.isEmpty()) {
Logger.w(LOG_TAG_PROXY, "$TAG empty country code for new win(rpn) server")
return Pair(false, "Empty country code for server")
}
@@ -2382,7 +2400,7 @@ class GoVpnAdapter : KoinComponent {
Logger.w(LOG_TAG_PROXY, "$TAG max win servers reached: $prevServerCount, skipping add")
return Pair(false, "Max servers reached: $prevServerCount, skipping add")
}
- val name = server.countryCode.togs()
+ val name = key.togs()
val res = win.fork(name)
Logger.i(LOG_TAG_PROXY, "$TAG add new win(rpn) server: $res")
return Pair(true, "Added new server: $name")
@@ -2390,7 +2408,28 @@ class GoVpnAdapter : KoinComponent {
Logger.e(LOG_TAG_PROXY, "$TAG err add new win(rpn) server: ${e.message}", e)
Pair(false, e.message ?: "Error adding new server")
}
- }*/
+ }
+
+ suspend fun removeWinServer(key: String): Pair {
+ if (!tunnel.isConnected) {
+ Logger.i(LOG_TAG_PROXY, "$TAG no tunnel, skip remove win(rpn) server")
+ return Pair(false, "No tunnel connected")
+ }
+ if (key.isEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG empty country code for remove win(rpn) server")
+ return Pair(false, "Empty country code for server")
+ }
+ return try {
+ val win = tunnel.proxies.rpn().win()
+ val name = key.togs()
+ val res = win.purge(name)
+ Logger.i(LOG_TAG_PROXY, "$TAG remove win(rpn) server: $res")
+ return Pair(true, "Removed server: $name")
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG err remove win(rpn) server: ${e.message}", e)
+ Pair(false, e.message ?: "Error removing server")
+ }
+ }
suspend fun unregisterWin(): Boolean {
if (!tunnel.isConnected) {
@@ -2407,7 +2446,7 @@ class GoVpnAdapter : KoinComponent {
}
}
- /*suspend fun setRpnAutoMode(): Boolean {
+ suspend fun setRpnAutoMode(): Boolean {
if (!tunnel.isConnected) {
Logger.i(LOG_TAG_PROXY, "$TAG no tunnel, skip set rpn auto mode")
return false
@@ -2435,7 +2474,7 @@ class GoVpnAdapter : KoinComponent {
Logger.e(LOG_TAG_PROXY, "$TAG err set rpn auto mode: ${e.message}", e)
false
}
- }*/
+ }
suspend fun isProxyReachable(
proxyId: String,
@@ -2504,17 +2543,13 @@ class GoVpnAdapter : KoinComponent {
}
}
- suspend fun setExperimentalSettings(value: Boolean = persistentState.nwEngExperimentalFeatures) {
+ suspend fun setExperimentalWireGuardSettings(value: Boolean = persistentState.nwEngExperimentalFeatures) {
if (!tunnel.isConnected) {
Logger.e(LOG_TAG_VPN, "$TAG no tunnel, skip set experimental settings")
- logEvent(
- Severity.CRITICAL,
- "Set experimental settings failed",
- "Failed to set experimental settings to $value: no tunnel"
- )
return
}
try {
+ // modified from overall experimental settings to only wireguard experimental settings
Intra.experimentalWireGuard(value)
// refresh proxies on experimental settings change (required for wireguard)
//refreshOrReAddProxies()
@@ -2650,7 +2685,7 @@ class GoVpnAdapter : KoinComponent {
}
}
- /*suspend fun updateRpnProxy(type: RpnProxyManager.RpnType): ByteArray? {
+ suspend fun updateRpnProxy(type: RpnProxyManager.RpnType): ByteArray? {
if (!tunnel.isConnected) {
Logger.i(LOG_TAG_PROXY, "$TAG no tunnel, skip update rpn proxy")
return null
@@ -2672,7 +2707,7 @@ class GoVpnAdapter : KoinComponent {
Logger.w(LOG_TAG_PROXY, "$TAG err update rpn proxy($type): ${e.message}")
null
}
- }*/
+ }
suspend fun addMultipleDnsAsPlus() {
if (!tunnel.isConnected) {
@@ -2836,7 +2871,6 @@ class GoVpnAdapter : KoinComponent {
try {
Intra.panicAtRandom(shouldPanic)
Logger.i(LOG_TAG_VPN, "$TAG panic at random: $shouldPanic")
- logEvent(Severity.HIGH, "Panic at random set", "Panic at random: $shouldPanic")
} catch (e: Exception) {
Logger.e(LOG_TAG_VPN, "$TAG err panic at random: ${e.message}")
}
@@ -2868,9 +2902,7 @@ class GoVpnAdapter : KoinComponent {
Logger.e(LOG_TAG_VPN, "$TAG err start flight recorder: ${e.message}")
try {
Intra.flightRecorder(false)
- } catch (_: Exception) {
- // ignore
- }
+ } catch (_: Exception) { }
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt b/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt
index 5617fbb35..aff116d09 100644
--- a/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt
@@ -14,26 +14,27 @@
* limitations under the License.
*/
package com.celzero.bravedns.rpnproxy
-/*
+
+import Logger
import Logger.LOG_TAG_PROXY
import android.content.Context
import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG
+import com.celzero.bravedns.data.SsidItem
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.database.CountryConfigRepository
import com.celzero.bravedns.database.RpnProxy
import com.celzero.bravedns.database.RpnProxyRepository
-import kotlin.collections.find
-
-import com.celzero.bravedns.database.SubscriptionStateHistoryDao
import com.celzero.bravedns.database.SubscriptionStatus
-import com.celzero.bravedns.database.SubscriptionStatusRepository
import com.celzero.bravedns.iab.InAppBillingHandler
import com.celzero.bravedns.iab.PurchaseDetail
-import com.celzero.bravedns.scheduler.WorkScheduler
import com.celzero.bravedns.service.DomainRulesManager
import com.celzero.bravedns.service.EncryptedFileManager
import com.celzero.bravedns.service.IpRulesManager
import com.celzero.bravedns.service.PersistentState
+import com.celzero.bravedns.service.ProxyManager
+import com.celzero.bravedns.service.ProxyManager.ID_RPN_WIN
+import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE
import com.celzero.bravedns.service.VpnController
-import com.celzero.bravedns.subscription.StateMachineStatistics
import com.celzero.bravedns.subscription.SubscriptionStateMachineV2
import com.celzero.bravedns.util.Constants
import com.celzero.bravedns.util.Constants.Companion.RPN_PROXY_FOLDER_NAME
@@ -45,6 +46,7 @@ import com.celzero.firestack.settings.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.json.JSONObject
@@ -62,9 +64,7 @@ object RpnProxyManager : KoinComponent {
private var preferredId = Backend.Auto
private val db: RpnProxyRepository by inject()
- private val subsDb: SubscriptionStatusRepository by inject()
- private val subsHistoryDb: SubscriptionStateHistoryDao by inject()
- private val workScheduler by inject()
+ private val countryConfigRepo: CountryConfigRepository by inject()
private val persistentState by inject()
private const val WIN_ID = 4
@@ -74,7 +74,8 @@ object RpnProxyManager : KoinComponent {
const val MAX_WIN_SERVERS = 5
private var winConfig: ByteArray? = null
- private var winServers: Set = emptySet()
+ // In-memory cache for WIN servers (CountryConfig as unified model)
+ private val winServersCache = mutableListOf()
private val rpnProxies = CopyOnWriteArraySet()
@@ -157,8 +158,6 @@ object RpnProxyManager : KoinComponent {
fun isInactive() = this == DISABLED
}
- data class RpnWinServer(val names: String, val countryCode: String, val address: String, val isActive: Boolean)
-
fun isRpnActive(): Boolean {
val isEnabled = RpnState.fromId(persistentState.rpnState).isEnabled()
val isActive = !rpnMode().isNone()
@@ -393,8 +392,19 @@ object RpnProxyManager : KoinComponent {
LOG_TAG_PROXY,
"$TAG; total selected countries: ${selectedCountries.size}, $selectedCountries"
)
- // subsDb.deleteAll()
- // subsHistoryDb.clearHistory()
+
+ // Populate WIN servers cache from DB at startup
+ try {
+ val dbServers = countryConfigRepo.getAllConfigs()
+ synchronized(winServersCache) {
+ winServersCache.clear()
+ winServersCache.addAll(dbServers)
+ }
+ Logger.i(LOG_TAG_PROXY, "$TAG; load: cached ${dbServers.size} WIN servers from DB")
+ } catch (e: Exception) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; load: failed to cache WIN servers: ${e.message}")
+ }
+
return rp.size
}
@@ -424,11 +434,17 @@ object RpnProxyManager : KoinComponent {
}
val currBytes = VpnController.registerAndFetchWinConfig(bytes) ?: return false
val ok = updateWinConfigState(currBytes)
- winServers = fetchAndConstructWinLocations()
- if (winServers.isEmpty()) {
+ // Fetch servers from API and sync to database and cache
+ val (servers, removedSelectedIds) = fetchAndConstructWinLocations()
+ if (servers.isEmpty()) {
Logger.w(LOG_TAG_PROXY, "$TAG; no win servers found, retry")
- io {
- retryLocationFetch()
+ retryLocationFetch()
+ } else {
+ syncWinServers(servers)
+ // Notify about removed servers if any were selected
+ if (removedSelectedIds.isNotEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; ${removedSelectedIds.size} selected servers were removed from the list")
+ // Notification will be shown by ServerSelectionFragment when it detects the change
}
}
return ok
@@ -441,261 +457,92 @@ object RpnProxyManager : KoinComponent {
}
suspend fun retryLocationFetch() {
- // keep retrying to fetch win properties for next 15 sec and see
- // if the locations are available
for (i in 1..15) {
Logger.i(LOG_TAG_PROXY, "$TAG; retrying to fetch win properties, attempt: $i")
- winServers = fetchAndConstructWinLocations()
- if (winServers.isNotEmpty()) {
- Logger.i(LOG_TAG_PROXY, "$TAG; win servers found after retry, attempt: $i, size: ${winServers.size}")
+ val (servers, removedSelectedIds) = fetchAndConstructWinLocations()
+ if (servers.isNotEmpty()) {
+ Logger.i(LOG_TAG_PROXY, "$TAG; win servers found after retry, attempt: $i, size: ${servers.size}")
+ syncWinServers(servers)
+ // Notify about removed servers if any were selected
+ if (removedSelectedIds.isNotEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; retryLocationFetch: ${removedSelectedIds.size} selected servers were removed")
+ }
break
}
- Thread.sleep(1000L) // wait for 1 second before next retry
+ delay(1000L)
}
}
- suspend fun getWinServers(): List {
- if (winServers.isNotEmpty()) {
- return winServers.toList()
- }
-
- Logger.w(LOG_TAG_PROXY, "$TAG; win servers are empty, fetching from tun")
- winServers = fetchAndConstructWinLocations()
- return winServers.toList()
- }
-
- suspend fun getWinEntitlement(): ByteArray? {
- if (winConfig == null || winConfig!!.isEmpty()) {
- Logger.w(LOG_TAG_PROXY, "$TAG; win config is null or empty, returning empty byte array")
- // read from database if available
- val winProxy = db.getProxyById(WIN_ID)
- if (winProxy != null) {
- val file = File(winProxy.serverResPath)
- val bytes = EncryptedFileManager.readByteArray(applicationContext, file)
- if (bytes.isNotEmpty()) {
- Logger.i(LOG_TAG_PROXY, "$TAG; win proxy found in db, returning bytes")
- return bytes
- } else {
- Logger.w(LOG_TAG_PROXY, "$TAG; win proxy file is empty, returning null")
- return null
- }
- } else {
- Logger.w(LOG_TAG_PROXY, "$TAG; win proxy not found in db, returning null")
- return null
+ /**
+ * Get all WIN servers.
+ * Prefers in-memory cache; falls back to DB; if empty, fetches from API, syncs DB, and updates cache.
+ */
+ suspend fun getWinServers(): List {
+ // Return cached list if available
+ synchronized(winServersCache) {
+ if (winServersCache.isNotEmpty()) {
+ Logger.v(LOG_TAG_PROXY, "$TAG; returning cached win servers, size: ${winServersCache.size}")
+ return winServersCache.toList()
}
- } else {
- Logger.i(LOG_TAG_PROXY, "$TAG; win config is not null, returning bytes")
- return winConfig
}
- }
- suspend fun updateWinConfigState(byteArray: ByteArray?): Boolean {
- if (byteArray == null || byteArray.isEmpty()) {
- Logger.e(LOG_TAG_PROXY, "$TAG; err; byte array is null for win config")
- return false
- }
-
- try {
- val res = updateWinConfigToFileAndDb(byteArray)
- Logger.i(LOG_TAG_PROXY, "$TAG; win config saved? $res")
- if (res) {
- winConfig = byteArray
- Logger.i(LOG_TAG_PROXY, "$TAG; win config updated")
+ // Try DB next
+ val dbServers = countryConfigRepo.getAllConfigs()
+ if (dbServers.isNotEmpty()) {
+ synchronized(winServersCache) {
+ winServersCache.clear()
+ winServersCache.addAll(dbServers)
}
- return res
- } catch (e: Exception) {
- Logger.e(LOG_TAG_PROXY, "$TAG; err updating win config: ${e.message}", e)
+ Logger.v(LOG_TAG_PROXY, "$TAG; loaded ${dbServers.size} win servers from DB into cache")
+ return dbServers
}
- return false
- }
-
- suspend fun getWinExistingData(): ByteArray? {
- return getExistingData(WIN_ID)
- }
- private suspend fun getExistingData(id: Int): ByteArray? {
- try {
- val db = db.getProxyById(id)
- if (db == null) {
- Logger.w(LOG_TAG_PROXY, "$TAG; db is null for id: $id")
- return null
- }
- val cfgFile = File(db.configPath)
- if (cfgFile.exists()) {
- Logger.d(LOG_TAG_PROXY, "$TAG; config for $id exists, reading the file")
- val bytes = EncryptedFileManager.readByteArray(applicationContext, cfgFile)
- Logger.d(LOG_TAG_PROXY, "$TAG; existing data for $id: ${bytes.size}")
- return bytes
- } else {
- Logger.e(LOG_TAG_PROXY, "$TAG; err; config for $id not found, ${cfgFile.absolutePath}")
+ // Database is empty, fetch from API and sync
+ Logger.w(LOG_TAG_PROXY, "$TAG; database is empty, fetching win servers from tun")
+ val (apiServers, removedSelectedIds) = fetchAndConstructWinLocations()
+ if (apiServers.isNotEmpty()) {
+ syncWinServers(apiServers)
+ if (removedSelectedIds.isNotEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; getWinServers: ${removedSelectedIds.size} selected servers were removed")
}
- } catch (e: Exception) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err getting existing data for $id: ${e.message}")
}
- return null
+ return apiServers.toList()
}
- suspend fun getSelectedCCs(): Set {
- return selectedCountries
- }
+ // ===== WIN Server Database Operations (delegated to CountryConfigRepository) =====
+ // These methods are now available through countryConfigRepo if needed elsewhere
- private suspend fun updateWinConfigToFileAndDb(state: ByteArray): Boolean {
- // write the win config to the file and update the database
- // store entitlement in serverResponse column and state in config path column
- val dir = File(
- applicationContext.filesDir.absolutePath +
- File.separator +
- RPN_PROXY_FOLDER_NAME +
- File.separator +
- WIN_NAME.lowercase() +
- File.separator
- )
- if (!dir.exists()) {
- Logger.d(LOG_TAG_PROXY, "$TAG; creating dir: ${dir.absolutePath}")
- dir.mkdirs()
- }
- val cfgFile = File(dir, getConfigFileName(WIN_ID))
- try {
- // write the entitlement to the config file
- val cfgRes = EncryptedFileManager.write(applicationContext, state, cfgFile)
- Logger.i(LOG_TAG_PROXY, "$TAG writing win config to file: ${cfgFile.absolutePath}")
- val existingDb = db.getProxyById(WIN_ID)
- val l = if (existingDb != null) {
- // if the proxy already exists, update it
- existingDb.configPath = cfgFile.absolutePath
- db.update(existingDb)
- } else {
- // if the proxy does not exist, insert it
- val rpnProxy = RpnProxy(
- id = WIN_ID,
- name = WIN_NAME,
- configPath = cfgFile.absolutePath,
- serverResPath = "", // serverResPath is used to store the entitlement
- isActive = true,
- isLockdown = false,
- createdTs = System.currentTimeMillis(),
- modifiedTs = System.currentTimeMillis(),
- misc = "",
- tunId = "",
- latency = 0,
- lastRefreshTime = System.currentTimeMillis()
- )
- db.insert(rpnProxy).toInt()
- }
- Logger.d(LOG_TAG_PROXY, "$TAG; win config saved in db? ${l > 0}")
- if (l > 0 && cfgRes) {
- winConfig = state
- Logger.i(LOG_TAG_PROXY, "$TAG; win config updated")
- return true
- }
- } catch (e: Exception) {
- Logger.e(LOG_TAG_PROXY, "$TAG; err writing win config to file: ${e.message}", e)
- }
- return false
- }
-
- private fun getConfigFileName(id: Int): String {
- return when (id) {
- WIN_ID -> WIN_STATE_FILE_NAME
- else -> ""
- }
- }
-
- private fun getJsonResponseFileName(id: Int): String {
- return when (id) {
- WIN_ID -> WIN_ENTITLEMENT_FILE_NAME
- else -> ""
- }
- }
-
- fun canSelectCountryCode(cc: String): Pair {
- // TODO: get country code from win config
- val isAvailable = false
- if (!isAvailable) {
- Logger.i(LOG_TAG_PROXY, "$TAG; cc not available in config: $cc")
- return Pair(false, "Country code not available in the config")
- }
- return if (selectedCountries.size >= 5) {
- Logger.i(LOG_TAG_PROXY, "$TAG; cc limit reached, selected: ${selectedCountries.size}, $selectedCountries")
- Pair(false, "Country code limit reached for the selected endpoint")
- } else {
- selectedCountries.add(cc)
- Logger.d(LOG_TAG_PROXY, "$TAG; cc added to selected list: $cc")
- Pair(true, "")
- }
- }
-
- fun stats(): String {
- val sb = StringBuilder()
- sb.append(" rpnState: ${rpnState().name}\n")
- sb.append(" rpnMode: ${rpnMode().name}\n")
- sb.append(" win config? ${winConfig != null}\n")
- //sb.append(" subscription stats: ${getSubscriptionStatistics()}\n")
- //sb.append(" current subscription: ${getDetailedSubscriptionInfo()}\n")
- sb.append(" selected countries: ${selectedCountries.size}, $selectedCountries\n")
- sb.append(" state machine stats: ${InAppBillingHandler.getConnectionStatusWithStateMachine()}\n")
- return sb.toString()
+ suspend fun getWinEntitlement(): ByteArray? {
+ val proxy = db.getProxyById(WIN_ID) ?: return null
+ val file = File(proxy.serverResPath)
+ if (!file.exists()) return null
+ val bytes = EncryptedFileManager.readByteArray(applicationContext, file)
+ return if (bytes.isNotEmpty()) bytes else null
}
-
/**
- * Validate the payload received from Play Billing.
- * The payload is expected to be in the format: "accountId:session_token"
- * where accountId is the account ID from PipKeyManager and hashkey represents the user
- * session_token created during the purchase by server
+ * Get current subscription data including purchase details and status.
+ * Used by UI fragments to display subscription information.
*/
-
- suspend fun isValidPayload(payload: String): Boolean {
- if (payload.isEmpty()) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; payload is empty")
- return false
- }
- val keyState = PipKeyManager.getToken(applicationContext)
- val keyFromPlayBilling = getCidFromPayload(payload)
- if (keyState.isEmpty() || keyFromPlayBilling.isEmpty()) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; key state or key from play billing is empty")
- return false
- }
- if (keyState != keyFromPlayBilling) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; key state and key from play billing do not match")
- return false
+ fun getSubscriptionData(): SubscriptionStateMachineV2.SubscriptionData? {
+ return try {
+ subscriptionStateMachine.getSubscriptionData()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; error getting subscription data: ${e.message}", e)
+ null
}
- Logger.i(LOG_TAG_PROXY, "$TAG; key state and key from play billing match, processing payment")
- return true
}
- private fun getCidFromPayload(payload: String): String {
- // sample payload: payload={"ws":{"cid":"aa95f04efcb19a54c7605a02e5dd0b435906b993d12bec031a60f3f1272f4f0e","sessiontoken":"22605:4:1752145272:1da0c248e6cf32ca071a96e477bdf0033368599b4b:307dfd06996672f735409fec4807fcf40a0677e2ef","status":"valid"}}
- val payloadJson = try {
- JSONObject(payload)
- } catch (e: Exception) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err parsing payload json: ${e.message}")
- return ""
- }
- val ws = payloadJson.optJSONObject("ws")
- if (ws == null) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; ws object is null in payload")
- return ""
- }
- return ws.optString("cid", "")
+ /**
+ * Get current subscription state.
+ * Used by UI to display the current state of the subscription.
+ */
+ fun getSubscriptionState(): SubscriptionStateMachineV2.SubscriptionState {
+ return subscriptionStateMachine.getCurrentState()
}
- suspend fun isValidAccountId(accountId: String): Boolean {
- if (accountId.isEmpty()) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; accountId is empty")
- return false
- }
- val keyState = PipKeyManager.getToken(applicationContext)
- if (keyState.isEmpty()) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; key state is empty")
- return false
- }
- if (keyState != accountId) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; key state and accountId do not match")
- return false
- }
- Logger.i(LOG_TAG_PROXY, "$TAG; key state and accountId match, processing payment")
- return true
+ suspend fun getCurrentSubscription(): SubscriptionStateMachineV2.SubscriptionData? {
+ return subscriptionStateMachine.getSubscriptionData()
}
suspend fun updateCancelledSubscription(accountId: String, purchaseToken: String): Boolean {
@@ -708,10 +555,6 @@ object RpnProxyManager : KoinComponent {
return true
}
- suspend fun getCurrentSubscription(): SubscriptionStateMachineV2.SubscriptionData? {
- return subscriptionStateMachine.getSubscriptionData()
- }
-
suspend fun updateRevokedSubscription(accountId: String, purchaseToken: String): Boolean {
if (purchaseToken.isEmpty() || accountId.isEmpty()) {
Logger.w(LOG_TAG_PROXY, "$TAG; err; purchaseToken is empty")
@@ -721,50 +564,13 @@ object RpnProxyManager : KoinComponent {
return true
}
- suspend fun fetchAndConstructWinLocations(): Set {
- // there will be multiple location names for single country code
- // construct the RpnWinServer object
- // contains pair RpnProps and errorMsg
- val winProps = VpnController.getRpnProps(RpnType.WIN).first
- if (winProps == null) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; win props is null")
- return emptySet()
- }
- val count = winProps.locations.len()
- if (count == 0L) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; no locations found in win props")
- return emptySet()
- }
- val servers = mutableSetOf()
- for( i in 0 until count) {
- val loc = winProps.locations.get(i)
- if (loc == null) {
- Logger.w(LOG_TAG_PROXY, "$TAG; err; location is null at index $i")
- continue
- }
- val prevNames = servers.filter { it.countryCode == loc.cc }.map { it.names }.toMutableList()
- prevNames.add(loc.name)
- val newNames = prevNames.distinct().sorted().joinToString { "," }
- // each cc will have multiple locations
- // add that to the list of servers
- val s = RpnWinServer(newNames, loc.cc, loc.addrs, true)
- servers.add(s)
- }
- // assign it to winServers
- return servers
- }
-
suspend fun processRpnPurchase(purchase: PurchaseDetail?, existingSubs: SubscriptionStatus): Boolean {
if (purchase == null) {
Logger.w(LOG_TAG_PROXY, "$TAG; err; no purchases to process")
try {
subscriptionStateMachine.subscriptionExpired()
} catch (e: Exception) {
- Logger.e(
- LOG_TAG_PROXY,
- "$TAG; error notifying state machine of expiration: ${e.message}",
- e
- )
+ Logger.e(LOG_TAG_PROXY, "$TAG; error notifying state machine of expiration: ${e.message}", e)
}
return false
}
@@ -774,32 +580,7 @@ object RpnProxyManager : KoinComponent {
try {
subscriptionStateMachine.purchaseFailed("Empty product ID", null)
} catch (e: Exception) {
- Logger.e(
- LOG_TAG_PROXY,
- "$TAG; error notifying state machine of purchase failure: ${e.message}",
- e
- )
- }
- return false
- }
-
- // Enhanced validation
- if (!isValidPayload(purchase.payload) && !isValidAccountId(purchase.accountId)) {
- Logger.w(
- LOG_TAG_PROXY,
- "$TAG; err; invalid payload or account ID for purchase: $purchase"
- )
- try {
- subscriptionStateMachine.purchaseFailed(
- "Invalid payload or account ID",
- null
- )
- } catch (e: Exception) {
- Logger.e(
- LOG_TAG_PROXY,
- "$TAG; error notifying state machine of validation failure: ${e.message}",
- e
- )
+ Logger.e(LOG_TAG_PROXY, "$TAG; error notifying state machine of purchase failure: ${e.message}", e)
}
return false
}
@@ -812,17 +593,12 @@ object RpnProxyManager : KoinComponent {
Logger.d(LOG_TAG_PROXY, "$TAG; existing subscription is still valid, no immediate action needed")
}
-
if (accExpiry > currTs) {
Logger.d(LOG_TAG_PROXY, "$TAG; existing account is still valid, no immediate action needed")
}
- // in case if the account expiry is less than the billing expiry, query the entitlement
- // from server and update the subscription state machine
- // Check if the billing expiry + 1 day is greater than the account expiry.
- // there is always a delay so just add 1 more day to the billing expiry
- val oneDay = 24 * 60 * 60 * 1000 // 1 day in milliseconds
- if (accExpiry < billingExpiry + oneDay ) {
+ val oneDay = 24 * 60 * 60 * 1000
+ if (accExpiry < billingExpiry + oneDay) {
Logger.d(LOG_TAG_PROXY, "$TAG; account expiry is less than billing expiry, querying entitlement")
try {
val developerPayload = InAppBillingHandler.queryEntitlementFromServer(purchase.accountId)
@@ -842,7 +618,6 @@ object RpnProxyManager : KoinComponent {
}
}
- // activate the RPN
activateRpn(purchase)
return true
}
@@ -853,7 +628,7 @@ object RpnProxyManager : KoinComponent {
subscriptionStateMachine.currentState.collect { state ->
Logger.d(LOG_TAG_PROXY, "$TAG; collect; initial subscription state: ${state.name}")
io { handleStateChange(state) }
- }
+ }
} catch (e: Exception) {
Logger.e(LOG_TAG_PROXY, "$TAG; collect; error in state observer: ${e.message}", e)
}
@@ -863,22 +638,17 @@ object RpnProxyManager : KoinComponent {
private suspend fun handleStateChange(state: SubscriptionStateMachineV2.SubscriptionState) {
when (state) {
is SubscriptionStateMachineV2.SubscriptionState.Active -> {
- Logger.i(
- LOG_TAG_PROXY,
- "$TAG; subscription activated, ensuring RPN is enabled if configured"
- )
- // Could potentially auto-enable RPN if conditions are met
+ Logger.i(LOG_TAG_PROXY, "$TAG; subscription activated, ensuring RPN is enabled if configured")
if (!isRpnEnabled()) {
val subs = subscriptionStateMachine.getSubscriptionData()
val purchaseDetail = subs?.purchaseDetail
- if (purchaseDetail == null) { // this should not happen
+ if (purchaseDetail == null) {
Logger.w(LOG_TAG_PROXY, "$TAG; no purchase detail available for activation, but state is active")
return
}
activateRpn(purchaseDetail)
}
}
-
is SubscriptionStateMachineV2.SubscriptionState.Cancelled -> {
Logger.i(LOG_TAG_PROXY, "$TAG; subscription cancelled, disabling RPN if active")
val subs = subscriptionStateMachine.getSubscriptionData()
@@ -890,22 +660,17 @@ object RpnProxyManager : KoinComponent {
Logger.w(LOG_TAG_PROXY, "$TAG; subscription cancelled but still valid, not deactivating RPN")
}
}
-
is SubscriptionStateMachineV2.SubscriptionState.Expired -> {
Logger.w(LOG_TAG_PROXY, "$TAG; subscription expired, disabling RPN")
deactivateRpn("Subscription expired")
}
-
is SubscriptionStateMachineV2.SubscriptionState.Revoked -> {
Logger.w(LOG_TAG_PROXY, "$TAG; subscription revoked, immediately disabling RPN")
deactivateRpn("Subscription revoked")
}
-
is SubscriptionStateMachineV2.SubscriptionState.Error -> {
Logger.e(LOG_TAG_PROXY, "$TAG; subscription state machine in error state")
- // Could implement error recovery logic here
}
-
else -> {
Logger.d(LOG_TAG_PROXY, "$TAG; subscription state: ${state.name}")
}
@@ -916,41 +681,16 @@ object RpnProxyManager : KoinComponent {
return subscriptionStateMachine.currentState
}
- fun getSubscriptionState(): SubscriptionStateMachineV2.SubscriptionState {
- return subscriptionStateMachine.getCurrentState()
- }
-
- fun getSubscriptionData(): SubscriptionStateMachineV2.SubscriptionData? {
- return subscriptionStateMachine.getSubscriptionData()
- }
-
- fun canMakePurchase(): Boolean {
- return subscriptionStateMachine.canMakePurchase()
- }
-
fun hasValidSubscription(): Boolean {
- if (DEBUG) {
+ /*if (DEBUG) {
persistentState.rpnState = RpnState.ENABLED.id
- return true // temporarily always return true
- }
+ return true
+ }*/
val valid = subscriptionStateMachine.hasValidSubscription()
Logger.i(LOG_TAG_PROXY, "$TAG; using state machine for subscription check, valid: $valid")
return valid
}
- fun isSubscriptionActiveInStateMachine(): Boolean {
- return subscriptionStateMachine.isSubscriptionActive()
- }
-
- suspend fun handleSubscriptionRestored(purchaseDetail: PurchaseDetail) {
- try {
- subscriptionStateMachine.restoreSubscription(purchaseDetail)
- Logger.i(LOG_TAG_PROXY, "$TAG; subscription restored: ${purchaseDetail.productId}")
- } catch (e: Exception) {
- Logger.e(LOG_TAG_PROXY, "$TAG; error restoring subscription: ${e.message}", e)
- }
- }
-
suspend fun handleUserCancellation() {
try {
subscriptionStateMachine.userCancelled()
@@ -964,8 +704,6 @@ object RpnProxyManager : KoinComponent {
try {
subscriptionStateMachine.subscriptionRevoked()
Logger.w(LOG_TAG_PROXY, "$TAG; subscription revocation handled")
-
- // Immediately deactivate RPN
deactivateRpn()
Logger.i(LOG_TAG_PROXY, "$TAG; RPN deactivated due to revocation")
} catch (e: Exception) {
@@ -973,124 +711,719 @@ object RpnProxyManager : KoinComponent {
}
}
- suspend fun performSystemCheck() {
- try {
- subscriptionStateMachine.systemCheck()
- Logger.d(LOG_TAG_PROXY, "$TAG; system check performed")
- } catch (e: Exception) {
- Logger.e(LOG_TAG_PROXY, "$TAG; error during system check: ${e.message}", e)
+ fun isRpnValidForCurrentSubscription(): Boolean {
+ if (!isRpnEnabled()) {
+ return false
+ }
+
+ val currentState = subscriptionStateMachine.getCurrentState()
+ when (currentState) {
+ is SubscriptionStateMachineV2.SubscriptionState.Active,
+ is SubscriptionStateMachineV2.SubscriptionState.Cancelled -> {
+ Logger.i(LOG_TAG_PROXY, "$TAG; RPN is valid for current subscription state: ${currentState.name}")
+ return true
+ }
+ is SubscriptionStateMachineV2.SubscriptionState.Revoked,
+ is SubscriptionStateMachineV2.SubscriptionState.Expired -> {
+ Logger.w(LOG_TAG_PROXY, "$TAG; RPN should be disabled - subscription ${currentState.name}")
+ return false
+ }
+ else -> {
+ Logger.d(LOG_TAG_PROXY, "$TAG; RPN validity uncertain for state: ${currentState.name}")
+ return true
+ }
}
}
- fun getSubscriptionStatistics(): StateMachineStatistics? {
- return subscriptionStateMachine.getStatistics()
+ suspend fun isValidPayload(payload: String): Boolean {
+ if (payload.isEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; payload is empty")
+ return false
+ }
+ val keyState = PipKeyManager.getToken(applicationContext)
+ val keyFromPlayBilling = getCidFromPayload(payload)
+ if (keyState.isEmpty() || keyFromPlayBilling.isEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; key state or key from play billing is empty")
+ return false
+ }
+ if (keyState != keyFromPlayBilling) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; key state and key from play billing do not match")
+ return false
+ }
+ Logger.i(LOG_TAG_PROXY, "$TAG; key state and key from play billing match, processing payment")
+ return true
}
- fun cleanup() {
- try {
- stateObserverJob.cancel()
- Logger.i(LOG_TAG_PROXY, "$TAG; subscription state machine integration cleaned up")
+ private fun getCidFromPayload(payload: String): String {
+ // sample payload: payload={"ws":{"cid":"aa95f04efcb19a54c7605a02e5dd0b435906b993d12bec031a60f3f1272f4f0e","sessiontoken":"22605:4:1752145272:1da0c248e6cf32ca071a96e477bdf0033368599b4b:307dfd06996672f735409fec4807fcf40a0677e2ef","status":"valid"}}
+ val payloadJson = try {
+ JSONObject(payload)
} catch (e: Exception) {
- Logger.e(LOG_TAG_PROXY, "$TAG; error during cleanup: ${e.message}", e)
+ Logger.w(LOG_TAG_PROXY, "$TAG; err parsing payload json: ${e.message}")
+ return ""
+ }
+ val ws = payloadJson.optJSONObject("ws")
+ if (ws == null) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; ws object is null in payload")
+ return ""
}
+ return ws.optString("cid", "")
}
- private fun isValidForRpnActivation(purchase: PurchaseDetail): Boolean {
- // Check basic purchase validity
- if (purchase.productId.isEmpty()) {
- Logger.w(LOG_TAG_PROXY, "$TAG; invalid purchase - empty product ID")
- return false
+ fun canSelectCountryCode(cc: String): Pair {
+ // TODO: get country code from win config
+ val isAvailable = false
+ if (!isAvailable) {
+ Logger.i(LOG_TAG_PROXY, "$TAG; cc not available in config: $cc")
+ return Pair(false, "Country code not available in the config")
}
+ return if (selectedCountries.size >= 5) {
+ Logger.i(
+ LOG_TAG_PROXY,
+ "$TAG; cc limit reached, selected: ${selectedCountries.size}, $selectedCountries"
+ )
+ Pair(false, "Country code limit reached for the selected endpoint")
+ } else {
+ selectedCountries.add(cc)
+ Logger.d(LOG_TAG_PROXY, "$TAG; cc added to selected list: $cc")
+ Pair(true, "")
+ }
+ }
+
+ suspend fun getSelectedCCs(): Set {
+ return selectedCountries
+ }
- // Check if purchase is revoked
- if (purchase.status == SubscriptionStatus.SubscriptionState.STATE_REVOKED.id) {
- Logger.w(LOG_TAG_PROXY, "$TAG; invalid purchase - revoked status")
+ suspend fun isValidAccountId(accountId: String): Boolean {
+ if (accountId.isEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; accountId is empty")
return false
}
-
- // Check if purchase is expired
- if (purchase.expiryTime > 0 && System.currentTimeMillis() > purchase.expiryTime) {
- Logger.w(LOG_TAG_PROXY, "$TAG; invalid purchase - expired")
+ val keyState = PipKeyManager.getToken(applicationContext)
+ if (keyState.isEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; key state is empty")
+ return false
+ }
+ if (keyState != accountId) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; key state and accountId do not match")
return false
}
+ Logger.i(LOG_TAG_PROXY, "$TAG; key state and accountId match, processing payment")
+ return true
+ }
- // Check state machine state if available
- val currentState = subscriptionStateMachine.getCurrentState()
- when (currentState) {
- is SubscriptionStateMachineV2.SubscriptionState.Revoked,
- is SubscriptionStateMachineV2.SubscriptionState.Expired -> {
- Logger.w(
+ private fun io(f: suspend () -> Unit) {
+ CoroutineScope(Dispatchers.IO).launch { f() }
+ }
+
+ private fun getConfigFileName(id: Int): String {
+ return when (id) {
+ WIN_ID -> WIN_STATE_FILE_NAME
+ else -> ""
+ }
+ }
+
+ private fun getJsonResponseFileName(id: Int): String {
+ return when (id) {
+ WIN_ID -> WIN_ENTITLEMENT_FILE_NAME
+ else -> ""
+ }
+ }
+
+ suspend fun getWinExistingData(): ByteArray? {
+ winConfig?.let { if (it.isNotEmpty()) return it }
+ val proxy = db.getProxyById(WIN_ID) ?: return null
+ val cfg = proxy.configPath
+ if (cfg.isEmpty()) return null
+ val file = File(cfg)
+ if (!file.exists()) return null
+ return EncryptedFileManager.readByteArray(applicationContext, file)
+ }
+
+
+ suspend fun updateWinConfigState(byteArray: ByteArray?): Boolean {
+ if (byteArray == null || byteArray.isEmpty()) return false
+ return try {
+ val fileName = getConfigFileName(WIN_ID)
+ val file = File(applicationContext.getExternalFilesDir(RPN_PROXY_FOLDER_NAME), fileName)
+ val ok = EncryptedFileManager.write(applicationContext, byteArray, file)
+ if (!ok) return false
+ val proxy = db.getProxyById(WIN_ID)
+ if (proxy != null) {
+ proxy.configPath = file.absolutePath
+ proxy.modifiedTs = System.currentTimeMillis()
+ db.update(proxy)
+ } else {
+ val newProxy = RpnProxy(
+ id = WIN_ID,
+ name = WIN_NAME,
+ configPath = file.absolutePath,
+ serverResPath = "",
+ isActive = true,
+ isLockdown = false,
+ createdTs = System.currentTimeMillis(),
+ modifiedTs = System.currentTimeMillis(),
+ misc = "",
+ tunId = "",
+ latency = 0,
+ lastRefreshTime = System.currentTimeMillis()
+ )
+ db.insert(newProxy)
+ }
+ winConfig = byteArray
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; err updating win config: ${e.message}", e)
+ false
+ }
+ }
+
+ /**
+ * Fetches WIN server locations from the API and syncs with database and cache.
+ * Returns a Pair of:
+ * - First: Set of new/updated servers from API
+ * - Second: List of removed server IDs that were in the selected list (for notifications)
+ */
+ suspend fun fetchAndConstructWinLocations(): Pair, List> {
+ // Fetch from API
+ val winProps = VpnController.getRpnProps(RpnType.WIN).first
+ if (winProps == null) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; win props is null")
+ return Pair(emptySet(), emptyList())
+ }
+ val count = winProps.locations.len()
+ if (count == 0L) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; no locations found in win props")
+ return Pair(emptySet(), emptyList())
+ }
+
+ val newServers = mutableSetOf()
+ for(i in 0 until count) {
+ val loc = winProps.locations.get(i)
+ if (loc == null) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; err; location is null at index $i")
+ continue
+ }
+ val id = "${loc.cc}-${loc.name}-${loc.key}"
+ val cfg = CountryConfig(
+ id = id,
+ cc = loc.cc,
+ name = loc.name,
+ address = loc.addrs,
+ city = loc.name,
+ key = loc.key,
+ load = 0,
+ link = 0,
+ count = 1,
+ isActive = true
+ )
+ newServers.add(cfg)
+ }
+
+ // Get existing servers from DB to identify removed ones
+ val existingServers = countryConfigRepo.getAllConfigs()
+ val existingIds = existingServers.map { it.id }.toSet()
+ val newIds = newServers.map { it.id }.toSet()
+ val removedIds = existingIds - newIds
+
+ // Check if any removed servers were in the selected list
+ val removedSelectedIds = mutableListOf()
+ if (removedIds.isNotEmpty()) {
+ for (removedId in removedIds) {
+ val removed = existingServers.firstOrNull { it.id == removedId }
+ if (removed != null && selectedCountries.contains(removed.cc)) {
+ removedSelectedIds.add(removedId)
+ // Remove from selected countries
+ selectedCountries.remove(removed.cc)
+ Logger.w(LOG_TAG_PROXY, "$TAG; removed server $removedId (${removed.cc}) was in selected list")
+ }
+ }
+ }
+
+ Logger.i(LOG_TAG_PROXY, "$TAG; fetchAndConstructWinLocations: new=${newServers.size}, existing=${existingIds.size}, removed=${removedIds.size}, removedSelected=${removedSelectedIds.size}")
+
+ return Pair(newServers, removedSelectedIds)
+ }
+
+ /**
+ * Enables a WIN server by its key.
+ * Updates the database and in-memory cache upon successful activation.
+ */
+ suspend fun enableWinServer(key: String): Pair {
+ val config = winServersCache.find { it.key == key }
+
+ if (config != null) {
+ val res = VpnController.addNewWinServer(config.key)
+ if (res.first) {
+ countryConfigRepo.update(config)
+ config.isActive = true
+ Logger.i(LOG_TAG_PROXY, "$TAG; enableWinServer: enabled rpn: $key")
+ } else {
+ Logger.w(LOG_TAG_PROXY, "$TAG; enableWinServer: failed to enable server with key $key, error: ${res.second}")
+ }
+ return res
+ } else {
+ Logger.w(LOG_TAG_PROXY, "$TAG; enableWinServer: server with key $key not found")
+ return Pair(false, "Server with key $key not found")
+ }
+ }
+
+ suspend fun setCatchAllForWinServer(key: String, catchAll: Boolean) {
+ winServersCache.find { it.key == key }?.let {
+ if (!it.isActive && catchAll) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; setCatchAllForWinServer: enabling inactive server $key for catch-all")
+ val res = VpnController.addNewWinServer(it.key)
+ if (res.first) {
+ it.isActive = true
+ Logger.i(LOG_TAG_PROXY, "$TAG; setCatchAllForWinServer: enabled server $key for catch-all")
+ } else {
+ Logger.w(LOG_TAG_PROXY, "$TAG; setCatchAllForWinServer: failed to enable server $key for catch-all, error: ${res.second}")
+ return@let
+ }
+ }
+ it.catchAll = catchAll
+ countryConfigRepo.update(it)
+ Logger.i(LOG_TAG_PROXY, "$TAG; setCatchAllForWinServer: set catchAll=$catchAll for server: $key")
+ } ?: Logger.w(LOG_TAG_PROXY, "$TAG; setCatchAllForWinServer: server with key $key not found")
+ }
+
+ suspend fun setLockdownForWinServer(key: String, lockdown: Boolean) {
+ winServersCache.find { it.key == key }?.let {
+ it.lockdown = lockdown
+ countryConfigRepo.update(it)
+ Logger.i(LOG_TAG_PROXY, "$TAG; setLockdownForWinServer: set lockdown=$lockdown for server: $key")
+ } ?: Logger.w(LOG_TAG_PROXY, "$TAG; setLockdownForWinServer: server with key $key not found")
+ }
+
+ suspend fun disableWinServer(key: String): Pair {
+ val config = winServersCache.find { it.key == key }
+ if (config != null) {
+ val res = VpnController.removeWinServer(config.key)
+ if (res.first) {
+ countryConfigRepo.update(config)
+ config.isActive = false
+ Logger.i(LOG_TAG_PROXY, "$TAG; disableWinServer: disabled rpn: $key")
+ } else {
+ Logger.w(LOG_TAG_PROXY, "$TAG; disableWinServer: failed to disable server with key $key, error: ${res.second}")
+ }
+ return res
+ } else {
+ Logger.w(LOG_TAG_PROXY, "$TAG; disableWinServer: server with key $key not found")
+ return Pair(false, "Server with key $key not found")
+ }
+ }
+
+ /**
+ * Syncs fetched servers with database and cache.
+ * This should be called after fetchAndConstructWinLocations.
+ */
+ suspend fun syncWinServers(servers: Set) {
+ try {
+ // Sync to database (this handles insertions, updates, and deletions)
+ val syncServerList = if (servers.isEmpty()) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; syncWinServers: empty server list, clearing DB")
+ emptyList()
+ } else {
+ servers.toList()
+ }
+ countryConfigRepo.syncServers(syncServerList)
+
+ // Update cache - clear and refill with new data
+ synchronized(winServersCache) {
+ winServersCache.clear()
+ winServersCache.addAll(servers)
+ }
+
+ Logger.i(LOG_TAG_PROXY, "$TAG; syncWinServers: synced ${servers.size} servers to DB & cache")
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; syncWinServers: error - ${e.message}", e)
+ }
+ }
+
+ /**
+ * Refreshes WIN servers from API and returns information about removed servers.
+ * This is useful for UI to detect and notify users about removed selected servers.
+ * Returns a Pair of:
+ * - First: List of all current servers after refresh
+ * - Second: List of CountryConfig objects that were removed and were in the selected list
+ */
+ suspend fun refreshWinServers(): Pair, List> {
+ try {
+ val existingServers = countryConfigRepo.getAllConfigs()
+ val (newServers, removedSelectedIds) = fetchAndConstructWinLocations()
+
+ // Sync to DB and cache
+ syncWinServers(newServers)
+
+ // Find the actual removed server objects for notification
+ val removedServers = existingServers.filter { removedSelectedIds.contains(it.id) }
+
+ Logger.i(LOG_TAG_PROXY, "$TAG; refreshWinServers: refreshed ${newServers.size} servers, ${removedServers.size} selected servers removed")
+ return Pair(newServers.toList(), removedServers)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; refreshWinServers: error - ${e.message}", e)
+ return Pair(emptyList(), emptyList())
+ }
+ }
+
+ suspend fun getAllPossibleConfigIdsForApp(
+ uid: Int,
+ ip: String,
+ port: Int,
+ domain: String,
+ usesMobileNw: Boolean,
+ ssid: String
+ ): List {
+ val block = Backend.Block
+ val proxyIds: MutableList = mutableListOf()
+
+ // --- App-specific WireGuard configs (multi-proxy aware) ---
+ // collect all proxy-ids for this uid and keep only WireGuard ones (wgX)
+ val allProxyIdsForApp = ProxyManager.getProxyIdsForApp(uid)
+ val rpnProxyIdsForApp = allProxyIdsForApp.filter { it.startsWith(ID_RPN_WIN) }
+
+ // app-specific configs may be empty if the app is not configured
+ if (rpnProxyIdsForApp.isNotEmpty()) {
+ for (pid in rpnProxyIdsForApp) {
+ val appProxyPair = canUseConfig(pid, "app($uid)", usesMobileNw, ssid)
+ if (!appProxyPair.second) {
+ // lockdown or block; honor it and stop further processing
+ proxyIds.clear()
+ if (appProxyPair.first == block) {
+ proxyIds.add(block)
+ } else if (appProxyPair.first.isNotEmpty()) {
+ proxyIds.add(appProxyPair.first)
+ }
+ Logger.i(LOG_TAG_PROXY, "lockdown wg for app($uid) => return $proxyIds")
+ return proxyIds
+ }
+ if (appProxyPair.first.isNotEmpty()) {
+ // add eligible app-specific config in the order we see them
+ proxyIds.add(appProxyPair.first)
+ }
+ }
+ }
+
+ // once the app-specific config is added, check if any catch-all config is enabled
+ // if catch-all config is enabled, then add the config id to the list
+ val cac = winServersCache.filter { it.isActive && it.catchAll }
+ cac.forEach {
+ if ((checkEligibilityBasedOnNw(
+ it.id,
+ usesMobileNw
+ ) && checkEligibilityBasedOnSsid(it.id, ssid)) &&
+ !proxyIds.contains(ID_WG_BASE + it.id)
+ ) {
+ proxyIds.add(ID_WG_BASE + it.id)
+ Logger.i(
LOG_TAG_PROXY,
- "$TAG; invalid for activation - state machine in ${currentState.name}"
+ "catch-all config is active: ${it.id}, ${it.name} => add ${ID_WG_BASE + it.id}"
)
- return false
}
+ }
- else -> {
- // Other states are potentially valid
+ // the proxyIds list will contain the ip-app specific, domain-app specific, app specific,
+ // universal ip, universal domain, catch-all and default configs in the order of priority
+ // the go-tun will check the routing based on the order of the list
+ Logger.i(LOG_TAG_PROXY, "returning proxy ids for $uid, $ip, $port, $domain: $proxyIds")
+ return proxyIds
+ }
+
+ private fun isDnsRequest(defaultTid: String): Boolean {
+ return defaultTid == Backend.System || defaultTid == Backend.Plus || defaultTid == Backend.Preferred
+ }
+
+ private suspend fun canUseConfig(
+ id: String,
+ type: String,
+ usesMtrdNw: Boolean,
+ ssid: String
+ ): Pair {
+ if (id.isEmpty()) {
+ return Pair("", true)
+ }
+
+ val config = winServersCache.find { it.id == id }
+
+ if (config == null) {
+ Logger.d(LOG_TAG_PROXY, "config null($id) no need to proceed, return empty")
+ winServersCache.forEach {
+ Logger.d(LOG_TAG_PROXY, "cached wg: ${it.id}, active: ${it.isActive}, lockdown: ${it.lockdown}")
}
+ return Pair("", true)
+ }
+
+ if (config.lockdown && (checkEligibilityBasedOnNw(
+ id,
+ usesMtrdNw
+ ) && checkEligibilityBasedOnSsid(id, ssid))
+ ) {
+ Logger.d(LOG_TAG_PROXY, "lockdown wg for $type => return $id")
+ return Pair(id, false) // no need to proceed further for lockdown
+ }
+
+ // check if the config is active and if it can be used on this network
+ if (config.isActive && (checkEligibilityBasedOnNw(
+ id,
+ usesMtrdNw
+ ) && checkEligibilityBasedOnSsid(id, ssid))
+ ) {
+ Logger.d(LOG_TAG_PROXY, "active wg for $type => add $id")
+ return Pair(id, true)
}
+ Logger.v(
+ LOG_TAG_PROXY,
+ "wg for $type not active or not eligible nw, return empty, for id: $id, usesMtrdNw: $usesMtrdNw, ssid: $ssid"
+ )
+ return Pair("", true)
+ }
+
+ // only when config is set to use on mobile network and current network is not mobile
+ // then return false, all other cases return true
+ private fun checkEligibilityBasedOnNw(id: String, usesMobileNw: Boolean): Boolean {
+ val config = winServersCache.find { it.id == id }
+ if (config == null) {
+ Logger.e(LOG_TAG_PROXY, "canAdd: wg not found, id: $id, ${winServersCache.size}")
+ return false
+ }
+
+ if (config.mobileOnly && !usesMobileNw) {
+ Logger.i(LOG_TAG_PROXY, "canAdd: useOnlyOnMetered is true, but not metered nw")
+ return false
+ }
+
+ Logger.d(LOG_TAG_PROXY, "canAdd: eligible for metered nw: $usesMobileNw")
return true
}
- fun isRpnValidForCurrentSubscription(): Boolean {
- if (!isRpnEnabled()) {
+ private fun checkEligibilityBasedOnSsid(id: String, ssid: String): Boolean {
+ val config = winServersCache.find { it.id == id }
+ if (config == null) {
+ Logger.e(LOG_TAG_PROXY, "canAdd: wg not found, id: $id, ${winServersCache.size}")
return false
}
- val currentState = subscriptionStateMachine.getCurrentState()
- when (currentState) {
- is SubscriptionStateMachineV2.SubscriptionState.Active,
- is SubscriptionStateMachineV2.SubscriptionState.Cancelled -> {
- Logger.i(
+ if (config.ssidBased) {
+ val ssids = "" //config.ssidList
+ val ssidItems = SsidItem.parseStorageList(ssids)
+ if (ssidItems.isEmpty()) { // treat empty as match all
+ Logger.d(
LOG_TAG_PROXY,
- "$TAG; RPN is valid for current subscription state: ${currentState.name}"
+ "canAdd: ssidEnabled is true, but ssid list is empty, match all"
)
return true
}
- is SubscriptionStateMachineV2.SubscriptionState.Revoked,
- is SubscriptionStateMachineV2.SubscriptionState.Expired -> {
- Logger.w(
- LOG_TAG_PROXY,
- "$TAG; RPN should be disabled - subscription ${currentState.name}"
- )
+ val notEqualItems = ssidItems.filter { !it.type.isEqual }
+ val notEqualMatch = notEqualItems.any { ssidItem ->
+ when {
+ ssidItem.type.isExact -> {
+ ssidItem.name.equals(ssid, ignoreCase = true)
+ }
+
+ else -> { // wildcard
+ matchesWildcard(ssidItem.name, ssid)
+ }
+ }
+ }
+
+ if (notEqualMatch) {
+ Logger.d(LOG_TAG_PROXY, "canAdd: ssid matched in NOT_EQUAL items, return false")
return false
}
- else -> {
+ val equalItems = ssidItems.filter { it.type.isEqual }
+ // If there are only NOT_EQUAL items and none matched, return true
+ if (equalItems.isEmpty() && notEqualItems.isNotEmpty()) {
Logger.d(
LOG_TAG_PROXY,
- "$TAG; RPN validity uncertain for state: ${currentState.name}"
+ "canAdd: only NOT_EQUAL items present and none matched, return true"
)
- return true // Allow by default for uncertain states
+ return true
+ }
+
+ // Check EQUAL items (exact or wildcard)
+ val equalMatch = equalItems.any { ssidItem ->
+ when {
+ ssidItem.type.isExact -> {
+ ssidItem.name.equals(ssid, ignoreCase = true)
+ }
+
+ else -> { // wildcard
+ matchesWildcard(ssidItem.name, ssid)
+ }
+ }
+ }
+
+ if (!equalMatch) {
+ Logger.d(LOG_TAG_PROXY, "canAdd: ssid did not match in EQUAL items, return false")
+ return false
}
}
+
+ Logger.d(LOG_TAG_PROXY, "canAdd: eligible for ssid: $ssid")
+ return true
}
- fun getDetailedSubscriptionInfo(): String {
- val statistics = subscriptionStateMachine.getStatistics()
- val subscriptionData = subscriptionStateMachine.getSubscriptionData()
- val currentState = subscriptionStateMachine.getCurrentState()
+ private fun matchesWildcard(pattern: String, text: String): Boolean {
+ // Convert wildcard pattern to regex
+ // * matches any sequence of characters
+ // ? matches any single character
+ val regexPattern = pattern
+ .replace(".", "\\.") // Escape dots
+ .replace("*", ".*") // Convert * to .*
+ .replace("?", ".") // Convert ? to .
+
+ return try {
+ text.matches(Regex(regexPattern, RegexOption.IGNORE_CASE)) || text.contains(
+ pattern,
+ ignoreCase = true
+ )
+ } catch (e: Exception) {
+ Logger.w(LOG_TAG_PROXY, "Invalid wildcard pattern: $pattern, error: ${e.message}")
+ false
+ }
+ }
- return """
- Current State: ${currentState.name}
- RPN Enabled: ${isRpnEnabled()}
- RPN Valid: ${isRpnValidForCurrentSubscription()}
- Total Transitions: ${statistics.totalTransitions}
- Success Rate: ${String.format("%.2f", statistics.successRate * 100)}%
- Product ID: ${subscriptionData?.subscriptionStatus?.productId ?: "None"}
- Subscription Status: ${subscriptionData?.subscriptionStatus?.status?.let { SubscriptionStatus.SubscriptionState.fromId(it).name } ?: "Unknown"}
- Billing Expiry: ${subscriptionData?.subscriptionStatus?.billingExpiry?.let { if (it > 0) java.util.Date(it) else "N/A" } ?: "N/A"}
- Account Expiry: ${subscriptionData?.subscriptionStatus?.accountExpiry?.let { if (it > 0) java.util.Date(it) else "N/A" } ?: "N/A"}
- """.trimIndent()
+ fun matchesSsidList(ssidList: String, ssid: String): Boolean {
+ val ssidItems = SsidItem.parseStorageList(ssidList)
+ if (ssidItems.isEmpty()) { // treat empty as match all
+ return true
+ }
+
+ // Separate EQUAL items from NOT_EQUAL items
+ val equalItems = ssidItems.filter { it.type.isEqual }
+ val notEqualItems = ssidItems.filter { !it.type.isEqual }
+
+ // Check NOT_EQUAL items first - if any match, return false
+ val notEqualMatch = notEqualItems.any { ssidItem ->
+ when {
+ ssidItem.type.isExact -> {
+ ssidItem.name.equals(ssid, ignoreCase = true)
+ }
+
+ else -> { // wildcard
+ matchesWildcard(ssidItem.name, ssid)
+ }
+ }
+ }
+
+ if (notEqualMatch) {
+ return false
+ }
+
+ // If there are only NOT_EQUAL items and none matched, return true
+ if (equalItems.isEmpty() && notEqualItems.isNotEmpty()) {
+ return true
+ }
+
+ // Check EQUAL items (exact or wildcard)
+ return equalItems.any { ssidItem ->
+ when {
+ ssidItem.type.isExact -> {
+ ssidItem.name.equals(ssid, ignoreCase = true)
+ }
+
+ else -> { // wildcard
+ matchesWildcard(ssidItem.name, ssid)
+ }
+ }
+ }
}
- private fun io(f: suspend () -> Unit) {
- CoroutineScope(Dispatchers.IO).launch { f() }
+ // ===== SSID-based connection management for Country Configs =====
+
+ /**
+ * Update SSID based status for a country
+ * @param cc Country code
+ * @param ssidBased Whether SSID-based connection is enabled
+ */
+ suspend fun updateSsidBased(cc: String, ssidBased: Boolean) {
+ try {
+ countryConfigRepo.updateSsidBased(cc, ssidBased)
+
+ // Update cache
+ synchronized(winServersCache) {
+ val config = winServersCache.find { it.cc == cc }
+ if (config != null) {
+ winServersCache.remove(config)
+ winServersCache.add(config.copy(ssidBased = ssidBased, lastModified = System.currentTimeMillis()))
+ }
+ }
+
+ Logger.i(LOG_TAG_PROXY, "$TAG; updateSsidBased: $cc = $ssidBased")
+
+ // Trigger connection monitor to update SSID info if country is selected
+ if (selectedCountries.contains(cc)) {
+ if (ssidBased) {
+ VpnController.notifyConnectionMonitor(enforcePolicyChange = true)
+ }
+ // Refresh proxies to immediately pause/resume based on new SSID setting
+ VpnController.refreshOrPauseOrResumeOrReAddProxies()
+ }
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; error updating SSID based for $cc: ${e.message}", e)
+ }
+ }
+
+ /**
+ * Update SSID list for a country
+ * @param cc Country code
+ * @param ssids JSON string of SSID items
+ */
+ suspend fun updateSsids(cc: String, ssids: String) {
+ try {
+ countryConfigRepo.updateSsids(cc, ssids)
+
+ // Update cache
+ synchronized(winServersCache) {
+ val config = winServersCache.find { it.cc == cc }
+ if (config != null) {
+ winServersCache.remove(config)
+ winServersCache.add(config.copy(ssids = ssids, lastModified = System.currentTimeMillis()))
+ }
+ }
+
+ Logger.i(LOG_TAG_PROXY, "$TAG; updateSsids: $cc, ssids length=${ssids.length}")
+
+ // If country is selected and SSID based enabled, refresh proxies
+ if (selectedCountries.contains(cc)) {
+ val config = winServersCache.find { it.cc == cc }
+ if (config?.ssidBased == true) {
+ VpnController.notifyConnectionMonitor(enforcePolicyChange = true)
+ VpnController.refreshOrPauseOrResumeOrReAddProxies()
+ }
+ }
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; error updating SSIDs for $cc: ${e.message}", e)
+ }
}
+ /**
+ * Get all country configs with SSID enabled
+ */
+ suspend fun getSsidEnabledCountries(): List {
+ return try {
+ countryConfigRepo.getSsidEnabledConfigs()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; error getting SSID enabled countries: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ /**
+ * Get country config by country code
+ */
+ suspend fun getCountryConfig(cc: String): CountryConfig? {
+ return try {
+ synchronized(winServersCache) {
+ winServersCache.find { it.cc == cc }
+ } ?: countryConfigRepo.getConfig(cc)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; error getting country config for $cc: ${e.message}", e)
+ null
+ }
+ }
}
-*/
\ No newline at end of file
diff --git a/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt b/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt
index 87d250ca6..ee3caac4c 100644
--- a/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt
@@ -22,6 +22,7 @@ import Logger
import Logger.LOG_BATCH_LOGGER
import Logger.LOG_GO_LOGGER
import Logger.LOG_TAG_CONNECTION
+import Logger.LOG_TAG_PROXY
import Logger.LOG_TAG_VPN
import android.annotation.SuppressLint
import android.app.ActivityManager
@@ -64,6 +65,9 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
import com.celzero.bravedns.R
import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG
import com.celzero.bravedns.customdownloader.IpInfoDownloader
@@ -77,10 +81,14 @@ import com.celzero.bravedns.database.ConsoleLog
import com.celzero.bravedns.database.EventSource
import com.celzero.bravedns.database.EventType
import com.celzero.bravedns.database.RefreshDatabase
+import com.celzero.bravedns.iab.InAppBillingHandler
+import com.celzero.bravedns.iab.SubscriptionCheckWorker
import com.celzero.bravedns.net.go.GoVpnAdapter
import com.celzero.bravedns.net.manager.ConnectionTracer
import com.celzero.bravedns.receiver.NotificationActionReceiver
import com.celzero.bravedns.receiver.UserPresentReceiver
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
+import com.celzero.bravedns.rpnproxy.RpnProxyManager.rpnMode
import com.celzero.bravedns.scheduler.EnhancedBugReport
import com.celzero.bravedns.service.FirewallManager.NOTIF_CHANNEL_ID_FIREWALL_ALERTS
import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE
@@ -131,6 +139,7 @@ import com.celzero.firestack.backend.DNSSummary
import com.celzero.firestack.backend.DNSTransport
import com.celzero.firestack.backend.Gostr
import com.celzero.firestack.backend.NetStat
+import com.celzero.firestack.backend.Proxy
import com.celzero.firestack.backend.RDNS
import com.celzero.firestack.backend.RouterStats
import com.celzero.firestack.backend.ServerSummary
@@ -172,7 +181,9 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
+import kotlin.collections.toSet
import kotlin.coroutines.cancellation.CancellationException
+import kotlin.jvm.java
import kotlin.math.abs
import kotlin.math.min
import kotlin.random.Random
@@ -1548,7 +1559,6 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
}
-
private fun registerUserPresentReceiver() {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_USER_PRESENT)
@@ -1940,7 +1950,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
return START_STICKY
}
- /*private suspend fun checkForPlusSubscription() {
+ private suspend fun checkForPlusSubscription() {
// initiate the billing client if it is not already initialized
if (!InAppBillingHandler.isBillingClientSetup()) {
InAppBillingHandler.initiate(this.applicationContext)
@@ -2025,7 +2035,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
Logger.w(LOG_TAG_VPN, "$TAG handleRpnProxies: win(rpn) no update needed")
return
}
- // if the bytes are null, then it means the win is either failer to update or
+ // if the bytes are null, then it means the win is either failed to update or
// no update is needed
val updated = RpnProxyManager.updateWinConfigState(bytes)
if (!updated) {
@@ -2052,7 +2062,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
val res = vpnAdapter?.setRpnAutoMode()
logd("set rpn mode to: ${rpnMode()}, set? $res")
handleRpnProxies()
- }*/
+ }
@SuppressLint("ForegroundServiceType")
@RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -2415,7 +2425,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
PersistentState.NETWORK_ENGINE_EXPERIMENTAL -> {
io("networkEngineExperimental") {
- setExperimentalSettings(persistentState.nwEngExperimentalFeatures)
+ setExperimentalWireGuardSettings(persistentState.nwEngExperimentalFeatures)
}
}
PersistentState.USE_RPN -> {
@@ -2508,9 +2518,9 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
}
- private suspend fun setExperimentalSettings(experimental: Boolean) {
- Logger.i(LOG_TAG_VPN, "set experimental settings: $experimental")
- vpnAdapter?.setExperimentalSettings(experimental)
+ private suspend fun setExperimentalWireGuardSettings(experimental: Boolean) {
+ Logger.i(LOG_TAG_VPN, "set experimental wg settings: $experimental")
+ vpnAdapter?.setExperimentalWireGuardSettings(experimental)
}
private suspend fun undelegatedDomains() {
@@ -2596,7 +2606,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
vpnAdapter?.addTransport()
}
- private fun handleIPProtoChanges() {
+ private suspend fun handleIPProtoChanges() {
Logger.i(LOG_TAG_VPN, "handle ip proto changes")
if (InternetProtocol.isAuto(persistentState.internetProtocolType)) {
// initiates connectivity checks if Auto mode and calls onNetworkConnected
@@ -2862,7 +2872,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
GoVpnAdapter.setLogLevel(persistentState.goLoggerLevel.toInt())
vpnAdapter = GoVpnAdapter(ctx, vpnScope, fd, ifaceAddresses, mtu, nwMtu, opts) // may throw
vpnAdapter?.initResolverProxiesPcap(opts)
- //checkForPlusSubscription()
+ checkForPlusSubscription()
return@withContext ok
} else {
Logger.i(LOG_TAG_VPN, "vpn-adapter exists, fd: $fd, policy: ${restartPolicy.name}, lockdown: $lockdown, protos: $protos, mtu: $mtu, nwMtu: $nwMtu")
@@ -4535,15 +4545,15 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
}
- /* private fun getRpnIds(): String {
+ private fun getRpnIds(): String {
// not needed as caller is already checking for rpn active
if (!RpnProxyManager.isRpnActive()) return ""
val mode = rpnMode()
- val ids = RpnMode.getPreferredId(mode.id)
+ val ids = RpnProxyManager.RpnMode.getPreferredId(mode.id)
Logger.vv(LOG_TAG_VPN, "getRpnIds; state:${RpnProxyManager.rpnState().name}, mode: ${mode.name}, ids: $ids")
return ids
- }*/
+ }
override fun onResponse(summary: DNSSummary?) {
if (summary == null) {
@@ -5253,6 +5263,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
val endpoint = appConfig.getSocks5ProxyDetails()
if (endpoint == null) {
Logger.e(LOG_TAG_VPN, "flow: socks5 proxy enabled but endpoint is null")
+ return false
}
val packageName = FirewallManager.getPackageNameByUid(uid)
logd("flow/inflow: socks5 proxy is enabled, $packageName, ${endpoint?.proxyAppName}")
@@ -5315,6 +5326,22 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
val connId = connTracker.connId
val uid = connTracker.uid
+ // add baseOrExit in the end of the list if needed (not true for lockdown)
+ val ssid = underlyingNetworks?.activeSsid ?: underlyingNetworks?.ipv4Net?.firstOrNull { it.ssid != null }?.ssid ?: underlyingNetworks?.ipv6Net?.firstOrNull() { it.ssid != null }?.ssid ?: ""
+ val usesMobileNw = isIfaceCellular(connTracker.destIP)
+
+ val rpnIds = if (RpnProxyManager.isRpnActive() && DEBUG) {
+ RpnProxyManager.getAllPossibleConfigIdsForApp(
+ uid,
+ connTracker.destIP,
+ connTracker.destPort,
+ connTracker.query ?: "",
+ usesMobileNw,
+ ssid
+ )
+ } else {
+ emptyList()
+ }
if (connTracker.uid == rethinkUid && !rinr) {
val pid = if (persistentState.autoProxyEnabled) Backend.Auto else Backend.Exit
@@ -5329,8 +5356,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
return persistAndConstructFlowResponse(connTracker, baseOrExit, connId, uid)
}
- // add baseOrExit in the end of the list if needed (not true for lockdown)
- val ssid = underlyingNetworks?.activeSsid ?: underlyingNetworks?.ipv4Net?.firstOrNull { it.ssid != null }?.ssid ?: underlyingNetworks?.ipv6Net?.firstOrNull { it.ssid != null }?.ssid ?: ""
+
val wgs = WireguardManager.getAllPossibleConfigIdsForApp(uid, connTracker.destIP, connTracker.destPort, connTracker.query ?: "", true, ssid, baseOrExit)
if (wgs.isNotEmpty() && wgs.first() != baseOrExit) {
// canRoute may fail for all configs.
@@ -5341,12 +5367,15 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
connTracker.isBlocked = true
connTracker.blockedByRule = FirewallRuleset.RULE17.id
}
- val ids = wgs.joinToString(",")
+ var ids = wgs.joinToString(",")
if (ids.isEmpty()) { // should not happen as wgs is not empty
logd("flow/inflow: wg ids is empty, returning $baseOrExit, $connId, $uid")
return persistAndConstructFlowResponse(connTracker, baseOrExit, connId, uid)
} else {
logd("flow/inflow: wg is active, returning $wgs, $connId, $uid")
+ if (rpnIds.isNotEmpty()) {
+ ids = rpnIds.joinToString(",") + "," + ids
+ }
return persistAndConstructFlowResponse(connTracker, ids, connId, uid)
}
} else {
@@ -5375,9 +5404,14 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
// pass-through
} else {
logd("flow/inflow: orbot proxy for $uid, $connId")
+ val pid = if (rpnIds.isNotEmpty()) {
+ rpnIds.joinToString(",") + "," + ProxyManager.ID_ORBOT_BASE
+ } else {
+ ProxyManager.ID_ORBOT_BASE
+ }
return persistAndConstructFlowResponse(
connTracker,
- ProxyManager.ID_ORBOT_BASE,
+ pid,
connId,
uid
)
@@ -5387,6 +5421,9 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
// chose socks5 proxy over http proxy
if (appConfig.isCustomSocks5Enabled()) {
val endpoint = appConfig.getSocks5ProxyDetails()
+ if (endpoint == null) {
+ Logger.e(LOG_TAG_VPN, "flow: socks5 proxy enabled but endpoint is null")
+ }
val packageName = FirewallManager.getPackageNameByUid(uid)
if (endpoint == null) {
Logger.e(LOG_TAG_VPN, "flow: socks5 proxy enabled but endpoint is null")
@@ -5400,9 +5437,14 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
logd("flow/inflow: socks5 proxy for $connId, $uid")
+ val pid = if (rpnIds.isNotEmpty()) {
+ rpnIds.joinToString(",") + "," + ProxyManager.ID_S5_BASE
+ } else {
+ ProxyManager.ID_S5_BASE
+ }
return persistAndConstructFlowResponse(
connTracker,
- ProxyManager.ID_S5_BASE,
+ pid,
connId,
uid
)
@@ -5519,7 +5561,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
return vpnAdapter?.getRDNS(type)
}
- private fun persistAndConstructFlowResponse(
+ private suspend fun persistAndConstructFlowResponse(
cm: ConnTrackerMetaData?,
proxyIds: String,
connId: String,
@@ -5528,8 +5570,8 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
// override exit in case of rethink plus subscription
// case: do not override the proxyId in case of rethink as rethink's traffic should
// always use Exit proxy not the rpn proxy
- // val iid = proxyIds
- /*if (proxyId == Backend.Exit && RpnProxyManager.isRpnActive() && !isRethink) {
+ /* val iid = proxyIds
+ if (proxyId == Backend.Exit && RpnProxyManager.isRpnActive() && !isRethink) {
val rpnId = getRpnIds()
logd("flow/inflow: returning $rpnId for connId: $connId, uid: $uid")
io("checkPlusSub") {
@@ -5540,6 +5582,11 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
proxyId
}*/
+ // added for testing, remove it later
+ if (RpnProxyManager.isRpnActive() && DEBUG) {
+ io("PlusTest") { initiatePlusSubscriptionCheckIfRequired() }
+
+ }
if (cm != null) {
// in case of multiple proxies we do not need to write the log as we are not sure
@@ -5588,7 +5635,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
return mark
}
- /*private suspend fun initiatePlusSubscriptionCheckIfRequired() {
+ private suspend fun initiatePlusSubscriptionCheckIfRequired() {
// consider enableWarp as the flag to check the plus subscription
if (!RpnProxyManager.isRpnEnabled()) {
Logger.i(LOG_TAG_VPN, "initiatePlusSubscriptionCheckIfRequired(rpn): plus not enabled")
@@ -5596,14 +5643,15 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
}
// initiate the check once in 4 hours, store last check time in local variable
val currentTime = System.currentTimeMillis()
- if (currentTime - lastSubscriptionCheckTime < PLUS_CHECK_INTERVAL) {
- Logger.v(LOG_TAG_VPN, "initiatePlusSubscriptionCheckIfRequired(rpn): check not required")
+ val checkTimeMs = if (DEBUG) 1 * 60 * 1000L else PLUS_CHECK_INTERVAL
+ if (currentTime - lastSubscriptionCheckTime < checkTimeMs) {
+ Logger.v(LOG_TAG_VPN, "initiatePlusSubscriptionCheckIfRequired(rpn): check not required, currentTime: $currentTime, lastCheckTime: $lastSubscriptionCheckTime")
return
}
// initiate the check
lastSubscriptionCheckTime = currentTime
checkForPlusSubscription()
- }*/
+ }
private suspend fun processFirewallRequest(
metadata: ConnTrackerMetaData,
@@ -5804,14 +5852,18 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
return vpnAdapter?.removeHop(src) ?: Pair(false, "vpn not active")
}
- /*suspend fun getRpnProps(type: RpnProxyManager.RpnType): Pair {
+ suspend fun getRpnProps(type: RpnProxyManager.RpnType): Pair {
return vpnAdapter?.getRpnProps(type) ?: Pair(null, null)
- }*/
+ }
suspend fun registerAndFetchWinIfNeeded(prevBytes: ByteArray?): ByteArray? {
return vpnAdapter?.registerAndFetchWinIfNeeded(prevBytes)
}
+ suspend fun isWinRegistered(): Boolean {
+ return vpnAdapter?.isWinRegistered() == true
+ }
+
suspend fun updateWin(): ByteArray? {
return vpnAdapter?.updateWin()
}
@@ -5819,10 +5871,10 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
suspend fun createWgHop(origin: String, hop: String): Pair {
return (vpnAdapter?.createHop(origin, hop)) ?: Pair(false, "adapter is null")
}
-/*
+
suspend fun updateRpnProxy(type: RpnProxyManager.RpnType): ByteArray? {
return vpnAdapter?.updateRpnProxy(type)
- }*/
+ }
suspend fun vpnStats(): String {
// create a string with the stats, add stats of firewall, dns, proxy, builder
@@ -5865,6 +5917,18 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
return appConfig.stats()
}
+ suspend fun getWinByKey(key: String): Proxy? {
+ return vpnAdapter?.getWinByKey(key)
+ }
+
+ suspend fun addNewWinServer(key: String): Pair {
+ return vpnAdapter?.addNewWinServer(key) ?: Pair(false, "adapter is null")
+ }
+
+ suspend fun removeWinServer(key: String): Pair {
+ return vpnAdapter?.removeWinServer(key) ?: Pair(false, "adapter is null")
+ }
+
private fun proxyStats(): String {
return ProxyManager.stats()
}
@@ -5910,8 +5974,8 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
val ipv4NwHandles = n.underlyingNws?.ipv4Net?.map { netid(it.network.networkHandle) } ?: emptyList()
val ipv6NwHandles = n.underlyingNws?.ipv6Net?.map { netid(it.network.networkHandle) } ?: emptyList()
- val linkAddresses4 = n.underlyingNws?.ipv4Net?.map { it.linkProperties?.linkAddresses?.filter { i -> IPAddressString(i.address.hostAddress).isIPv4 } } ?: emptyList()
- val linkAddresses6 = n.underlyingNws?.ipv6Net?.map { it.linkProperties?.linkAddresses?.filter { i -> IPAddressString(i.address.hostAddress).isIPv6 } } ?: emptyList()
+ val linkAddresses4 = n.underlyingNws?.ipv4Net?.map { it.linkProperties?.linkAddresses?.filter { IPAddressString(it.address.hostAddress).isIPv4 } } ?: emptyList()
+ val linkAddresses6 = n.underlyingNws?.ipv6Net?.map { it.linkProperties?.linkAddresses?.filter { IPAddressString(it.address.hostAddress).isIPv6 } } ?: emptyList()
val link4Mtu = if (isAtleastQ()) n.underlyingNws?.ipv4Net?.map { it.linkProperties?.mtu ?: 0 } ?: listOf(-1) else listOf(-1)
val link6Mtu = if (isAtleastQ()) n.underlyingNws?.ipv6Net?.map { it.linkProperties?.mtu ?: 0 } ?: listOf(-1) else listOf(-1)
val ssid = getUnderlyingSsid() ?: "N/A"
@@ -5919,12 +5983,12 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Bridge,
val linkAddr4String = if (linkAddresses4.isEmpty()) {
"N/A"
} else {
- linkAddresses4.joinToString(", ") { it?.joinToString(", ") { addr -> addr.address.hostAddress.orEmpty() }.orEmpty() }.ifEmpty { "N/A" }
+ linkAddresses4.joinToString(", ") { it?.joinToString(", ") { addr -> addr.address.hostAddress } ?: "N/A" }
}
val linkAddr6String = if (linkAddresses6.isEmpty()) {
"N/A"
} else {
- linkAddresses6.joinToString(", ") { it?.joinToString(", ") { addr -> addr.address.hostAddress.orEmpty() }.orEmpty() }.ifEmpty { "N/A" }
+ linkAddresses6.joinToString(", ") { it?.joinToString(", ") { addr -> addr.address.hostAddress } ?: "N/A" }
}
val vpnServiceLockdown = if (isAtleastQ()) {
isLockdownEnabled
diff --git a/app/src/main/java/com/celzero/bravedns/service/CountryConfigManager.kt b/app/src/main/java/com/celzero/bravedns/service/CountryConfigManager.kt
new file mode 100644
index 000000000..7dbc3bf25
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/service/CountryConfigManager.kt
@@ -0,0 +1,405 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.service
+
+import Logger
+import Logger.LOG_TAG_VPN
+import com.celzero.bravedns.database.CountryConfig
+import com.celzero.bravedns.database.CountryConfigRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+object CountryConfigManager : KoinComponent {
+
+ private const val TAG = "CountryConfigMgr"
+
+ private val db: CountryConfigRepository by inject()
+ private val scope = CoroutineScope(Dispatchers.IO)
+ private val mutex = Mutex()
+
+ private val _allConfigsFlow = MutableStateFlow>(emptyList())
+ val allConfigsFlow: StateFlow> = _allConfigsFlow.asStateFlow()
+
+ private val _catchAllConfigsFlow = MutableStateFlow>(emptyList())
+ val catchAllConfigsFlow: StateFlow> = _catchAllConfigsFlow.asStateFlow()
+
+ private val _lockdownConfigsFlow = MutableStateFlow>(emptyList())
+ val lockdownConfigsFlow: StateFlow> = _lockdownConfigsFlow.asStateFlow()
+
+ private val _isInitialized = MutableStateFlow(false)
+ val isInitialized: StateFlow = _isInitialized.asStateFlow()
+
+ fun initialize() {
+ if (_isInitialized.value) {
+ Logger.d(LOG_TAG_VPN, "$TAG: Already initialized")
+ return
+ }
+
+ try {
+ scope.launch {
+ db.getAllConfigsFlow().collect { configs ->
+ _allConfigsFlow.value = configs
+ }
+ }
+
+ scope.launch {
+ db.getCatchAllConfigsFlow().collect { configs ->
+ _catchAllConfigsFlow.value = configs
+ }
+ }
+
+ scope.launch {
+ db.getLockdownConfigsFlow().collect { configs ->
+ _lockdownConfigsFlow.value = configs
+ }
+ }
+
+ _isInitialized.value = true
+ Logger.i(LOG_TAG_VPN, "$TAG: Initialized successfully")
+
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: Initialization failed: ${e.message}", e)
+ }
+ }
+
+ suspend fun getConfig(cc: String): CountryConfig? = withContext(Dispatchers.IO) {
+ try {
+ db.getConfig(cc)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getConfig($cc) failed: ${e.message}", e)
+ null
+ }
+ }
+
+ fun getConfigFlow(cc: String): Flow {
+ return db.getConfigFlow(cc)
+ }
+
+ suspend fun getAllConfigs(): List = withContext(Dispatchers.IO) {
+ try {
+ db.getAllConfigs()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getAllConfigs failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun getEnabledConfigs(): List = withContext(Dispatchers.IO) {
+ try {
+ db.getEnabledConfigs()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getEnabledConfigs failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun getConfigsByPriority(): List = withContext(Dispatchers.IO) {
+ try {
+ db.getConfigsByPriority()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getConfigsByPriority failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun getCatchAllCountries(): List = withContext(Dispatchers.IO) {
+ try {
+ db.getCatchAllCountryCodes()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getCatchAllCountries failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun getLockdownCountries(): List = withContext(Dispatchers.IO) {
+ try {
+ db.getLockdownCountryCodes()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getLockdownCountries failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun getMobileOnlyCountries(): List = withContext(Dispatchers.IO) {
+ try {
+ db.getMobileOnlyCountryCodes()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getMobileOnlyCountries failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun getSsidBasedCountries(): List = withContext(Dispatchers.IO) {
+ try {
+ db.getSsidBasedCountryCodes()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getSsidBasedCountries failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun canCountryBeUsed(
+ cc: String,
+ isMobileData: Boolean = false,
+ currentSsid: String? = null
+ ): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val config = getConfig(cc) ?: return@withContext true
+ val preferredCcs = getLockdownCountries()
+ config.canBeUsed(isMobileData, currentSsid, preferredCcs)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: canCountryBeUsed($cc) failed: ${e.message}", e)
+ true
+ }
+ }
+
+ suspend fun getEligibleCountries(
+ isMobileData: Boolean = false,
+ currentSsid: String? = null
+ ): List = withContext(Dispatchers.IO) {
+ try {
+ val allConfigs = getEnabledConfigs()
+ val preferredCcs = getLockdownCountries()
+ allConfigs
+ .filter { it.canBeUsed(isMobileData, currentSsid, preferredCcs) }
+ .sortedByDescending { it.priority }
+ .map { it.cc }
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getEligibleCountries failed: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ suspend fun getBestCountry(
+ isMobileData: Boolean = false,
+ currentSsid: String? = null
+ ): String? = withContext(Dispatchers.IO) {
+ try {
+ val eligible = getEligibleCountries(isMobileData, currentSsid)
+ eligible.firstOrNull()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getBestCountry failed: ${e.message}", e)
+ null
+ }
+ }
+
+ suspend fun upsertConfig(config: CountryConfig): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.insert(config.copy(lastModified = System.currentTimeMillis()))
+ Logger.d(LOG_TAG_VPN, "$TAG: Upserted config for ${config.cc}")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: upsertConfig(${config.cc}) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun upsertConfigs(configs: List): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ val updated = configs.map { it.copy(lastModified = System.currentTimeMillis()) }
+ db.insertAll(updated)
+ Logger.d(LOG_TAG_VPN, "$TAG: Upserted ${configs.size} configs")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: upsertConfigs failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun deleteConfig(cc: String): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.deleteByCountryCode(cc)
+ Logger.d(LOG_TAG_VPN, "$TAG: Deleted config for $cc")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: deleteConfig($cc) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun updateCatchAll(cc: String, value: Boolean): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ if (value) {
+ db.clearAllCatchAll()
+ }
+ db.updateCatchAll(cc, value)
+ Logger.d(LOG_TAG_VPN, "$TAG: Updated catchAll for $cc = $value")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: updateCatchAll($cc, $value) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun updateLockdown(cc: String, value: Boolean): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.updateLockdown(cc, value)
+ Logger.d(LOG_TAG_VPN, "$TAG: Updated lockdown for $cc = $value")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: updateLockdown($cc, $value) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun updateMobileOnly(cc: String, value: Boolean): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.updateMobileOnly(cc, value)
+ Logger.d(LOG_TAG_VPN, "$TAG: Updated mobileOnly for $cc = $value")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: updateMobileOnly($cc, $value) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun updateSsidBased(cc: String, value: Boolean): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.updateSsidBased(cc, value)
+ Logger.d(LOG_TAG_VPN, "$TAG: Updated ssidBased for $cc = $value")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: updateSsidBased($cc, $value) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun updateEnabled(cc: String, value: Boolean): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.updateEnabled(cc, value)
+ Logger.d(LOG_TAG_VPN, "$TAG: Updated enabled for $cc = $value")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: updateEnabled($cc, $value) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun updatePriority(cc: String, priority: Int): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.updatePriority(cc, priority)
+ Logger.d(LOG_TAG_VPN, "$TAG: Updated priority for $cc = $priority")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: updatePriority($cc, $priority) failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun clearAllCatchAll(): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.clearAllCatchAll()
+ Logger.d(LOG_TAG_VPN, "$TAG: Cleared all catchAll settings")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: clearAllCatchAll failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun clearAllLockdown(): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.clearAllLockdown()
+ Logger.d(LOG_TAG_VPN, "$TAG: Cleared all lockdown settings")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: clearAllLockdown failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun deleteAllConfigs(): Boolean = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ try {
+ db.deleteAll()
+ Logger.d(LOG_TAG_VPN, "$TAG: Deleted all configs")
+ true
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: deleteAllConfigs failed: ${e.message}", e)
+ false
+ }
+ }
+ }
+
+ suspend fun exists(cc: String): Boolean = withContext(Dispatchers.IO) {
+ try {
+ db.exists(cc)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: exists($cc) failed: ${e.message}", e)
+ false
+ }
+ }
+
+ suspend fun isEnabled(cc: String): Boolean = withContext(Dispatchers.IO) {
+ try {
+ db.isEnabled(cc)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: isEnabled($cc) failed: ${e.message}", e)
+ false
+ }
+ }
+
+ suspend fun getCount(): Int = withContext(Dispatchers.IO) {
+ try {
+ db.getCount()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getCount failed: ${e.message}", e)
+ 0
+ }
+ }
+
+ suspend fun getEnabledCount(): Int = withContext(Dispatchers.IO) {
+ try {
+ db.getEnabledCount()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG: getEnabledCount failed: ${e.message}", e)
+ 0
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/celzero/bravedns/service/FirewallManager.kt b/app/src/main/java/com/celzero/bravedns/service/FirewallManager.kt
index 36507e240..807720534 100644
--- a/app/src/main/java/com/celzero/bravedns/service/FirewallManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/FirewallManager.kt
@@ -709,8 +709,8 @@ object FirewallManager : KoinComponent {
mutex.withLock {
appInfos.clear()
apps.forEach { appInfos.put(it.uid, it) }
- informObservers()
}
+ informObservers()
return apps.size
}
@@ -925,7 +925,8 @@ object FirewallManager : KoinComponent {
fun stats(): String {
// add count of apps in each firewall status
val statusCount = HashMap()
- appInfos.values().forEach {
+ // snapshot under lock to avoid iterator faults from concurrent writes
+ runBlockingWithMutex { appInfos.values().toList() }.forEach {
val status = FirewallStatus.getStatus(it.firewallStatus)
statusCount[status] = (statusCount[status] ?: 0) + 1
}
@@ -950,10 +951,25 @@ object FirewallManager : KoinComponent {
data class AppInfoTuple(val uid: Int, val packageName: String)
+ private fun snapshotAppInfos(): List {
+ // take a stable snapshot under lock to avoid concurrent modification during LiveData post
+ return try {
+ runBlockingWithMutex { appInfos.values().toList() }
+ } catch (e: NoSuchElementException) {
+ // Guava's CompactHashSet iterator can throw if mutated mid-iteration; retry on a fresh view
+ Logger.w(LOG_TAG_FIREWALL, "snapshot retry after iterator fault: ${e.message}", e)
+ runBlockingWithMutex { appInfos.asMap().values.flatten() }
+ }
+ }
+
+ private fun runBlockingWithMutex(block: suspend () -> T): T {
+ return kotlinx.coroutines.runBlocking { mutex.withLock { block() } }
+ }
+
private fun informObservers() {
// existing code expects this to broadcast appInfos snapshot.
// Use a snapshot to avoid exposing internal live collections.
- appInfosLiveData.postValue(appInfos.values().toList())
+ appInfosLiveData.postValue(snapshotAppInfos())
}
fun isUnknownPackage(uid: Int): Boolean {
diff --git a/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt b/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt
index 590f03ed8..4973df8b9 100644
--- a/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt
@@ -23,6 +23,7 @@ import androidx.lifecycle.MutableLiveData
import com.celzero.bravedns.R
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.DnsCryptRelayEndpoint
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
import com.celzero.bravedns.ui.activity.AntiCensorshipActivity
import com.celzero.bravedns.util.Constants
import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS
@@ -364,7 +365,7 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
var rpnMode by intPref("rpn_mode").withDefault(1)
// current rpn state, see enum RpnState
- //var rpnState by intPref("rpn_state").withDefault(RpnProxyManager.RpnState.DISABLED.id)
+ var rpnState by intPref("rpn_state").withDefault(RpnProxyManager.RpnState.DISABLED.id)
// subscribe product id for the current user, empty string if not subscribed
var rpnProductId by stringPref("rpn_product_id").withDefault("")
diff --git a/app/src/main/java/com/celzero/bravedns/service/VpnController.kt b/app/src/main/java/com/celzero/bravedns/service/VpnController.kt
index f59eef654..bc53107dc 100644
--- a/app/src/main/java/com/celzero/bravedns/service/VpnController.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/VpnController.kt
@@ -27,12 +27,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.celzero.bravedns.R
import com.celzero.bravedns.database.ConsoleLog
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
import com.celzero.bravedns.util.Constants.Companion.INVALID_UID
import com.celzero.bravedns.util.Utilities
import com.celzero.firestack.backend.DNSTransport
import com.celzero.firestack.backend.NetStat
import com.celzero.firestack.backend.RDNS
import com.celzero.firestack.backend.RouterStats
+import com.celzero.firestack.backend.Proxy
import com.celzero.firestack.intra.Controller
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -388,6 +390,10 @@ object VpnController : KoinComponent {
return braveVpnService?.registerAndFetchWinIfNeeded(prevBytes)
}
+ suspend fun isWinRegistered(): Boolean {
+ return braveVpnService?.isWinRegistered() ?: false
+ }
+
suspend fun createWgHop(origin: String, hop: String): Pair {
return (braveVpnService?.createWgHop(origin, hop) ?: Pair(false, "vpn service not available"))
}
@@ -408,10 +414,18 @@ object VpnController : KoinComponent {
return braveVpnService?.removeHop(src) ?: Pair(false, "vpn service not available")
}
- /*suspend fun getRpnProps(type: RpnProxyManager.RpnType): Pair {
+ suspend fun getRpnProps(type: RpnProxyManager.RpnType): Pair {
return braveVpnService?.getRpnProps(type) ?: Pair(null, null)
}
-*/
+
+ suspend fun addNewWinServer(key: String): Pair {
+ return braveVpnService?.addNewWinServer(key) ?: Pair(false, "vpn service not available")
+ }
+
+ suspend fun removeWinServer(key: String): Pair {
+ return braveVpnService?.removeWinServer(key) ?: Pair(false, "vpn service not available")
+ }
+
suspend fun vpnStats(): String? {
return braveVpnService?.vpnStats()
}
@@ -451,4 +465,8 @@ object VpnController : KoinComponent {
suspend fun performFlightRecording() {
braveVpnService?.performFlightRecording()
}
+
+ suspend fun getWinByKey(key: String): Proxy? {
+ return braveVpnService?.getWinByKey(key)
+ }
}
diff --git a/app/src/main/java/com/celzero/bravedns/subscription/StateMachineDatabaseSyncService.kt b/app/src/main/java/com/celzero/bravedns/subscription/StateMachineDatabaseSyncService.kt
index dae055649..648a28d7d 100644
--- a/app/src/main/java/com/celzero/bravedns/subscription/StateMachineDatabaseSyncService.kt
+++ b/app/src/main/java/com/celzero/bravedns/subscription/StateMachineDatabaseSyncService.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.subscription
-/*
+
import Logger.LOG_IAB
import com.android.billingclient.api.BillingResult
import com.celzero.bravedns.database.SubscriptionStateHistory
@@ -402,4 +402,3 @@ class StateMachineDatabaseSyncService : KoinComponent {
val timestamp: Long
)
}
-*/
diff --git a/app/src/main/java/com/celzero/bravedns/subscription/SubscriptionStateMachineV2.kt b/app/src/main/java/com/celzero/bravedns/subscription/SubscriptionStateMachineV2.kt
index 640b17d44..739009525 100644
--- a/app/src/main/java/com/celzero/bravedns/subscription/SubscriptionStateMachineV2.kt
+++ b/app/src/main/java/com/celzero/bravedns/subscription/SubscriptionStateMachineV2.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.subscription
-/*
+
import Logger.LOG_IAB
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
@@ -1174,4 +1174,3 @@ class SubscriptionStateMachineV2 : KoinComponent {
CoroutineScope(Dispatchers.IO).launch { f() }
}
}
-*/
diff --git a/app/src/main/java/com/celzero/bravedns/util/MemoryUtils.kt b/app/src/main/java/com/celzero/bravedns/util/MemoryUtils.kt
index 867d659a3..bcf03f192 100644
--- a/app/src/main/java/com/celzero/bravedns/util/MemoryUtils.kt
+++ b/app/src/main/java/com/celzero/bravedns/util/MemoryUtils.kt
@@ -81,7 +81,7 @@ object MemoryUtils {
val debugMemInfo = Debug.MemoryInfo()
Debug.getMemoryInfo(debugMemInfo)
- val totalPssBytes = debugMemInfo.totalPss * BYTES_TO_KB
+ val totalPssBytes = debugMemInfo.totalPss * 1024L
// "summary.native-heap" etc return values in KB
val nativePssBytes = (debugMemInfo.getMemoryStat("summary.native-heap")?.toLongOrNull() ?: 0L) * BYTES_TO_KB
diff --git a/app/src/main/res/anim/fade_scale_in.xml b/app/src/main/res/anim/fade_scale_in.xml
new file mode 100644
index 000000000..94aee37bf
--- /dev/null
+++ b/app/src/main/res/anim/fade_scale_in.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/anim/slide_in_bottom.xml b/app/src/main/res/anim/slide_in_bottom.xml
new file mode 100644
index 000000000..9c4c4893f
--- /dev/null
+++ b/app/src/main/res/anim/slide_in_bottom.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/color/bottom_nav_icon_color.xml b/app/src/main/res/color/bottom_nav_icon_color.xml
new file mode 100644
index 000000000..ae538936f
--- /dev/null
+++ b/app/src/main/res/color/bottom_nav_icon_color.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_card_ripple.xml b/app/src/main/res/drawable/bg_card_ripple.xml
new file mode 100644
index 000000000..d83376a67
--- /dev/null
+++ b/app/src/main/res/drawable/bg_card_ripple.xml
@@ -0,0 +1,11 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_premium_header_gradient.xml b/app/src/main/res/drawable/bg_premium_header_gradient.xml
new file mode 100644
index 000000000..27df6c444
--- /dev/null
+++ b/app/src/main/res/drawable/bg_premium_header_gradient.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_vpn_header_gradient.xml b/app/src/main/res/drawable/bg_vpn_header_gradient.xml
new file mode 100644
index 000000000..9b09cb7d1
--- /dev/null
+++ b/app/src/main/res/drawable/bg_vpn_header_gradient.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_vpn_header_gradient_dark.xml b/app/src/main/res/drawable/bg_vpn_header_gradient_dark.xml
new file mode 100644
index 000000000..a8ec9b43b
--- /dev/null
+++ b/app/src/main/res/drawable/bg_vpn_header_gradient_dark.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_vpn_premium_gradient.xml b/app/src/main/res/drawable/bg_vpn_premium_gradient.xml
new file mode 100644
index 000000000..91b477848
--- /dev/null
+++ b/app/src/main/res/drawable/bg_vpn_premium_gradient.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_vpn_status_chip.xml b/app/src/main/res/drawable/bg_vpn_status_chip.xml
new file mode 100644
index 000000000..8afb83edc
--- /dev/null
+++ b/app/src/main/res/drawable/bg_vpn_status_chip.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/chip_bg_positive.xml b/app/src/main/res/drawable/chip_bg_positive.xml
new file mode 100644
index 000000000..26498f58e
--- /dev/null
+++ b/app/src/main/res/drawable/chip_bg_positive.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/gradient_premium_bg.xml b/app/src/main/res/drawable/gradient_premium_bg.xml
new file mode 100644
index 000000000..88d59e73a
--- /dev/null
+++ b/app/src/main/res/drawable/gradient_premium_bg.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml
index 1a22f601f..85d81af1b 100644
--- a/app/src/main/res/drawable/ic_arrow_down.xml
+++ b/app/src/main/res/drawable/ic_arrow_down.xml
@@ -4,10 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
+ android:fillColor="?attr/svgStrokeColor"
+ android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z" />
diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml
index acfeeb0ce..20e1b57be 100644
--- a/app/src/main/res/drawable/ic_arrow_up.xml
+++ b/app/src/main/res/drawable/ic_arrow_up.xml
@@ -5,6 +5,5 @@
android:viewportHeight="24">
+ android:pathData="M7.41,15.41L12,10.83l4.59,4.58L18,14l-6,-6 -6,6z"/>
diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml
new file mode 100644
index 000000000..9eb9cbb37
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check_circle.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_circle.xml b/app/src/main/res/drawable/ic_circle.xml
new file mode 100644
index 000000000..48bf65457
--- /dev/null
+++ b/app/src/main/res/drawable/ic_circle.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_cloud_error.xml b/app/src/main/res/drawable/ic_cloud_error.xml
new file mode 100644
index 000000000..ef2c33b2b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud_error.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 000000000..2050e8fff
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_rethink_plus_gradient.xml b/app/src/main/res/drawable/ic_rethink_plus_gradient.xml
new file mode 100644
index 000000000..851bc4e9a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_rethink_plus_gradient.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_world_map.xml b/app/src/main/res/drawable/ic_world_map.xml
new file mode 100644
index 000000000..2c6310781
--- /dev/null
+++ b/app/src/main/res/drawable/ic_world_map.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shimmer_placeholder.xml b/app/src/main/res/drawable/shimmer_placeholder.xml
new file mode 100644
index 000000000..e18239a67
--- /dev/null
+++ b/app/src/main/res/drawable/shimmer_placeholder.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/shimmer_placeholder_light.xml b/app/src/main/res/drawable/shimmer_placeholder_light.xml
new file mode 100644
index 000000000..6c87bbfec
--- /dev/null
+++ b/app/src/main/res/drawable/shimmer_placeholder_light.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_location_selector.xml b/app/src/main/res/layout/activity_location_selector.xml
deleted file mode 100644
index f5f8d2eaf..000000000
--- a/app/src/main/res/layout/activity_location_selector.xml
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_rethink_plus_dashboard.xml b/app/src/main/res/layout/activity_rethink_plus_dashboard.xml
index b80cd6196..794d142fb 100644
--- a/app/src/main/res/layout/activity_rethink_plus_dashboard.xml
+++ b/app/src/main/res/layout/activity_rethink_plus_dashboard.xml
@@ -30,7 +30,7 @@
android:alpha="0.5"
android:paddingStart="7dp"
android:paddingEnd="7dp"
- android:text="" />
+ android:text="@string/rethink_plus_title" />
+ android:text="@string/rethink_plus_title_desc" />
@@ -142,7 +142,7 @@
android:layout_height="wrap_content"
android:paddingStart="5dp"
android:paddingEnd="5dp"
- android:text=""
+ android:text="@string/rethink_off"
android:textColor="?attr/primaryTextColor"
android:textSize="@dimen/large_font_text_view" />
@@ -152,7 +152,7 @@
android:layout_height="wrap_content"
android:paddingStart="5dp"
android:paddingEnd="5dp"
- android:text=""
+ android:text="@string/rethink_off_desc"
android:textColor="?attr/primaryLightColorText"
android:textSize="@dimen/default_font_text_view" />
@@ -286,7 +286,7 @@
android:layout_height="wrap_content"
android:paddingStart="5dp"
android:paddingEnd="5dp"
- android:text=""
+ android:text="@string/rethink_plus_hide_ip_title"
android:textColor="?attr/primaryTextColor"
android:textSize="@dimen/large_font_text_view" />
@@ -296,7 +296,7 @@
android:layout_height="wrap_content"
android:paddingStart="5dp"
android:paddingEnd="5dp"
- android:text=""
+ android:text="@string/rethink_plus_hide_ip_desc"
android:textColor="?attr/primaryLightColorText"
android:textSize="@dimen/default_font_text_view" />
@@ -384,7 +384,7 @@
android:fontFamily="sans-serif-smallcaps"
android:lineSpacingExtra="5dp"
android:padding="5dp"
- android:text=""
+ android:text="@string/rethink_plus_troubleshoot_title"
android:textColor="?attr/accentGood"
android:textSize="@dimen/extra_large_font_text_view"
android:textStyle="bold" />
@@ -433,7 +433,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="10dp"
- android:text=""
+ android:text="@string/rethink_plus_perform_test"
android:textSize="@dimen/large_font_text_view" />
@@ -443,7 +443,7 @@
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:paddingBottom="5dp"
- android:text=""
+ android:text="@string/rethink_plus_test_desc"
android:textSize="@dimen/default_font_text_view" />
@@ -494,7 +494,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="10dp"
- android:text=""
+ android:text="@string/manage_subscription_title"
android:textSize="@dimen/large_font_text_view" />
@@ -504,7 +504,7 @@
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:paddingBottom="5dp"
- android:text=""
+ android:text="@string/manage_subscription_title"
android:textSize="@dimen/default_font_text_view" />
@@ -555,7 +555,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="10dp"
- android:text=""
+ android:text="@string/payment_history_title"
android:textSize="@dimen/large_font_text_view" />
@@ -565,7 +565,7 @@
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:paddingBottom="5dp"
- android:text=""
+ android:text="@string/payment_history_title"
android:textSize="@dimen/default_font_text_view" />
@@ -616,7 +616,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="10dp"
- android:text=""
+ android:text="@string/rethink_plus_report_issue"
android:textSize="@dimen/large_font_text_view" />
@@ -626,7 +626,7 @@
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:paddingBottom="5dp"
- android:text=""
+ android:text="@string/rethink_plus_report_desc"
android:textSize="@dimen/default_font_text_view" />
diff --git a/app/src/main/res/layout/activity_server_wg_detail.xml b/app/src/main/res/layout/activity_server_wg_detail.xml
new file mode 100644
index 000000000..6eaf41de4
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_wg_detail.xml
@@ -0,0 +1,640 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/bottomsheet_purchase_processing.xml b/app/src/main/res/layout/bottomsheet_purchase_processing.xml
new file mode 100644
index 000000000..ccfe497c7
--- /dev/null
+++ b/app/src/main/res/layout/bottomsheet_purchase_processing.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/bottomsheet_server_removal_notification.xml b/app/src/main/res/layout/bottomsheet_server_removal_notification.xml
new file mode 100644
index 000000000..3714b441f
--- /dev/null
+++ b/app/src/main/res/layout/bottomsheet_server_removal_notification.xml
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_country_ssid_premium.xml b/app/src/main/res/layout/dialog_country_ssid_premium.xml
new file mode 100644
index 000000000..80ac2127e
--- /dev/null
+++ b/app/src/main/res/layout/dialog_country_ssid_premium.xml
@@ -0,0 +1,382 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_server_wg_peer_edit.xml b/app/src/main/res/layout/dialog_server_wg_peer_edit.xml
new file mode 100644
index 000000000..b14841d5b
--- /dev/null
+++ b/app/src/main/res/layout/dialog_server_wg_peer_edit.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_rethink_plus.xml b/app/src/main/res/layout/fragment_rethink_plus.xml
index e94ac4746..220761e32 100644
--- a/app/src/main/res/layout/fragment_rethink_plus.xml
+++ b/app/src/main/res/layout/fragment_rethink_plus.xml
@@ -92,28 +92,10 @@
android:clickable="true"
android:focusable="true"
android:padding="15dp"
- android:text=""
+ android:text="@string/rethink_terms"
android:textColor="?attr/primaryLightColorText"
android:textSize="@dimen/small_font_text_view" />
-
-
-
@@ -145,7 +127,7 @@
android:id="@+id/tvPendingMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text=""
+ android:text="@string/payment_processing_desc"
android:textAlignment="center"
android:layout_marginTop="8dp"
android:paddingHorizontal="16dp" />
@@ -177,7 +159,7 @@
android:id="@+id/title_unavailable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text=""
+ android:text="@string/rethink_plus_title"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginTop="24dp"
@@ -188,7 +170,7 @@
android:id="@+id/desc_unavailable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text=""
+ android:text="@string/rethink_plus_not_available_desc"
android:textSize="16sp"
android:layout_marginTop="16dp"
android:textAlignment="center"
@@ -241,7 +223,7 @@
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp"
android:letterSpacing="0.15"
- android:text=""
+ android:text="@string/subscribe_title"
android:textColor="?attr/chipTextColor"
android:background="@drawable/rounded_corners_button_accent"
android:textSize="18sp" />
diff --git a/app/src/main/res/layout/fragment_rethink_plus_premium.xml b/app/src/main/res/layout/fragment_rethink_plus_premium.xml
new file mode 100644
index 000000000..1ac0c489e
--- /dev/null
+++ b/app/src/main/res/layout/fragment_rethink_plus_premium.xml
@@ -0,0 +1,339 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_server_selection.xml b/app/src/main/res/layout/fragment_server_selection.xml
new file mode 100644
index 000000000..cf292af32
--- /dev/null
+++ b/app/src/main/res/layout/fragment_server_selection.xml
@@ -0,0 +1,534 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_removed_server.xml b/app/src/main/res/layout/item_removed_server.xml
new file mode 100644
index 000000000..027753d23
--- /dev/null
+++ b/app/src/main/res/layout/item_removed_server.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_server.xml b/app/src/main/res/layout/item_server.xml
index 1d0944c0f..118ee15e3 100644
--- a/app/src/main/res/layout/item_server.xml
+++ b/app/src/main/res/layout/item_server.xml
@@ -1,50 +1,48 @@
+ android:minHeight="48dp">
-
+
+ android:textSize="14sp"
+ android:textColor="?android:attr/textColorPrimary"
+ android:ellipsize="end"
+ android:maxLines="1"
+ tools:text="New York" />
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_country_card.xml b/app/src/main/res/layout/list_item_country_card.xml
similarity index 72%
rename from app/src/main/res/layout/item_country_card.xml
rename to app/src/main/res/layout/list_item_country_card.xml
index 24f9f6008..ba2c81b5a 100644
--- a/app/src/main/res/layout/item_country_card.xml
+++ b/app/src/main/res/layout/list_item_country_card.xml
@@ -5,8 +5,14 @@
android:id="@+id/card_country"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_margin="8dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginBottom="6dp"
app:cardCornerRadius="12dp"
+ app:cardUseCompatPadding="false"
+ app:cardElevation="2dp"
+ app:strokeColor="@android:color/transparent"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground">
@@ -21,16 +27,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
- android:padding="16dp"
+ android:paddingStart="14dp"
+ android:paddingEnd="14dp"
+ android:paddingTop="12dp"
+ android:paddingBottom="12dp"
android:gravity="center_vertical">
-
+
-
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_item_play_subs.xml b/app/src/main/res/layout/list_item_play_subs.xml
index fdd441526..7d22ac7ea 100644
--- a/app/src/main/res/layout/list_item_play_subs.xml
+++ b/app/src/main/res/layout/list_item_play_subs.xml
@@ -1,75 +1,200 @@
+ android:focusable="true"
+ android:foreground="?attr/selectableItemBackground"
+ app:cardCornerRadius="20dp"
+ app:cardElevation="4dp"
+ app:strokeWidth="2dp"
+ app:strokeColor="@android:color/transparent"
+ app:cardBackgroundColor="?attr/background">
-
+ android:padding="20dp">
-
+
+ android:visibility="gone"
+ app:cardBackgroundColor="?attr/accentGood"
+ app:cardCornerRadius="12dp"
+ app:cardElevation="0dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ tools:visibility="visible">
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+ android:text="@string/price_placeholder"
+ android:textColor="?attr/accentGood"
+ android:textSize="32sp"
+ android:textStyle="bold"
+ tools:text="$4.99" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_rpn_win_proxy.xml b/app/src/main/res/layout/list_item_rpn_win_proxy.xml
deleted file mode 100644
index 46a2143d4..000000000
--- a/app/src/main/res/layout/list_item_rpn_win_proxy.xml
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/list_item_server_wg_peers.xml b/app/src/main/res/layout/list_item_server_wg_peers.xml
new file mode 100644
index 000000000..18d7f5b18
--- /dev/null
+++ b/app/src/main/res/layout/list_item_server_wg_peers.xml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_vpn_server.xml b/app/src/main/res/layout/list_item_vpn_server.xml
new file mode 100644
index 000000000..c2b175bfd
--- /dev/null
+++ b/app/src/main/res/layout/list_item_vpn_server.xml
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_wg_peer_server.xml b/app/src/main/res/layout/list_item_wg_peer_server.xml
new file mode 100644
index 000000000..4ba981a8e
--- /dev/null
+++ b/app/src/main/res/layout/list_item_wg_peer_server.xml
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/shimmer_subscription_loading.xml b/app/src/main/res/layout/shimmer_subscription_loading.xml
new file mode 100644
index 000000000..c84ab8dfa
--- /dev/null
+++ b/app/src/main/res/layout/shimmer_subscription_loading.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml
index 9ec8c19f5..473579695 100644
--- a/app/src/main/res/menu/bottom_nav_menu.xml
+++ b/app/src/main/res/menu/bottom_nav_menu.xml
@@ -10,11 +10,11 @@
android:icon="@drawable/ic_statistics"
android:title="@string/title_statistics" />
-
+ android:title="@string/rethink_plus_title" />
-
-
+ android:name="com.celzero.bravedns.ui.fragment.ServerSelectionFragment"
+ android:label="Rethink Plus Dashboard" />
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index b7089f632..229ff9f53 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -47,4 +47,7 @@
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index e3bc256b9..f36560559 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -101,6 +101,11 @@
#FDFDFD
#393939
+
+ #006f67
+ #18ffff
+ #80CBC4
+
#ececec
#1e88e5
@@ -170,6 +175,11 @@
#000000
#fafafa
+
+ #0277BD
+ #039BE5
+ #29B6F6
+
#383838
#1b1818
@@ -232,6 +242,11 @@
#FDFDFD
#121212
+
+ #004D40
+ #00897B
+ #4fc3f7
+
#d2fff5
#4c796f
@@ -302,6 +317,10 @@
#000000
#e6f4f2
+
+ #00796B
+ #21a095
+ #4DB6AC
#2D404E
@@ -368,6 +387,11 @@
#D0E1F2
#0E1B21
+
+ #004D40
+ #00695C
+ #00A859
+
#383838
#1b1818
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 751355d24..6e5b14b16 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -260,7 +260,6 @@
protected with proxy & private dns
protected with socks5 & private dns
-
protected with http proxy & private dns
protected with socks proxy
@@ -360,7 +359,7 @@
Settings
Turn off Private DNS
- Rethink\'s DNS settings are overridden by Android\'s Private DNS.\n\nTo use Rethink\'s DNS go to Android Settings and turn off \"Private DNS\".
+ Rethink\'s DNS settings are overridden by Android\'s Private DNS.\n\nTo use Rethink\'s DNS go to Android Settings and turn off "Private DNS".
Go to Settings
universal
@@ -1921,6 +1920,13 @@
Matches partially
Wi-Fi identifiers (%1$s) must be 32 characters or less.
Delete "%1$s"?
+ Add SSID
+ SSID name cannot be empty
+ SSID already exists in the list
+ Delete SSID
+ Are you sure you want to delete "%1$s"?
+ Action
+ Match Type
Stability program
Capture error logs to help improve stability.
@@ -1994,4 +2000,232 @@
Could not save custom LAN IPs
Could not open Custom LAN IPs dialog
Invalid IP addresses. Ensure:\n• IPv4 must be private (10.x.x.x, 172.16-31.x.x, or 192.168.x.x)\n• IPv6 must be unique local (fc00::/7)\n• Both IP and prefix must be provided together
+ VPN not active
+ Rethink is not started. Please start to use this feature.
+
+ Error creating Amnezia config
+ Error registering SE to tunnel
+
+ Strength
+
+ Rethink+
+ Anti-censorship, and anti-surveillance
+
+ Hide your IP
+ Hide your IP address from websites and apps.
+
+ troubleshoot
+
+ Perform test
+ Test your connection to Rethink+ proxies.
+
+ Report issue
+ Report an issue with Rethink+.
+
+ Rethink+ availability
+
+ Congratulations!
+ You are now subscribed to Rethink+.
+
+ Purchase
+
+ Not available
+
+ Already Rethink+ member
+
+ Dashboard
+
+ Payment History
+
+ Manage Subscription
+
+ Contact Support
+
+ Pause Rethink+
+ Resume Rethink+
+
+ Subscribe
+
+ Resubscribe
+
+
+ Off
+ Off
+
+ Rethink Plus is active, fallback dns is not supported
+
+ Error loading manage subscription
+
+ Rethink+ is not available for your device.\n reason: %1$s
+
+ No Internet Connection
+
+ Google Play Services is not available on your device.
+
+ Error fetching product details
+
+ Loading
+ "Please wait while we check the availability of Rethink+.
+
+ Payment processing
+ Your payment is being processed. You’ll get access once it completes.
+
+ Checking Rethink+ status
+
+ Error purchasing Rethink+, would you like to try again?
+ Use this
+
+ There seems to be an issue with Rethink+ availability on your device. Please try again later or contact support.
+ Last refresh: %1$s
+ [Rethink_plus]: Report Issue
+ By subscribing to Rethink Plus you agree to the <a href="https://rethinkdns.com/terms">Rethink Terms of Service</a> and <a href="https://rethinkdns.com/privacy">Privacy Policy</a>.
+
+
+ Enhanced privacy, speed, and security
+ Choose Your Plan
+ Select the plan that works best for you
+ Subscribe Now
+ Purchase Now
+ Subscription
+ One-Time
+ BEST VALUE
+ Monthly
+ $0.00
+ /month
+ /year
+ Was %1$s
+ Cancel anytime
+ Free trial available
+ Save %1$s
+ Please select a plan first
+ Your subscription has been activated!
+
+
+ Processing Purchase
+ Please wait while we process your purchase…
+ Verifying Purchase
+ Verifying with Google Play Store…
+ Purchase Successful!
+ Purchase Failed
+ Something went wrong. Please try again.
+ Continue
+ Close
+ Retry
+ Error
+ Unable to load subscription plans
+
+
+
+
+
+ Select VPN Servers
+ Search countries or locations…
+ Clear search
+ No servers selected
+ Select up to 5 servers
+ Maximum %1$d servers can be selected
+ No servers found
+ Try adjusting your search or check back later
+ No servers available to connect. Please check your subscription or try again later.
+ No Servers Available
+ We couldn\'t find any servers to connect to. Please check your subscription status or try refreshing.
+ Retry
+ Loading…
+ Tap a server to expand and select
+ All Servers
+ Current Location
+ Up to 5 WireGuard connections can be active at the same time
+
+ Connected
+ Disconnected
+ Connecting…
+
+ %1$s • %2$s
+
+ Latency
+ Upload
+ Download
+ Country: %1$s
+ Latency: %1$s
+
+ Secure
+ Multi-hop
+ Split IP
+
+ %1$s / %2$s
+
+
+
- No servers selected
+ - %1$d server selected
+ - %1$d servers selected
+
+
+
+ - %1$d server
+ - %1$d servers
+
+
+
+ Server Location Removed
+ This location is no longer available
+ I Understand
+ Removed
+ Notification Icon
+
+
+ WiFi Settings for %1$s
+ %1$s to %2$s when connected to %3$s that %4$s match the list below
+ Add New SSID
+ Configured SSIDs
+ No SSIDs configured yet
+ SSID Icon
+
+
+ Peers
+ Server Configuration
+ Server Configuration (Read-only)
+ e.g. 1420
+ e.g. 25 (seconds)
+ Not set
+ MTU cannot be empty
+ MTU must be between 1280 and 1500
+ MTU updated successfully
+ Listen port updated successfully
+ Error updating configuration
+ Persistent keepalive must be between 0 and 65535
+ Peer updated successfully
+ Edit Peer Keepalive
+ Modify the persistent keepalive interval for this peer. This determines how often keepalive packets are sent to maintain the connection.
+
+
+ Server Configuration
+
+
+ Country Configuration
+ Use this country only on mobile data
+ SSID Based
+ Use based on WiFi network
+
+
+ Configuration saved successfully
+ Failed to update configuration
+ Failed to save configuration
+
+
+
+ - %d peer
+ - %d peers
+
+
+ Seconds
+
+
+ Settings
+ Force all traffic through this server
+
+
+ Connection
+ Quick Actions
+
+ Subscription processing is taking longer than expected. Please check your internet connection and try again.
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 462783c1c..a08ce01ed 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -87,6 +87,9 @@
- @color/colorPrimaryDark
- @color/colorPrimary
+ - @color/vpnHeaderGradientStart
+ - @color/vpnHeaderGradientCenter
+ - @color/vpnHeaderGradientEnd
- @style/Widget.Rethink.MaterialCardView
@@ -162,6 +165,9 @@
- @color/colorPrimaryDarkLight
- @color/accentGoodLight
+ - @color/vpnHeaderGradientStartLight
+ - @color/vpnHeaderGradientCenterLight
+ - @color/vpnHeaderGradientEndLight
- @style/Widget.Rethink.MaterialCardView
@@ -238,6 +244,9 @@
- @color/homeScreenBtnBackgroundBlack
- @color/colorPrimaryDarkBlack
+ - @color/vpnHeaderGradientStartBlack
+ - @color/vpnHeaderGradientCenterBlack
+ - @color/vpnHeaderGradientEndBlack
- @style/Widget.Rethink.MaterialCardView
@@ -313,6 +322,9 @@
- @color/accentGoodLightPlus
- @color/colorPrimaryDarkLightPlus
+ - @color/vpnHeaderGradientStartLightPlus
+ - @color/vpnHeaderGradientCenterLightPlus
+ - @color/vpnHeaderGradientEndLightPlus
- @style/Widget.Rethink.MaterialCardView
@@ -389,6 +401,9 @@
- @color/colorPrimaryDarkBlackPlus
- @color/colorBackgroundFloatingBlackPlus
+ - @color/vpnHeaderGradientStartBlackPlus
+ - @color/vpnHeaderGradientCenterBlackPlus
+ - @color/vpnHeaderGradientEndBlackPlus
- @style/Widget.Rethink.MaterialCardView
@@ -465,6 +480,9 @@
- @color/homeScreenBtnBackgroundBlack
- @color/colorPrimaryDarkBlack
+ - @color/vpnHeaderGradientStartBlack
+ - @color/vpnHeaderGradientCenterBlack
+ - @color/vpnHeaderGradientEndBlack
- @style/Widget.Rethink.MaterialCardView.Frost
diff --git a/app/src/play/java/com/celzero/bravedns/adapter/GooglePlaySubsAdapter.kt b/app/src/play/java/com/celzero/bravedns/adapter/GooglePlaySubsAdapter.kt
index d74f56fcb..eb6e0ec3b 100644
--- a/app/src/play/java/com/celzero/bravedns/adapter/GooglePlaySubsAdapter.kt
+++ b/app/src/play/java/com/celzero/bravedns/adapter/GooglePlaySubsAdapter.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.adapter
-/*
+
import Logger.LOG_IAB
import Logger.LOG_TAG_UI
import android.content.Context
@@ -23,6 +23,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
+import com.android.billingclient.api.BillingClient.ProductType
import com.celzero.bravedns.R
import com.celzero.bravedns.databinding.ListItemPlaySubsBinding
import com.celzero.bravedns.databinding.ListItemShimmerCardBinding
@@ -38,18 +39,25 @@ class GooglePlaySubsAdapter(val listener: SubscriptionChangeListener, val contex
private const val SHIMMER_ITEM_COUNT = 2
private const val VIEW_TYPE_SHIMMER = 0
private const val VIEW_TYPE_REAL = 1
+ private const val RETHINK_TITLE = " (Rethink: DNS + Firewall + VPN)"
}
-
override fun getItemViewType(position: Int): Int {
- return if (showShimmer) VIEW_TYPE_SHIMMER else VIEW_TYPE_REAL
+ return if (showShimmer) {
+ VIEW_TYPE_SHIMMER
+ } else {
+ VIEW_TYPE_REAL
+ }
+ }
+
+ override fun getItemCount(): Int {
+ return if (showShimmer) SHIMMER_ITEM_COUNT else pds.size
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
-
return if (viewType == VIEW_TYPE_SHIMMER) {
val binding = ListItemShimmerCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)
ShimmerViewHolder(binding)
@@ -71,10 +79,13 @@ class GooglePlaySubsAdapter(val listener: SubscriptionChangeListener, val contex
holder: RecyclerView.ViewHolder,
position: Int
) {
- if (holder is ShimmerViewHolder) {
- holder.shimmerLayout.startShimmer()
- } else if (holder is SubscriptionPlansViewHolder) {
- holder.bind(pds[position], position)
+ when (holder) {
+ is ShimmerViewHolder -> {
+ holder.shimmerLayout.startShimmer()
+ }
+ is SubscriptionPlansViewHolder -> {
+ holder.bind(pds[position], position)
+ }
}
}
@@ -86,15 +97,12 @@ class GooglePlaySubsAdapter(val listener: SubscriptionChangeListener, val contex
}
fun setData(data: List) {
+ Logger.d(LOG_TAG_UI, "setData called with ${data.size} products, showShimmer: $showShimmer -> false")
this.pds = data
showShimmer = false
notifyDataSetChanged()
}
- override fun getItemCount(): Int {
- return if (showShimmer) SHIMMER_ITEM_COUNT else pds.size
- }
-
inner class ShimmerViewHolder(private val binding: ListItemShimmerCardBinding) : RecyclerView.ViewHolder(binding.root) {
val shimmerLayout: ShimmerFrameLayout = binding.shimmerViewContainer
}
@@ -103,72 +111,183 @@ class GooglePlaySubsAdapter(val listener: SubscriptionChangeListener, val contex
RecyclerView.ViewHolder(binding.root) {
fun bind(prod: ProductDetail, pos: Int) {
- // remove (Rethink: DNS + Firewall + VPN) from the title
val pricing = prod.pricingDetails.firstOrNull() ?: return
- val title = pricing.planTitle.replace(" (Rethink: DNS + Firewall + VPN)", "")
- var offerPrice = ""
- var originalPrice = ""
+
/**
- * sample
+ * sample
* [productDetail=ProductDetail(productId=test_product_acp, planId=test-proxy-yearly, productTitle=test_product_acp (Rethink: DNS + Firewall + VPN), productType=subs, pricingDetails=[PricingPhase(recurringMode=ORIGINAL, price=₹1,700, currencyCode=INR, planTitle=Yearly, billingCycleCount=0, billingPeriod=P1Y, priceAmountMicros=1700000000, freeTrialPeriod=0)]), productDetails=ProductDetails{jsonString='{"productId":"test_product_acp","type":"subs","title":"test_product_acp (Rethink: DNS + Firewall + VPN)","name":"test_product_acp","localizedIn":["en-GB"],"skuDetailsToken":"AEuhp4JS1isixh_29bFQ6VAUZIGGn46BJIo2_Vdg5EpA40dZnrVghFzBmVEOCGhoBDTY","subscriptionOfferDetails":[{"offerIdToken":"Aezw0slCKFuN3u6CLJqjmmCXlPvzNEDtWjWlaD4dLU71Z2xdPT337FKuo8z5Q\/3dPfd8A5GAY7JiV9TByoq1EBYQRIYcIlKt\/bUO","basePlanId":"test-proxy-yearly","pricingPhases":[{"priceAmountMicros":1700000000,"priceCurrencyCode":"INR","formattedPrice":"₹1,700.00","billingPeriod":"P1Y","recurrenceMode":1}],"offerTags":[]},{"offerIdToken":"Aezw0sk0TeTw2PEG172yjUKkSak0JtKFsIcSLQUrKJDRkkSOnFxZvvhgFlLOlOp\/Jge6TIvquUNLNbQ0U5BR0wZ3PnTYUfZwnhZ2","basePlanId":"test-proxy-monthly","pricingPhases":[{"priceAmountMicros":210000000,"priceCurrencyCode":"INR","formattedPrice":"₹210.00","billingPeriod":"P1M","recurrenceMode":1}],"offerTags":[]}]}', parsedJson={"productId":"test_product_acp","type":"subs","title":"test_product_acp (Rethink: DNS + Firewall + VPN)","name":"test_product_acp","localizedIn":["en-GB"],"skuDetailsToken":"AEuhp4JS1isixh_29bFQ6VAUZIGGn46BJIo2_Vdg5EpA40dZnrVghFzBmVEOCGhoBDTY","subscriptionOfferDetails":[{"offerIdToken":"Aezw0slCKFuN3u6CLJqjmmCXlPvzNEDtWjWlaD4dLU71Z2xdPT337FKuo8z5Q\/3dPfd8A5GAY7JiV9TByoq1EBYQRIYcIlKt\/bUO","basePlanId":"test-proxy-yearly","pricingPhases":[{"priceAmountMicros":1700000000,"priceCurrencyCode":"INR","formattedPrice":"₹1,700.00","billingPeriod":"P1Y","recurrenceMode":1}],"offerTags":[]},{"offerIdToken":"Aezw0sk0TeTw2PEG172yjUKkSak0JtKFsIcSLQUrKJDRkkSOnFxZvvhgFlLOlOp\/Jge6TIvquUNLNbQ0U5BR0wZ3PnTYUfZwnhZ2","basePlanId":"test-proxy-monthly","pricingPhases":[{"priceAmountMicros":210000000,"priceCurrencyCode":"INR","formattedPrice":"₹210.00","billingPeriod":"P1M","recurrenceMode":1}],"offerTags":[]}]}, productId='test_product_acp', productType='subs', title='test_product_acp (Rethink: DNS + Firewall + VPN)', productDetailsToken='AEuhp4JS1isixh_29bFQ6VAUZIGGn46BJIo2_Vdg5EpA40dZnrVghFzBmVEOCGhoBDTY', subscriptionOfferDetails=[com.android.billingclient.api.ProductDetails$SubscriptionOfferDetails@83478a9, com.android.billingclient.api.ProductDetails$SubscriptionOfferDetails@6d10e2e]}, offerDetails=com.android.billingclient.api.ProductDetails$SubscriptionOfferDetails@83478a9), QueryProductDetail(productDetail=ProductDetail(productId=test_product_acp, planId=test-proxy-monthly, productTitle=test_product_acp (Rethink: DNS + Firewall + VPN), productType=subs, pricingDetails=[PricingPhase(recurringMode=ORIGINAL, price=₹210, currencyCode=INR, planTitle=Monthly, billingCycleCount=0, billingPeriod=P1M, priceAmountMicros=210000000, freeTrialPeriod=0)]), productDetails=ProductDetails{jsonString='{"productId":"test_product_acp","type":"subs","title":"test_product_acp (Rethink: DNS + Firewall + VPN)","name":"test_product_acp","localizedIn":["en-GB"],"skuDetailsToken":"AEuhp4JS1isixh_29bFQ6VAUZIGGn46BJIo2_Vdg5EpA40dZnrVghFzBmVEOCGhoBDTY","subscriptionOfferDetails":[{"offerIdToken":"Aezw0slCKFuN3u6CLJqjmmCXlPvzNEDtWjWlaD4dLU71Z2xdPT337FKuo8z5Q\/3dPfd8A5GAY7JiV9TByoq1EBYQRIYcIlKt\/bUO","basePlanId":"test-proxy-yearly","pricingPhases":[{"priceAmountMicros":1700000000,"priceCurrencyCode":"INR","formattedPrice":"₹1,700.00","billingPeriod":"P1Y","recurrenceMode":1}],"offerTags":[]},{"offerIdToken":"Aezw0sk0TeTw2PEG172yjUKkSak0JtKFsIcSLQUrKJDRkkSOnFxZvvhgFlLOlOp\/Jge6TIvquUNLNbQ0U5BR0wZ3PnTYUfZwnhZ2","basePlanId":"test-proxy-monthly","pricingPhases":[{"priceAmountMicros":210000000,"priceCurrencyCode":"INR","formattedPrice":"₹21
*
* Pricing Phase: DISCOUNTED, Price: ₹189, Currency: INR, Plan Title: Monthly, Billing Period: P1M, Price Amount Micros: 189000000, Free Trial Period: 0, cycleCount: 1
* Pricing Phase: ORIGINAL, Price: ₹210, Currency: INR, Plan Title: Monthly, Billing Period: P1M, Price Amount Micros: 210000000, Free Trial Period: 0, cycleCount: 0
*/
+ // Extract plan title and clean it
+ val planTitle = pricing.planTitle
- prod.pricingDetails.forEach {
- if (it.freeTrialPeriod > 0) {
- offerPrice = "Trial period: ${it.freeTrialPeriod} days"
- } else if (it.recurringMode == InAppBillingHandler.RecurringMode.DISCOUNTED && it.price.isNotEmpty()) {
- offerPrice = it.price
- } else if (it.recurringMode == InAppBillingHandler.RecurringMode.ORIGINAL && it.price.isNotEmpty()) {
- originalPrice = it.price
+ // Variables for pricing logic
+ var currentPrice = ""
+ var discountedPrice = ""
+ var freeTrialDays = 0
+ var isYearly = false
+
+ // Parse pricing details
+ prod.pricingDetails.forEach { phase ->
+ when {
+ phase.freeTrialPeriod > 0 -> {
+ freeTrialDays = phase.freeTrialPeriod
+ }
+ phase.recurringMode == InAppBillingHandler.RecurringMode.DISCOUNTED -> {
+ discountedPrice = phase.price
+ }
+ phase.recurringMode == InAppBillingHandler.RecurringMode.ORIGINAL -> {
+ currentPrice = phase.price
+ isYearly = phase.billingPeriod.contains("Y")
+ }
}
}
- if (offerPrice.isEmpty()) {
- binding.originalPrice.visibility = View.GONE
- binding.offerPrice.text = originalPrice
+ // Determine final price to display
+ val displayPrice = discountedPrice.ifEmpty { currentPrice }
+
+ // 1. Plan Duration
+ binding.planDuration.text = planTitle
+
+ // 2. Badge - Show "BEST VALUE" for yearly plans
+ if (isYearly) {
+ binding.badgeContainer.visibility = View.VISIBLE
+ binding.badgeText.text = context.getString(R.string.best_value)
+ } else {
+ binding.badgeContainer.visibility = View.GONE
+ }
+
+ // 3. Price Display
+ binding.price.text = displayPrice
+
+ // 4. Price Period
+ val periodText = if (isYearly) {
+ context.getString(R.string.per_year)
} else {
- binding.offerPrice.text = offerPrice
+ context.getString(R.string.per_month)
+ }
+ binding.pricePeriod.text = periodText
+
+ // 5. Original Price (if discounted)
+ if (discountedPrice.isNotEmpty() && currentPrice.isNotEmpty()) {
binding.originalPrice.visibility = View.VISIBLE
- binding.originalPrice.paintFlags = binding.offerPrice.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
- binding.originalPrice.text = originalPrice
+ binding.originalPrice.text = context.getString(R.string.original_price, currentPrice)
+ binding.originalPrice.paintFlags = binding.originalPrice.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
+ } else {
+ binding.originalPrice.visibility = View.GONE
+ }
+
+ // 6. Billing Info
+ val billingText = getBillingText(prod.productType, pricing.billingPeriod)
+ binding.billingInfo.text = billingText
+
+ // 7. Free Trial Container
+ if (freeTrialDays > 0) {
+ binding.trialContainer.visibility = View.VISIBLE
+ binding.trialText.text = "$freeTrialDays days free trial"
+ } else {
+ binding.trialContainer.visibility = View.GONE
}
- binding.productName.text = title
+ // 8. Savings Badge - Calculate savings for yearly plans
+ if (isYearly && discountedPrice.isNotEmpty()) {
+ binding.savingsText.visibility = View.VISIBLE
+ // Calculate approximate savings percentage
+ val savingsPercentage = calculateSavings(currentPrice, discountedPrice)
+ if (savingsPercentage > 0) {
+ binding.savingsText.text = context.getString(R.string.save_percentage, "${savingsPercentage}%")
+ } else {
+ binding.savingsText.visibility = View.GONE
+ }
+ } else {
+ binding.savingsText.visibility = View.GONE
+ }
+
+ // 9. Selection Indicator
+ binding.selectionIndicator.isChecked = (selectedPos == pos)
+
+ // 10. Card Stroke for selection
setCardStroke(selectedPos == pos)
- Logger.d(LOG_TAG_UI, "Product Title: $title (${selectedPos == pos}), Price: $originalPrice, Offer: $offerPrice")
+
+ Logger.d(LOG_TAG_UI, "Premium Plan: $planTitle, Price: $displayPrice, Yearly: $isYearly, Selected: ${selectedPos == pos}")
+
+ // Setup click listeners
setupClickListeners(prod, pos)
}
+ private fun calculateSavings(originalPrice: String, discountedPrice: String): Int {
+ return try {
+ // Extract numeric values from price strings
+ val original = originalPrice.replace(Regex("[^0-9.]"), "").toDoubleOrNull() ?: 0.0
+ val discounted = discountedPrice.replace(Regex("[^0-9.]"), "").toDoubleOrNull() ?: 0.0
+
+ if (original > 0 && discounted > 0) {
+ val savings = ((original - discounted) / original * 100).toInt()
+ savings
+ } else {
+ 0
+ }
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_UI, "err calculating savings: ${e.message}")
+ 0
+ }
+ }
+
private fun setupClickListeners(prod: ProductDetail, pos: Int) {
- binding.subsCard.setOnClickListener {
- // update the selected item
+ binding.planCard.setOnClickListener {
+ // Update selected position
selectedPos = pos
- // inform the activity about the selected item
+ // Notify listener
listener.onSubscriptionSelected(prod.productId, prod.planId)
- Logger.d(LOG_IAB, "Selected Subscription: ${prod.productId}, ${prod.planId}")
+ Logger.d(LOG_IAB, "Selected Plan: ${prod.productId}, ${prod.planId}")
+ // Refresh all items to update selection state
notifyDataSetChanged()
}
}
private fun setCardStroke(checked: Boolean) {
if (checked) {
- binding.subsCard.strokeWidth = 2
+ binding.planCard.strokeWidth = 4
+ binding.planCard.strokeColor = fetchColor(context, R.attr.accentGood)
} else {
- binding.subsCard.strokeWidth = 0
+ binding.planCard.strokeWidth = 0
+ binding.planCard.strokeColor = fetchColor(context, R.attr.chipBgColorNeutral)
}
- binding.subsCard.strokeColor = getStrokeColorForStatus(checked)
}
- private fun getStrokeColorForStatus(isActive: Boolean): Int {
- return if (isActive) {
- fetchColor(context, R.attr.accentGood)
- } else {
- fetchColor(context, R.attr.chipBgColorNeutral)
+ /**
+ * Get billing text based on product type and billing period
+ * Supports: Monthly (P1M), Yearly (P1Y), 2 Years (P2Y), 5 Years (P5Y)
+ * For one-time purchases (INAPP), shows "One-time payment"
+ */
+ private fun getBillingText(productType: String, billingPeriod: String): String {
+ // Check if it's a one-time purchase (INAPP)
+ if (productType == ProductType.INAPP) {
+ return "One-time payment • No recurring charges"
+ }
+
+ // Parse billing period for subscriptions
+ // as of now, only monthly and yearly plans are available
+ return when {
+ billingPeriod.contains("P1M", ignoreCase = true) -> {
+ "Billed monthly • Cancel anytime"
+ }
+ billingPeriod.contains("P1Y", ignoreCase = true) -> {
+ "Billed annually • Cancel anytime"
+ }
+ billingPeriod.contains("P2Y", ignoreCase = true) -> {
+ "Billed every 2 years • Cancel anytime"
+ }
+ billingPeriod.contains("P5Y", ignoreCase = true) -> {
+ "Billed every 5 years • Cancel anytime"
+ }
+ else -> {
+ // Fallback for unknown billing periods
+ "Subscription • Cancel anytime"
+ }
}
}
}
}
-*/
diff --git a/app/src/play/java/com/celzero/bravedns/iab/BillingListener.kt b/app/src/play/java/com/celzero/bravedns/iab/BillingListener.kt
index 2eab71497..9621eeda1 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/BillingListener.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/BillingListener.kt
@@ -1,7 +1,7 @@
package com.celzero.bravedns.iab
-/*
+
interface BillingListener {
fun onConnectionResult(isSuccess: Boolean, message: String)
fun purchasesResult(isSuccess: Boolean, purchaseDetailList: List)
fun productResult(isSuccess: Boolean,productList: List)
-}*/
+}
diff --git a/app/src/play/java/com/celzero/bravedns/iab/BillingResponse.kt b/app/src/play/java/com/celzero/bravedns/iab/BillingResponse.kt
index e4f3a90b3..9f0ad337e 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/BillingResponse.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/BillingResponse.kt
@@ -1,6 +1,6 @@
package com.celzero.bravedns.iab
-/*import com.android.billingclient.api.BillingClient
+import com.android.billingclient.api.BillingClient
@JvmInline
value class BillingResponse(private val code: Int) {
@@ -35,4 +35,4 @@ value class BillingResponse(private val code: Int) {
BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
BillingClient.BillingResponseCode.ITEM_NOT_OWNED,
)
-}*/
+}
diff --git a/app/src/play/java/com/celzero/bravedns/iab/InAppBillingHandler.kt b/app/src/play/java/com/celzero/bravedns/iab/InAppBillingHandler.kt
index 824adcf80..3e1eaa517 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/InAppBillingHandler.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/InAppBillingHandler.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.iab
-/*
+
import Logger
import Logger.LOG_IAB
import android.app.Activity
@@ -55,7 +55,11 @@ import java.util.Date
import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
import com.celzero.bravedns.customdownloader.ITcpProxy
+import com.celzero.bravedns.service.TcpProxyHelper
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
import org.json.JSONObject
+import java.net.URLEncoder
// ref: github.com/hypersoftdev/inappbilling
object InAppBillingHandler : KoinComponent {
@@ -71,12 +75,14 @@ object InAppBillingHandler : KoinComponent {
const val LINK = "https://play.google.com/store/account/subscriptions?sku=$1&package=$2"
const val STD_PRODUCT_ID = "standard.tier"
+ const val ONE_TIME_PRODUCT_ID = "test_product"
private lateinit var queryUtils: QueryUtils
private val productDetails: CopyOnWriteArrayList = CopyOnWriteArrayList()
private val storeProductDetails: CopyOnWriteArrayList =
CopyOnWriteArrayList()
- private val purchaseDetails = CopyOnWriteArrayList()
+
+ // NOTE: Removed purchaseDetails list - using state machine as single source of truth
val productDetailsLiveData = MutableLiveData>()
val purchasesLiveData = MutableLiveData>()
@@ -84,8 +90,16 @@ object InAppBillingHandler : KoinComponent {
val connectionResultLiveData = MutableLiveData()
private val subscriptionStateMachine: SubscriptionStateMachineV2 by inject()
- private val stateObserverJob = SupervisorJob()
- private val stateObserverScope = CoroutineScope(Dispatchers.IO + stateObserverJob)
+
+ // Structured concurrency with supervisor job for error isolation
+ private val billingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ // Connection synchronization
+ private val connectionMutex = kotlinx.coroutines.sync.Mutex()
+
+ // State tracking (DO NOT use for business logic - use state machine instead)
+ @Volatile
+ private var isInitialized = false
// Result state for the billing client
enum class Priority(val value: Int) {
@@ -124,29 +138,41 @@ object InAppBillingHandler : KoinComponent {
}
fun initiate(context: Context, billingListener: BillingListener? = null) {
+ val mname = this::initiate.name
this.billingListener = billingListener
+
+ // Initialize billing client
setupBillingClient(context)
- startConnection { isSuccess, message ->
- if (isSuccess) {
- // Initialize subscription state machine after successful connection
- io {
- try {
- // Start observing state changes
- startStateObserver()
- logd(
- "initiate",
- "subscription state machine initialized and observer started"
- )
- } catch (e: Exception) {
- loge(
- "initiate",
- "failed to initialize subscription state machine: ${e.message}",
- e
- )
- // Continue with legacy handling
+
+ // Initialize state machine first (before connection)
+ if (!isInitialized) {
+ billingScope.launch {
+ try {
+ subscriptionStateMachine.initialize()
+ startStateObserver()
+ isInitialized = true
+ logd(mname, "State machine initialized successfully")
+ } catch (e: Exception) {
+ loge(mname, "Failed to initialize state machine: ${e.message}", e)
+ // Critical error - notify listener
+ withContext(Dispatchers.Main) {
+ billingListener?.onConnectionResult(false, "State machine initialization failed: ${e.message}")
}
+ return@launch
}
}
+ }
+
+ // Start billing connection
+ startConnection { isSuccess, message ->
+ if (isSuccess) {
+ logd(mname, "Billing connected successfully, fetching initial state")
+ // Billing connected - fetch purchases to sync state
+ val prodTypes = listOf(ProductType.SUBS, ProductType.INAPP)
+ fetchPurchases(prodTypes)
+ } else {
+ loge(mname, "Billing connection failed: $message")
+ }
billingListener?.onConnectionResult(isSuccess, message)
}
}
@@ -183,7 +209,7 @@ object InAppBillingHandler : KoinComponent {
logd(mname, "enableInAppMessaging: no action needed")
} else {
logd(TAG, "enableInAppMessaging: subs status update, fetching purchases")
- fetchPurchases(listOf(ProductType.SUBS))
+ fetchPurchases(listOf(ProductType.SUBS, ProductType.INAPP))
}
}
}
@@ -223,175 +249,153 @@ object InAppBillingHandler : KoinComponent {
private fun startConnection(callback: (isSuccess: Boolean, message: String) -> Unit) {
val mname = this::startConnection.name
- logv(mname, "starting connection")
-
- if (Result.getResultState() == ResultState.CONNECTION_ESTABLISHING) {
- logd(mname, "connection establishing in progress")
- Result.setResultState(ResultState.CONNECTION_ESTABLISHING_IN_PROGRESS)
- onConnectionResultMain(
- callback,
- false,
- ResultState.CONNECTION_ESTABLISHING_IN_PROGRESS.message
- )
- return
- }
- Result.setResultState(ResultState.CONNECTION_ESTABLISHING)
+ logv(mname, "Starting billing connection")
+
+ // Use coroutine for mutex (thread-safe connection attempt)
+ billingScope.launch {
+ if (!connectionMutex.tryLock()) {
+ logd(mname, "Connection attempt already in progress")
+ withContext(Dispatchers.Main) {
+ callback.invoke(false, "Connection already in progress")
+ }
+ return@launch
+ }
- if (::billingClient.isInitialized && billingClient.isReady) {
- logd(mname, "connection already established")
- Result.setResultState(ResultState.CONNECTION_ALREADY_ESTABLISHED)
- onConnectionResultMain(
- callback,
- true,
- ResultState.CONNECTION_ALREADY_ESTABLISHED.message
- )
- return
- }
+ try {
+ // Check if already connected
+ if (::billingClient.isInitialized && billingClient.isReady) {
+ logd(mname, "Billing already connected")
+ withContext(Dispatchers.Main) {
+ callback.invoke(true, "Already connected")
+ }
+ return@launch
+ }
- billingClient.startConnection(object : BillingClientStateListener {
- override fun onBillingSetupFinished(billingResult: BillingResult) {
- if (billingResult.responseCode == BillingResponseCode.OK) {
- logd(mname, "billing client is ready")
- // only fetch the purchases for the subscription type
- queryUtils = QueryUtils(billingClient)
- queryBillingConfig()
- val prodTypes = listOf(ProductType.SUBS)
- fetchPurchases(prodTypes)
- } else {
- log(
- mname,
- "billing client setup failed; code: ${billingResult.responseCode}, msg: ${billingResult.debugMessage}"
- )
+ // Start connection
+ withContext(Dispatchers.Main) {
+ billingClient.startConnection(object : BillingClientStateListener {
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
+ val isOk = BillingResponse(billingResult.responseCode).isOk
+
+ if (isOk) {
+ logd(mname, "Billing connected successfully")
+ queryUtils = QueryUtils(billingClient)
+ queryBillingConfig()
+ fetchPurchases(listOf(ProductType.SUBS, ProductType.INAPP))
+ } else {
+ loge(mname, "Billing connection failed: ${billingResult.responseCode}, ${billingResult.debugMessage}")
+ }
+
+ callback.invoke(isOk, if (isOk) "Connected" else billingResult.debugMessage)
+ connectionMutex.unlock()
+ }
+
+ override fun onBillingServiceDisconnected() {
+ log(mname, "Billing service disconnected")
+
+ // Notify state machine of disconnection
+ billingScope.launch {
+ try {
+ subscriptionStateMachine.systemCheck()
+ } catch (e: Exception) {
+ loge(mname, "Error during system check on disconnect: ${e.message}", e)
+ }
+ }
+
+ callback.invoke(false, "Service disconnected")
+ if (connectionMutex.isLocked) {
+ connectionMutex.unlock()
+ }
+ }
+ })
}
- val isOk = BillingResponse(billingResult.responseCode).isOk
- when (isOk) {
- true -> Result.setResultState(ResultState.CONNECTION_ESTABLISHED)
- false -> Result.setResultState(ResultState.CONNECTION_FAILED)
+ } catch (e: Exception) {
+ loge(mname, "Error starting connection: ${e.message}", e)
+ withContext(Dispatchers.Main) {
+ callback.invoke(false, "Connection error: ${e.message}")
}
- val message = when (isOk) {
- true -> ResultState.CONNECTION_ESTABLISHED.message
- false -> billingResult.debugMessage
+ if (connectionMutex.isLocked) {
+ connectionMutex.unlock()
}
- onConnectionResultMain(callback = callback, isSuccess = isOk, message = message)
}
-
- override fun onBillingServiceDisconnected() {
- // Try to restart the connection on the next request to
- // Google Play by calling the startConnection() method.
- log(mname, "billing service disconnected")
- Result.setResultState(ResultState.CONNECTION_DISCONNECTED)
- onConnectionResultMain(
- callback,
- isSuccess = false,
- message = ResultState.CONNECTION_DISCONNECTED.message
- )
- // Don't automatically reconnect to avoid infinite loops
- // Let the caller handle reconnection logic
- }
- })
+ }
}
private val purchasesUpdatedListener: PurchasesUpdatedListener =
PurchasesUpdatedListener { billingResult, purchasesList ->
val mname = this::purchasesUpdatedListener.name
- logd(
- mname,
- "purchases listener: ${billingResult.responseCode}; ${billingResult.debugMessage}"
- )
+ logd(mname, "Purchase update: code=${billingResult.responseCode}, msg=${billingResult.debugMessage}")
val response = BillingResponse(billingResult.responseCode)
- when {
- response.isOk -> {
- Result.setResultState(ResultState.PURCHASING_SUCCESSFULLY)
- io {
- handlePurchase(purchasesList)
- }
- return@PurchasesUpdatedListener
- }
- response.isAlreadyOwned -> {
- Result.setResultState(ResultState.PURCHASING_ALREADY_OWNED)
- log(mname, "already owned, but not consumed yet")
- io {
- try {
+ // Use structured concurrency for state machine updates
+ billingScope.launch {
+ try {
+ when {
+ response.isOk -> {
+ log(mname, "Purchase successful, processing ${purchasesList?.size ?: 0} items")
+ handlePurchase(purchasesList)
+ }
+
+ response.isAlreadyOwned -> {
+ log(mname, "Item already owned - restoring subscription")
purchasesList?.forEach { purchase ->
- val purchaseDetail = createPurchaseDetailFromPurchase(purchase)
- subscriptionStateMachine.restoreSubscription(purchaseDetail)
+ try {
+ val purchaseDetail = createPurchaseDetailFromPurchase(purchase)
+ subscriptionStateMachine.restoreSubscription(purchaseDetail)
+ } catch (e: Exception) {
+ loge(mname, "Error restoring purchase: ${e.message}", e)
+ }
}
- } catch (e: Exception) {
- loge(
- mname,
- "Error restoring subscription through state machine: ${e.message}",
- e
- )
}
- }
- }
- response.isUserCancelled -> {
- log(mname, "user cancelled the purchase flow")
- // no-op, just return
- }
+ response.isUserCancelled -> {
+ log(mname, "User cancelled purchase flow")
+ // Post to LiveData so UI can dismiss bottom sheet
+ transactionErrorLiveData.postValue(billingResult)
+ try {
+ subscriptionStateMachine.userCancelled()
+ } catch (e: Exception) {
+ loge(mname, "Error notifying cancellation: ${e.message}", e)
+ }
+ }
- response.isTerribleFailure -> {
- loge(
- mname,
- "terrible failure occurred, billing result: ${billingResult.responseCode}, ${billingResult.debugMessage}"
- )
- Result.setResultState(ResultState.PURCHASING_FAILURE)
- io {
- try {
- subscriptionStateMachine.purchaseFailed(billingResult.debugMessage, billingResult)
- } catch (e: Exception) {
- loge(
- mname,
- "Error handling terrible failure in state machine: ${e.message}",
- e
+ response.isTerribleFailure || response.isNonrecoverableError -> {
+ loge(mname, "Fatal billing error: ${billingResult.responseCode}, ${billingResult.debugMessage}")
+ // Post to LiveData so UI can dismiss bottom sheet and show error
+ transactionErrorLiveData.postValue(billingResult)
+ subscriptionStateMachine.purchaseFailed(
+ "Fatal error: ${billingResult.debugMessage}",
+ billingResult
)
}
- }
- }
- response.isRecoverableError -> {
- log(mname, "recoverable error occurred")
- Result.setResultState(ResultState.LAUNCHING_FLOW_INVOCATION_EXCEPTION_FOUND)
- io {
- try {
- subscriptionStateMachine.purchaseFailed(billingResult.debugMessage, billingResult)
- } catch (e: Exception) {
- loge(
- mname,
- "Error handling recoverable error in state machine: ${e.message}",
- e
+ response.isRecoverableError -> {
+ log(mname, "Recoverable billing error: ${billingResult.debugMessage}")
+ // Post to LiveData so UI can dismiss bottom sheet and show error
+ transactionErrorLiveData.postValue(billingResult)
+ subscriptionStateMachine.purchaseFailed(
+ "Recoverable error: ${billingResult.debugMessage}",
+ billingResult
)
}
- }
- }
-
- response.isNonrecoverableError -> {
- loge(
- mname,
- "non-recoverable error occurred, billing result: ${billingResult.responseCode}, ${billingResult.debugMessage}"
- )
- Result.setResultState(ResultState.PURCHASING_FAILURE)
- io {
- try {
- subscriptionStateMachine.purchaseFailed(billingResult.debugMessage, billingResult)
- }catch (e: Exception) {
- loge(mname, "Error handling non-recoverable error in state machine: ${e.message}", e)
+ else -> {
+ loge(mname, "Unknown billing error: ${billingResult.responseCode}")
+ // Post to LiveData so UI can dismiss bottom sheet and show error
+ transactionErrorLiveData.postValue(billingResult)
+ subscriptionStateMachine.purchaseFailed(
+ "Unknown error: ${billingResult.debugMessage}",
+ billingResult
+ )
}
}
- }
-
- else -> {
- log(mname, "unknown error occurred")
- io {
- try {
- subscriptionStateMachine.purchaseFailed(billingResult.debugMessage, billingResult)
- }catch (e: Exception) {
- loge(mname, "Error handling unknown error in state machine: ${e.message}", e)
- }
+ } catch (e: Exception) {
+ loge(mname, "Critical error in purchase listener: ${e.message}", e)
+ try {
+ subscriptionStateMachine.purchaseFailed("Critical error: ${e.message}", billingResult)
+ } catch (smError: Exception) {
+ loge(mname, "Failed to notify state machine: ${smError.message}", smError)
}
}
}
@@ -400,128 +404,119 @@ object InAppBillingHandler : KoinComponent {
private suspend fun handlePurchase(purchasesList: List?) {
val mname = "handlePurchase"
if (purchasesList == null || purchasesList.isEmpty()) {
- loge(mname, "purchases list is null")
- Result.setResultState(ResultState.PURCHASING_NO_PURCHASES_FOUND)
- // TODO: no purchases found, handle this case. Either should be in cancelled state or expired
+ log(mname, "Purchases list is empty - checking subscription state")
try {
- if (subscriptionStateMachine.getCurrentState() is SubscriptionStateMachineV2.SubscriptionState.Active) {
+ val currentState = subscriptionStateMachine.getCurrentState()
+ if (currentState is SubscriptionStateMachineV2.SubscriptionState.Active) {
val billingExpiry = subscriptionStateMachine.getSubscriptionData()?.subscriptionStatus?.billingExpiry
if (billingExpiry == null) {
- loge(mname, "no billing or account expiry found, should not be in active state")
+ loge(mname, "No billing expiry found in active state - transitioning to expired")
subscriptionStateMachine.subscriptionExpired()
return
}
- // if there is a valid billing period then user has cancelled the subscription
- // but in grace period. notify the state machine to handle user cancellation
- // if billing expiry are less than current time then the subscription is expired
+
if (billingExpiry < System.currentTimeMillis()) {
- log(mname, "no purchases found, subscription is expired")
- try {
- subscriptionStateMachine.subscriptionExpired()
- } catch (e: Exception) {
- loge(mname, "err handling expiry in state machine: ${e.message}", e)
- }
+ log(mname, "Billing expired - transitioning to expired state")
+ subscriptionStateMachine.subscriptionExpired()
} else {
- // no need to take account expiry into account here
- log(mname, "no purchases found, user has cancelled the subscription.")
- // Notify state machine about user cancellation
- try {
- RpnProxyManager.handleUserCancellation() // this will notify the state machine
- } catch (e: Exception) {
- loge(mname, "err handling user cancellation in state machine: ${e.message}", e)
- }
+ log(mname, "No purchases found but billing valid - user cancelled subscription")
+ RpnProxyManager.handleUserCancellation()
}
+ } else {
+ logd(mname, "No active subscription state - no action needed, current state: ${currentState.name}")
}
} catch (e: Exception) {
- loge(mname, "err handling no purchases: ${e.message}", e)
+ loge(mname, "Error handling empty purchase list: ${e.message}", e)
}
return
}
- // Validate current state machine state
-
- val canProcess: Boolean
+ // Get current state for validation
val currentState = subscriptionStateMachine.getCurrentState()
- logd(mname, "Current state machine state: ${currentState.name}")
+ logd(mname, "Processing ${purchasesList.size} purchases in state: ${currentState.name}")
- // Allow processing in most states, but warn about unexpected ones
- when (currentState) {
- is SubscriptionStateMachineV2.SubscriptionState.Error -> {
- loge(mname, "Processing purchase while in error state")
- canProcess = false
- }
- else -> {
- canProcess = true
+ // Check if we can process in current state
+ if (currentState is SubscriptionStateMachineV2.SubscriptionState.Error) {
+ loge(mname, "Cannot process purchases in error state - attempting system check")
+ try {
+ subscriptionStateMachine.systemCheck()
+ } catch (e: Exception) {
+ loge(mname, "System check failed: ${e.message}", e)
}
+ return
}
- // Filter out duplicates based on purchase token
+ // Filter duplicates
val uniquePurchases = purchasesList.distinctBy { it.purchaseToken }
if (uniquePurchases.size != purchasesList.size) {
- logd(mname, "Found ${purchasesList.size - uniquePurchases.size} duplicate purchases")
+ logd(mname, "Filtered ${purchasesList.size - uniquePurchases.size} duplicate purchases")
}
+ // Process each purchase through state machine
uniquePurchases.forEach { purchase ->
- when (purchase.purchaseState) {
- Purchase.PurchaseState.PURCHASED -> {
- logd(mname, "purchase state is purchased, processing...")
- Result.setResultState(ResultState.PURCHASING_SUCCESSFULLY)
-
- if (isPurchaseStateCompleted(purchase)) {
- logd(mname, "purchase is acknowledged, processing as valid purchase")
- // processValidPurchase(purchase) - This is now handled by the state machine
-
- // Notify state machine about successful payment
- try {
- val purchaseDetail = createPurchaseDetailFromPurchase(purchase)
- subscriptionStateMachine.paymentSuccessful(purchaseDetail)
- } catch (e: Exception) {
- loge(mname, "Error notifying state machine of successful payment: ${e.message}", e)
- }
- } else { // isPurchaseStatePurchased will be true in this case
- // treat as ack pending
- Result.setResultState(ResultState.PURCHASE_ACK_PENDING)
- createUnacknowledgedPurchase(purchase)
- try {
- // Correct event for new unacknowledged purchase
- subscriptionStateMachine.completePurchase(createPurchaseDetailFromPurchase(purchase))
- } catch (e: Exception) {
- loge(mname, "Error handling pending purchase in state machine: ${e.message}", e)
- }
+ try {
+ processSinglePurchase(purchase)
+ } catch (e: Exception) {
+ loge(mname, "Error processing purchase ${purchase.purchaseToken}: ${e.message}", e)
+ }
+ }
+ }
+
+ private suspend fun processSinglePurchase(purchase: Purchase) {
+ val mname = "processSinglePurchase"
+
+ when (purchase.purchaseState) {
+ Purchase.PurchaseState.PURCHASED -> {
+ logd(mname, "Processing purchased state for token: ${purchase.purchaseToken}")
+
+ if (isPurchaseStateCompleted(purchase)) {
+ logd(mname, "Purchase acknowledged - notifying state machine")
+ try {
+ val purchaseDetail = createPurchaseDetailFromPurchase(purchase)
+ subscriptionStateMachine.paymentSuccessful(purchaseDetail)
+ } catch (e: Exception) {
+ loge(mname, "Error processing acknowledged purchase: ${e.message}", e)
+ }
+ } else {
+ logd(mname, "Purchase needs acknowledgement")
+ try {
+ val purchaseDetail = createPurchaseDetailFromPurchase(purchase)
+ subscriptionStateMachine.completePurchase(purchaseDetail)
+ subscriptionStateMachine.completePurchase(purchaseDetail)
+ } catch (e: Exception) {
+ loge(mname, "Error handling unacknowledged purchase: ${e.message}", e)
}
}
+ }
- Purchase.PurchaseState.PENDING -> {
- logd(mname, "purchase state is pending, showing pending UI")
- Result.setResultState(ResultState.PURCHASE_PENDING)
+ Purchase.PurchaseState.PENDING -> {
+ logd(mname, "Purchase pending for token: ${purchase.purchaseToken}")
+ try {
+ val purchaseDetail = createPurchaseDetailFromPurchase(purchase)
+ subscriptionStateMachine.completePurchase(purchaseDetail)
+ } catch (e: Exception) {
+ loge(mname, "Error handling pending purchase: ${e.message}", e)
}
+ }
- Purchase.PurchaseState.UNSPECIFIED_STATE -> {
- logd(mname, "purchase state unspecified")
- if (canProcess) {
- try {
- subscriptionStateMachine.purchaseFailed(
- "Purchase state unspecified",
- null
- )
- } catch (e: Exception) {
- loge(mname, "Error handling unspecified state in state machine: ${e.message}", e)
- }
- }
+ Purchase.PurchaseState.UNSPECIFIED_STATE -> {
+ loge(mname, "Purchase state unspecified for token: ${purchase.purchaseToken}")
+ try {
+ subscriptionStateMachine.purchaseFailed("Purchase state unspecified", null)
+ } catch (e: Exception) {
+ loge(mname, "Error notifying unspecified state: ${e.message}", e)
}
+ }
- else -> {
- logd(mname, "purchase state unknown: ${purchase.purchaseState}")
- if (canProcess) {
- try {
- subscriptionStateMachine.purchaseFailed(
- "Purchase state unknown: ${purchase.purchaseState}",
- null
- )
- } catch (e: Exception) {
- loge(mname, "Error handling unknown state in state machine: ${e.message}", e)
- }
- }
+ else -> {
+ loge(mname, "Unknown purchase state: ${purchase.purchaseState}")
+ try {
+ subscriptionStateMachine.purchaseFailed(
+ "Unknown purchase state: ${purchase.purchaseState}",
+ null
+ )
+ } catch (e: Exception) {
+ loge(mname, "Error notifying unknown state: ${e.message}", e)
}
}
}
@@ -556,7 +551,10 @@ object InAppBillingHandler : KoinComponent {
planTitle = offerDetails?.let { queryUtils.getPlanTitle(it) } ?: "",
state = purchase.purchaseState,
purchaseToken = purchase.purchaseToken,
- productType = ProductType.SUBS,
+ productType = purchase.products.firstOrNull()?.let {
+ productDetails?.productDetail?.productType
+ ?: ProductType.SUBS
+ } ?: ProductType.SUBS,
purchaseTime = purchase.purchaseTime.toFormattedDate(),
purchaseTimeMillis = purchase.purchaseTime,
isAutoRenewing = purchase.isAutoRenewing,
@@ -595,33 +593,6 @@ object InAppBillingHandler : KoinComponent {
}
}
- private fun createUnacknowledgedPurchase(purchase: Purchase) {
- val unacknowledgedPurchase = PurchaseDetail(
- productId = purchase.products.firstOrNull().orEmpty(),
- planId = "",
- productTitle = "Pending Purchase",
- state = Purchase.PurchaseState.PENDING,
- status = SubscriptionStatus.SubscriptionState.STATE_ACK_PENDING.id,
- planTitle = "Pending",
- purchaseToken = purchase.purchaseToken,
- productType = ProductType.SUBS,
- purchaseTime = purchase.purchaseTime.toFormattedDate(),
- purchaseTimeMillis = purchase.purchaseTime,
- isAutoRenewing = false,
- accountId = purchase.accountIdentifiers?.obfuscatedAccountId ?: "",
- payload = purchase.developerPayload,
- expiryTime = 0L,
- )
-
- // Update the purchase details list atomically
- synchronized(purchaseDetails) {
- // Remove any existing pending purchase with the same token
- purchaseDetails.removeAll { it.purchaseToken == purchase.purchaseToken }
- purchaseDetails.add(unacknowledgedPurchase)
- }
-
- purchasesLiveData.postValue(listOf(unacknowledgedPurchase))
- }
private fun calculateExpiryTime(purchase: Purchase, offerDetails: ProductDetails.SubscriptionOfferDetails?): Long {
@@ -653,7 +624,9 @@ object InAppBillingHandler : KoinComponent {
}
private fun isPurchaseStateCompleted(purchase: Purchase?): Boolean {
+ val mname = this::isPurchaseStateCompleted.name
if (purchase?.purchaseState == null) {
+ loge(mname, "purchase or purchase state is null, $purchase")
return false
}
@@ -661,10 +634,30 @@ object InAppBillingHandler : KoinComponent {
val isAcknowledged = purchase.isAcknowledged
// For subscriptions, check auto-renewal; for one-time purchases, don't require auto-renewal
- return if (purchase.products.any { productDetails.find { pd -> pd.productId == it }?.productType == ProductType.SUBS }) {
+ // treat empty product as subscription
+ purchase.products.forEach {
+ logv(mname, "Product in purchase: $it")
+ }
+ val isSubs = getPurchaseType(purchase) == ProductType.SUBS
+ val isOneTime = getPurchaseType(purchase) == ProductType.INAPP
+ //isSubs = purchase.products.any { if (productDetails.isEmpty()) { true } else productDetails.find { pd -> pd.productId == it }?.productType == ProductType.SUBS }
+ log(mname, "isPurchaseStateCompleted: isSubs?$isSubs, isOneTime?$isOneTime, isPurchased?$isPurchased, isAcknowledged?$isAcknowledged, isAutoRenewing?${purchase.isAutoRenewing}")
+ return if (isSubs) {
isPurchased && purchase.isAutoRenewing && isAcknowledged
- } else {
+ } else if (isOneTime) {
isPurchased && isAcknowledged
+ } else {
+ false
+ }
+ }
+
+ fun getPurchaseType(purchase: Purchase): String {
+ val pId = purchase.products.firstOrNull() ?: return "UNKNOWN"
+
+ return when {
+ STD_PRODUCT_ID.contains(pId) -> ProductType.SUBS
+ ONE_TIME_PRODUCT_ID.contains(pId) -> ProductType.INAPP
+ else -> "UNKNOWN"
}
}
@@ -677,9 +670,9 @@ object InAppBillingHandler : KoinComponent {
}
fun fetchPurchases(productType: List) {
- io {
- val mname = this::fetchPurchases.name
- logv(mname, "fetching purchases...")
+ billingScope.launch {
+ val mname = "fetchPurchases"
+ logv(mname, "Fetching purchases for types: $productType")
// Determine product types to be fetched
val hasInApp = productType.any { it == ProductType.INAPP }
val hasSubs = productType.any { it == ProductType.SUBS }
@@ -692,72 +685,59 @@ object InAppBillingHandler : KoinComponent {
hasInApp -> queryPurchases(ProductType.INAPP, false)
hasSubs -> queryPurchases(ProductType.SUBS, false)
else -> {
- // should not happen
loge(mname, "No valid product types provided for fetching purchases")
- return@io
+ return@launch
}
}
- logv(mname, "purchases fetch complete, processing...")
+ logv(mname, "Purchases fetch complete")
}
}
private fun queryPurchases(productType: String, hasBoth: Boolean) {
-
val mname = this::queryPurchases.name
- log(mname, "type: $productType, hasBoth? $hasBoth")
-
- when (productType) {
- ProductType.INAPP -> Result.setResultState(ResultState.CONSOLE_PURCHASE_PRODUCTS_INAPP_FETCHING)
- ProductType.SUBS -> Result.setResultState(ResultState.CONSOLE_PURCHASE_PRODUCTS_SUB_FETCHING)
- }
+ log(mname, "Querying purchases for type: $productType, hasBoth: $hasBoth")
- val queryPurchasesParams =
- QueryPurchasesParams.newBuilder().setProductType(productType).build()
+ val queryPurchasesParams = QueryPurchasesParams.newBuilder().setProductType(productType).build()
billingClient.queryPurchasesAsync(queryPurchasesParams) { billingResult, purchases ->
- // sample purchase: [Purchase. Json: {"orderId":"GPA.3377-3462-2269-94965","packageName":"com.celzero.bravedns","productId":"standard.tier","purchaseTime":1753189609259,"purchaseState":0,"purchaseToken":"fjeciomnalbegbfjfgaaedoa.AO-J1Oy8-PhoHC-Uu23oYerGLKJachIeqicR-bAUn5c0bfN5j4L_rZ34pFUMSEdJi43XaC-Remq9HSdbViCMEqzHbedLURq47g","obfuscatedAccountId":"aa95f04efcb19a54c7605a02e5dd0b435906b993d12bec031a60f3f1272f4f0e","quantity":1,"autoRenewing":true,"acknowledged":true,"developerPayload":"{\"ws\":{\"kind\":\"ws#v1\",\"cid\":\"aa95f04efcb19a54c7605a02e5dd0b435906b993d12bec031a60f3f1272f4f0e\",\"sessiontoken\":\"22695:4:1752256088:524537c17ba103463ba1d330efaf05c146ba3404af:023f958b6c1949568f55078e3c58fe6885d3e57322\",\"expiry\":\"2025-08-11T00:00:00.000Z\",\"status\":\"valid\",\"test\":true}}"}]
- log(
- mname,
- "type: $productType -> purchases: ${purchases}, result: ${billingResult.responseCode}, ${billingResult.debugMessage}"
- )
+ log(mname, "Query result for $productType: ${billingResult.responseCode}, purchases: ${purchases.size}")
+
if (BillingResponse(billingResult.responseCode).isOk) {
- when (productType) {
- ProductType.INAPP -> Result.setResultState(ResultState.CONSOLE_PURCHASE_PRODUCTS_INAPP_FETCHING_SUCCESS)
- ProductType.SUBS -> Result.setResultState(ResultState.CONSOLE_PURCHASE_PRODUCTS_SUB_FETCHING_SUCCESS)
- }
- io {
- logv(mname, "processing purchases...")
- handlePurchase(purchases)
+ billingScope.launch {
+ try {
+ logv(mname, "Processing ${purchases.size} purchases")
+ handlePurchase(purchases)
+ } catch (e: Exception) {
+ loge(mname, "Error processing purchases: ${e.message}", e)
+ }
}
} else {
- loge(mname, "failed to query purchases. result: ${billingResult.responseCode}")
- when (productType) {
- ProductType.INAPP -> Result.setResultState(ResultState.CONSOLE_PURCHASE_PRODUCTS_INAPP_FETCHING_FAILED)
- ProductType.SUBS -> Result.setResultState(ResultState.CONSOLE_PURCHASE_PRODUCTS_SUB_FETCHING_FAILED)
- }
+ loge(mname, "Failed to query purchases for $productType: ${billingResult.responseCode}")
}
+ // Query SUBS if we were querying INAPP and hasBoth is true
if (productType == ProductType.INAPP && hasBoth) {
queryPurchases(ProductType.SUBS, false)
- return@queryPurchasesAsync
}
}
}
suspend fun queryProductDetailsWithTimeout(timeout: Long = 5000) {
val mname = this::queryProductDetailsWithTimeout.name
- logv(mname, "init query product details with timeout")
+ logv(mname, "Querying product details with timeout: ${timeout}ms")
+
if (storeProductDetails.isNotEmpty() && productDetails.isNotEmpty()) {
- logd(mname, "store product details is not empty, skipping product details query")
+ logd(mname, "Product details already cached, skipping query")
productDetailsLiveData.postValue(productDetails)
return
}
+
val result = withTimeoutOrNull(timeout) {
queryProductDetails()
}
+
if (result == null) {
- loge(mname, "query product details timed out")
- Result.setResultState(ResultState.CONSOLE_QUERY_PRODUCTS_FAILED)
+ loge(mname, "Product details query timed out after ${timeout}ms")
}
}
@@ -768,8 +748,8 @@ object InAppBillingHandler : KoinComponent {
productDetails.clear()
val productListParams = listOf(
QueryProductDetailsParams.Product.newBuilder()
- .setProductId(STD_PRODUCT_ID)
- .setProductType(ProductType.SUBS)
+ .setProductId(ONE_TIME_PRODUCT_ID)
+ .setProductType(ProductType.INAPP)
.build()
)
logd(mname, "query product params, size: ${productListParams.size}")
@@ -784,16 +764,44 @@ object InAppBillingHandler : KoinComponent {
if (billingResult.responseCode == BillingResponseCode.OK && productDetailsList.isNotEmpty()) {
processProductList(productDetailsList)
} else {
- loge(
- mname,
- "failed to query product details, response code: ${billingResult.responseCode}, message: ${billingResult.debugMessage}"
- )
+ loge(mname, "Failed to query product details: ${billingResult.responseCode}, ${billingResult.debugMessage}")
billingListener?.productResult(false, emptyList())
- Result.setResultState(ResultState.CONSOLE_QUERY_PRODUCTS_FAILED)
}
}
+
+ // query SUBS product details
+ querySubsProductDetails()
}
+ private fun querySubsProductDetails() {
+ val mname = this::querySubsProductDetails.name
+
+ val subsParams = listOf(
+ QueryProductDetailsParams.Product.newBuilder()
+ .setProductId(STD_PRODUCT_ID)
+ .setProductType(ProductType.SUBS)
+ .build()
+ )
+
+ logd(mname, "query product params, size: ${subsParams.size}")
+ val params = QueryProductDetailsParams.newBuilder()
+ .setProductList(subsParams)
+ .build()
+
+ billingClient.queryProductDetailsAsync(params) { br, result ->
+ if (br.responseCode == BillingClient.BillingResponseCode.OK) {
+ processProductList(result.productDetailsList)
+ } else {
+ loge(mname, "SUBS query failed: ${br.responseCode}, ${br.debugMessage}")
+ }
+
+ // Final callback after all products are processed
+ productDetailsLiveData.postValue(productDetails)
+ billingListener?.productResult(productDetails.isNotEmpty(), productDetails)
+ }
+ }
+
+
private fun processProductList(productDetailsList: List) {
val mname = this::processProductList.name
val queryProductDetail = arrayListOf()
@@ -803,28 +811,40 @@ object InAppBillingHandler : KoinComponent {
when (pd.productType) {
ProductType.INAPP -> {
- val pricingPhase = PricingPhase(
- planTitle = "",
- recurringMode = RecurringMode.ORIGINAL,
- price = pd.oneTimePurchaseOfferDetails?.formattedPrice.toString()
- .removeSuffix(".00"),
- currencyCode = pd.oneTimePurchaseOfferDetails?.priceCurrencyCode.toString(),
- billingCycleCount = 0,
- billingPeriod = "",
- priceAmountMicros = pd.oneTimePurchaseOfferDetails?.priceAmountMicros
- ?: 0L,
- freeTrialPeriod = 0
- )
+ val offers = pd.oneTimePurchaseOfferDetailsList.orEmpty()
+ if (offers.isEmpty()) {
+ loge(mname, "INAPP product has no one-time offers: ${pd.productId}")
+ return@forEach
+ }
- val productDetail = ProductDetail(
- productId = pd.productId,
- planId = "",
- productTitle = pd.title,
- productType = ProductType.INAPP,
- pricingDetails = listOf(pricingPhase)
- )
- this.productDetails.add(productDetail)
- queryProductDetail.add(QueryProductDetail(productDetail, pd, null))
+ offers.forEachIndexed { index, offer ->
+
+ val billingPeriod = getOneTimeProductBillingPeriod(offer)
+ val planTitle = QueryUtils.getPlanTitle(billingPeriod)
+ val planId = offer.purchaseOptionId ?: offer.offerId
+ ?: "one_time_${pd.productId}_$index"
+
+ val pricingPhase = PricingPhase(
+ planTitle = planTitle,
+ recurringMode = RecurringMode.ORIGINAL,
+ price = offer.formattedPrice.removeSuffix(".00"),
+ currencyCode = offer.priceCurrencyCode,
+ billingCycleCount = 0,
+ billingPeriod = billingPeriod,
+ priceAmountMicros = offer.priceAmountMicros,
+ freeTrialPeriod = 0
+ )
+
+ val productDetail = ProductDetail(
+ productId = pd.productId,
+ planId = planId,
+ productTitle = planTitle,
+ productType = ProductType.INAPP,
+ pricingDetails = listOf(pricingPhase)
+ )
+ this.productDetails.add(productDetail)
+ queryProductDetail.add(QueryProductDetail(productDetail, pd, null, offer))
+ }
}
ProductType.SUBS -> {
@@ -895,7 +915,7 @@ object InAppBillingHandler : KoinComponent {
this.productDetails.add(productDetail)
}
if (!isExistInStore) {
- queryProductDetail.add(QueryProductDetail(productDetail, pd, offer))
+ queryProductDetail.add(QueryProductDetail(productDetail, pd, offer, null))
}
logd(
mname,
@@ -908,8 +928,15 @@ object InAppBillingHandler : KoinComponent {
}
storeProductDetails.addAll(queryProductDetail)
- log(mname, "storeProductDetailsList: $storeProductDetails")
- Result.setResultState(ResultState.CONSOLE_QUERY_PRODUCTS_COMPLETED)
+ log(mname, "Processed product details list: ${storeProductDetails.size} items")
+
+ storeProductDetails.forEach {
+ log(mname, "storeProductDetails item: ${it.productDetail.productId}, ${it.productDetail.planId}, ${it.productDetail.pricingDetails}" )
+ }
+
+ productDetails.forEach {
+ log(mname, "productDetails item: ${it.productId}, ${it.planId}, ${it.pricingDetails}" )
+ }
// remove duplicates from storeProductDetails and productDetails
val s = storeProductDetails.distinctBy { it.productDetail.planId }
@@ -941,6 +968,17 @@ object InAppBillingHandler : KoinComponent {
}
}
+ private fun getOneTimeProductBillingPeriod(offer: ProductDetails.OneTimePurchaseOfferDetails): String {
+ // One-time purchases do not have a billing period like subscriptions
+ if (offer.purchaseOptionId == "legacy-base") {
+ return "P2Y"
+ } else if (offer.purchaseOptionId == "legacy-max") {
+ return "P5Y"
+ } else {
+ return "P2Y" // Default to 2 years if unknown
+ }
+ }
+
suspend fun purchaseSubs(
activity: Activity,
productId: String,
@@ -953,17 +991,8 @@ object InAppBillingHandler : KoinComponent {
val currentState = subscriptionStateMachine.getCurrentState()
loge(mname, "Cannot make purchase - current state: ${currentState.name}")
- // Provide specific error messages based on state
- val errorMessage = when (currentState) {
- is SubscriptionStateMachineV2.SubscriptionState.Active -> "Subscription is already active"
- is SubscriptionStateMachineV2.SubscriptionState.PurchasePending -> "Purchase is already pending"
- is SubscriptionStateMachineV2.SubscriptionState.Error -> "System is in error state"
- else -> "Cannot make purchase in current state: ${currentState.name}"
- }
-
- // TODO: handle error in billing listener and notify user
+ loge(mname, "Cannot make purchase in current state: ${currentState.name}")
billingListener?.purchasesResult(false, emptyList())
- Result.setResultState(ResultState.PURCHASING_FAILURE)
return
}
@@ -972,28 +1001,28 @@ object InAppBillingHandler : KoinComponent {
subscriptionStateMachine.startPurchase()
} catch (e: Exception) {
loge(mname, "Error starting purchase in state machine: ${e.message}", e)
+ billingListener?.purchasesResult(false, emptyList())
+ return
}
- log(mname, "productId: $productId, planId: $planId, ${storeProductDetails.size}")
+ log(mname, "Looking for product: $productId, plan: $planId")
val queryProductDetail = storeProductDetails.find {
- it.productDetail.productId == productId
- && it.productDetail.planId == planId
- && it.productDetail.productType == ProductType.SUBS
+ it.productDetail.productId == productId &&
+ it.productDetail.planId == planId &&
+ it.productDetail.productType == ProductType.SUBS
}
if (queryProductDetail == null) {
- val errorMsg = "no product details found for productId: $productId, planId: $planId"
+ val errorMsg = "No product details found for productId: $productId, planId: $planId"
loge(mname, errorMsg)
try {
subscriptionStateMachine.purchaseFailed(errorMsg, null)
} catch (e: Exception) {
- loge(mname, "Error reporting purchase failure to state machine: ${e.message}", e)
+ loge(mname, "Error notifying state machine: ${e.message}", e)
}
- // TODO: handle error in billing listener and notify user
billingListener?.purchasesResult(false, emptyList())
- Result.setResultState(ResultState.PURCHASING_FAILURE)
return
}
@@ -1004,18 +1033,53 @@ object InAppBillingHandler : KoinComponent {
offerToken = queryProductDetail.offerDetails?.offerToken
)
} catch (e: Exception) {
- val errorMsg = "failed to launch purchase flow: ${e.message}"
+ val errorMsg = "Failed to launch purchase flow: ${e.message}"
loge(mname, errorMsg, e)
try {
subscriptionStateMachine.purchaseFailed(errorMsg, null)
} catch (stateMachineError: Exception) {
- loge(mname, "Error reporting launch failure to state machine: ${stateMachineError.message}", stateMachineError)
+ loge(mname, "Error notifying state machine: ${stateMachineError.message}", stateMachineError)
}
- // TODO: handle error in billing listener and notify user
billingListener?.purchasesResult(false, emptyList())
- Result.setResultState(ResultState.PURCHASING_FAILURE)
+ }
+ }
+
+ suspend fun purchaseOneTime(activity: Activity, productId: String, planId: String) {
+ val mname = this::purchaseOneTime.name
+
+ log(mname, "Looking for one-time product: $productId, plan: $planId")
+
+ // Find the INAPP (one-time) product in your cached list
+ val queryProductDetail = storeProductDetails.find {
+ it.productDetail.productId == productId &&
+ it.productDetail.productType == ProductType.INAPP
+ }
+
+ if (queryProductDetail == null) {
+ val errorMsg = "No one-time product details found for productId: $productId, planId: $planId"
+ loge(mname, errorMsg)
+
+ // No subscription state machine here – just notify listener
+ billingListener?.purchasesResult(false, emptyList())
+ return
+ }
+
+ try {
+ val offerToken = queryProductDetail.oneTimeOfferDetails?.offerToken
+
+ launchFlow(
+ activity = activity,
+ pds = queryProductDetail.productDetails,
+ offerToken = offerToken
+ )
+ logv(mname, "One-time purchase flow launched for productId: $productId, plan: $planId, offerToken: $offerToken")
+ } catch (e: Exception) {
+ val errorMsg = "Failed to launch one-time purchase flow: ${e.message}"
+ loge(mname, errorMsg, e)
+
+ billingListener?.purchasesResult(false, emptyList())
}
}
@@ -1027,24 +1091,23 @@ object InAppBillingHandler : KoinComponent {
val mname = this::launchFlow.name
if (pds == null) {
- loge(mname, "no product details found, purchase flow cannot be initiated")
+ loge(mname, "No product details available, cannot launch purchase flow")
try {
subscriptionStateMachine.purchaseFailed("No product details found", null)
} catch (e: Exception) {
- loge(mname, "Error reporting purchase failure to state machine: ${e.message}", e)
+ loge(mname, "Error notifying state machine: ${e.message}", e)
}
billingListener?.purchasesResult(false, emptyList())
- Result.setResultState(ResultState.PURCHASING_FAILURE)
return
}
- logd(mname, "proceeding with purchase flow for product: ${pds.title}")
+ logd(mname, "Launching purchase flow for: ${pds.title}, ${pds.productId}, offerToken: $offerToken, ${pds.productType}, ${pds.oneTimePurchaseOfferDetailsList}")
+
val paramsList = when (offerToken.isNullOrEmpty()) {
true -> listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(pds).build()
)
-
false -> listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(pds).setOfferToken(offerToken).build()
@@ -1052,33 +1115,30 @@ object InAppBillingHandler : KoinComponent {
}
val accountId = getObfuscatedAccountId(activity.applicationContext)
- val flowParams = BillingFlowParams.newBuilder().setProductDetailsParamsList(paramsList)
- .setObfuscatedAccountId(accountId).build()
+ val flowParams = BillingFlowParams.newBuilder()
+ .setProductDetailsParamsList(paramsList)
+ .setObfuscatedAccountId(accountId)
+ .build()
+
val billingResult = billingClient.launchBillingFlow(activity, flowParams)
+ val isSuccess = billingResult.responseCode == BillingResponseCode.OK
- Result.setResultState(ResultState.LAUNCHING_FLOW_INVOCATION_SUCCESSFULLY)
- billingListener?.purchasesResult(
- billingResult.responseCode == BillingResponseCode.OK,
- emptyList()
- )
+ billingListener?.purchasesResult(isSuccess, emptyList())
- if (billingResult.responseCode != BillingResponseCode.OK) {
+ if (!isSuccess) {
+ loge(mname, "Failed to launch billing flow: ${billingResult.responseCode}")
transactionErrorLiveData.postValue(billingResult)
}
}
- private suspend fun getObfuscatedAccountId(context: Context): String {
+ suspend fun getObfuscatedAccountId(context: Context): String {
// consider token as obfuscated account id
return PipKeyManager.getToken(context)
}
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private fun io(f: suspend () -> Unit) {
- scope.launch { f() }
- }
-
// Query user's billing configuration using BillingClient v7+ API
fun queryBillingConfig() {
+ val mname = this::queryBillingConfig.name
val getBillingConfigParams = GetBillingConfigParams.newBuilder().build()
billingClient.getBillingConfigAsync(getBillingConfigParams) { billingResult, billingConfig ->
if (billingResult.responseCode == BillingResponseCode.OK
@@ -1086,24 +1146,41 @@ object InAppBillingHandler : KoinComponent {
) {
val countryCode = billingConfig.countryCode
// TODO: Handle country code, see if we need to use it
- Logger.i(LOG_IAB, "BillingConfig country code: $countryCode")
+ log(mname, "BillingConfig country code: $countryCode")
} else {
// TODO: Handle errors
- Logger.i(LOG_IAB, "err in billing config: ${billingResult.debugMessage}")
+ log(mname, "err in billing config: ${billingResult.debugMessage}")
}
}
}
private fun startStateObserver() {
- stateObserverScope.launch {
+ billingScope.launch {
try {
subscriptionStateMachine.currentState.collect { state ->
logd("StateObserver", "Subscription state changed to: ${state.name}")
- // Handle state changes if needed
handleStateChange(state)
+ updateUIForState(state)
}
} catch (e: Exception) {
- loge("StateObserver", "Error in state observer: ${e.message}", e)
+ loge("StateObserver", "Critical error in state observer: ${e.message}", e)
+ // Try to recover by doing a system check
+ try {
+ subscriptionStateMachine.systemCheck()
+ } catch (recoveryError: Exception) {
+ loge("StateObserver", "Failed system check during recovery: ${recoveryError.message}", recoveryError)
+ }
+ }
+ }
+ }
+
+ private suspend fun updateUIForState(state: SubscriptionStateMachineV2.SubscriptionState) {
+ withContext(Dispatchers.Main) {
+ // Get current subscription data from state machine
+ val subscriptionData = subscriptionStateMachine.getSubscriptionData()
+ subscriptionData?.purchaseDetail?.let { purchaseDetail ->
+ // Update LiveData with current purchase
+ purchasesLiveData.postValue(listOf(purchaseDetail))
}
}
}
@@ -1159,9 +1236,8 @@ object InAppBillingHandler : KoinComponent {
Total Transitions: ${statistics.totalTransitions}
Success Rate: ${String.format(Locale.getDefault(), "%.2f", statistics.successRate * 100)}%
Failed Transitions: ${statistics.failedTransitions}
- Current Data: ${subscriptionData?.subscriptionStatus?.productId ?: "None"}
+ Current Product: ${subscriptionData?.subscriptionStatus?.productId ?: "None"}
Last Updated: ${subscriptionData?.lastUpdated?.let { Date(it) } ?: "Never"}
- Legacy Purchase Details: ${purchaseDetails.size}
""".trimIndent()
}
@@ -1198,105 +1274,122 @@ object InAppBillingHandler : KoinComponent {
}
}
- suspend fun cancelPlaySubscription(accountId: String, purchaseToken: String): Pair {
- // g/stop?cid&purchaseToken&test
- // response: {"message":"canceled subscription","purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
- // {"error":"cannot revoke, subscription canceled or expired","expired":false,"canceled":true,"cancelCtx":{"userInitiatedCancellation":{"cancelSurveyResult":null,"cancelTime":"2025-07-10T13:21:24.743Z"},"systemInitiatedCancellation":null,"developerInitiatedCancellation":null,"replacementCancellation":null},"purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
- val mname = this::cancelPlaySubscription.name
- // call ITcpProxy.cancelSubscription with the current account ID and purchase token
- // make an API call to cancel the subscription
- logd(mname, "canceling subscription for accountId: $accountId, purchaseToken: $purchaseToken")
- // use retrofit to make the API call
- val retrofit = RetrofitManager.getTcpProxyBaseBuilder(persistentState.routeRethinkInRethink)
- .addConverterFactory(GsonConverterFactory.create())
- .build()
- val retrofitInterface = retrofit.create(ITcpProxy::class.java)
- try {
- val response = retrofitInterface.cancelSubscription(
- accountId,
- purchaseToken,
- DEBUG
- )
- logd(mname, "cancel subscription, response? ${response != null} url: ${response?.raw()?.request?.url}")
- if (response == null) {
- loge(mname, "response is null, failed to cancel subscription")
- return Pair(false, "No response from server, try again later")
- }
- if (!response.isSuccessful) {
- loge(mname, "failed to cancel subscription, response code: ${response.code()}")
- return Pair(
- false,
- "Failed to cancel subscription, response code: ${response.code()}, error: ${response.errorBody()?.string()}"
- )
- }
- // check if the response body is not null and has the status field
- return if (response.code() == 200) {
- val res = RpnProxyManager.updateCancelledSubscription(accountId, purchaseToken)
- Pair(res, "Subscription cancelled successfully")
- } else {
- loge(mname, "err in canceling subscription: ${response.errorBody()?.string()}")
- Pair(false, "Error canceling subscription, reason: ${response.errorBody()?.string()}")
+ suspend fun cancelPlaySubscription(accountId: String, purchaseToken: String): Pair =
+ withContext(Dispatchers.IO) {
+ val mname = "cancelPlaySubscription"
+ logd(mname, "Cancelling subscription for account: $accountId")
+
+ // Use mutex to ensure atomic operation with state machine
+ connectionMutex.withLock {
+ try {
+ // 1. Call backend API
+ val retrofit = RetrofitManager.getTcpProxyBaseBuilder(persistentState.routeRethinkInRethink)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ val retrofitInterface = retrofit.create(ITcpProxy::class.java)
+
+ val response = retrofitInterface.cancelSubscription(persistentState.appVersion.toString(), accountId, purchaseToken, DEBUG)
+
+ // 2. Validate response
+ if (response == null) {
+ loge(mname, "No response from server")
+ return@withLock Pair(false, "No response from server")
+ }
+
+ if (!response.isSuccessful || response.code() != 200) {
+ loge(mname, "API error: ${response.code()}")
+ return@withLock Pair(false, "Server error: ${response.code()}")
+ }
+
+ // 3. Update local state
+ val localSuccess = RpnProxyManager.updateCancelledSubscription(accountId, purchaseToken)
+ if (!localSuccess) {
+ loge(mname, "Failed to update local state")
+ return@withLock Pair(false, "Local state update failed")
+ }
+
+ // 4. Update state machine (atomic operation)
+ try {
+ subscriptionStateMachine.userCancelled()
+ logd(mname, "Subscription cancelled successfully")
+ Pair(true, "Subscription cancelled successfully")
+ } catch (e: Exception) {
+ loge(mname, "State machine update failed: ${e.message}", e)
+ Pair(false, "Cancelled on server but state sync failed")
+ }
+ } catch (e: Exception) {
+ loge(mname, "Error cancelling subscription: ${e.message}", e)
+ Pair(false, "Exception: ${e.message}")
+ }
}
- } catch (e: Exception) {
- loge(mname, "err in canceling subscription: ${e.message}")
}
- return Pair(false, "Error canceling subscription, reason: Unknown error")
- }
+ suspend fun revokeSubscription(accountId: String, purchaseToken: String): Pair =
+ withContext(Dispatchers.IO) {
+ val mname = "revokeSubscription"
+ logd(mname, "Revoking subscription for account: $accountId")
+
+ // Use mutex for atomic operation
+ connectionMutex.withLock {
+ try {
+ // 1. Call backend API
+ val retrofit = RetrofitManager.getTcpProxyBaseBuilder(persistentState.routeRethinkInRethink)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ val retrofitInterface = retrofit.create(ITcpProxy::class.java)
+
+ val response = retrofitInterface.revokeSubscription(persistentState.appVersion.toString(), accountId, purchaseToken, DEBUG)
+
+ // 2. Validate response
+ if (response == null) {
+ loge(mname, "No response from server")
+ return@withLock Pair(false, "No response from server")
+ }
- suspend fun revokeSubscription(accountId: String, purchaseToken: String): Pair {
- // g/refund?cid&purchaseToken&test
- // response: {"message":"canceled subscription","purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
- // {"error":"cannot revoke, subscription canceled or expired","expired":false,"canceled":true,"cancelCtx":{"userInitiatedCancellation":{"cancelSurveyResult":null,"cancelTime":"2025-07-10T13:21:24.743Z"},"systemInitiatedCancellation":null,"developerInitiatedCancellation":null,"replacementCancellation":null},"purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
- val mname = this::revokeSubscription.name
- logd(mname, "revoking subscription for accountId: $accountId, purchaseToken: $purchaseToken")
- // use retrofit to make the API call
- val retrofit = RetrofitManager.getTcpProxyBaseBuilder(persistentState.routeRethinkInRethink)
- .addConverterFactory(GsonConverterFactory.create())
- .build()
- val retrofitInterface = retrofit.create(ITcpProxy::class.java)
- try {
- val response = retrofitInterface.revokeSubscription(
- accountId,
- purchaseToken,
- DEBUG
- )
- logd(mname, "revoke subscription response: ${response?.headers()}, ${response?.message()}, ${response?.raw()?.request?.url}")
- if (response == null) {
- loge(mname, "response is null, failed to revoke subscription")
- return Pair(false, "No response from server, try again later")
- }
- if (!response.isSuccessful) {
- loge(mname, "failed to revoke subscription, response code: ${response.code()}, error: ${response.errorBody()?.string()}, message: ${response.message()}, url: ${response.raw().request.url}")
- return Pair(false, "Failed to revoke subscription, response code: ${response.code()}, error: ${response.errorBody()?.string()}")
- }
- // check if the response body is not null and has the status field
- return if (response.code() == 200) {
- val res = RpnProxyManager.updateRevokedSubscription(accountId, purchaseToken)
- Pair(res, "Subscription revoked successfully")
- } else {
- loge(mname, "err in canceling subscription: ${response.errorBody()?.string()}")
- Pair(false, "Error revoking subscription, reason: ${response.errorBody()?.string()}")
+ if (!response.isSuccessful || response.code() != 200) {
+ loge(mname, "API error: ${response.code()}")
+ return@withLock Pair(false, "Server error: ${response.code()}")
+ }
+
+ // 3. Update local state
+ val localSuccess = RpnProxyManager.updateRevokedSubscription(accountId, purchaseToken)
+ if (!localSuccess) {
+ loge(mname, "Failed to update local state")
+ return@withLock Pair(false, "Local state update failed")
+ }
+
+ // 4. Update state machine (atomic operation)
+ try {
+ subscriptionStateMachine.subscriptionRevoked()
+ logd(mname, "Subscription revoked successfully")
+ Pair(true, "Subscription revoked successfully")
+ } catch (e: Exception) {
+ loge(mname, "State machine update failed: ${e.message}", e)
+ Pair(false, "Revoked on server but state sync failed")
+ }
+ } catch (e: Exception) {
+ loge(mname, "Error revoking subscription: ${e.message}", e)
+ Pair(false, "Exception: ${e.message}")
+ }
}
- } catch (e: Exception) {
- loge(mname, "err in revoking subscription: ${e.message}")
}
- return Pair(false, "Error revoking subscription, reason: Unknown error")
- }
-
suspend fun queryEntitlementFromServer(accountId: String): String? {
// g/entitlement?cid&purchaseToken&test
// response: {"message":"canceled subscription","purchaseId":"c078ba1a42e042f3745e195aa52c952b3c99751f3de9880e6c754682698d5133"}
val mname = this::queryEntitlementFromServer.name
logd(mname, "querying entitlement for accountId: $accountId, test? $DEBUG")
+ if (accountId.isEmpty()) {
+ loge(mname, "accountId is empty, cannot query entitlement")
+ return null
+ }
// use retrofit to make the API call
val retrofit = RetrofitManager.getTcpProxyBaseBuilder(persistentState.routeRethinkInRethink)
.addConverterFactory(GsonConverterFactory.create())
.build()
val retrofitInterface = retrofit.create(ITcpProxy::class.java)
try {
- val response = retrofitInterface.queryEntitlement(accountId, DEBUG)
+ val response = retrofitInterface.queryEntitlement(persistentState.appVersion.toString(), accountId, DEBUG)
logd(mname, "query entitlement response: ${response?.headers()}, ${response?.message()}, ${response?.raw()?.request?.url}")
if (response == null) {
loge(mname, "response is null, failed to query entitlement")
@@ -1323,5 +1416,45 @@ object InAppBillingHandler : KoinComponent {
return null
}
+
+ suspend fun acknowledgePurchaseFromServer(
+ accountId: String,
+ purchaseToken: String
+ ): Pair = withContext(Dispatchers.IO) {
+ val mname = "acknowledgePurchaseFromServer"
+ logd(mname, "Acknowledging purchase for account: $accountId")
+
+ try {
+ // 1. Call backend API
+ val retrofit =
+ RetrofitManager.getTcpProxyBaseBuilder(persistentState.routeRethinkInRethink)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ val retrofitInterface = retrofit.create(ITcpProxy::class.java)
+ val pt = URLEncoder.encode(purchaseToken, "UTF-8")
+ val response = retrofitInterface.acknowledgePurchase(persistentState.appVersion.toString(), accountId, pt, true)
+
+ // 2. Validate response
+ if (response == null) {
+ loge(mname, "No response from server")
+ return@withContext Pair(false, "No response from server")
+ }
+
+ if (!response.isSuccessful || response.code() != 200) {
+ loge(mname, "API error: ${response.code()}")
+ loge(mname, "failed acknowledgePurchaseFromServer, response code: ${response.code()}, error: ${response.errorBody()?.string()}, message: ${response.message()}, url: ${response.raw().request.url}")
+ return@withContext Pair(false, "Server error: ${response.code()}")
+ }
+
+ logd(mname, "Purchase acknowledged successfully")
+ Pair(true, "Purchase acknowledged successfully")
+ } catch (e: Exception) {
+ loge(mname, "Error acknowledging purchase: ${e.message}", e)
+ Pair(false, "Exception: ${e.message}")
+ }
+ }
+
+ fun getLatestPurchaseToken(): String? {
+ return purchasesLiveData.value?.maxByOrNull { it.purchaseTime }?.purchaseToken
+ }
}
-*/
diff --git a/app/src/play/java/com/celzero/bravedns/iab/PricingPhase.kt b/app/src/play/java/com/celzero/bravedns/iab/PricingPhase.kt
index b754d8d03..6cab3f913 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/PricingPhase.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/PricingPhase.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.iab
-/*
+
import com.celzero.bravedns.iab.InAppBillingHandler.RecurringMode
@@ -39,4 +39,3 @@ data class PricingPhase(
freeTrialPeriod = 0,
)
}
-*/
diff --git a/app/src/play/java/com/celzero/bravedns/iab/ProductDetail.kt b/app/src/play/java/com/celzero/bravedns/iab/ProductDetail.kt
index 1f63f733d..36bdbe9cf 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/ProductDetail.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/ProductDetail.kt
@@ -1,5 +1,5 @@
package com.celzero.bravedns.iab
-/*
+
import com.android.billingclient.api.BillingClient
@@ -18,4 +18,4 @@ data class ProductDetail(
productType = BillingClient.ProductType.SUBS,
pricingDetails = listOf(),
)
-}*/
+}
diff --git a/app/src/play/java/com/celzero/bravedns/iab/PurchaseDetail.kt b/app/src/play/java/com/celzero/bravedns/iab/PurchaseDetail.kt
index a6b36ba21..edfb02d16 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/PurchaseDetail.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/PurchaseDetail.kt
@@ -1,5 +1,5 @@
package com.celzero.bravedns.iab
-/*
+
data class PurchaseDetail(
val productId: String,
@@ -17,4 +17,3 @@ data class PurchaseDetail(
val expiryTime: Long,
val status: Int
)
-*/
diff --git a/app/src/play/java/com/celzero/bravedns/iab/QueryProductDetail.kt b/app/src/play/java/com/celzero/bravedns/iab/QueryProductDetail.kt
index 5c85d4307..5ae37ea12 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/QueryProductDetail.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/QueryProductDetail.kt
@@ -1,9 +1,10 @@
package com.celzero.bravedns.iab
-/*
+
import com.android.billingclient.api.ProductDetails
data class QueryProductDetail(
val productDetail: ProductDetail,
val productDetails: ProductDetails,
- val offerDetails: ProductDetails.SubscriptionOfferDetails?
-)*/
+ val offerDetails: ProductDetails.SubscriptionOfferDetails?,
+ val oneTimeOfferDetails: ProductDetails.OneTimePurchaseOfferDetails?
+)
diff --git a/app/src/play/java/com/celzero/bravedns/iab/QueryUtils.kt b/app/src/play/java/com/celzero/bravedns/iab/QueryUtils.kt
index 710a70e86..e556acef4 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/QueryUtils.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/QueryUtils.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.celzero.bravedns.iab
-/*
+
import Logger
import Logger.LOG_IAB
import com.android.billingclient.api.AcknowledgePurchaseParams
@@ -44,6 +44,8 @@ internal class QueryUtils(private val billingClient: BillingClient) {
"P6M" -> "6 months"
"P8M" -> "8 months"
"P1Y" -> "Yearly"
+ "P2Y" -> "2 years"
+ "P5Y" -> "5 years"
else -> ""
}
@@ -57,6 +59,8 @@ internal class QueryUtils(private val billingClient: BillingClient) {
"P6M" -> 180
"P8M" -> 240
"P1Y" -> 365
+ "P2Y" -> 730
+ "P5Y" -> 1825
else -> 0
}
}
@@ -144,4 +148,4 @@ internal class QueryUtils(private val billingClient: BillingClient) {
if (map.values.all { it }) callback(BillingResponse(result.responseCode).isOk)
}
}
-}*/
+}
diff --git a/app/src/play/java/com/celzero/bravedns/iab/Result.kt b/app/src/play/java/com/celzero/bravedns/iab/Result.kt
index 0b5b038fa..c5eadb79b 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/Result.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/Result.kt
@@ -1,5 +1,5 @@
package com.celzero.bravedns.iab
-/*
+
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -80,4 +80,4 @@ object Result {
ResultState.PURCHASE_FAILURE -> ResultState.PURCHASE_FAILURE.message
}
}
-}*/
+}
diff --git a/app/src/play/java/com/celzero/bravedns/iab/ResultState.kt b/app/src/play/java/com/celzero/bravedns/iab/ResultState.kt
index f8e60c944..725ce6932 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/ResultState.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/ResultState.kt
@@ -1,7 +1,5 @@
package com.celzero.bravedns.iab
-/*
-
enum class ResultState(val message: String, val priority: InAppBillingHandler.Priority = InAppBillingHandler.Priority.LOW) {
NONE("Not Started"),
@@ -60,4 +58,3 @@ enum class ResultState(val message: String, val priority: InAppBillingHandler.Pr
PURCHASE_ACK_PENDING("Purchase confirmation is pending", InAppBillingHandler.Priority.MEDIUM),
PURCHASE_FAILURE("Failed to consume product", InAppBillingHandler.Priority.HIGH),
}
-*/
diff --git a/app/src/play/java/com/celzero/bravedns/iab/SubscriptionCheckWorker.kt b/app/src/play/java/com/celzero/bravedns/iab/SubscriptionCheckWorker.kt
index d63b051c5..0f59ddbe5 100644
--- a/app/src/play/java/com/celzero/bravedns/iab/SubscriptionCheckWorker.kt
+++ b/app/src/play/java/com/celzero/bravedns/iab/SubscriptionCheckWorker.kt
@@ -1,6 +1,6 @@
package com.celzero.bravedns.iab
-/*import Logger
+import Logger
import Logger.LOG_IAB
import android.content.Context
import androidx.work.CoroutineWorker
@@ -39,12 +39,12 @@ class SubscriptionCheckWorker(
private fun initiateAndFetchPurchases() {
if (InAppBillingHandler.isBillingClientSetup() && isListenerRegistered(listener)) {
Logger.i(LOG_IAB, "$TAG; initBilling: billing client already setup")
- InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS))
+ InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS, ProductType.INAPP))
return
}
if (InAppBillingHandler.isBillingClientSetup() && !isListenerRegistered(listener)) {
InAppBillingHandler.registerListener(listener)
- InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS))
+ InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS, ProductType.INAPP))
Logger.i(LOG_IAB, "$TAG; initBilling: billing listener registered")
return
}
@@ -70,7 +70,7 @@ class SubscriptionCheckWorker(
return
}
// check for the subscription status after the connection is established
- val productType = listOf(ProductType.SUBS)
+ val productType = listOf(ProductType.SUBS, ProductType.INAPP)
InAppBillingHandler.fetchPurchases(productType)
}
@@ -94,4 +94,4 @@ class SubscriptionCheckWorker(
}
}
-}*/
+}
diff --git a/app/src/play/java/com/celzero/bravedns/ui/fragment/RethinkPlusFragment.kt b/app/src/play/java/com/celzero/bravedns/ui/fragment/RethinkPlusFragment.kt
index 8eadb4d48..8048db263 100644
--- a/app/src/play/java/com/celzero/bravedns/ui/fragment/RethinkPlusFragment.kt
+++ b/app/src/play/java/com/celzero/bravedns/ui/fragment/RethinkPlusFragment.kt
@@ -13,886 +13,593 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.celzero.bravedns.ui.fragment
-/*
+ package com.celzero.bravedns.ui.fragment
import Logger
-import Logger.LOG_IAB
-import android.content.Intent
+import android.animation.ObjectAnimator
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.text.Spanned
import android.text.method.LinkMovementMethod
-import android.util.TypedValue
-import android.view.LayoutInflater
import android.view.View
-import android.widget.TextView
+import android.view.animation.AnticipateOvershootInterpolator
import android.widget.Toast
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.widget.AppCompatButton
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.core.content.ContentProviderCompat.requireContext
-import androidx.core.content.ContextCompat.startActivity
import androidx.core.text.HtmlCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
-import androidx.lifecycle.distinctUntilChanged
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import by.kirich1409.viewbindingdelegate.viewBinding
-import com.android.billingclient.api.BillingClient.ProductType
import com.celzero.bravedns.R
-import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG
import com.celzero.bravedns.adapter.GooglePlaySubsAdapter
-import com.celzero.bravedns.adapter.GooglePlaySubsAdapter.SubscriptionChangeListener
-import com.celzero.bravedns.databinding.FragmentRethinkPlusBinding
+import com.celzero.bravedns.databinding.FragmentRethinkPlusPremiumBinding
import com.celzero.bravedns.iab.BillingListener
import com.celzero.bravedns.iab.InAppBillingHandler
-import com.celzero.bravedns.iab.InAppBillingHandler.fetchPurchases
-import com.celzero.bravedns.iab.InAppBillingHandler.purchaseSubs
import com.celzero.bravedns.iab.ProductDetail
import com.celzero.bravedns.iab.PurchaseDetail
-import com.celzero.bravedns.rpnproxy.PipKeyManager
-import com.celzero.bravedns.rpnproxy.RpnProxyManager
-import com.celzero.bravedns.service.VpnController
-import com.celzero.bravedns.subscription.SubscriptionStateMachineV2
-import com.celzero.bravedns.ui.activity.FragmentHostActivity
-import com.celzero.bravedns.ui.activity.RpnAvailabilityCheckActivity
-import com.celzero.bravedns.util.Constants.Companion.PKG_NAME_PLAY_STORE
-import com.celzero.bravedns.util.UIUtils.fetchColor
-import com.celzero.bravedns.util.UIUtils.underline
+import com.celzero.bravedns.ui.bottomsheet.PurchaseProcessingBottomSheet
+import com.celzero.bravedns.util.UIUtils
import com.celzero.bravedns.util.Utilities
+import com.celzero.bravedns.viewmodel.RethinkPlusViewModel
+import com.celzero.bravedns.viewmodel.SubscriptionUiState
import com.facebook.shimmer.Shimmer
-import com.google.android.gms.common.GooglePlayServicesUtilLight.isGooglePlayServicesAvailable
-import com.google.android.material.bottomnavigation.BottomNavigationView
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-class RethinkPlusFragment : Fragment(R.layout.fragment_rethink_plus), SubscriptionChangeListener, BillingListener {
- private val b by viewBinding(FragmentRethinkPlusBinding::bind)
- private var productId = ""
- private var planId = ""
- private var loadingDialog: AlertDialog? = null
- private var errorDialog: AlertDialog? = null
- private var msgDialog: AlertDialog? = null
+class RethinkPlusFragment : Fragment(R.layout.fragment_rethink_plus_premium),
+ GooglePlaySubsAdapter.SubscriptionChangeListener,
+ BillingListener {
- private var pollingJob: Job? = null
- private var pollingStartTime = 0L
+ private val b by viewBinding(FragmentRethinkPlusPremiumBinding::bind)
+ private val viewModel: RethinkPlusViewModel by viewModels()
private var adapter: GooglePlaySubsAdapter? = null
+ private var processingBottomSheet: PurchaseProcessingBottomSheet? = null
- //private val subsProducts: MutableList = mutableListOf()
+ // timeout job to avoid keeping the processing bottom sheet forever
+ private var processingTimeoutJob: kotlinx.coroutines.Job? = null
+ private var shouldRecheckOnResume: Boolean = false
companion object {
private const val TAG = "R+Ui"
- private const val POLLING_INTERVAL_MS = 1500L // 1.5 seconds
- private const val POLLING_TIMEOUT_MS = 30000L // 60 seconds
- private const val ON_HOLD_PERIOD = 1 * 24 * 60 * 60 * 1000L
+ private const val PROCESSING_TIMEOUT_MS = 60_000L
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- // this should not happen, but just in case, as the caller fragment would have already
- // checked for the subscription status
- if (isRethinkPlusSubscribed()) {
- handlePlusSubscribed(RpnProxyManager.getRpnProductId())
- return
- }
- initView()
- initObservers()
+ setupUI()
+ setupObservers()
setupClickListeners()
+ initializeBilling()
}
- override fun onSubscriptionSelected(productId: String, planId: String) {
- this.productId = productId
- this.planId = planId
- Logger.d(LOG_IAB, "Selected product: $productId, $planId")
+ private fun setupUI() {
+ setupRecyclerView()
+ setupTermsAndPolicy()
+ setupProductTypeToggle()
}
- private fun initView() {
- // show a loading dialog
- showLoadingDialog()
+ private fun setupRecyclerView() {
+ b.subscriptionPlans.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ setHasFixedSize(true)
+ }
+ }
- io {
- val playAvailable = isGooglePlayServicesAvailable()
- if (!playAvailable) {
- uiCtx { showRethinkNotAvailableUi(requireContext().getString(R.string.play_service_not_available)) }
- return@io
- }
+ private fun setupTermsAndPolicy() {
+ b.termsText.apply {
+ text = updateHtmlEncodedText(getString(R.string.rethink_terms))
+ movementMethod = LinkMovementMethod.getInstance()
+ highlightColor = Color.TRANSPARENT
+ }
+ }
- val works = isRethinkPlusAvailable()
+ private fun setupProductTypeToggle() {
+ // Set initial state
+ updateToggleState(RethinkPlusViewModel.ProductTypeFilter.SUBSCRIPTION)
- if (!works.first) {
- uiCtx { showRethinkNotAvailableUi(works.second) }
- return@io
- }
+ b.btnSubscription.setOnClickListener {
+ animateButtonPress(it)
+ viewModel.selectProductType(RethinkPlusViewModel.ProductTypeFilter.SUBSCRIPTION)
+ }
- // initiate the product details query
- queryProductDetail()
+ b.btnOneTime.setOnClickListener {
+ animateButtonPress(it)
+ viewModel.selectProductType(RethinkPlusViewModel.ProductTypeFilter.ONE_TIME)
}
}
- private fun isRethinkPlusSubscribed(): Boolean {
- // check whether the rethink+ is subscribed or not
- return InAppBillingHandler.hasValidSubscription()
+ private fun updateToggleState(selectedType: RethinkPlusViewModel.ProductTypeFilter) {
+ when (selectedType) {
+ RethinkPlusViewModel.ProductTypeFilter.SUBSCRIPTION -> {
+ b.btnSubscription.apply {
+ setBackgroundColor(UIUtils.fetchColor(requireContext(), R.attr.accentGood))
+ setTextColor(UIUtils.fetchColor(requireContext(), android.R.attr.textColorPrimaryInverse))
+ }
+ b.btnOneTime.apply {
+ setBackgroundColor(Color.TRANSPARENT)
+ setTextColor(UIUtils.fetchColor(requireContext(), R.attr.primaryTextColor))
+ }
+ }
+ RethinkPlusViewModel.ProductTypeFilter.ONE_TIME -> {
+ b.btnOneTime.apply {
+ setBackgroundColor(UIUtils.fetchColor(requireContext(), R.attr.accentGood))
+ setTextColor(UIUtils.fetchColor(requireContext(), android.R.attr.textColorPrimaryInverse))
+ }
+ b.btnSubscription.apply {
+ setBackgroundColor(Color.TRANSPARENT)
+ setTextColor(UIUtils.fetchColor(requireContext(), R.attr.primaryTextColor))
+ }
+ }
+ }
+
+ // Update subscribe button text based on product type and resubscribe state
+ val isResubscribe = checkIfResubscribe()
+ updateSubscribeButtonText(selectedType, isResubscribe)
}
- */
-/*private fun setBanner() {
- b.shimmerViewBanner.setShimmer(Shimmer.AlphaHighlightBuilder()
- .setDuration(2000)
- .setBaseAlpha(0.85f)
- .setDropoff(1f)
- .setHighlightAlpha(0.35f)
- .build())
- b.shimmerViewBanner.startShimmer()
- // Initialize adapter
- val myPagerAdapter = MyPagerAdapter()
+ /**
+ * Update subscribe button text based on product type and resubscribe state
+ */
+ private fun updateSubscribeButtonText(
+ productType: RethinkPlusViewModel.ProductTypeFilter,
+ isResubscribe: Boolean = false
+ ) {
+ b.subscribeButton.text = when (productType) {
+ RethinkPlusViewModel.ProductTypeFilter.SUBSCRIPTION -> {
+ if (isResubscribe) {
+ getString(R.string.resubscribe_title)
+ } else {
+ getString(R.string.subscribe_now)
+ }
+ }
+ RethinkPlusViewModel.ProductTypeFilter.ONE_TIME -> {
+ getString(R.string.purchase_now)
+ }
+ }
+ }
- // Set up ViewPager
- b.viewPager.adapter = myPagerAdapter
- b.viewPager.addOnPageChangeListener(
- object : ViewPager.OnPageChangeListener {
- override fun onPageScrollStateChanged(state: Int) {}
+ // Track resubscribe state
+ private var isResubscribeState: Boolean = false
- override fun onPageScrolled(
- position: Int,
- positionOffset: Float,
- positionOffsetPixels: Int
- ) {
- }
+ /**
+ * Check if user needs to resubscribe (e.g., cancelled subscription)
+ */
+ private fun checkIfResubscribe(): Boolean {
+ return isResubscribeState
+ }
- override fun onPageSelected(position: Int) {
- addBottomDots(position)
+ private fun setupObservers() {
+ // observe UI state
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect { state ->
+ handleUiState(state)
}
}
- )
- }
-
- inner class MyPagerAdapter : PagerAdapter() {
- override fun isViewFromObject(view: View, `object`: Any): Boolean {
- return view == `object`
}
- override fun getCount(): Int {
- return layouts.count()
+ // observe selected product
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.selectedProduct.collect { selection ->
+ selection?.let {
+ Logger.d(Logger.LOG_IAB, "$TAG: Selected product: ${it.first}, plan: ${it.second}")
+ }
+ }
+ }
}
- override fun instantiateItem(container: ViewGroup, position: Int): Any {
- val imageView = ImageView(requireContext()).apply {
- setImageResource(layouts[position])
- scaleType =
- ImageView.ScaleType.CENTER_CROP // or FIT_CENTER, CENTER_INSIDE depending on your need
- layoutParams = ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
+ // observe product type filter changes
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.selectedProductType.collect { productType ->
+ Logger.d(Logger.LOG_IAB, "$TAG: Product type changed to: ${productType.name}")
+ updateToggleState(productType)
+ }
}
- container.addView(imageView)
- return imageView
}
- override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
- container.removeView(`object` as View)
+ // observe InAppBillingHandler LiveData
+ InAppBillingHandler.productDetailsLiveData.observe(viewLifecycleOwner) { products ->
+ viewModel.onProductsFetched(products.isNotEmpty(), products)
}
- }*//*
+ InAppBillingHandler.connectionResultLiveData.observe(viewLifecycleOwner) { result ->
+ viewModel.onBillingConnected(result.isSuccess, result.message)
+ }
- private fun initTermsAndPolicy() {
- b.termsText.text = updateHtmlEncodedText(getString(R.string.rethink_terms))
- b.termsText.movementMethod = LinkMovementMethod.getInstance()
- b.termsText.highlightColor = Color.TRANSPARENT
- }
+ // observe transaction errors - dismiss bottom sheet and show error
+ InAppBillingHandler.transactionErrorLiveData.observe(viewLifecycleOwner) { billingResult ->
+ Logger.w(Logger.LOG_IAB, "$TAG: Transaction error received: ${billingResult.responseCode}, ${billingResult.debugMessage}")
+ // Dismiss bottom sheet immediately
+ dismissProcessingBottomSheet()
- fun updateHtmlEncodedText(text: String): Spanned {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY)
- } else {
- HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY)
+ // Show error based on response code
+ when (billingResult.responseCode) {
+ 1 -> { // USER_CANCELED
+ // User cancelled - just dismiss, no error message needed
+ Logger.d(Logger.LOG_IAB, "$TAG: User cancelled purchase, bottom sheet dismissed")
+ }
+ else -> {
+ // Show error for other cases
+ val errorMessage = billingResult.debugMessage ?: "Transaction failed"
+ showTransactionError(errorMessage)
+ }
+ }
}
}
- override fun onResume() {
- super.onResume()
+ /**
+ * Handle UI state changes
+ */
+ private fun handleUiState(state: SubscriptionUiState) {
+ Logger.d(Logger.LOG_IAB, "$TAG: Handling UI state: ${state::class.simpleName}")
+
+ when (state) {
+ is SubscriptionUiState.Loading -> showLoading()
+ is SubscriptionUiState.Ready -> showReady(state.products, state.isResubscribe)
+ is SubscriptionUiState.Processing -> showProcessing(state.message)
+ is SubscriptionUiState.PendingPurchase -> showPendingPurchase()
+ is SubscriptionUiState.Success -> showSuccess(state.productId)
+ is SubscriptionUiState.Error -> showError(state.title, state.message, state.isRetryable)
+ is SubscriptionUiState.AlreadySubscribed -> navigateToDashboard(state.productId)
+ }
+ }
+
+ /**
+ * Show loading state
+ */
+ private fun showLoading() {
+ hideAllContainers()
+ b.loadingContainer.isVisible = true
startShimmer()
}
- override fun onPause() {
- super.onPause()
+ /**
+ * Show ready state with products
+ */
+ private fun showReady(products: List, isResubscribe: Boolean) {
+ hideAllContainers()
stopShimmer()
- }
- private fun stopShimmer() {
- if (!b.shimmerViewContainer.isShimmerStarted) return
-
- b.shimmerViewContainer.stopShimmer()
- }
+ b.scrollView.isVisible = true
+ b.subscribeButtonContainer.isVisible = true
- private fun startShimmer() {
- if (!b.shimmerViewContainer.isVisible) return
+ // Store resubscribe state
+ isResubscribeState = isResubscribe
- if (b.shimmerViewContainer.isShimmerStarted) return
+ // update button text based on product type and resubscribe state
+ updateSubscribeButtonText(viewModel.selectedProductType.value, isResubscribe)
- val builder = Shimmer.AlphaHighlightBuilder()
- builder.setDuration(2000)
- builder.setBaseAlpha(0.85f)
- builder.setDropoff(1f)
- builder.setHighlightAlpha(0.35f)
- b.shimmerViewContainer.setShimmer(builder.build())
- b.shimmerViewContainer.startShimmer()
- }
-
- private fun isBillingAvailable(): Boolean {
- return InAppBillingHandler.isBillingClientSetup()
- }
-
- private fun initiateBillingIfNeeded() {
- if (isBillingAvailable()) {
- Logger.i(LOG_IAB, "ensureBillingSetup: billing client already setup")
- return
+ // set adapter data
+ if (adapter == null) {
+ // create adapter with products and showShimmer = false
+ adapter = GooglePlaySubsAdapter(this, requireContext(), products, 0, false)
+ b.subscriptionPlans.adapter = adapter
+ } else {
+ adapter?.setData(products)
}
- InAppBillingHandler.initiate(requireContext().applicationContext, this)
- Logger.i(LOG_IAB, "ensureBillingSetup: billing client initiated")
+ // animate entrance
+ animateContentEntrance()
}
- private suspend fun queryProductDetail() {
- initiateBillingIfNeeded()
- InAppBillingHandler.queryProductDetailsWithTimeout()
- Logger.v(LOG_IAB, "queryProductDetails: initiated")
+ /**
+ * Show processing state
+ */
+ private fun showProcessing(message: String) {
+ showProcessingBottomSheet(
+ PurchaseProcessingBottomSheet.ProcessingState.Processing,
+ message
+ )
+ startProcessingTimeout()
}
- private fun purchaseSubs() {
- if (!isBillingAvailable()) {
- Logger.e(LOG_IAB, "purchaseSubs: billing client not available")
- Utilities.showToastUiCentered(
- requireContext(),
- "Billing client not available, please try again later",
- Toast.LENGTH_LONG
- )
- showNotAvailableUi()
- return
- }
- if (!InAppBillingHandler.canMakePurchase()) {
- Logger.e(LOG_IAB, "purchaseSubs: cannot make purchase")
- Utilities.showToastUiCentered(
- requireContext(),
- "Cannot make purchase, please try again later",
- Toast.LENGTH_LONG
- )
- showNotAvailableUi()
- return
- }
- // initiate the payment flow
- io { InAppBillingHandler.purchaseSubs(requireActivity(), productId, planId) }
- Logger.v(LOG_IAB, "purchaseSubs: initiated for $productId, $planId")
+ /**
+ * Show pending purchase state
+ */
+ private fun showPendingPurchase() {
+ showProcessingBottomSheet(
+ PurchaseProcessingBottomSheet.ProcessingState.PendingVerification,
+ null
+ )
+ startProcessingTimeout()
}
- */
-/*private lateinit var dots: Array
- private val layouts: IntArray = intArrayOf(
- R.drawable.rethink_plus_home_banner,
- R.drawable.rethink_plus_banner_anti_censorship,
- R.drawable.rethink_plus_hide_ip_banner
- )
-
- private fun addBottomDots(currentPage: Int) {
- dots = arrayOfNulls(layouts.size)
-
- val colorActive = fetchColor(requireContext(), R.attr.primaryColor)
- val colorInActive = fetchColor(requireContext(), R.attr.primaryDarkColor)
+ /**
+ * Show success state
+ */
+ private fun showSuccess(productId: String) {
+ // cancel any timeout since we have a result
+ cancelProcessingTimeout()
- b.layoutDots.removeAllViews()
+ // show success state in bottom sheet
+ showProcessingBottomSheet(
+ PurchaseProcessingBottomSheet.ProcessingState.Success,
+ getString(R.string.subscription_activated)
+ )
- for (i in dots.indices) {
- dots[i] = TextView(requireContext())
- dots[i]?.text = updateHtmlEncodedText("•")
- dots[i]?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 30F)
- dots[i]?.setTextColor(colorInActive)
- b.layoutDots.addView(dots[i])
+ // show subscription animation dialog immediately on successful purchase
+ try {
+ com.celzero.bravedns.ui.dialog.SubscriptionAnimDialog()
+ .show(childFragmentManager, "SubscriptionAnimDialog")
+ } catch (e: Exception) {
+ Logger.w(Logger.LOG_IAB, "$TAG: err showing subscription anim dialog: ${e.message}")
}
- if (dots.isNotEmpty()) {
- dots[currentPage]?.setTextColor(colorActive)
+ // Navigate after delay
+ viewLifecycleOwner.lifecycleScope.launch {
+ delay(1500)
+ navigateToDashboard(productId)
}
- }*//*
+ }
+ /**
+ * Show error state
+ */
+ private fun showError(title: String, message: String, isRetryable: Boolean) {
+ // dismiss any processing bottom sheet and cancel timeout
+ cancelProcessingTimeout()
+ dismissProcessingBottomSheet()
- private fun showLoadingDialog() {
- val builder = MaterialAlertDialogBuilder(requireContext())
- // show progress dialog
- builder.setTitle(requireContext().getString(R.string.loading_dialog_title))
- builder.setMessage(requireContext().getString(R.string.rethink_plus_loading_dialog_desc))
- builder.setCancelable(true)
- loadingDialog = builder.create()
- loadingDialog?.show()
- loadingDialog?.setOnCancelListener {
- Logger.v(LOG_IAB, "loading dialog cancelled")
- // if the user cancels the dialog, stop the pending purchase polling
- stopPendingPurchasePolling()
- // navigate to home screen
- //navigateToHomeScreen()
- }
+ hideAllContainers()
+ stopShimmer()
+
+ b.errorContainer.isVisible = true
+ b.errorTitle.text = title
+ b.errorMessage.text = message
+ b.retryButton.isVisible = isRetryable
}
- private fun hideLoadingDialog() {
- Logger.v(LOG_IAB, "hide loading dialog")
- if (isAdded && loadingDialog?.isShowing == true) {
- loadingDialog?.dismiss()
+ /**
+ * Show transaction error via toast
+ */
+ private fun showTransactionError(message: String) {
+ if (!isAdded || context == null) {
+ Logger.w(Logger.LOG_IAB, "$TAG: Cannot show error - fragment not attached")
+ return
}
- Logger.v(LOG_IAB, "loading dialog dismissed")
- }
- private suspend fun isRethinkPlusAvailable(): Pair {
- // TODO: added for testing, remove later
- // check whether the rethink+ is available for the user or not
- val res = PipKeyManager.isRethinkPlusActive(requireContext())
- // added as default text, maybe pass relevant message from the server/calling function
- // the message part is used only when the result is false
- Logger.i(LOG_IAB, "isRethinkPlusAvailable? $res")
- return res
+ Utilities.showToastUiCentered(
+ requireContext(),
+ message,
+ Toast.LENGTH_LONG
+ )
}
- private fun showPendingPurchaseUi() {
- if (!isAdded) return
- hideLoadingDialog()
- hidePaymentContainerUi()
- hideNotAvailableUi()
- hideErrorDialog()
- hideMsgDialog()
-
- b.topBanner.visibility = View.VISIBLE
- b.shimmerViewContainer.visibility = View.GONE
- b.pendingPurchaseLayout.visibility = View.VISIBLE
- }
-
- private fun showPaymentContainerUi() {
- Logger.v(LOG_IAB, "showPaymentContainerUi: showing payment container UI")
- initTermsAndPolicy()
- hideLoadingDialog()
- hideErrorDialog()
- hideMsgDialog()
- hideNotAvailableUi()
-
- b.topBanner.visibility = View.VISIBLE
- b.shimmerViewContainer.visibility = View.VISIBLE
- b.paymentContainer.visibility = View.VISIBLE
- b.paymentButtonContainer.visibility = View.VISIBLE
- b.testPingButton.underline()
- setAdapter(emptyList())
- Logger.i(LOG_IAB, "adapter set")
- }
-
- private fun setAdapterData(subsProduct: List) {
- hideLoadingDialog()
- hideErrorDialog()
- hideMsgDialog()
- if (b.paymentContainer.visibility != View.VISIBLE) {
- showPaymentContainerUi()
- }
- if (adapter == null) {
- Logger.d(LOG_IAB, "Adapter is null, initializing it")
- setAdapter(subsProduct)
+ /**
+ * Show/update processing bottom sheet
+ */
+ private fun showProcessingBottomSheet(
+ state: PurchaseProcessingBottomSheet.ProcessingState,
+ message: String?
+ ) {
+ if (processingBottomSheet == null || processingBottomSheet?.isAdded != true) {
+ processingBottomSheet = PurchaseProcessingBottomSheet.Companion.newInstance(state, message)
+ processingBottomSheet?.show(childFragmentManager, "processing")
} else {
- Logger.d(LOG_IAB, "Adapter is not null, updating data")
- adapter?.setData(subsProduct)
+ processingBottomSheet?.updateState(state, message)
}
}
- private fun hidePaymentContainerUi() {
- if (!isAdded) return
-
- b.paymentContainer.visibility = View.GONE
- b.paymentButtonContainer.visibility = View.GONE
- b.shimmerViewContainer.visibility = View.GONE
- }
-
- private fun showNotAvailableUi() {
- hideLoadingDialog()
- hidePaymentContainerUi()
- b.topBanner.visibility = View.GONE
-
- b.notAvailableLayout.visibility = View.VISIBLE
- }
-
- private fun hideNotAvailableUi() {
- b.notAvailableLayout.visibility = View.GONE
- }
-
- private fun showRethinkNotAvailableUi(msg: String) {
- showNotAvailableUi()
- hideLoadingDialog()
- hideMsgDialog()
- hideErrorDialog()
- val dialogView = LayoutInflater.from(requireContext())
- .inflate(R.layout.dialog_transaction_error, null)
-
- dialogView.findViewById(R.id.dialog_title).text = requireContext().getString(R.string.rpn_availablity)
- dialogView.findViewById(R.id.dialog_message).text = msg
-
- msgDialog = MaterialAlertDialogBuilder(requireContext())
- .setView(dialogView)
- .setCancelable(false)
- .create()
-
- dialogView.findViewById(R.id.button_ok).apply {
- text = requireContext().getString(R.string.dns_info_positive)
- setOnClickListener {
- msgDialog?.dismiss()
+ /**
+ * Dismiss processing bottom sheet
+ */
+ private fun dismissProcessingBottomSheet() {
+ cancelProcessingTimeout()
+ try {
+ processingBottomSheet?.dismissAllowingStateLoss()
+ } catch (e: Exception) {
+ Logger.w(Logger.LOG_IAB, "$TAG: err dismissing btmsht: ${e.message}")
+ } finally {
+ processingBottomSheet = null
+ }
+ }
+
+ private fun startProcessingTimeout() {
+ cancelProcessingTimeout()
+ processingTimeoutJob = viewLifecycleOwner.lifecycleScope.launch {
+ delay(PROCESSING_TIMEOUT_MS)
+ // if still showing processing, dismiss and notify user
+ if (isAdded && processingBottomSheet?.isAdded == true) {
+ Logger.w(Logger.LOG_IAB, "$TAG: processing timeout reached, dismissing bottom sheet")
+ dismissProcessingBottomSheet()
+ shouldRecheckOnResume = true
+ showTransactionError(getString(R.string.subscription_processing_timeout))
}
}
- msgDialog?.show()
}
- private fun setAdapter(productDetails: List) {
- // set the adapter for the recycler view
- Logger.i(LOG_IAB, "setting adapter for the recycler view: ${productDetails.size}")
- b.subscriptionPlans.setHasFixedSize(true)
- val layoutManager = LinearLayoutManager(requireContext())
- b.subscriptionPlans.layoutManager = layoutManager
- adapter = GooglePlaySubsAdapter(this, requireContext(), productDetails)
- b.subscriptionPlans.adapter = adapter
+ private fun cancelProcessingTimeout() {
+ processingTimeoutJob?.cancel()
+ processingTimeoutJob = null
}
- fun startPendingPurchasePolling(scope: CoroutineScope) {
- if (pollingJob != null) return
-
- pollingStartTime = System.currentTimeMillis()
- pollingJob = scope.launch(Dispatchers.IO) {
- while (isActive) {
- val elapsedTime = System.currentTimeMillis() - pollingStartTime
- if (elapsedTime > POLLING_TIMEOUT_MS) {
- Logger.i(LOG_IAB, "Polling timeout reached, stopping pending purchase polling, elapsed: $elapsedTime ms")
- stopPendingPurchasePolling()
- //navigateToHomeScreen()
- break
- }
+ /**
+ * Hide all container views
+ */
+ private fun hideAllContainers() {
+ b.loadingContainer.isVisible = false
+ b.scrollView.isVisible = false
+ b.subscribeButtonContainer.isVisible = false
+ b.errorContainer.isVisible = false
+ }
- Logger.d(LOG_IAB, "Polling pending purchase status, elapsed: $elapsedTime ms")
- fetchPurchases(listOf(ProductType.SUBS))
- delay(POLLING_INTERVAL_MS)
- }
+ /**
+ * Setup click listeners
+ */
+ private fun setupClickListeners() {
+ b.subscribeButton.setOnClickListener {
+ animateButtonPress(it)
+ purchaseSubscription()
}
- }
- fun stopPendingPurchasePolling() {
- pollingJob?.cancel()
- pollingJob = null
- Logger.i(LOG_IAB, "Pending purchase polling stopped")
+ b.retryButton.setOnClickListener {
+ animateButtonPress(it)
+ viewModel.retry()
+ }
}
- private fun hidePendingPurchaseUi() {
- if (!isAdded) return
-
- b.pendingPurchaseLayout.visibility = View.GONE
- b.topBanner.visibility = View.GONE
- b.shimmerViewContainer.visibility = View.GONE
- }
-
- private fun navigateToHomeScreen() {
- ui {
- if (!isAdded) return@ui
- try {
- val btmNavView = activity?.findViewById(R.id.nav_view)
- val homeId = R.id.homeScreenFragment
- btmNavView?.selectedItemId = homeId
- findNavController().navigate(R.id.action_switch_to_homeScreenFragment)
- } catch (e: Exception) {
- Logger.e(LOG_IAB, "Navigation failed: ${e.message}")
- }
+ /**
+ * Initialize billing
+ */
+ private fun initializeBilling() {
+ if (!InAppBillingHandler.isBillingClientSetup()) {
+ InAppBillingHandler.initiate(requireContext().applicationContext, this)
}
+ viewModel.initializeBilling()
}
- private fun initObservers() {
- */
-/*io {
- Result.getResultStateFlow().collect { i ->
- Logger.d(LOG_IAB, "res state: ${i.name}, ${i.message};p? ${i.priority}")
- if (i.priority == InAppBillingHandler.Priority.HIGH) {
- ui {
- Logger.e(LOG_IAB, "res failure: ${i.name}, ${i.message}; p? ${i.priority}")
-
- if (isAdded && isVisible) {
- hideLoadingDialog()
- showErrorDialog(requireContext().getString(R.string.settings_gologger_dialog_option_5), i.message)
- }
- }
- }
- }
- }*//*
-
-
- InAppBillingHandler.connectionResultLiveData.distinctUntilChanged().observe(viewLifecycleOwner) { i ->
- if (!i.isSuccess) {
- Logger.e(LOG_IAB, "Billing connection failed: ${i.message}")
- ui {
- if (isAdded && isVisible) {
- hideLoadingDialog()
- Utilities.showToastUiCentered(
- requireContext(),
- i.message,
- Toast.LENGTH_SHORT
- )
- showNotAvailableUi()
- }
- }
- return@observe
- }
- // check for the subscription status after the connection is established
- val productType = listOf(ProductType.SUBS)
- io {
- fetchPurchases(productType)
- }
+ /**
+ * Purchase subscription or one-time product based on current selection
+ */
+ private fun purchaseSubscription() {
+ val selection = viewModel.selectedProduct.value
+ if (selection == null) {
+ Utilities.showToastUiCentered(
+ requireContext(),
+ getString(R.string.select_plan_first),
+ Toast.LENGTH_SHORT
+ )
+ return
}
- io {
- RpnProxyManager.collectSubscriptionState().collect { state ->
- Logger.d(LOG_IAB, "Subscription state changed: ${state.name}")
- // Handle state changes if needed
- handleStateChange(state)
- }
- }
+ val (productId, planId) = selection
+ val productType = viewModel.selectedProductType.value
- InAppBillingHandler.productDetailsLiveData.observe(viewLifecycleOwner) { list ->
- Logger.d(LOG_IAB, "product details: ${list.size}")
- if (list.isEmpty()) {
- Logger.e(LOG_IAB, "product details is empty")
- ui {
- if (isAdded && isVisible) {
- hideLoadingDialog()
- Utilities.showToastUiCentered(
- requireContext(),
- requireContext().getString(R.string.product_details_error),
- Toast.LENGTH_SHORT
- )
- showNotAvailableUi()
- showRethinkNotAvailableUi(
- requireContext().getString(R.string.product_details_error)
- )
- return@ui
- }
+ lifecycleScope.launch {
+ when (productType) {
+ RethinkPlusViewModel.ProductTypeFilter.SUBSCRIPTION -> {
+ InAppBillingHandler.purchaseSubs(requireActivity(), productId, planId)
}
- return@observe
- }
- val subsProducts = mutableListOf()
- subsProducts.addAll(list.filter { it.productType == ProductType.SUBS })
- // set the first product as the default selected product
- val first = subsProducts.first()
- productId = first.productId
- planId = first.planId
-
- val currState = RpnProxyManager.getSubscriptionState()
- if (!currState.canMakePurchase) {
- // if the user has a valid subscription, handle it
- Logger.i(LOG_IAB, "canMakePurchase is false, no purchase allowed, current state: ${currState.name}")
- return@observe
- }
-
- if (subsProducts.isEmpty()) {
- Logger.e(LOG_IAB, "subscription product details is empty")
- ui {
- if (isAdded && isVisible) {
- hideLoadingDialog()
- Utilities.showToastUiCentered(
- requireContext(),
- requireContext().getString(R.string.product_details_error),
- Toast.LENGTH_SHORT
- )
- showNotAvailableUi()
- showRethinkNotAvailableUi(
- requireContext().getString(R.string.product_details_error)
- )
- return@ui
- }
+ RethinkPlusViewModel.ProductTypeFilter.ONE_TIME -> {
+ InAppBillingHandler.purchaseOneTime(requireActivity(), productId, planId)
}
- return@observe
- }
- if (isAdded && isVisible) {
- setAdapterData(subsProducts)
- Logger.i(LOG_IAB, "product details fetched, size: ${subsProducts.size}")
}
}
-
- */
-/*InAppBillingHandler.transactionErrorLiveData.observe(viewLifecycleOwner) { billingResult ->
- if (isAdded && isVisible) {
- hideLoadingDialog()
- val error = getTransactionError(billingResult)
- showErrorDialog(error.title, error.message)
- }
- }*//*
-
}
- private fun handleStateChange(state: SubscriptionStateMachineV2.SubscriptionState) {
- Logger.d(LOG_IAB, "$TAG handleStateChange: ${state.name}")
- when (state) {
- SubscriptionStateMachineV2.SubscriptionState.PurchasePending -> {
- // show the pending purchase UI
- ui { showPendingPurchaseUi() }
- startPendingPurchasePolling(this.lifecycleScope)
- }
- SubscriptionStateMachineV2.SubscriptionState.Active -> {
- // handle the active state
- // hide the loading dialog and pending purchase UI
- ui {
- if (!isAdded) return@ui
- hideLoadingDialog()
- hidePendingPurchaseUi()
- hidePaymentContainerUi()
- // navigate to the rethink+ dashboard
- handlePlusSubscribed(RpnProxyManager.getRpnProductId())
- }
+ /**
+ * Navigate to dashboard
+ */
+ private fun navigateToDashboard(productId: String) {
+ if (!isAdded) return
- }
- SubscriptionStateMachineV2.SubscriptionState.Error -> {
- // handle the error state
- ui { showErrorDialog("Subscription Error", "An error occurred while processing your subscription.") }
- }
- SubscriptionStateMachineV2.SubscriptionState.Initial -> {
- // do nothing for initial state, as it is handled when product details are fetched
- }
- SubscriptionStateMachineV2.SubscriptionState.Cancelled,
- SubscriptionStateMachineV2.SubscriptionState.Revoked,
- SubscriptionStateMachineV2.SubscriptionState.Expired -> {
- // show the products UI with the option to resubscribe
- // edit the button text to "Resubscribe"
- ui {
- if (!isAdded) return@ui
- hideLoadingDialog()
- hidePendingPurchaseUi()
- showPaymentContainerUi()
-
- val data = RpnProxyManager.getSubscriptionData()
- if (data == null) {
- Logger.e(LOG_IAB, "Subscription data is null, cannot show resubscribe UI")
- b.paymentButton.text = getString(R.string.subscribe_title)
- return@ui
- }
- val billingExpiry = data.purchaseDetail?.expiryTime ?: 0L
- // if expiry time is greater than 60 days do not show the resubscribe option
- Logger.v(LOG_IAB, "billingExpiry: $billingExpiry, current time: ${System.currentTimeMillis()}, on-hold period: $ON_HOLD_PERIOD, debug: $DEBUG, resubscribe? ${billingExpiry > 0L && (System.currentTimeMillis() - billingExpiry < ON_HOLD_PERIOD)}")
- if (billingExpiry <= 0L || (System.currentTimeMillis() - billingExpiry < ON_HOLD_PERIOD || DEBUG)) {
- // if the subscription is cancelled or revoked, show the resubscribe option
- b.paymentButton.text = getString(R.string.resubscribe_title)
- } else {
- // subscription is expired and not in the on-hold period, show the subscribe option
- b.paymentButton.text = getString(R.string.subscribe_title)
- }
- }
- }
- else -> {
- // do nothing for other states
- }
+ Logger.i(Logger.LOG_IAB, "$TAG: Navigating to dashboard for product: $productId")
+ try {
+ findNavController().navigate(R.id.action_switch_to_rethinkPlusDashboardFragment)
+ } catch (e: Exception) {
+ Logger.e(Logger.LOG_IAB, "$TAG: Navigation failed: ${e.message}", e)
}
}
- private fun showErrorDialog(title: String, message: String) {
- if (!isAdded) return
-
- // hide all the existing dialogs
- hideLoadingDialog()
- hideMsgDialog()
- hideErrorDialog()
-
- val dialogView = LayoutInflater.from(requireContext())
- .inflate(R.layout.dialog_transaction_error, null)
-
- dialogView.findViewById(R.id.dialog_title).text = title
- dialogView.findViewById(R.id.dialog_message).text = message
-
- errorDialog = MaterialAlertDialogBuilder(requireContext())
- .setView(dialogView)
- .setCancelable(false)
- .create()
-
- dialogView.findViewById(R.id.button_ok).apply {
- setOnClickListener {
- if (!isAdded || !isVisible) {
- errorDialog?.dismiss()
- return@setOnClickListener
- }
- //navigateToHomeScreen()
- errorDialog?.dismiss()
- }
- }
- errorDialog?.setOnCancelListener {
- errorDialog?.dismiss()
+ /**
+ * Shimmer animations
+ */
+ private fun startShimmer() {
+ if (!b.shimmerContainer.isShimmerStarted) {
+ val shimmer = Shimmer.AlphaHighlightBuilder()
+ .setDuration(2000)
+ .setBaseAlpha(0.85f)
+ .setDropoff(1f)
+ .setHighlightAlpha(0.35f)
+ .build()
+ b.shimmerContainer.setShimmer(shimmer)
+ b.shimmerContainer.startShimmer()
}
- errorDialog?.show()
}
- private fun hideErrorDialog() {
- if (isAdded && errorDialog?.isShowing == true) {
- errorDialog?.dismiss()
+ private fun stopShimmer() {
+ if (b.shimmerContainer.isShimmerStarted) {
+ b.shimmerContainer.stopShimmer()
}
}
- private fun hideMsgDialog() {
- if (isAdded && msgDialog?.isShowing == true) {
- msgDialog?.dismiss()
- }
+ /**
+ * Animate content entrance
+ */
+ private fun animateContentEntrance() {
+ b.scrollView.alpha = 0f
+ b.scrollView.animate()
+ .alpha(1f)
+ .setDuration(300)
+ .start()
}
- private fun handlePlusSubscribed(productId: String) {
- if (!isAdded) return
- // finish this fragment and navigate to the rethink+ dashboard
- hideLoadingDialog()
- // close any error/message dialog if it is showing
- hideErrorDialog()
- hideMsgDialog()
- Logger.i(LOG_IAB, "R+ subscribed, productId: $productId, navigating to dashboard")
- if (!isAdded) {
- Logger.w(LOG_IAB, "Fragment not added, cannot navigate")
- return
+ /**
+ * Animate button press
+ */
+ private fun animateButtonPress(view: View) {
+ ObjectAnimator.ofFloat(view, "scaleX", 1f, 0.95f, 1f).apply {
+ duration = 100
+ interpolator = AnticipateOvershootInterpolator()
+ start()
}
- try {
- findNavController().navigate(R.id.rethinkPlusDashboardFragment)
- } catch (e: Exception) {
- Logger.e(LOG_IAB, "Navigation failed: ${e.message}")
- launchRethinkPlusDashboardInFragmentHost()
+ ObjectAnimator.ofFloat(view, "scaleY", 1f, 0.95f, 1f).apply {
+ duration = 100
+ interpolator = AnticipateOvershootInterpolator()
+ start()
}
}
- private fun launchRethinkPlusDashboardInFragmentHost() {
- // Prepare arguments if needed
- val args = Bundle().apply { putString("ARG_KEY", "Launch_Manage_Subscriptions") }
-
- // Create intent using the helper
- val intent = FragmentHostActivity.createIntent(
- context = requireContext(),
- fragmentClass = RethinkPlusDashboardFragment::class.java,
- args = args // or null if none
- )
-
- // Start the activity
- startActivity(intent)
- }
-
- private fun setupClickListeners() {
-
- b.paymentButton.setOnClickListener { purchaseSubs() }
-
- b.testPingButton.setOnClickListener {
- if (!VpnController.hasTunnel()) {
- Logger.i(LOG_IAB, "$TAG; VPN not active, cannot perform tests")
- Utilities.showToastUiCentered(
- requireContext(),
- getString(R.string.settings_socks5_vpn_disabled_error),
- Toast.LENGTH_LONG
- )
- return@setOnClickListener
- }
- val intent = Intent(requireContext(), RpnAvailabilityCheckActivity::class.java)
- startActivity(intent)
+ /**
+ * Utility: Update HTML encoded text
+ */
+ private fun updateHtmlEncodedText(text: String): Spanned {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY)
+ } else {
+ HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
- private fun isGooglePlayServicesAvailable(): Boolean {
- // applicationInfo.enabled - When false, indicates that all components within
- // this application are considered disabled, regardless of their individually set enabled
- // status.
- // TODO: prompt dialog to user that Play service is disabled, so switch to update
- // check for website
- return Utilities.getApplicationInfo(requireContext(), PKG_NAME_PLAY_STORE)?.enabled == true
- }
-
- override fun onDetach() {
- super.onDetach()
- // cancel any pending purchase polling job
- stopPendingPurchasePolling()
- Logger.v(LOG_IAB, "onDetach: pending purchase polling job cancelled")
- // hide any dialogs
- hideLoadingDialog()
- hideErrorDialog()
- hideMsgDialog()
- Logger.v(LOG_IAB, "onDetach: dialogs hidden")
- // reset the productId and planId
- productId = ""
- planId = ""
- Logger.v(LOG_IAB, "onDetach: productId and planId reset")
- // reset the polling start time
- pollingStartTime = 0L
- Logger.v(LOG_IAB, "onDetach: polling start time reset")
- // reset the polling job
- pollingJob = null
- Logger.v(LOG_IAB, "onDetach: polling job reset")
+ // BillingListener callbacks
+ override fun onConnectionResult(isSuccess: Boolean, message: String) {
+ viewModel.onBillingConnected(isSuccess, message)
}
- private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ override fun purchasesResult(isSuccess: Boolean, purchaseDetailList: List) {
+ // Handled by state machine
}
- private fun ui(f: () -> Unit) {
- lifecycleScope.launch(Dispatchers.Main) { f() }
+ override fun productResult(isSuccess: Boolean, productList: List) {
+ viewModel.onProductsFetched(isSuccess, productList)
}
- private fun io(f: suspend () -> Unit) {
- lifecycleScope.launch(SupervisorJob() + Dispatchers.IO) { f() }
+ // SubscriptionChangeListener callback
+ override fun onSubscriptionSelected(productId: String, planId: String) {
+ viewModel.selectProduct(productId, planId)
}
- override fun onConnectionResult(isSuccess: Boolean, message: String) {
- if (!isSuccess) {
- Logger.e(LOG_IAB, "Billing connection failed: $message")
- ui {
- if (isAdded && isVisible) {
- hideLoadingDialog()
- Utilities.showToastUiCentered(
- requireContext(),
- message,
- Toast.LENGTH_SHORT
- )
- showNotAvailableUi()
- }
- }
- return
+ override fun onResume() {
+ super.onResume()
+ if (b.loadingContainer.isVisible) {
+ startShimmer()
+ }
+ // if a timeout occurred earlier, trigger a fresh billing re-check on resume
+ if (shouldRecheckOnResume) {
+ shouldRecheckOnResume = false
+ viewModel.initializeBilling()
}
}
- override fun purchasesResult(
- isSuccess: Boolean,
- purchaseDetailList: List
- ) {
- if (!isSuccess) {
- Logger.e(LOG_IAB, "purchasesResult: failed to fetch purchases")
- return
- }
+ override fun onPause() {
+ super.onPause()
+ stopShimmer()
}
- override fun productResult(
- isSuccess: Boolean,
- productList: List
- ) {
- if (!isSuccess) {
- Logger.e(LOG_IAB, "productResult: failed to fetch product details")
- ui {
- if (isAdded && isVisible) {
- hideLoadingDialog()
- Utilities.showToastUiCentered(
- requireContext(),
- requireContext().getString(R.string.product_details_error),
- Toast.LENGTH_SHORT
- )
- showNotAvailableUi()
- showRethinkNotAvailableUi(
- requireContext().getString(R.string.product_details_error)
- )
- return@ui
- }
- }
- return
- }
+ override fun onDestroyView() {
+ super.onDestroyView()
+ cancelProcessingTimeout()
+ dismissProcessingBottomSheet()
+ adapter = null
}
}
-*/
diff --git a/app/src/play/java/com/celzero/bravedns/viewmodel/RethinkPlusViewModel.kt b/app/src/play/java/com/celzero/bravedns/viewmodel/RethinkPlusViewModel.kt
new file mode 100644
index 000000000..f538459f3
--- /dev/null
+++ b/app/src/play/java/com/celzero/bravedns/viewmodel/RethinkPlusViewModel.kt
@@ -0,0 +1,411 @@
+/*
+ * Copyright 2025 RethinkDNS and its authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.celzero.bravedns.viewmodel
+
+import Logger
+import Logger.LOG_IAB
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.android.billingclient.api.BillingClient.ProductType
+import com.celzero.bravedns.iab.InAppBillingHandler
+import com.celzero.bravedns.iab.ProductDetail
+import com.celzero.bravedns.rpnproxy.PipKeyManager
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
+import com.celzero.bravedns.subscription.SubscriptionStateMachineV2
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+/**
+ * ViewModel for Rethink Plus subscription management
+ * Handles all business logic and state management for the subscription UI
+ */
+class RethinkPlusViewModel(application: Application) : AndroidViewModel(application), KoinComponent {
+
+ private val subscriptionStateMachine: SubscriptionStateMachineV2 by inject()
+
+ // UI State
+ private val _uiState = MutableStateFlow(SubscriptionUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ // Selected product
+ private val _selectedProduct = MutableStateFlow?>(null)
+ val selectedProduct: StateFlow