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?> = _selectedProduct.asStateFlow() + + // Product details + private val _products = MutableStateFlow>(emptyList()) + val products: StateFlow> = _products.asStateFlow() + + // All products (unfiltered) + private var allProducts: List = emptyList() + + // Product type selection + private val _selectedProductType = MutableStateFlow(ProductTypeFilter.SUBSCRIPTION) + val selectedProductType: StateFlow = _selectedProductType.asStateFlow() + + // Filtered products based on type + private val _filteredProducts = MutableStateFlow>(emptyList()) + val filteredProducts: StateFlow> = _filteredProducts.asStateFlow() + + // Polling job for pending purchases + private var pollingJob: Job? = null + private var pollingStartTime = 0L + + companion object { + private const val TAG = "RethinkPlusVM" + private const val POLLING_INTERVAL_MS = 1500L + private const val POLLING_TIMEOUT_MS = 30000L + } + + enum class ProductTypeFilter { + SUBSCRIPTION, + ONE_TIME + } + + init { + observeSubscriptionState() + } + + /** + * Observe subscription state machine changes + */ + private fun observeSubscriptionState() { + viewModelScope.launch { + RpnProxyManager.collectSubscriptionState().collect { state -> + Logger.d(LOG_IAB, "$TAG: Subscription state changed to ${state.name}") + handleSubscriptionStateChange(state) + } + } + } + + /** + * Check if Rethink Plus is available for the user + */ + suspend fun checkAvailability(): Pair { + return withContext(Dispatchers.IO) { + PipKeyManager.isRethinkPlusActive(getApplication()) + } + } + + /** + * Initialize billing and query products + */ + fun initializeBilling() { + viewModelScope.launch { + _uiState.value = SubscriptionUiState.Loading + + try { + // Check if already subscribed + if (InAppBillingHandler.hasValidSubscription()) { + _uiState.value = SubscriptionUiState.AlreadySubscribed( + RpnProxyManager.getRpnProductId() + ) + return@launch + } + + // Check availability + val availability = checkAvailability() + if (!availability.first) { + _uiState.value = SubscriptionUiState.Error( + title = "Not Available", + message = availability.second, + isRetryable = false + ) + return@launch + } + + // Query product details + InAppBillingHandler.queryProductDetailsWithTimeout() + + } catch (e: Exception) { + Logger.e(LOG_IAB, "$TAG: Error initializing billing: ${e.message}", e) + _uiState.value = SubscriptionUiState.Error( + title = "Initialization Failed", + message = e.message ?: "Unknown error occurred", + isRetryable = true + ) + } + } + } + + /** + * Set product details from billing handler + */ + fun setProducts(productList: List) { + allProducts = productList + _products.value = productList + + if (productList.isEmpty()) { + _uiState.value = SubscriptionUiState.Error( + title = "No Products Available", + message = "Unable to load subscription plans", + isRetryable = true + ) + return + } + + // Filter by current selection + filterProductsByType(_selectedProductType.value) + + // Check if can make purchase + val currentState = subscriptionStateMachine.getCurrentState() + if (!currentState.canMakePurchase) { + Logger.i(LOG_IAB, "$TAG: Cannot make purchase in state: ${currentState.name}") + return + } + + _uiState.value = SubscriptionUiState.Ready(_filteredProducts.value) + } + + /** + * Filter products by type + */ + private fun filterProductsByType(type: ProductTypeFilter) { + val filtered = when (type) { + ProductTypeFilter.SUBSCRIPTION -> allProducts.filter { it.productType == ProductType.SUBS } + ProductTypeFilter.ONE_TIME -> allProducts.filter { it.productType == ProductType.INAPP } + } + + _filteredProducts.value = filtered + Logger.d(LOG_IAB, "$TAG: Filtered products by $type: ${filtered.size} items") + + // Auto-select first product if available + if (filtered.isNotEmpty()) { + val first = filtered.first() + _selectedProduct.value = Pair(first.productId, first.planId) + } + } + + /** + * Switch product type filter + */ + fun selectProductType(type: ProductTypeFilter) { + if (_selectedProductType.value == type) return + + _selectedProductType.value = type + filterProductsByType(type) + + // Update UI state with filtered products + if (_filteredProducts.value.isNotEmpty()) { + _uiState.value = SubscriptionUiState.Ready(_filteredProducts.value) + } else { + _uiState.value = SubscriptionUiState.Error( + title = "No Products", + message = "No ${type.name.lowercase()} products available", + isRetryable = false + ) + } + } + + /** + * Update selected product + */ + fun selectProduct(productId: String, planId: String) { + _selectedProduct.value = Pair(productId, planId) + Logger.d(LOG_IAB, "$TAG: Selected product: $productId, plan: $planId") + } + + /** + * Handle subscription state machine changes + */ + private fun handleSubscriptionStateChange(state: SubscriptionStateMachineV2.SubscriptionState) { + when (state) { + is SubscriptionStateMachineV2.SubscriptionState.PurchaseInitiated -> { + _uiState.value = SubscriptionUiState.Processing("Initializing purchase...") + } + + is SubscriptionStateMachineV2.SubscriptionState.PurchasePending -> { + _uiState.value = SubscriptionUiState.PendingPurchase + startPendingPurchasePolling() + } + + is SubscriptionStateMachineV2.SubscriptionState.Active -> { + stopPendingPurchasePolling() + _uiState.value = SubscriptionUiState.Success( + RpnProxyManager.getRpnProductId() + ) + } + + is SubscriptionStateMachineV2.SubscriptionState.Error -> { + stopPendingPurchasePolling() + _uiState.value = SubscriptionUiState.Error( + title = "Subscription Error", + message = "An error occurred while processing your subscription", + isRetryable = true + ) + } + + is SubscriptionStateMachineV2.SubscriptionState.Cancelled, + is SubscriptionStateMachineV2.SubscriptionState.Revoked, + is SubscriptionStateMachineV2.SubscriptionState.Expired -> { + stopPendingPurchasePolling() + // Show products with resubscribe option + if (_products.value.isNotEmpty()) { + _uiState.value = SubscriptionUiState.Ready( + products = _products.value, + isResubscribe = true + ) + } + } + + else -> { + // Handle other states if needed + } + } + } + + /** + * Start polling for pending purchase status + */ + private fun startPendingPurchasePolling() { + if (pollingJob != null) return + + pollingStartTime = System.currentTimeMillis() + var counter = 0 + + pollingJob = viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + val elapsedTime = System.currentTimeMillis() - pollingStartTime + + if (elapsedTime > POLLING_TIMEOUT_MS) { + Logger.i(LOG_IAB, "$TAG: Polling timeout reached") + stopPendingPurchasePolling() + withContext(Dispatchers.Main) { + _uiState.value = SubscriptionUiState.Error( + title = "Timeout", + message = "Purchase verification timeout. Please check your subscription status.", + isRetryable = false + ) + } + break + } + + Logger.d(LOG_IAB, "$TAG: Polling pending purchase, elapsed: $elapsedTime ms") + InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS, ProductType.INAPP)) + + // Query entitlement from server once + if (counter == 0) { + val accountId = InAppBillingHandler.getObfuscatedAccountId(getApplication()) + val purchaseToken = InAppBillingHandler.getLatestPurchaseToken() + Logger.d( + LOG_IAB, + "$TAG: Querying entitlement from server; with purchaseToken: $purchaseToken" + ) + if (purchaseToken == null) { + Logger.e(LOG_IAB, "$TAG: Purchase token is null, cannot query entitlement") + } else { + // this will initiate (if not done) the acknowledgement from server side + InAppBillingHandler.acknowledgePurchaseFromServer(accountId, purchaseToken) + } + counter++ + } + + delay(POLLING_INTERVAL_MS) + } + } + } + + /** + * Stop polling for pending purchase + */ + private fun stopPendingPurchasePolling() { + pollingJob?.cancel() + pollingJob = null + Logger.i(LOG_IAB, "$TAG: Pending purchase polling stopped") + } + + /** + * Handle billing connection result + */ + fun onBillingConnected(isSuccess: Boolean, message: String) { + if (!isSuccess) { + Logger.e(LOG_IAB, "$TAG: Billing connection failed: $message") + _uiState.value = SubscriptionUiState.Error( + title = "Connection Failed", + message = message, + isRetryable = true + ) + } else { + // Fetch purchases after connection + viewModelScope.launch(Dispatchers.IO) { + InAppBillingHandler.fetchPurchases(listOf(ProductType.SUBS, ProductType.INAPP)) + } + } + } + + /** + * Handle product details result + */ + fun onProductsFetched(isSuccess: Boolean, productList: List) { + if (!isSuccess || productList.isEmpty()) { + _uiState.value = SubscriptionUiState.Error( + title = "Products Unavailable", + message = "Unable to load subscription plans. Please try again.", + isRetryable = true + ) + } else { + setProducts(productList) + } + } + + /** + * Retry initialization + */ + fun retry() { + initializeBilling() + } + + override fun onCleared() { + super.onCleared() + stopPendingPurchasePolling() + } +} + +/** + * Sealed class representing all possible UI states + */ +sealed class SubscriptionUiState { + object Loading : SubscriptionUiState() + + data class Ready( + val products: List, + val isResubscribe: Boolean = false + ) : SubscriptionUiState() + + data class Processing(val message: String) : SubscriptionUiState() + + object PendingPurchase : SubscriptionUiState() + + data class Success(val productId: String) : SubscriptionUiState() + + data class Error( + val title: String, + val message: String, + val isRetryable: Boolean + ) : SubscriptionUiState() + + data class AlreadySubscribed(val productId: String) : SubscriptionUiState() +} +