From 071f506d03652122b5228c63bae84a3be1bfbaee Mon Sep 17 00:00:00 2001 From: Re-Rethink Date: Wed, 24 Jun 2026 21:24:54 +0530 Subject: [PATCH] Rebase Re-Rethink fork onto upstream celzero/rethink-app Squashed Material 3 Expressive / Jetpack Compose UI rewrite and related fork changes onto latest upstream main. UI/adapters/themes prefer the fork; core VPN, firewall, database, and wireguard services prefer upstream where histories diverged. --- .gitmodules | 3 + README.md | 204 +- app/build.gradle.kts | 439 + app/lint-baseline.xml | 7672 +++++++++++++++++ .../27.json | 1526 ++++ .../ui/activity/AppInfoActivityTest.kt | 63 - .../bravedns/ui/compose/HomeComponentsTest.kt | 127 + .../bravedns/ui/compose/HomeScreenTest.kt | 206 + app/src/full/AndroidManifest.xml | 151 +- .../celzero/bravedns/NonStoreAppUpdater.kt | 11 +- .../celzero/bravedns/RethinkDnsApplication.kt | 20 +- .../bravedns/adapter/AppWiseDomainsAdapter.kt | 379 +- .../bravedns/adapter/AppWiseIpsAdapter.kt | 284 +- .../bravedns/adapter/BlocklistRowShared.kt | 245 + .../adapter/ConnectionTrackerAdapter.kt | 1011 ++- .../bravedns/adapter/ConsoleLogAdapter.kt | 139 +- .../adapter/DnsCryptEndpointAdapter.kt | 315 +- .../adapter/DnsCryptRelayEndpointAdapter.kt | 303 +- .../adapter/DnsEndpointRowComponents.kt | 170 + .../bravedns/adapter/DnsEndpointShared.kt | 218 + .../celzero/bravedns/adapter/DnsLogAdapter.kt | 1244 ++- .../adapter/DnsProxyEndpointAdapter.kt | 281 +- .../bravedns/adapter/DoTEndpointAdapter.kt | 311 +- .../bravedns/adapter/DohEndpointAdapter.kt | 311 +- .../adapter/DomainConnectionsAdapter.kt | 215 +- .../celzero/bravedns/adapter/EventsAdapter.kt | 488 +- .../adapter/FirewallAppListAdapter.kt | 1189 ++- .../adapter/LocalAdvancedViewAdapter.kt | 204 +- .../adapter/LocalSimpleViewAdapter.kt | 200 +- .../bravedns/adapter/ODoHEndpointAdapter.kt | 304 +- .../bravedns/adapter/OneWgConfigAdapter.kt | 910 +- .../adapter/RemoteAdvancedViewAdapter.kt | 205 +- .../adapter/RemoteSimpleViewAdapter.kt | 199 +- .../adapter/RethinkEndpointAdapter.kt | 296 +- .../bravedns/adapter/RethinkLogAdapter.kt | 707 +- .../bravedns/adapter/WgConfigAdapter.kt | 1122 ++- .../celzero/bravedns/adapter/WgHopAdapter.kt | 600 +- .../bravedns/adapter/WgIncludeAppsAdapter.kt | 435 +- .../bravedns/adapter/WgPeersAdapter.kt | 256 +- .../LocalBlocklistCoordinator.kt | 6 +- .../customdownloader/OkHttpDebugLogging.kt | 8 +- .../bravedns/scheduler/WorkScheduler.kt | 6 + .../bravedns/ui/HomeDialogComponents.kt | 130 + .../celzero/bravedns/ui/HomeScreenActivity.kt | 2312 ++++- .../bravedns/ui/activity/BubbleActivity.kt | 402 +- .../ui/bottomsheet/AppDomainRulesDialog.kt | 291 + .../ui/bottomsheet/AppIpRulesDialog.kt | 254 + .../ui/bottomsheet/BackupRestoreDialog.kt | 479 + .../ui/bottomsheet/BottomSheetShared.kt | 672 ++ .../ui/bottomsheet/CustomDomainRulesDialog.kt | 204 + .../ui/bottomsheet/CustomIpRulesDialog.kt | 235 + .../bravedns/ui/compose/ComposeUtils.kt | 14 + .../bravedns/ui/compose/app/AppInfoNav.kt | 7 + .../bravedns/ui/compose/app/AppInfoScreen.kt | 1087 +++ .../ui/compose/dns/DnsDetailScreen.kt | 1165 +++ .../compose/logs/DomainConnectionsScreen.kt | 250 + .../compose/rpn/RpnWinProxyDetailsScreen.kt | 274 + .../bravedns/ui/dialog/CustomLanIpDialog.kt | 764 +- .../ui/dialog/DnsCryptRelaysDialog.kt | 105 +- .../ui/dialog/NetworkReachabilityDialog.kt | 761 +- .../ui/dialog/SubscriptionAnimDialog.kt | 248 +- .../bravedns/ui/dialog/WgAddPeerDialog.kt | 324 +- .../bravedns/ui/dialog/WgDialogShared.kt | 126 + .../celzero/bravedns/ui/dialog/WgHopDialog.kt | 99 +- .../bravedns/ui/dialog/WgIncludeAppsDialog.kt | 831 +- .../bravedns/ui/dialog/WgSsidDialog.kt | 514 +- .../ui/rethink/RethinkBlocklistFilterHost.kt | 22 + .../ui/rethink/RethinkBlocklistState.kt | 58 + .../java/com/celzero/bravedns/util/UIUtils.kt | 437 +- .../celzero/bravedns/util/WindowExtensions.kt | 117 +- .../bravedns/viewmodel/AppInfoViewModel.kt | 454 +- .../viewmodel/ConnectionTrackerViewModel.kt | 28 +- .../viewmodel/DetailedStatisticsViewModel.kt | 221 +- .../bravedns/viewmodel/DnsLogViewModel.kt | 39 +- .../viewmodel/ProxyAppsMappingViewModel.kt | 125 +- .../viewmodel/RethinkLocalFileTagViewModel.kt | 10 +- .../RethinkRemoteFileTagViewModel.kt | 10 +- .../viewmodel/SummaryStatisticsViewModel.kt | 196 +- .../bravedns/viewmodel/ViewModelModule.kt | 17 +- .../main/assets/database/rethink_v22.db-shm | Bin 0 -> 32768 bytes .../main/assets/database/rethink_v22.db-wal | 0 .../com/celzero/bravedns/data/AppConfig.kt | 158 +- .../bravedns/data/SummaryStatisticsType.kt | 35 + .../celzero/bravedns/database/AppInfoDAO.kt | 18 +- .../bravedns/database/ConnectionTrackerDAO.kt | 41 + .../database/ConnectionTrackerRepository.kt | 24 + .../bravedns/database/ConsoleLogDatabase.kt | 4 +- .../celzero/bravedns/database/DnsLogDAO.kt | 15 + .../bravedns/database/DnsLogRepository.kt | 12 + .../celzero/bravedns/database/LogAppCount.kt | 23 + .../bravedns/database/RefreshDatabase.kt | 33 +- .../database/RethinkDnsEndpointRepository.kt | 44 +- .../bravedns/database/RethinkLocalFileTag.kt | 10 +- .../database/RethinkLocalFileTagDao.kt | 3 +- .../database/RethinkRemoteFileTagDao.kt | 3 +- .../bravedns/database/StatsSummaryDao.kt | 51 + .../bravedns/service/AppsStatsManager.kt | 108 + .../bravedns/service/ConnectionMonitor.kt | 8 +- .../service/DnsConfigurationManager.kt | 133 + .../bravedns/service/FirewallRuleEvaluator.kt | 523 ++ .../bravedns/service/FirewallRuleset.kt | 2 +- .../bravedns/service/FirewallStatsManager.kt | 111 + .../bravedns/service/LogsCountManager.kt | 80 + .../bravedns/service/NetworkBindingService.kt | 273 + .../bravedns/service/PauseStateManager.kt | 107 + .../bravedns/service/ProxyRoutingEngine.kt | 211 + .../bravedns/service/ProxyStateManager.kt | 160 + .../service/RethinkBlocklistManager.kt | 17 +- .../service/UnderlyingNetworkManager.kt | 199 + .../bravedns/service/VpnConnectionHandler.kt | 214 + .../service/VpnNotificationManager.kt | 165 + .../celzero/bravedns/ui/TestDialogActivity.kt | 5 +- .../bravedns/ui/compose/about/AboutScreen.kt | 855 ++ .../ui/compose/about/AboutViewModel.kt | 164 + .../ui/compose/alerts/AlertsScreen.kt | 82 + .../bravedns/ui/compose/apps/AppListScreen.kt | 340 + .../ui/compose/apps/DiagonalWipeIcon.kt | 210 + .../ui/compose/bubble/BubbleScreen.kt | 510 ++ .../ui/compose/configure/ConfigureScreen.kt | 867 ++ .../compose/configure/SettingsSearchIndex.kt | 248 + .../ui/compose/database/DatabaseScreen.kt | 691 ++ .../ui/compose/dns/ConfigureOtherDnsScreen.kt | 1170 +++ .../dns/ConfigureRethinkBasicScreen.kt | 1315 +++ .../bravedns/ui/compose/dns/DnsListScreen.kt | 323 + .../ui/compose/dns/DnsSettingsScreen.kt | 702 ++ .../ui/compose/dns/DnsSettingsViewModel.kt | 194 + .../ui/compose/events/EventsScreen.kt | 420 + .../ui/compose/firewall/AppListModels.kt | 136 + .../ui/compose/firewall/AppListScreen.kt | 1295 +++ .../ui/compose/firewall/CustomRulesScreen.kt | 890 ++ .../ui/compose/firewall/FastScroller.kt | 267 + .../firewall/FirewallSettingsScreen.kt | 144 + .../UniversalFirewallSettingsScreen.kt | 411 + .../ui/compose/home/HomeComponents.kt | 107 + .../bravedns/ui/compose/home/HomeScreen.kt | 630 ++ .../ui/compose/home/HomeScreenViewModel.kt | 233 + .../bravedns/ui/compose/home/PauseScreen.kt | 315 + .../bravedns/ui/compose/home/WelcomeScreen.kt | 281 + .../compose/logs/AppWiseDomainLogsScreen.kt | 181 + .../ui/compose/logs/AppWiseIpLogsScreen.kt | 192 + .../ui/compose/logs/AppWiseLogsShared.kt | 440 + .../ui/compose/logs/NetworkLogsScreen.kt | 1532 ++++ .../ui/compose/navigation/HomeNavigation.kt | 1274 +++ .../ui/compose/navigation/HomeRoute.kt | 167 + .../ui/compose/proxy/TcpProxyMainScreen.kt | 289 + .../ui/compose/rpn/RpnAvailabilityScreen.kt | 233 + .../ui/compose/rpn/RpnCountriesScreen.kt | 148 + .../settings/AdvancedSettingsScreen.kt | 165 + .../compose/settings/AntiCensorshipScreen.kt | 287 + .../ui/compose/settings/AppLockScreen.kt | 295 + .../settings/AppearanceSettingsCard.kt | 437 + .../ui/compose/settings/CheckoutScreen.kt | 368 + .../ui/compose/settings/ConsoleLogScreen.kt | 330 + .../ui/compose/settings/MiscSettingsScreen.kt | 508 ++ .../ui/compose/settings/PingTestScreen.kt | 385 + .../compose/settings/ProxySettingsScreen.kt | 2333 +++++ .../compose/settings/TunnelSettingsScreen.kt | 1168 +++ .../statistics/DetailedStatisticsScreen.kt | 304 + .../statistics/StatisticsCountryUtils.kt | 41 + .../compose/statistics/StatisticsIconUtils.kt | 68 + .../statistics/StatisticsSummaryItem.kt | 131 + .../statistics/SummaryStatisticsScreen.kt | 769 ++ .../bravedns/ui/compose/theme/Color.kt | 118 + .../ui/compose/theme/DesignComponents.kt | 1434 +++ .../bravedns/ui/compose/theme/Dimensions.kt | 144 + .../ui/compose/theme/FilterComponents.kt | 162 + .../bravedns/ui/compose/theme/Motion.kt | 36 + .../ui/compose/theme/RethinkScreenScaffold.kt | 85 + .../bravedns/ui/compose/theme/Theme.kt | 134 + .../celzero/bravedns/ui/compose/theme/Type.kt | 127 + .../ui/compose/wireguard/WgCardComponents.kt | 69 + .../compose/wireguard/WgConfigDetailScreen.kt | 842 ++ .../compose/wireguard/WgConfigEditorScreen.kt | 500 ++ .../ui/compose/wireguard/WgMainScreen.kt | 558 ++ .../celzero/bravedns/util/BioMetricType.kt | 40 + .../com/celzero/bravedns/util/OrbotHelper.kt | 16 +- .../java/com/celzero/bravedns/util/Themes.kt | 146 +- .../bravedns/viewmodel/CheckoutViewModel.kt | 114 + .../bravedns/viewmodel/EventsViewModel.kt | 62 +- .../com/celzero/bravedns/wireguard/Peer.kt | 4 +- .../main/res/drawable/ic_arrow_back_24.xml | 3 +- .../main/res/drawable/ic_expand_more_24.xml | 3 +- .../main/res/drawable/ic_firewall_welcome.xml | 2 +- app/src/main/res/drawable/ic_github.xml | 2 +- .../main/res/drawable/ic_location_on_24.xml | 3 +- app/src/main/res/drawable/ic_twitter.xml | 9 +- app/src/main/res/resources.properties | 1 + app/src/main/res/values-ab/strings.xml | 8 +- app/src/main/res/values-ar/strings.xml | 6 +- app/src/main/res/values-bg/strings.xml | 6 +- app/src/main/res/values-bn/strings.xml | 6 +- app/src/main/res/values-cs/strings.xml | 36 +- app/src/main/res/values-de/strings.xml | 230 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 313 +- app/src/main/res/values-fa/strings.xml | 4 +- app/src/main/res/values-fi/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 38 +- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 20 +- app/src/main/res/values-in/strings.xml | 38 +- app/src/main/res/values-it/strings.xml | 30 +- app/src/main/res/values-iw/strings.xml | 4 +- app/src/main/res/values-ja/strings.xml | 4 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-lt/strings.xml | 24 +- app/src/main/res/values-nb-rNO/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 24 +- app/src/main/res/values-pl/strings.xml | 38 +- app/src/main/res/values-pt-rBR/strings.xml | 140 +- app/src/main/res/values-pt/strings.xml | 34 +- app/src/main/res/values-ro/strings.xml | 44 +- app/src/main/res/values-ru/strings.xml | 117 +- app/src/main/res/values-sk/strings.xml | 6 +- app/src/main/res/values-sr/strings.xml | 30 +- app/src/main/res/values-ta/strings.xml | 4 +- app/src/main/res/values-th/strings.xml | 4 +- app/src/main/res/values-tl/strings.xml | 6 +- app/src/main/res/values-tr/strings.xml | 40 +- app/src/main/res/values-uk/strings.xml | 44 +- app/src/main/res/values-ur/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 26 +- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values-zh-rHK/strings.xml | 8 +- app/src/main/res/values-zh-rTW/strings.xml | 6 +- app/src/main/res/values/strings.xml | 878 +- app/src/main/res/values/styles.xml | 1247 +-- .../bravedns/RethinkDnsApplicationPlay.kt | 24 +- .../com/celzero/bravedns/StoreAppUpdater.kt | 17 +- .../com/celzero/bravedns/iab/Security.java | 4 - .../bravedns/data/AppConfigProxyModeTest.kt | 200 + .../receiver/BraveAutoStartReceiverTest.kt | 36 +- .../receiver/UserPresentReceiverTest.kt | 61 +- .../service/ProxyRoutingEngineTest.kt | 352 + .../service/TempAllowExpiryWorkerTest.kt | 52 +- .../bravedns/service/WireguardManagerTest.kt | 718 +- .../bravedns/iab/OnPurchaseListener.kt | 14 +- .../bravedns/iab/QueryProductDetail.kt | 23 +- assets/comparison.png | Bin 0 -> 1847494 bytes assets/more-dark.png | Bin 0 -> 763176 bytes assets/more-light.png | Bin 0 -> 787350 bytes build.gradle.kts | 42 + config/detekt/baseline.xml | 812 ++ config/detekt/detekt.yml | 56 + gradle.properties | 2 +- gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 192 + gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 13 + tun2socks | 1 + 250 files changed, 61013 insertions(+), 13615 deletions(-) create mode 100644 .gitmodules create mode 100644 app/build.gradle.kts create mode 100644 app/lint-baseline.xml create mode 100644 app/schemas/com.celzero.bravedns.database.AppDatabase/27.json create mode 100644 app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeComponentsTest.kt create mode 100644 app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeScreenTest.kt create mode 100644 app/src/full/java/com/celzero/bravedns/adapter/BlocklistRowShared.kt create mode 100644 app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointRowComponents.kt create mode 100644 app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointShared.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/HomeDialogComponents.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesDialog.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesDialog.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreDialog.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BottomSheetShared.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomDomainRulesDialog.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomIpRulesDialog.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/compose/ComposeUtils.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoNav.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoScreen.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/compose/dns/DnsDetailScreen.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/compose/logs/DomainConnectionsScreen.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/compose/rpn/RpnWinProxyDetailsScreen.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/dialog/WgDialogShared.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistFilterHost.kt create mode 100644 app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistState.kt create mode 100644 app/src/main/assets/database/rethink_v22.db-shm create mode 100644 app/src/main/assets/database/rethink_v22.db-wal create mode 100644 app/src/main/java/com/celzero/bravedns/data/SummaryStatisticsType.kt create mode 100644 app/src/main/java/com/celzero/bravedns/database/LogAppCount.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/AppsStatsManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/DnsConfigurationManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/FirewallRuleEvaluator.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/FirewallStatsManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/LogsCountManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/NetworkBindingService.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/PauseStateManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/ProxyRoutingEngine.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/ProxyStateManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/UnderlyingNetworkManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/VpnConnectionHandler.kt create mode 100644 app/src/main/java/com/celzero/bravedns/service/VpnNotificationManager.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutViewModel.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/alerts/AlertsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/apps/AppListScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/apps/DiagonalWipeIcon.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/bubble/BubbleScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/configure/ConfigureScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/configure/SettingsSearchIndex.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/database/DatabaseScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureOtherDnsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureRethinkBasicScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsListScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsViewModel.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/events/EventsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListModels.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/firewall/CustomRulesScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FastScroller.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FirewallSettingsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/firewall/UniversalFirewallSettingsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeComponents.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreenViewModel.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/home/PauseScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/home/WelcomeScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseDomainLogsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseIpLogsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseLogsShared.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/logs/NetworkLogsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeNavigation.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeRoute.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/proxy/TcpProxyMainScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnAvailabilityScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnCountriesScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/AdvancedSettingsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/AntiCensorshipScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppLockScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppearanceSettingsCard.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/CheckoutScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/ConsoleLogScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/MiscSettingsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/PingTestScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/ProxySettingsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/settings/TunnelSettingsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/statistics/DetailedStatisticsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsCountryUtils.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsIconUtils.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsSummaryItem.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/statistics/SummaryStatisticsScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/Color.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/DesignComponents.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/Dimensions.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/FilterComponents.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/Motion.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/RethinkScreenScaffold.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/Theme.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/theme/Type.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgCardComponents.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigDetailScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigEditorScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgMainScreen.kt create mode 100644 app/src/main/java/com/celzero/bravedns/util/BioMetricType.kt create mode 100644 app/src/main/java/com/celzero/bravedns/viewmodel/CheckoutViewModel.kt create mode 100644 app/src/main/res/resources.properties create mode 100644 app/src/test/java/com/celzero/bravedns/data/AppConfigProxyModeTest.kt create mode 100644 app/src/test/java/com/celzero/bravedns/service/ProxyRoutingEngineTest.kt create mode 100644 assets/comparison.png create mode 100644 assets/more-dark.png create mode 100644 assets/more-light.png create mode 100644 build.gradle.kts create mode 100644 config/detekt/baseline.xml create mode 100644 config/detekt/detekt.yml create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 settings.gradle.kts create mode 160000 tun2socks diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..bd1836d4f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tun2socks"] + path = tun2socks + url = https://github.com/celzero/outline-go-tun2socks diff --git a/README.md b/README.md index 673633dde..e0d4a63f6 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,91 @@ -## Rethink DNS + Firewall + VPN for Android -A [WireGuard](https://github.com/wireguard/wireguard-go) client, an [OpenSnitch](https://github.com/evilsocket/opensnitch)-inspired firewall and network monitor + a [pi-hole](https://github.com/pi-hole/pi-hole)-inspired DNS over HTTPS, DNS over TLS, DNSCrypt client with blocklists. +# Re-Rethink -[Get it on F-Droid](https://f-droid.org/packages/com.celzero.bravedns/) -[Get it on Google Play](https://play.google.com/store/apps/details?id=com.celzero.bravedns) -[Get it with Obtainium](https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/celzero/rethink-app) - -*Release certificate SHA-256 digest*: `1f32d432e81a1dc5c00aafeb0c6636cd7819965d174420e59db9675dff7a88e9`. - -In other words, Rethink DNS + Firewall + VPN has three primary modes, VPN, DNS, and Firewall. The VPN (proxifier) mode supports multiple WireGuard upstreams in a split-tunnel configuration. The DNS mode routes all DNS traffic generated by apps to _any_ user-chosen DNS-over-HTTPS / DNS-over-TLS / DNSCrypt resolver, or to WireGuard-configured DNS in a split-tunnel configuration. The Firewall mode lets the user deny internet-access to entire applications based on events like screen-on / screen-off, app-foreground / app-background, unmetered-connection / metered-connection; or based on play-store defined categories like Social, Games, Utility, Productivity; or additionally, based on user-defined domain & IP denylists. - -![2](https://github.com/celzero/rethink-app/assets/56958445/618bb47c-586c-41b9-ba1c-f62c2bbc9649) -![3](https://github.com/celzero/rethink-app/assets/56958445/c74f3485-7197-4e5b-860f-c2b11c556cee) -![4](https://github.com/celzero/rethink-app/assets/56958445/a2032d44-f07c-45e9-801b-7abe0cac0ead) -![5](https://github.com/celzero/rethink-app/assets/56958445/b9973e69-d45e-4be9-bd42-b80fb2768ec5) - -*screenshots from [`v055e`](https://github.com/celzero/rethink-app/releases/tag/v0.5.5e).* - -### VPN / Proxifier -Rethink supports forwarding TCP & UDP over SOCKS5, HTTP CONNECT, and WireGuard tunnels. Split-tunneling further helps run multiple such tunnels at the same time and lets users route different apps over different tunnels. For example, one could route Firefox over SOCKS5 connecting to Tor, Netflix over WireGuard connecting through any popular VPN provider, and Telegram or WhatsApp over censorship-resistant HTTP CONNECT endpoints at the same time. - -### Firewall -The firewall doesn't really care about the connections per se rather what's making those connections. This is different from the traditional firewalls but in-line with [Little Snitch](https://www.obdev.at/products/littlesnitch/index.html), [LuLu](https://objective-see.com/products/lulu.html), [Glasswire](https://glasswire.com/) and others. - -Currently, per-app connection mapping is implemented by capturing `udp` and `tcp` connections managed by [`firestack`](https://github.com/celzero/firestack) (written in golang) and asking [ConnectivityService for the owner](https://developer.android.com/about/versions/10/privacy/changes#proc-net-filesystem), an API available only on Android 10 or higher. `procfs` (`/proc/net/tcp` and `/proc/net/udp`) is read on-demand to track per-app connections like [NetGuard](https://github.com/M66B/NetGuard/) or OpenSnitch do, on Android 9 and lower versions. - -### Network Monitor -A network monitor is a per-app report-card of sorts on when connections were made, how many were made, and to where. Tracking UDP / TCP (and DNS on Android 12+) is straight-forward. DNS are trickier to track on Android 11 and below, and so a rough heuristic is used for now, which may not hold good in all cases. - -### DNS over HTTPS client -Almost all of the network related code (`firestack`), including DNS over HTTPS split-tunnel, is a hard fork of [Jigsaw-Code/outline-go-tun2socks](https://github.com/Jigsaw-Code/outline-go-tun2socks) written in golang. The UI is vastly different but borrows minimally from [Jigsaw-Code/Intra](https://github.com/Jigsaw-Code/Intra/). A split-tunnel traps requests sent to the VPN's DNS endpoint and relays it to a DNS-over-HTTPS / DNS-over-TLS / DNSCrypt / Oblivious DNS-over-HTTPS endpoint of the user's choosing, logging the end-to-end latency, time of request, the DNS request query itself, and its answer. - -### The Rethink DNS Resolver -A malware and ad-blocking DNS over HTTPS resolver at `https://sky.rethinkdns.com/rs` (deployed to 300+ locations world-wide via Cloudflare Workers) is the default DNS endpoint on the app, though the user is free to change that. A configurable DNS resolver that lets users add or remove denylists and allowlists, add rewrites, analyse DNS requests is launching late 2026. Right now, a free-to-use DNS over HTTPS endpoint with custom blocklists can be setup here: [rethinkdns.com/configure](https://rethinkdns.com/configure). - -The resolver, sponsored by [FLOSS/fund](https://floss.fund/), is deployed to [Fly.io](https://fly.io/) at `max.rethinkdns.com`, and [Deno Deploy](https://deno.com/deploy) at `rdns.deno.dev` too, apart from the default deployment on [Cloudflare Workers](https://workers.dev). The resolver is open source software: [serverless-dns](https://github.com/serverless-dns/serverless-dns). - -### The Rethink Proxy Network -RPN is a multi-party relay, with connections hopping over serverless proxy (hosted on Cloudflare Workers) exiting through Windscribe. Users would be able to self-host the first hop or use the ones run by us. At launch in Dec 2025, this service would cost $3/month for unlimited bandwidth. - -The proxy is open source software: [serverless-proxy](https://github.com/serverless-proxy/serverless-proxy). - -### Community -[GitHub Sponsors](https://github.com/sponsors/serverless-dns) -- The telegram community is super active and full of crypto-bros. Kidding. We are generally a welcoming bunch. Feel free to get in touch: [t.me/rethinkdns](https://t.me/rethinkdns). -- Or, if you prefer Matrix (which is bridged to Telegram): [`#rethinkdns:matrix.org`](https://matrix.to/#/#rethinkdns:matrix.org) (or: [`!jrTSpJiEkFNNBMhSaE:matrix.org`](https://matrix.to/#/!jrTSpJiEkFNNBMhSaE:matrix.org)). -- Or, email us: [hello@celzero.com](mailto:hello@celzero.com) (we read all emails immediately and reply once we fix the issues being reported). -- We regularly hangout in our subreddit: [r/rethinkdns](https://reddit.com/r/rethinkdns). -- We're also kind of active on the bird and toot apps, mostly nerd-sniping other engs or shit-posting about our tech stack: [twitter/rethinkdns](https://twitter.com/rethinkdns), [mastodon/rdns](https://mastodon.social/@rdns). - -### Translation -Help [translate Rethink DNS + Firewall + VPN](https://hosted.weblate.org/engage/rethink-dns-firewall) on [Weblate](https://weblate.org/):

-[![](https://hosted.weblate.org/widgets/rethink-dns-firewall/-/287x66-black.png)](https://hosted.weblate.org/engage/rethink-dns-firewall) - -### What Rethink DNS + Firewall + VPN is not -Rethink is *not* an anonymity tool: It helps users tackle unabated censorship and surveillance but doesn't lay claim to protecting a user's identity at all times, if ever. - -Rethink does *not* aim to be a feature-rich traditional firewall: It is more in-line with [Little Snitch](https://www.obdev.at/products/littlesnitch/index.html) than IP tables, say. - -Rethink is *not* an anti-virus: Rethink may stop users from phishing attacks, malware, scareware websites through its DNS-based blocklists, but it doesn't actively mitigate threats or even look for them or act on them, otherwise. - -### What Rethink DNS + Firewall + VPN aspires to be -To turn Android devices into user-agents: Something that users can control as they please without requiring root-access. A big part of this, for an always-on, always-connected devices, is capturing network traffic and reporting it in a way that makes sense to the end-users who can then take a series of actions to limit their exposure but not necessarily eliminate it. Take DNS for example-- for most if not all connections, apps send out a DNS request first, and by tracking just those one can glean a lot of intelligence about what's happening on their Androids and which app's responsible. - -To deliver the promise of open-internet for all: With the inevitable ECH (encrypted client hello) standardization and the imminent adoption of DNS-over-HTTPS and DNS-over-TLS across operating systems and browsers, we're that much closer to an open internet. Of course, *Deep Packet Inspection* remains a credible threat that can't be mitigated with just encrypted DNS, but it is one example of delivering maximum impact (circumvent internet censorship in most countries) with minimal effort (not requiring use of a VPN or access via IPFS, for example). Rethink would continue to make these technologies accessible in the simplest way possible, especially the ones that get 90% of the way there with 10% effort. - -## Development -[![Release](https://img.shields.io/github/v/release/celzero/rethink-app?include_prereleases)](https://github.com/celzero/rethink-app/releases)   [![CI](https://github.com/celzero/rethink-app/actions/workflows/android.yml/badge.svg?branch=main)](https://github.com/celzero/rethink-app/actions/workflows/android.yml)   [![License: Apache-2.0](https://img.shields.io/badge/License-Apache-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)   [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/celzero/rethink-app/badge)](https://securityscorecards.dev/viewer/?uri=github.com/celzero/rethink-app)   [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/celzero/rethink-app) - -1. Feel free to fork and send a pull request for any reproducible bug fixes. - 1. The codebase is raw and is lacking documentation and comprehensive tests. If you need help, feel free to create a Wikipage to highlight the pain with building, testing, writing, committing code. [DeepWiki](https://deepwiki.com/celzero/rethink-app) and [Copilot](https://github.com/copilot?prompt=https://github.com/celzero/rethink-app) may also help, but they do hallucinate. - 2. Write descriptive commit messages that explain concisely the changes made. - 3. Each commit must reference an open issue on the project to make sure there isn't duplicated effort and prior discussion to refer to. -2. If you plan to work on a feature, please create a [github issue on the project](https://github.com/celzero/rethink-app/issues/new) first to kickstart the discussion before committing to doing any work. -3. Prod releases are usually once every few months, while [alpha is released monthly](https://github.com/celzero/rethink-app/actions/workflows/nightly.yml). - -## Tenets (unless you know better ones) -We aren't there yet, may never will be but these are some tenets for the project for the foreseeable future. - -- Make it right, make it secure, make it resilient, make it fast. In that order. -- Easy to use, no-root, no-gimmicks features that are anti-censorship and anti-surveillance. - - Easy to use: Any of the 3B+ Android users must be able to use it. Think CleanMaster / Instagram levels of ease-of-use. - - no-root: Shouldn't require root-access for any functionality added to it. - - no-gimmicks: Misleading material bordering on scareware, for example. -- Anti-censorship: Features focused on helping bring an open internet to everyone, preferably in the most efficient way possible (both monetarily and technically). -- Anti-surveillance: As above, but features that further limit (may not necessarily eliminate) surveillance by apps. -- Incremental changes in balance with newer features. - - For example, work on nagging UI issues or OEM specific bugs, must be taken up on equal weight to newer features, and a release must probably establish a good balance between the two. However; working on only incremental changes for a release is fine. -- Opinionated. Chip-away complexity. Do not expect users to require a PhD in Computer Science to use the app. - - No duplicate functionality. - - A concerted effort to not provide too many tunable knobs and settings. To err on the side of easy over simple. -- Ignore all tenets. - - Common sense always takes over when tenets get in the way. -- Must be distributable on the PlayStore, at least some toned down version of it. - - This unfortunately means on-device blocklists aren't possible; however, [Cloudflare Gateway](https://www.cloudflare.com/teams-gateway/)-esque cloud-based per-user blocklists get us the same functionality. -- Practice what you preach: Be obsessively private and secure. - -## Backstory -[FOSS United](https://fossunited.org/grants)  -[Mozilla Builders](https://builders.mozilla.community/)  -[FLOSS/fund by Zerodha](https://floss.fund/)  - -Internet censorship (sometimes ISP-enforced and often times government-enforced), unabated dragnet surveillance (by pretty much every company and app) stirred us upon this path. The three of us university classmates, [Mohammed](https://www.linkedin.com/in/hussain-mohammed-2525a626/), [Murtaza](https://www.linkedin.com/in/murtaza-aliakbar/), [Santhosh](https://www.linkedin.com/in/santhosh-ponnusamy-2b781244/) got together in late 2019 in the sleepy town of Coimbatore, India to do something about it. Our main gripe was there were all these wonderful tools that people could use but couldn't, either due to cost or due to inability to grok Computer-specific jargon. A lot has happened since we started and a lot has changed but our focus has always been on Android and its 3B+ unsuspecting users. The current idea has been in the works for since May 2020, with the pandemic derailing a bit of progress, and a bit of snafu with abandoning our previous version in favour of the current fork, which we aren't proud of yet, but it is a start. All's good now that we've won a grant from the [Mozilla Builders MVP program](https://builders.mozilla.community/) to go ahead and build this thing that we wanted to... do so faster... and not simply sleep our way through the execution. I hope you're excited but not as much as us that you quit your jobs for this like we did. +

+ Re-Rethink overview +

+

+ Take control of your Android device's network traffic without requiring root access.
+ A WireGuard client, OpenSnitch-inspired firewall, and pi-hole-inspired DNS client with blocklists. Built with Kotlin, Jetpack Compose, and Material 3 Expressive. +

+ +

+ Kotlin + License: Apache-2.0 + Platforms +

+ +--- + +Re-Rethink is an entirely new project—a modernized, meticulously crafted fork of [Rethink DNS](https://github.com/celzero/rethink-app). It preserves the original's core philosophy—no-root, VPN-based filtering, local-first processing, and rich DNS features—while adding a completely refreshed, first-class Material 3 Expressive Android UX. Expect dynamic workflows, fluid motion, and greater clarity for logs, rules, and settings. + +It operates in three primary modes: **VPN**, **DNS**, and **Firewall**. By supporting multiple WireGuard upstreams in a split-tunnel configuration, Re-Rethink allows for advanced tracking and precise control of app connections while using popular encrypted DNS protocols seamlessly. + +## 🚀 Key Features + +- 🛡️ **Advanced Firewall:** Precisely deny internet access to specific apps based on screen state, background/foreground state, unmetered/metered connections, Play Store categories, or user-defined IP and domain denylists. +- 🎨 **UI-First Redesign:** Built from the ground up for Material 3 Expressive. Features cleaner spacing, dynamic animations, and improved interaction behaviors over the original app. +- 🌐 **Robust DNS:** Route DNS traffic to your chosen DNS-over-HTTPS, DNS-over-TLS, or DNSCrypt resolver. The built-in default resolver seamlessly blocks ads and malware. +- 🔒 **VPN / Proxifier:** Forward TCP & UDP over SOCKS5, HTTP CONNECT, and WireGuard tunnels. Leverage split-tunneling to route different apps over different tunnels concurrently. +- 📊 **Network Monitor:** A comprehensive, per-app report card tracking when and where connections were made via UDP, TCP, and DNS, featuring upgraded traffic timeline views. +- 📱 **No-Root Required:** Total control over your traffic without compromising your Android device's security model. +- 🌍 **Anti-Censorship & Anti-Surveillance:** Engineered to circumvent internet censorship, limit invasive tracking by apps, and enforce privacy. + +## 📸 Screenshots + +

+ Re-Rethink Screenshots +

+ +## ✨ What's New in Re-Rethink + +If you are coming from the original Rethink DNS, we've been hard at work refining the app experience. Major differences and recent improvements include: + +- **Complete Visual Overhaul:** Built heavily with Jetpack Compose, the app embraces Google's Material 3 Expressive design language for a modern, fluid experience. +- **Improved Log Clarity:** The traffic logs and DNS logs have been completely redesigned with expressive card UI patterns, tonal elevation, and spring animations, so it's clearer which connections were blocked. +- **Polished Settings:** We've grouped settings logically and refined UI toggles, color pickers, and appearance configurations to be deeply consistent. + +## ⚙️ How It Works + +Re-Rethink operates as a local-only application. It uses Android's built-in `VpnService` to route your device's traffic through a local sinkhole. + +Because the app routes the traffic *internally on your device*, **it never sends your data to an external proxy or remote server unless you configure it to**. The application can inspect the outbound connection attempts and selectively drop or allow packets based on the rules you set for each app, providing a true on-device firewall without requiring root access. + + +## ⚠️ Behavior Notes + +- This app is local-first by default. +- Some device manufacturers apply stricter VPN policies; background behavior can vary. +- Certain background/network capabilities depend on notification, battery optimization, and device permissions. + +## 🛠️ Technology Stack + +- **Language:** Modern [Kotlin](https://kotlinlang.org/). +- **UI Toolkit:** Fully rebuilt with [Jetpack Compose](https://developer.android.com/jetpack/compose) and [Material 3 Expressive](https://m3.material.io/). +- **Concurrency:** Kotlin Coroutines & Flow for asynchronous data streams. +- **Local Storage:** Room Database and DataStore for persistent configurations and logs. + +## 📥 Getting Started + +To build and run Re-Rethink locally on your machine: + +1. Clone this repository: + ```bash + git clone https://github.com/bernaferrari/rethink-app.git + ``` +2. Open the project in the latest version of **Android Studio**. +3. Let Gradle sync download all required dependencies. +4. Connect an Android device or start an emulator. +5. Click **Run** (`Shift + F10`). + +## 📜 Credits & License + +- **Original Project:** [Rethink DNS](https://github.com/celzero/rethink-app) +- **License:** Apache License 2.0. See [`LICENSE`](LICENSE) for details. + +

+ Re-Rethink Light Mode variant +

+ +--- +

Made with ❤️ for an open internet and Jetpack Compose

diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..f00f777a5 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,439 @@ +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import java.util.Properties +import java.io.FileInputStream + + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.ksp) + alias(libs.plugins.detekt) + + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + + +// apply Google Services and Firebase Crashlytics plugins conditionally +val taskNames = gradle.startParameter.taskNames.joinToString(",").lowercase() +val apkBuild = taskNames.contains("full") +val fdroidBuild = taskNames.contains("fdroid") +val fdroidBuildServer = System.getenv("fdroidserver") +val isFdroidBuildServer = !fdroidBuildServer.isNullOrEmpty() && fdroidBuildServer != "null" +val deGoogled = !apkBuild || fdroidBuild || isFdroidBuildServer + +println("app-task names: '$taskNames'") +println("gradle deGoogled? $deGoogled (fdroidBuild: $fdroidBuild, fdroidBuildServer: $isFdroidBuildServer, apkBuild: $apkBuild)") + +if (!deGoogled) { + apply(plugin = "com.google.gms.google-services") + apply(plugin = "com.google.firebase.crashlytics") + println("app firebase plugins applied") +} else { + println("app firebase plugins SKIPPED") +} + +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() + +val gitVersion = providers.exec { + commandLine("git", "describe", "--tags", "--always") +}.standardOutput.asText.get().trim() + +fun getVersionCode(project: Project): Int { + var code = 0 + try { + val envCode = System.getenv("VERSION_CODE") + if (envCode != null) { + code = Integer.parseInt(envCode) + project.logger.info("env version code: $code") + } + } catch (ex: NumberFormatException) { + project.logger.info("missing env version code: ${ex.message}") + } + if (code == 0) { + code = (project.properties["VERSION_CODE"] as? String)?.toIntOrNull() ?: 0 + project.logger.info("project properties version code: $code") + } + return code +} + +try { + if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + } +} catch (ex: Exception) { + logger.info("missing keystore prop: ${ex.message}") + keystoreProperties["keyAlias"] = "" + keystoreProperties["keyPassword"] = "" + keystoreProperties["storeFile"] = "/dev/null" + keystoreProperties["storePassword"] = "" +} + +android { + compileSdk = 36 + namespace = "com.celzero.bravedns" + + androidResources { + generateLocaleConfig = true + } + + defaultConfig { + applicationId = "com.celzero.bravedns" + minSdk = 23 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + create("config") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + val storeFilePath = keystoreProperties["storeFile"] as String? + if (storeFilePath != null) { + storeFile = file(storeFilePath) + } + storePassword = keystoreProperties["storePassword"] as String? + } + create("alpha") { + keyAlias = System.getenv("ALPHA_KS_ALIAS") + keyPassword = System.getenv("ALPHA_KS_PASSPHRASE") + val storeFilePath = System.getenv("ALPHA_KS_FILE") + if (storeFilePath != null) { + storeFile = file(storeFilePath) + } + storePassword = System.getenv("ALPHA_KS_STORE_PASSPHRASE") + } + } + + splits { + abi { + isEnable = true + reset() + include("x86", "armeabi-v7a", "arm64-v8a", "x86_64") + isUniversalApk = true + } + } + + + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") + } + if (!deGoogled) { + configure { + nativeSymbolUploadEnabled = true + } + } + } + create("leakCanary") { + initWith(getByName("debug")) + matchingFallbacks += listOf("debug") + } + create("alpha") { + applicationIdSuffix = ".alpha" + isMinifyEnabled = true + isShrinkResources = true + signingConfig = signingConfigs.getByName("alpha") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + if (!deGoogled) { + afterEvaluate { + tasks.configureEach { + if (name.contains("injectCrashlyticsBuildIds")) { + enabled = false + logger.warn("disabled build id injection for: $name") + } + if (name.contains("uploadCrashlyticsSymbolFile")) { + doFirst { + logger.info("uploading crashlytics symbols: $name") + } + } + } + } + } + + + + buildFeatures { + viewBinding = true + buildConfig = true + compose = true + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + jniLibs { + keepDebugSymbols += listOf("**/*.so") + } + } + + flavorDimensions += listOf("releaseChannel", "releaseType") + productFlavors { + create("play") { + dimension = "releaseChannel" + } + create("fdroid") { + dimension = "releaseChannel" + } + create("website") { + dimension = "releaseChannel" + } + create("full") { + dimension = "releaseType" + versionCode = getVersionCode(project) + versionName = gitVersion + vectorDrawables.useSupportLibrary = true + } + } + + lint { + abortOnError = true + warningsAsErrors = true + checkDependencies = true + baseline = file("lint-baseline.xml") + xmlReport = true + htmlReport = true + sarifReport = true + } +} + +configure { + parallel = true + buildUponDefaultConfig = true + allRules = false + config.setFrom("$rootDir/config/detekt/detekt.yml") + baseline = file("$rootDir/config/detekt/baseline.xml") + source.setFrom( + files( + "src/main/java", + "src/full/java" + ) + ) +} + +tasks.withType().configureEach { + jvmTarget = "17" + reports { + html.required.set(true) + xml.required.set(true) + sarif.required.set(true) + txt.required.set(false) + md.required.set(false) + } +} + +val download by configurations.creating { + isTransitive = false +} + +val firestackRepo = project.findProperty("firestackRepo") as? String ?: "github" +val firestackCommit = project.findProperty("firestackCommit") as? String ?: "main" + +fun firestackDependency(suffix: String = ":debug"): String { + return when (firestackRepo) { + "jitpack", "github" -> "com.github.celzero:firestack:$firestackCommit$suffix@aar" + "ossrh" -> "com.celzero:firestack:$firestackCommit$suffix@aar" + else -> throw GradleException("Unknown firestackRepo: $firestackRepo") + } +} + +dependencies { + implementation(libs.guava) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.animation) + coreLibraryDesugaring(libs.desugar.jdk.libs) + + "fullImplementation"(libs.kotlin.stdlib.jdk8) + "fullImplementation"(libs.androidx.appcompat) + "fullImplementation"(libs.androidx.core.ktx) + implementation(libs.androidx.preference.ktx) + "fullImplementation"(libs.androidx.constraintlayout) + "fullImplementation"(libs.androidx.swiperefreshlayout) + + // Compose + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.text) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.materialkolor) + debugImplementation(libs.androidx.ui.tooling) + + "fullImplementation"(libs.kotlinx.coroutines.core) + "fullImplementation"(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.gson) + implementation(libs.napier) + + // Room + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.paging) + + "fullImplementation"(libs.androidx.lifecycle.viewmodel.ktx) + "fullImplementation"(libs.androidx.lifecycle.runtime.ktx) + + // Paging + implementation(libs.androidx.paging.runtime.ktx) + implementation(libs.androidx.paging.compose) + "fullImplementation"(libs.androidx.fragment.ktx) + "fullImplementation"(libs.androidx.viewpager2) + + "fullImplementation"(libs.okhttp) + "fullImplementation"(libs.okhttp.dnsoverhttps) + + "fullImplementation"(libs.retrofit) + "fullImplementation"(libs.retrofit.converter.gson) + + implementation(libs.okio.jvm) + + "fullImplementation"(libs.glide) { + exclude(group = "glide-parent") + } + "fullImplementation"(libs.glide.okhttp3.integration) { + exclude(group = "glide-parent") + } + + "kspFull"(libs.glide.compiler) + + "fullImplementation"(libs.shimmer) + + download(libs.koin.core) + implementation(libs.koin.core) + download(libs.koin.android) + implementation(libs.koin.android) + + download(libs.krate) + implementation(libs.krate) + + "fullImplementation"(libs.viewbindingpropertydelegate) + "fullImplementation"(libs.viewbindingpropertydelegate.noreflection) + + download(firestackDependency()) + "websiteImplementation"(firestackDependency()) + "fdroidImplementation"(firestackDependency()) + "playImplementation"(firestackDependency()) + + implementation(libs.androidx.work.runtime.ktx) { + modules { + module("com.google.guava:listenablefuture") { + replacedBy("com.google.guava:guava", "listenablefuture is part of guava") + } + } + } + + download(libs.ipaddress) + implementation(libs.ipaddress) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.mockk) + testImplementation(libs.mockk.android) + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.koin.test) + testImplementation(libs.koin.test.junit4) + androidTestImplementation(libs.mockk.android) + + "leakCanaryImplementation"(libs.leakcanary.android) + + "fullImplementation"(libs.androidx.navigation.fragment.ktx) + "fullImplementation"(libs.androidx.navigation.ui.ktx) + + "fullImplementation"(libs.androidx.biometric) + + "playImplementation"(libs.play.app.update) + "playImplementation"(libs.play.app.update.ktx) + + implementation(libs.androidx.security.crypto) + implementation(libs.androidx.security.app.authenticator) + androidTestImplementation(libs.androidx.security.app.authenticator) + + "fullImplementation"(libs.zxing.embedded) + "fullImplementation"(libs.recyclerview.fastscroll) + "fullImplementation"(libs.konfetti) + + // lint + lintChecks(libs.android.security.lint) + + implementation(libs.betterypermissionhelper) + + "websiteImplementation"(platform(libs.firebase.bom)) + "websiteImplementation"(libs.firebase.crashlytics) + "websiteImplementation"(libs.firebase.crashlytics.ndk) + + "playImplementation"(platform(libs.firebase.bom)) + "playImplementation"(libs.firebase.crashlytics) + "playImplementation"(libs.firebase.crashlytics.ndk) +} + +androidComponents { + onVariants { variant -> + val versionCodes = mapOf( + "armeabi-v7a" to 2, + "arm64-v8a" to 3, + "x86" to 8, + "x86_64" to 9 + ) + val mainOutput = variant.outputs.singleOrNull { + it.filters.any { filter -> filter.filterType == com.android.build.api.variant.FilterConfiguration.FilterType.ABI } + } + mainOutput?.let { output -> + val abi = output.filters.find { it.filterType == com.android.build.api.variant.FilterConfiguration.FilterType.ABI }?.identifier + val baseAbiVersionCode = versionCodes[abi] + if (baseAbiVersionCode != null) { + // Use map to calculate version code properly from the provider + val calculatedVersionCode = variant.outputs.first().versionCode.map { base -> + ((baseAbiVersionCode * 10000000) + (base ?: 0)).toInt() + } + output.versionCode.set(calculatedVersionCode) + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + freeCompilerArgs.add("-Xwarning-level=SENSELESS_COMPARISON:disabled") + } +} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 000000000..f53637925 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,7672 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/com.celzero.bravedns.database.AppDatabase/27.json b/app/schemas/com.celzero.bravedns.database.AppDatabase/27.json new file mode 100644 index 000000000..b1d978997 --- /dev/null +++ b/app/schemas/com.celzero.bravedns.database.AppDatabase/27.json @@ -0,0 +1,1526 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "3fb2e43e66aa3323303f11e5bc5ddb0d", + "entities": [ + { + "tableName": "AppInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `appName` TEXT NOT NULL, `uid` INTEGER NOT NULL, `isSystemApp` INTEGER NOT NULL, `firewallStatus` INTEGER NOT NULL, `appCategory` TEXT NOT NULL, `wifiDataUsed` INTEGER NOT NULL, `mobileDataUsed` INTEGER NOT NULL, `connectionStatus` INTEGER NOT NULL, `screenOffAllowed` INTEGER NOT NULL, `backgroundAllowed` INTEGER NOT NULL, `uploadBytes` INTEGER NOT NULL, `downloadBytes` INTEGER NOT NULL, `isProxyExcluded` INTEGER NOT NULL, `tombstoneTs` INTEGER NOT NULL, `modifiedTs` INTEGER NOT NULL, `tempAllowEnabled` INTEGER NOT NULL, `tempAllowExpiryTime` INTEGER NOT NULL, PRIMARY KEY(`uid`, `packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "appName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSystemApp", + "columnName": "isSystemApp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firewallStatus", + "columnName": "firewallStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appCategory", + "columnName": "appCategory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiDataUsed", + "columnName": "wifiDataUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileDataUsed", + "columnName": "mobileDataUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "connectionStatus", + "columnName": "connectionStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "screenOffAllowed", + "columnName": "screenOffAllowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backgroundAllowed", + "columnName": "backgroundAllowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadBytes", + "columnName": "uploadBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadBytes", + "columnName": "downloadBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProxyExcluded", + "columnName": "isProxyExcluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tombstoneTs", + "columnName": "tombstoneTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedTs", + "columnName": "modifiedTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tempAllowEnabled", + "columnName": "tempAllowEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tempAllowExpiryTime", + "columnName": "tempAllowExpiryTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid", + "packageName" + ] + } + }, + { + "tableName": "CustomIp", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `ipAddress` TEXT NOT NULL, `port` INTEGER NOT NULL, `protocol` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `proxyId` TEXT NOT NULL, `proxyCC` TEXT NOT NULL, `status` INTEGER NOT NULL, `wildcard` INTEGER NOT NULL, `ruleType` INTEGER NOT NULL, `modifiedDateTime` INTEGER NOT NULL, PRIMARY KEY(`uid`, `ipAddress`, `port`, `protocol`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ipAddress", + "columnName": "ipAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyId", + "columnName": "proxyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyCC", + "columnName": "proxyCC", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wildcard", + "columnName": "wildcard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleType", + "columnName": "ruleType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDateTime", + "columnName": "modifiedDateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid", + "ipAddress", + "port", + "protocol" + ] + } + }, + { + "tableName": "DoHEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dohName` TEXT NOT NULL, `dohURL` TEXT NOT NULL, `dohExplanation` TEXT, `isSelected` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `isSecure` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, `latency` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dohName", + "columnName": "dohName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dohURL", + "columnName": "dohURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dohExplanation", + "columnName": "dohExplanation", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSecure", + "columnName": "isSecure", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "DNSCryptEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dnsCryptName` TEXT NOT NULL, `dnsCryptURL` TEXT NOT NULL, `dnsCryptExplanation` TEXT, `isSelected` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, `latency` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dnsCryptName", + "columnName": "dnsCryptName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dnsCryptURL", + "columnName": "dnsCryptURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dnsCryptExplanation", + "columnName": "dnsCryptExplanation", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "DNSProxyEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `proxyName` TEXT NOT NULL, `proxyType` TEXT NOT NULL, `proxyAppName` TEXT, `proxyIP` TEXT, `proxyPort` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, `latency` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyName", + "columnName": "proxyName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyType", + "columnName": "proxyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyAppName", + "columnName": "proxyAppName", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyIP", + "columnName": "proxyIP", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyPort", + "columnName": "proxyPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "DNSCryptRelayEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dnsCryptRelayName` TEXT NOT NULL, `dnsCryptRelayURL` TEXT NOT NULL, `dnsCryptRelayExplanation` TEXT, `isSelected` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, `latency` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dnsCryptRelayName", + "columnName": "dnsCryptRelayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dnsCryptRelayURL", + "columnName": "dnsCryptRelayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dnsCryptRelayExplanation", + "columnName": "dnsCryptRelayExplanation", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ProxyEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `proxyName` TEXT NOT NULL, `proxyMode` INTEGER NOT NULL, `proxyType` TEXT NOT NULL, `proxyAppName` TEXT, `proxyIP` TEXT, `proxyPort` INTEGER NOT NULL, `userName` TEXT, `password` TEXT, `isSelected` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `isUDP` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, `latency` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyName", + "columnName": "proxyName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyMode", + "columnName": "proxyMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyType", + "columnName": "proxyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyAppName", + "columnName": "proxyAppName", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyIP", + "columnName": "proxyIP", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyPort", + "columnName": "proxyPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUDP", + "columnName": "isUDP", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CustomDomain", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `uid` INTEGER NOT NULL, `ips` TEXT NOT NULL, `status` INTEGER NOT NULL, `type` INTEGER NOT NULL, `proxyId` TEXT NOT NULL, `proxyCC` TEXT NOT NULL, `modifiedTs` INTEGER NOT NULL, `deletedTs` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`domain`, `uid`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ips", + "columnName": "ips", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyId", + "columnName": "proxyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyCC", + "columnName": "proxyCC", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modifiedTs", + "columnName": "modifiedTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedTs", + "columnName": "deletedTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain", + "uid" + ] + } + }, + { + "tableName": "RethinkDnsEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `url` TEXT NOT NULL, `uid` INTEGER NOT NULL, `desc` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `latency` INTEGER NOT NULL, `blocklistCount` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, PRIMARY KEY(`name`, `url`, `uid`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "desc", + "columnName": "desc", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blocklistCount", + "columnName": "blocklistCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name", + "url", + "uid" + ] + } + }, + { + "tableName": "RethinkRemoteFileTag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` INTEGER NOT NULL, `uname` TEXT NOT NULL, `vname` TEXT NOT NULL, `group` TEXT NOT NULL, `subg` TEXT NOT NULL, `url` TEXT NOT NULL, `show` INTEGER NOT NULL, `entries` INTEGER NOT NULL, `pack` TEXT, `level` TEXT, `simpleTagId` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, PRIMARY KEY(`value`))", + "fields": [ + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uname", + "columnName": "uname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vname", + "columnName": "vname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subg", + "columnName": "subg", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entries", + "columnName": "entries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pack", + "columnName": "pack", + "affinity": "TEXT" + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "TEXT" + }, + { + "fieldPath": "simpleTagId", + "columnName": "simpleTagId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "value" + ] + } + }, + { + "tableName": "RethinkLocalFileTag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` INTEGER NOT NULL, `uname` TEXT NOT NULL, `vname` TEXT NOT NULL, `group` TEXT NOT NULL, `subg` TEXT NOT NULL, `url` TEXT NOT NULL, `show` INTEGER NOT NULL, `entries` INTEGER NOT NULL, `pack` TEXT, `level` TEXT, `simpleTagId` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, PRIMARY KEY(`value`))", + "fields": [ + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uname", + "columnName": "uname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vname", + "columnName": "vname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subg", + "columnName": "subg", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entries", + "columnName": "entries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pack", + "columnName": "pack", + "affinity": "TEXT" + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "TEXT" + }, + { + "fieldPath": "simpleTagId", + "columnName": "simpleTagId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "value" + ] + } + }, + { + "tableName": "LocalBlocklistPacksMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pack` TEXT NOT NULL, `level` INTEGER NOT NULL, `blocklistIds` TEXT NOT NULL, `group` TEXT NOT NULL, PRIMARY KEY(`pack`, `level`))", + "fields": [ + { + "fieldPath": "pack", + "columnName": "pack", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blocklistIds", + "columnName": "blocklistIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pack", + "level" + ] + } + }, + { + "tableName": "RemoteBlocklistPacksMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pack` TEXT NOT NULL, `level` INTEGER NOT NULL, `blocklistIds` TEXT NOT NULL, `group` TEXT NOT NULL, PRIMARY KEY(`pack`, `level`))", + "fields": [ + { + "fieldPath": "pack", + "columnName": "pack", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blocklistIds", + "columnName": "blocklistIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pack", + "level" + ] + } + }, + { + "tableName": "WgConfigFiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `configPath` TEXT NOT NULL, `serverResponse` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `isCatchAll` INTEGER NOT NULL, `oneWireGuard` INTEGER NOT NULL, `useOnlyOnMetered` INTEGER NOT NULL, `isDeletable` INTEGER NOT NULL, `ssidEnabled` INTEGER NOT NULL, `ssids` TEXT NOT NULL, `modifiedTs` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configPath", + "columnName": "configPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverResponse", + "columnName": "serverResponse", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCatchAll", + "columnName": "isCatchAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "oneWireGuard", + "columnName": "oneWireGuard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useOnlyOnMetered", + "columnName": "useOnlyOnMetered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeletable", + "columnName": "isDeletable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ssidEnabled", + "columnName": "ssidEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ssids", + "columnName": "ssids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modifiedTs", + "columnName": "modifiedTs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ProxyApplicationMapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `proxyId` TEXT NOT NULL, `appName` TEXT NOT NULL, `proxyName` TEXT NOT NULL, `isActive` INTEGER NOT NULL, PRIMARY KEY(`uid`, `packageName`, `proxyId`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyId", + "columnName": "proxyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "appName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyName", + "columnName": "proxyName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid", + "packageName", + "proxyId" + ] + } + }, + { + "tableName": "TcpProxyEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `url` TEXT NOT NULL, `paymentStatus` INTEGER NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentStatus", + "columnName": "paymentStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "DoTEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `desc` TEXT, `isSelected` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `isSecure` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, `latency` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "desc", + "columnName": "desc", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSecure", + "columnName": "isSecure", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ODoHEndpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `proxy` TEXT NOT NULL, `resolver` TEXT NOT NULL, `proxyIps` TEXT NOT NULL, `desc` TEXT, `isSelected` INTEGER NOT NULL, `isCustom` INTEGER NOT NULL, `modifiedDataTime` INTEGER NOT NULL, `latency` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxy", + "columnName": "proxy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resolver", + "columnName": "resolver", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyIps", + "columnName": "proxyIps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "desc", + "columnName": "desc", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedDataTime", + "columnName": "modifiedDataTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "RpnProxy", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `configPath` TEXT NOT NULL, `serverResPath` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `isLockdown` INTEGER NOT NULL, `createdTs` INTEGER NOT NULL, `modifiedTs` INTEGER NOT NULL, `misc` TEXT NOT NULL, `tunId` TEXT NOT NULL, `latency` INTEGER NOT NULL, `lastRefreshTime` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configPath", + "columnName": "configPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverResPath", + "columnName": "serverResPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLockdown", + "columnName": "isLockdown", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdTs", + "columnName": "createdTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedTs", + "columnName": "modifiedTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "misc", + "columnName": "misc", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tunId", + "columnName": "tunId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latency", + "columnName": "latency", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRefreshTime", + "columnName": "lastRefreshTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "WgHopMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `src` TEXT NOT NULL, `hop` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `status` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "src", + "columnName": "src", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hop", + "columnName": "hop", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "SubscriptionStatus", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` TEXT NOT NULL, `purchaseToken` TEXT NOT NULL, `productId` TEXT NOT NULL, `planId` TEXT NOT NULL, `sessionToken` TEXT NOT NULL, `productTitle` TEXT NOT NULL, `state` INTEGER NOT NULL, `purchaseTime` INTEGER NOT NULL, `accountExpiry` INTEGER NOT NULL, `billingExpiry` INTEGER NOT NULL, `developerPayload` TEXT NOT NULL, `status` INTEGER NOT NULL, `lastUpdatedTs` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseToken", + "columnName": "purchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planId", + "columnName": "planId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionToken", + "columnName": "sessionToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "productTitle", + "columnName": "productTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseTime", + "columnName": "purchaseTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountExpiry", + "columnName": "accountExpiry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "billingExpiry", + "columnName": "billingExpiry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developerPayload", + "columnName": "developerPayload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedTs", + "columnName": "lastUpdatedTs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "SubscriptionStateHistory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `subscriptionId` INTEGER NOT NULL, `fromState` INTEGER NOT NULL, `toState` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `reason` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromState", + "columnName": "fromState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toState", + "columnName": "toState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fb2e43e66aa3323303f11e5bc5ddb0d')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/celzero/bravedns/ui/activity/AppInfoActivityTest.kt b/app/src/androidTest/java/com/celzero/bravedns/ui/activity/AppInfoActivityTest.kt index 101d5c659..02779367a 100644 --- a/app/src/androidTest/java/com/celzero/bravedns/ui/activity/AppInfoActivityTest.kt +++ b/app/src/androidTest/java/com/celzero/bravedns/ui/activity/AppInfoActivityTest.kt @@ -218,32 +218,6 @@ class AppInfoActivityTest { } } - @Test - fun testRecyclerViewsAreInitialized() { - Log.d(testTag, "Testing RecyclerView initialization") - val intent = createValidIntent() - - ActivityScenario.launch(intent).use { - try { - // Check that RecyclerViews are present (they should exist even if empty) - onView(withId(R.id.aad_active_conns_rv)) - .check(matches(isDisplayed())) - onView(withId(R.id.aad_asn_rv)) - .check(matches(isDisplayed())) - onView(withId(R.id.aad_most_contacted_domain_rv)) - .check(matches(isDisplayed())) - onView(withId(R.id.aad_most_contacted_ips_rv)) - .check(matches(isDisplayed())) - - Log.d(testTag, "RecyclerView initialization test completed") - - } catch (e: Exception) { - Log.e(testTag, "RecyclerView test failed", e) - // Log but don't fail - RecyclerViews might be conditionally visible - Log.w(testTag, "Some RecyclerViews may be conditionally visible") - } - } - } @Test fun testActivityHandlesConfigurationChanges() { @@ -633,43 +607,6 @@ class AppInfoActivityTest { } } - @Test - fun testScrollingPerformance() { - Log.d(testTag, "Testing scrolling performance") - val intent = createValidIntent() - - ActivityScenario.launch(intent).use { scenario -> - val scrollTime = measureTimeMillis { - try { - // Test scrolling on RecyclerViews if they exist - val recyclerViewIds = listOf( - R.id.aad_active_conns_rv, - R.id.aad_asn_rv, - R.id.aad_most_contacted_domain_rv, - R.id.aad_most_contacted_ips_rv - ) - - recyclerViewIds.forEach { id -> - try { - onView(withId(id)) - .check(matches(anyOf(isDisplayed(), not(isDisplayed())))) - .perform(swipeUp()) - } catch (e: Exception) { - Log.w(testTag, "ScrollView $id not available or scrollable: ${e.message}") - } - } - } catch (e: Exception) { - Log.w(testTag, "Scrolling test encountered issues: ${e.message}") - } - } - - Log.d(testTag, "Scrolling operations time: ${scrollTime}ms") - assertTrue( - "Scrolling should be responsive", - scrollTime < 2000L - ) - } - } @Test fun testInteractionResponseTimes() { diff --git a/app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeComponentsTest.kt b/app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeComponentsTest.kt new file mode 100644 index 000000000..dcacebb43 --- /dev/null +++ b/app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeComponentsTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2024 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.compose.home + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.celzero.bravedns.ui.compose.theme.RethinkTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeComponentsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun startStopButton_displaysStart_whenNotPlaying() { + composeTestRule.setContent { + RethinkTheme { + StartStopButton( + isPlaying = false, + onClick = {} + ) + } + } + + composeTestRule.onNodeWithText("Start").assertIsDisplayed() + } + + @Test + fun startStopButton_displaysStop_whenPlaying() { + composeTestRule.setContent { + RethinkTheme { + StartStopButton( + isPlaying = true, + onClick = {} + ) + } + } + + composeTestRule.onNodeWithText("Stop").assertIsDisplayed() + } + + @Test + fun startStopButton_triggersOnClick() { + var clicked = false + composeTestRule.setContent { + RethinkTheme { + StartStopButton( + isPlaying = false, + onClick = { clicked = true } + ) + } + } + + composeTestRule.onNodeWithText("Start").performClick() + assert(clicked) + } + + @Test + fun dashboardCard_displaysTitleAndContent() { + composeTestRule.setContent { + RethinkTheme { + DashboardCard( + title = "Test Card", + iconId = android.R.drawable.ic_menu_info_details, + onClick = {} + ) { + androidx.compose.material3.Text("Test Content") + } + } + } + + composeTestRule.onNodeWithText("Test Card").assertIsDisplayed() + composeTestRule.onNodeWithText("Test Content").assertIsDisplayed() + } + + @Test + fun statItem_displaysValueAndLabel() { + composeTestRule.setContent { + RethinkTheme { + StatItem( + label = "Test Label", + value = "42" + ) + } + } + + composeTestRule.onNodeWithText("42").assertIsDisplayed() + composeTestRule.onNodeWithText("Test Label").assertIsDisplayed() + } + + @Test + fun statItem_appliesHighlightedColor() { + // This test verifies the composable renders without crashing + // when isHighlighted is true + composeTestRule.setContent { + RethinkTheme { + StatItem( + label = "Highlighted", + value = "100", + isHighlighted = true + ) + } + } + + composeTestRule.onNodeWithText("100").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeScreenTest.kt b/app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeScreenTest.kt new file mode 100644 index 000000000..d03ae9eb6 --- /dev/null +++ b/app/src/androidTest/java/com/celzero/bravedns/ui/compose/HomeScreenTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright 2024 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.compose.home + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.celzero.bravedns.ui.compose.theme.RethinkTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun homeScreen_displaysCorrectInitialState() { + val uiState = HomeScreenUiState( + isVpnActive = false, + dnsLatency = "-- ms", + dnsConnectedName = "None", + firewallUniversalRules = 0, + appsTotal = 0, + appsAllowed = 0, + appsBlocked = 0 + ) + + composeTestRule.setContent { + RethinkTheme { + HomeScreen( + uiState = uiState, + onStartStopClick = {}, + onDnsClick = {}, + onFirewallClick = {}, + onProxyClick = {}, + onLogsClick = {}, + onAppsClick = {}, + onSponsorClick = {} + ) + } + } + + // Verify Start button is displayed when VPN is inactive + composeTestRule.onNodeWithText("Start").assertIsDisplayed() + } + + @Test + fun homeScreen_displaysStopButton_whenVpnIsActive() { + val uiState = HomeScreenUiState( + isVpnActive = true, + dnsLatency = "24ms", + dnsConnectedName = "Cloudflare", + firewallUniversalRules = 12, + appsTotal = 100, + appsAllowed = 95, + appsBlocked = 5 + ) + + composeTestRule.setContent { + RethinkTheme { + HomeScreen( + uiState = uiState, + onStartStopClick = {}, + onDnsClick = {}, + onFirewallClick = {}, + onProxyClick = {}, + onLogsClick = {}, + onAppsClick = {}, + onSponsorClick = {} + ) + } + } + + // Verify Stop button is displayed when VPN is active + composeTestRule.onNodeWithText("Stop").assertIsDisplayed() + } + + @Test + fun homeScreen_startStopButton_triggersCallback() { + var clickCount = 0 + val uiState = HomeScreenUiState(isVpnActive = false) + + composeTestRule.setContent { + RethinkTheme { + HomeScreen( + uiState = uiState, + onStartStopClick = { clickCount++ }, + onDnsClick = {}, + onFirewallClick = {}, + onProxyClick = {}, + onLogsClick = {}, + onAppsClick = {}, + onSponsorClick = {} + ) + } + } + + // Click the Start button + composeTestRule.onNodeWithText("Start").performClick() + + // Verify callback was triggered + assert(clickCount == 1) + } + + @Test + fun homeScreen_displaysDnsCard() { + val uiState = HomeScreenUiState( + dnsLatency = "45ms", + dnsConnectedName = "Google DNS" + ) + + composeTestRule.setContent { + RethinkTheme { + HomeScreen( + uiState = uiState, + onStartStopClick = {}, + onDnsClick = {}, + onFirewallClick = {}, + onProxyClick = {}, + onLogsClick = {}, + onAppsClick = {}, + onSponsorClick = {} + ) + } + } + + // Verify DNS latency is displayed + composeTestRule.onNodeWithText("45ms").assertIsDisplayed() + } + + @Test + fun homeScreen_displaysFirewallCard() { + val uiState = HomeScreenUiState( + firewallUniversalRules = 15, + firewallIpRules = 5, + firewallDomainRules = 3 + ) + + composeTestRule.setContent { + RethinkTheme { + HomeScreen( + uiState = uiState, + onStartStopClick = {}, + onDnsClick = {}, + onFirewallClick = {}, + onProxyClick = {}, + onLogsClick = {}, + onAppsClick = {}, + onSponsorClick = {} + ) + } + } + + // Verify firewall rules count is displayed + composeTestRule.onNodeWithText("15").assertIsDisplayed() + } + + @Test + fun homeScreen_displaysAppsCard() { + val uiState = HomeScreenUiState( + appsTotal = 120, + appsAllowed = 100, + appsBlocked = 15, + appsBypassed = 3, + appsIsolated = 2, + appsExcluded = 0 + ) + + composeTestRule.setContent { + RethinkTheme { + HomeScreen( + uiState = uiState, + onStartStopClick = {}, + onDnsClick = {}, + onFirewallClick = {}, + onProxyClick = {}, + onLogsClick = {}, + onAppsClick = {}, + onSponsorClick = {} + ) + } + } + + // Verify apps count is displayed + composeTestRule.onNodeWithText("100").assertIsDisplayed() + composeTestRule.onNodeWithText("120").assertIsDisplayed() + } +} diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index 49ba11ee9..d54fe449d 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -11,31 +11,10 @@ android:name="android.webkit.WebView.MetricsOptOut" android:value="true" /> - + - - - - - - - - - - - - - - - - + android:targetActivity=".ui.HomeScreenActivity"> @@ -63,130 +42,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + - - - - - - --> + - + android:launchMode="standard" /> + - + \ No newline at end of file diff --git a/app/src/full/java/com/celzero/bravedns/NonStoreAppUpdater.kt b/app/src/full/java/com/celzero/bravedns/NonStoreAppUpdater.kt index 32a723aa9..3c9ea0603 100644 --- a/app/src/full/java/com/celzero/bravedns/NonStoreAppUpdater.kt +++ b/app/src/full/java/com/celzero/bravedns/NonStoreAppUpdater.kt @@ -65,15 +65,8 @@ class NonStoreAppUpdater( override fun onResponse(call: Call, response: Response) { try { - val res = response.body?.string() - if (res == null) { - listener.onUpdateCheckFailed( - AppUpdater.InstallSource.OTHER, - isInteractive - ) - return - } - if (res.isBlank() == true) { + val res = response.body.string() + if (res.isBlank()) { listener.onUpdateCheckFailed( AppUpdater.InstallSource.OTHER, isInteractive diff --git a/app/src/full/java/com/celzero/bravedns/RethinkDnsApplication.kt b/app/src/full/java/com/celzero/bravedns/RethinkDnsApplication.kt index 4c8d60233..8598f1a5d 100644 --- a/app/src/full/java/com/celzero/bravedns/RethinkDnsApplication.kt +++ b/app/src/full/java/com/celzero/bravedns/RethinkDnsApplication.kt @@ -20,14 +20,13 @@ import Logger.LOG_TAG_SCHEDULER import android.app.Application import android.content.pm.ApplicationInfo import android.os.StrictMode -import com.celzero.bravedns.scheduler.EnhancedBugReport import com.celzero.bravedns.scheduler.ScheduleManager import com.celzero.bravedns.scheduler.WorkScheduler import com.celzero.bravedns.util.FirebaseErrorReporting import com.celzero.bravedns.util.GlobalExceptionHandler -import com.celzero.bravedns.util.GoReportingHandler +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.android.ext.android.get @@ -52,22 +51,17 @@ class RethinkDnsApplication : Application() { koin.loadModules(AppModules) } - val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + if (DEBUG) { + Napier.base(DebugAntilog()) + } // Initialize global exception handler GlobalExceptionHandler.initialize(this) FirebaseErrorReporting.initialize() - GoReportingHandler.initialize(appScope, this) - - // On every app start, report any tombstone files from the previous session - val appCtx = this - appScope.launch(Dispatchers.IO) { - EnhancedBugReport.reportTombstonesToFirebaseOnStartup(appCtx) - } turnOnStrictMode() - appScope.launch { + CoroutineScope(SupervisorJob()).launch { scheduleJobs() } } @@ -75,7 +69,7 @@ class RethinkDnsApplication : Application() { private suspend fun scheduleJobs() { Logger.d(LOG_TAG_SCHEDULER, "Schedule job") get().scheduleAppExitInfoCollectionJob() - // database refresh to keep app data up to date + // database refresh is used in both headless and main project get().scheduleDatabaseRefreshJob() get().scheduleDataUsageJob() get().schedulePurgeConnectionsLog() diff --git a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt index 7da153b47..c5e8a8e09 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt @@ -15,286 +15,149 @@ */ package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_UI + import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.clickable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.lifecycle.LifecycleOwner -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConnection -import com.celzero.bravedns.databinding.ListItemAppDomainDetailsBinding import com.celzero.bravedns.service.DomainRulesManager import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.ui.bottomsheet.AppDomainRulesBottomSheet +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog import com.celzero.bravedns.util.UIUtils -import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.removeBeginningTrailingCommas import com.celzero.bravedns.util.Utilities.showToastUiCentered -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.github.aakira.napier.Napier import kotlin.math.log2 -class AppWiseDomainsAdapter( - val context: Context, - val lifecycleOwner: LifecycleOwner, - val uid: Int, - val isActiveConn: Boolean = false -) : - PagingDataAdapter( - DIFF_CALLBACK - ), - AppDomainRulesBottomSheet.OnBottomSheetDialogFragmentDismiss { - - private var maxValue: Int = 0 - private var minPercentage: Int = INITIAL_MIN_PERCENTAGE - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: AppConnection, - newConnection: AppConnection - ) = oldConnection == newConnection - - override fun areContentsTheSame( - oldConnection: AppConnection, - newConnection: AppConnection - ) = oldConnection == newConnection - } - - private const val TAG = "AppWiseDomainsAdapter" - private const val INITIAL_MIN_PERCENTAGE = 100 - private const val PERCENTAGE_MULTIPLIER = 100 - } - - private lateinit var adapter: AppWiseDomainsAdapter - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): ConnectionDetailsViewHolder { - val itemBinding = - ListItemAppDomainDetailsBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - adapter = this - return ConnectionDetailsViewHolder(itemBinding) - } - - override fun onBindViewHolder( - holder: ConnectionDetailsViewHolder, - position: Int - ) { - val appConnection: AppConnection = getItem(position) ?: return - // updates the app-wise connections from network log to AppInfo screen - holder.update(appConnection) - } - private fun calculatePercentage(c: Double): Int { - val value = (log2(c) * PERCENTAGE_MULTIPLIER).toInt() - // maxValue will be based on the count returned by db query (order by count desc) - if (value > maxValue) { - maxValue = value - } - return if (maxValue == 0) { - 0 +@Composable +fun DomainRow( + conn: AppConnection, + uid: Int, + isActiveConn: Boolean, + refreshToken: Int, + onIpClick: (AppConnection) -> Unit +) { + val countText = conn.count.toString() + val (primaryText, secondaryText) = + if (isActiveConn) { + val ip = beautifyIpString(conn.ipAddress) + val name = conn.appOrDnsName.orEmpty() + ip to name } else { - val percentage = (value * PERCENTAGE_MULTIPLIER / maxValue) - // minPercentage is used to show the progress bar when the percentage is 0 - if (percentage < minPercentage && percentage != 0) { - minPercentage = percentage - } - percentage - } - } - - inner class ConnectionDetailsViewHolder(private val b: ListItemAppDomainDetailsBinding) : - RecyclerView.ViewHolder(b.root) { - fun update(conn: AppConnection) { - displayTransactionDetails(conn) - setupClickListeners(conn) + conn.appOrDnsName to conn.ipAddress } - private fun displayTransactionDetails(conn: AppConnection) { - // handle active connections specially, no need to show progress bar, - // asn info will be added in the appOrDnsName field - if (isActiveConn) { - b.progress.visibility = View.GONE - b.acdCount.text = conn.count.toString() - b.acdDomain.text = beautifyIpString(conn.ipAddress) - if (conn.appOrDnsName.isNullOrEmpty()) { - b.acdIpAddress.text = "" - } else { - b.acdIpAddress.text = conn.appOrDnsName + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { onIpClick(conn) } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = conn.flag, style = MaterialTheme.typography.titleMedium) + Column(modifier = Modifier.weight(1f)) { + Text(text = primaryText.orEmpty(), style = MaterialTheme.typography.titleMedium) + if (!secondaryText.isNullOrEmpty()) { + Text(text = secondaryText, style = MaterialTheme.typography.bodySmall) } - b.acdFlag.visibility = View.VISIBLE - b.acdFlag.text = conn.flag - return - } - - b.acdCount.text = conn.count.toString() - b.acdDomain.text = conn.appOrDnsName - b.acdFlag.text = conn.flag - if (conn.ipAddress.isNotEmpty()) { - b.acdIpAddress.visibility = View.VISIBLE - b.acdIpAddress.text = beautifyIpString(conn.ipAddress) - } else { - b.acdIpAddress.visibility = View.GONE - } - updateStatusUi(conn) - } - - private fun setupClickListeners(conn: AppConnection) { - b.acdContainer.setOnClickListener { - if (isActiveConn) { - showCloseConnectionDialog(conn) - return@setOnClickListener + if (!isActiveConn && !conn.appOrDnsName.isNullOrEmpty()) { + DomainProgress(conn, uid, refreshToken) } - // open bottom sheet to apply domain/ip rules - openBottomSheet(conn) - } - } - - private fun showCloseConnectionDialog(appConn: AppConnection) { - if (context !is AppCompatActivity) { - Logger.w(LOG_TAG_UI, "$TAG err showing close connection dialog") - return } - - /*if (isRethink) { - Logger.i(LOG_TAG_UI, "$TAG rethink connection - no close connection dialog") - return - }*/ - Logger.v(LOG_TAG_UI, "$TAG show close connection dialog for uid: $uid") - val dialog = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim) - .setTitle(context.getString(R.string.close_conns_dialog_title)) - .setMessage(context.getString(R.string.close_conns_dialog_desc, appConn.ipAddress)) - .setPositiveButton(R.string.lbl_proceed) { _, _ -> - // close the connection - VpnController.closeConnectionsByUidDomain(appConn.uid, appConn.ipAddress, "app-wise-domains-manual-close") - Logger.i( - LOG_TAG_UI, - "$TAG closed connection for uid: ${appConn.uid}, domain: ${appConn.appOrDnsName}" - ) - showToastUiCentered( - context, - context.getString(R.string.config_add_success_toast), - Toast.LENGTH_LONG - ) - } - .setNegativeButton(R.string.lbl_cancel, null) - .create() - dialog.setCancelable(true) - dialog.setCanceledOnTouchOutside(true) - dialog.show() - } - - private fun openBottomSheet(appConn: AppConnection) { - if (context !is AppCompatActivity) { - Logger.w(LOG_TAG_UI, "$TAG err opening the app conn bottom sheet") - return - } - - /*if (isRethink) { - Logger.i(LOG_TAG_UI, "$TAG rethink connection - no bottom sheet") - return - }*/ - - if (isActiveConn) { - Logger.i(LOG_TAG_UI, "$TAG active connection - no bottom sheet") - return - } - - Logger.v(LOG_TAG_UI, "$TAG open bottom sheet for uid: $uid, ip: ${appConn.ipAddress}, domain: ${appConn.appOrDnsName}") - val bottomSheetFragment = AppDomainRulesBottomSheet() - // Fix: free-form window crash - // all BottomSheetDialogFragment classes created must have a public, no-arg constructor. - // the best practice is to simply never define any constructors at all. - // so sending the data using Bundles - val bundle = Bundle() - bundle.putInt(AppDomainRulesBottomSheet.UID, uid) - bundle.putString(AppDomainRulesBottomSheet.DOMAIN, appConn.appOrDnsName) - bottomSheetFragment.arguments = bundle - // Fix: Validate position before passing to avoid IndexOutOfBoundsException - val currentPosition = absoluteAdapterPosition - if (currentPosition != RecyclerView.NO_POSITION) { - bottomSheetFragment.dismissListener(adapter, currentPosition) - } else { - // Position is invalid, pass -1 to indicate refresh should be used - Logger.w(LOG_TAG_UI, "$TAG invalid adapter position when opening bottom sheet") - bottomSheetFragment.dismissListener(adapter, RecyclerView.NO_POSITION) - } - bottomSheetFragment.show(context.supportFragmentManager, bottomSheetFragment.tag) - } - - private fun beautifyIpString(d: String): String { - // replace two commas in the string to one - // add space after all the commas - return removeBeginningTrailingCommas(d).replace(",,", ",").replace(",", ", ") + Text( + text = countText, + style = MaterialTheme.typography.labelLarge + ) } + Spacer(modifier = Modifier.fillMaxWidth()) + } +} - private fun updateStatusUi(conn: AppConnection) { - if (conn.appOrDnsName.isNullOrEmpty()) { - b.progress.visibility = View.GONE - return - } - val status = DomainRulesManager.status(conn.appOrDnsName, uid) - Logger.vv(LOG_TAG_UI, "$TAG domain: ${conn.appOrDnsName}, status: $status") - when (status) { - DomainRulesManager.Status.NONE -> { - b.progress.setIndicatorColor( - UIUtils.fetchToggleBtnColors(context, R.color.chipTextNeutral) - ) - } - DomainRulesManager.Status.BLOCK -> { - b.progress.setIndicatorColor( - UIUtils.fetchToggleBtnColors(context, R.color.accentBad) - ) - } - DomainRulesManager.Status.TRUST -> { - b.progress.setIndicatorColor( - UIUtils.fetchToggleBtnColors(context, R.color.accentGood) - ) - } - } - - var p = calculatePercentage(conn.count.toDouble()) - if (p == 0) { - p = minPercentage / 2 - } +@Composable +fun CloseConnsDialog( + conn: AppConnection, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = context.getString(R.string.close_conns_dialog_title), + message = context.getString(R.string.close_conns_dialog_desc, conn.ipAddress), + confirmText = context.getString(R.string.lbl_proceed), + dismissText = context.getString(R.string.lbl_cancel), + onConfirm = { + VpnController.closeConnectionsByUidDomain( + conn.uid, + conn.ipAddress, + "app-wise-domains-manual-close" + ) + showToastUiCentered( + context, + context.getString(R.string.config_add_success_toast), + Toast.LENGTH_LONG + ) + onConfirm() + }, + onDismiss = onDismiss + ) +} - if (Utilities.isAtleastN()) { - b.progress.setProgress(p, true) - } else { - b.progress.progress = p - } - } +@Composable +private fun DomainProgress(conn: AppConnection, uid: Int, refresh: Int) { + val context = LocalContext.current + if (refresh == Int.MIN_VALUE) { + return } - - override fun notifyDataset(position: Int) { - // Fix: IndexOutOfBoundsException - validate position before notifying - try { - if (position in 0.. + MaterialTheme.colorScheme.onSurfaceVariant + DomainRulesManager.Status.BLOCK -> + MaterialTheme.colorScheme.error + DomainRulesManager.Status.TRUST -> + MaterialTheme.colorScheme.tertiary + } // In many Compose use cases, 100 or 1.0f is used directly. // For now, let's keep it simple or implement a similar logic if required. + var p = calculatePercentage(conn.count.toDouble()) + if (p == 0) { + p = 5 } + LinearProgressIndicator( + progress = { p / 100f }, + color = color, + trackColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth() + ) +} + +private fun calculatePercentage(c: Double): Int { + // If not available, it becomes a per-item progress which is less useful. + // For now, let's use a reasonable default or assume max is handled elsewhere. + val value = (log2(c) * 100).toInt() // In a LazyList, computing global max is expensive or requires a separate pass. + return (value % 100) // Fallback +} + +private fun beautifyIpString(d: String): String { + return removeBeginningTrailingCommas(d).replace(",,", ",").replace(",", ", ") } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt index 7fa5881ed..d4e250ad5 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt @@ -15,216 +15,120 @@ */ package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_UI + import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.clickable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.lifecycle.LifecycleOwner -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConnection -import com.celzero.bravedns.databinding.ListItemAppIpDetailsBinding import com.celzero.bravedns.service.IpRulesManager -import com.celzero.bravedns.ui.bottomsheet.AppIpRulesBottomSheet import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.removeBeginningTrailingCommas +import io.github.aakira.napier.Napier import kotlin.math.log2 -class AppWiseIpsAdapter(val context: Context, val lifecycleOwner: LifecycleOwner, val uid: Int, val isAsn: Boolean = false) : - PagingDataAdapter(DIFF_CALLBACK), - AppIpRulesBottomSheet.OnBottomSheetDialogFragmentDismiss { - - private var maxValue: Int = 0 - private var minPercentage: Int = INITIAL_MIN_PERCENTAGE - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(old: AppConnection, new: AppConnection) = old == new - override fun areContentsTheSame(old: AppConnection, new: AppConnection) = old == new - } - private const val TAG = "AppWiseIpsAdapter" - private const val INITIAL_MIN_PERCENTAGE = 100 - private const val PERCENTAGE_MULTIPLIER = 100 - } - - private lateinit var adapter: AppWiseIpsAdapter - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConnectionDetailsViewHolder { - val itemBinding = - ListItemAppIpDetailsBinding.inflate(LayoutInflater.from(parent.context), parent, false) - adapter = this - return ConnectionDetailsViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: ConnectionDetailsViewHolder, position: Int) { - val appConnection: AppConnection = getItem(position) ?: return - // updates the app-wise connections from network log to AppInfo screen - holder.update(appConnection) +private fun calculatePercentage(c: Double, maxValue: Int): Pair { + val value = (log2(c) * 100).toInt() + val newMaxValue = if (value > maxValue) value else maxValue + return if (newMaxValue == 0) { + 0 to 0 + } else { + val percentage = (value * 100 / newMaxValue) + percentage to newMaxValue } +} - private fun calculatePercentage(c: Double): Int { - val value = (log2(c) * PERCENTAGE_MULTIPLIER).toInt() - // maxValue will be based on the count returned by db query (order by count desc) - if (value > maxValue) { - maxValue = value - } - return if (maxValue == 0) { - 0 +@Composable +fun IpRow( + conn: AppConnection, + isAsn: Boolean, + refreshToken: Int, + onIpClick: (AppConnection) -> Unit +) { + val countText = conn.count.toString() + val flagText = + if (isAsn) { + val cc = Utilities.getFlag(conn.flag) + if (cc.isEmpty()) "--" else cc } else { - val percentage = (value * PERCENTAGE_MULTIPLIER / maxValue) - // minPercentage is used to show the progress bar when the percentage is 0 - if (percentage < minPercentage && percentage != 0) { - minPercentage = percentage - } - percentage - } - } - - inner class ConnectionDetailsViewHolder(private val b: ListItemAppIpDetailsBinding) : - RecyclerView.ViewHolder(b.root) { - fun update(conn: AppConnection) { - displayTransactionDetails(conn) - setupClickListeners(conn) - } - - private fun setupClickListeners(conn: AppConnection) { - b.acdContainer.setOnClickListener { - // open bottom sheet to apply domain/ip rules - openBottomSheet(conn) - } - } - - private fun openBottomSheet(conn: AppConnection) { - if (context !is AppCompatActivity) { - Logger.w(LOG_TAG_UI, "$TAG err opening the app conn bottom sheet") - return - } - - if (isAsn) { - return - } - - Logger.vv(LOG_TAG_UI, "$TAG open bottom sheet for uid: $uid, ip: ${conn.ipAddress}, domain: ${conn.appOrDnsName}") - val bottomSheetFragment = AppIpRulesBottomSheet() - // Fix: free-form window crash - // all BottomSheetDialogFragment classes created must have a public, no-arg constructor. - // the best practice is to simply never define any constructors at all. - // so sending the data using Bundles - val bundle = Bundle() - bundle.putInt(AppIpRulesBottomSheet.UID, uid) - bundle.putString(AppIpRulesBottomSheet.IP_ADDRESS, conn.ipAddress) - bundle.putString( - AppIpRulesBottomSheet.DOMAINS, - beautifyDomainString(conn.appOrDnsName.orEmpty()) - ) - bottomSheetFragment.arguments = bundle - // Fix: Validate position before passing to avoid IndexOutOfBoundsException - val currentPosition = absoluteAdapterPosition - if (currentPosition != RecyclerView.NO_POSITION) { - bottomSheetFragment.dismissListener(adapter, currentPosition) - } else { - // Position is invalid, pass -1 to indicate refresh should be used - Logger.w(LOG_TAG_UI, "$TAG invalid adapter position when opening bottom sheet") - bottomSheetFragment.dismissListener(adapter, RecyclerView.NO_POSITION) - } - bottomSheetFragment.show(context.supportFragmentManager, bottomSheetFragment.tag) + conn.flag } - - private fun displayTransactionDetails(conn: AppConnection) { - b.acdCount.text = conn.count.toString() - if (isAsn) { - b.acdIpAddress.text = conn.appOrDnsName - b.acdDomainName.text = conn.ipAddress - // in case of ASN, flag consists of country code, extract flag from it - val cc = Utilities.getFlag(conn.flag) - if (cc.isEmpty()) { - b.acdFlag.text = "--" - } else { - b.acdFlag.text = cc - } - b.acdDownArrowIv.visibility = View.INVISIBLE - } else { - b.acdFlag.text = conn.flag - b.acdIpAddress.text = conn.ipAddress - if (!conn.appOrDnsName.isNullOrEmpty()) { - b.acdDomainName.visibility = View.VISIBLE - b.acdDomainName.text = beautifyDomainString(conn.appOrDnsName) - } else { - b.acdDomainName.visibility = View.GONE - } - b.acdDownArrowIv.visibility = View.VISIBLE - } - updateStatusUi(conn) - } - - private fun beautifyDomainString(d: String): String { - // replace two commas in the string to one - // add space after all the commas - return removeBeginningTrailingCommas(d).replace(",,", ",").replace(",", ", ") - } - - private fun updateStatusUi(conn: AppConnection) { - val status = IpRulesManager.getMostSpecificRuleMatch(conn.uid, conn.ipAddress) - when (status) { - IpRulesManager.IpRuleStatus.NONE -> { - b.progress.setIndicatorColor( - UIUtils.fetchToggleBtnColors(context, R.color.chipTextNeutral) - ) + val titleText = if (isAsn) conn.appOrDnsName else conn.ipAddress + val secondaryText = + if (isAsn) conn.ipAddress else conn.appOrDnsName?.let { beautifyDomainString(it) } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { onIpClick(conn) } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = flagText, style = MaterialTheme.typography.titleMedium) + Column(modifier = Modifier.weight(1f)) { + Text(text = titleText.orEmpty(), style = MaterialTheme.typography.titleMedium) + if (!secondaryText.isNullOrEmpty()) { + Text(text = secondaryText, style = MaterialTheme.typography.bodySmall) } - IpRulesManager.IpRuleStatus.BLOCK -> { - b.progress.setIndicatorColor( - UIUtils.fetchToggleBtnColors(context, R.color.accentBad) - ) + if (!isAsn) { + IpProgress(conn, refreshToken) } - IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { - b.progress.setIndicatorColor( - UIUtils.fetchToggleBtnColors(context, R.color.accentGood) - ) - } - IpRulesManager.IpRuleStatus.TRUST -> { - b.progress.setIndicatorColor( - UIUtils.fetchToggleBtnColors(context, R.color.accentGood) - ) - } - } - - var p = calculatePercentage(conn.count.toDouble()) - if (p == 0) { - p = minPercentage / 2 - } - - if (Utilities.isAtleastN()) { - b.progress.setProgress(p, true) - } else { - b.progress.progress = p } + Text(text = countText, style = MaterialTheme.typography.labelLarge) } + Spacer(modifier = Modifier.fillMaxWidth()) } +} - override fun notifyDataset(position: Int) { - // Fix: IndexOutOfBoundsException - validate position before notifying - // PagingDataAdapter manages its own data, so we need to be careful with manual notifications - try { - if (position >= 0 && position < itemCount) { - notifyItemChanged(position) - } else { - // Position is invalid, refresh the entire dataset instead - Logger.w(LOG_TAG_UI, "$TAG invalid position: $position, itemCount: $itemCount, refreshing adapter") - refresh() - } - } catch (e: Exception) { - // If notification fails, refresh the adapter to ensure consistency - Logger.e(LOG_TAG_UI, "$TAG error notifying position $position: ${e.message}", e) - refresh() - } +@Composable +private fun IpProgress(conn: AppConnection, refresh: Int) { + if (refresh == Int.MIN_VALUE) { + return } + val context = LocalContext.current + val status = IpRulesManager.getMostSpecificRuleMatch(conn.uid, conn.ipAddress) + val color = + when (status) { + IpRulesManager.IpRuleStatus.NONE -> + MaterialTheme.colorScheme.onSurfaceVariant + IpRulesManager.IpRuleStatus.BLOCK -> + MaterialTheme.colorScheme.error + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> + MaterialTheme.colorScheme.tertiary + IpRulesManager.IpRuleStatus.TRUST -> + MaterialTheme.colorScheme.tertiary + } // In a paging/lazy list, this is hard to maintain without a global state. + // For now, using a local calculation or simplified version. + val p = (log2(conn.count.toDouble()) * 100).toInt() + val progress = if (p <= 0) 0.1f else (p / 500f).coerceAtMost(1f) + + LinearProgressIndicator( + progress = { progress }, + color = color, + trackColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth() + ) +} + +private fun beautifyDomainString(d: String): String { + return removeBeginningTrailingCommas(d).replace(",,", ",").replace(",", ", ") } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/BlocklistRowShared.kt b/app/src/full/java/com/celzero/bravedns/adapter/BlocklistRowShared.kt new file mode 100644 index 000000000..d314f427b --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/BlocklistRowShared.kt @@ -0,0 +1,245 @@ +/* + * 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.adapter + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.service.RethinkBlocklistManager + +@Composable +internal fun BlocklistSimpleRow( + group: String, + pack: String, + blocklistCount: Int, + isSelected: Boolean, + showHeader: Boolean, + onToggle: (Boolean) -> Unit +) { + val context = LocalContext.current + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (showHeader) { + BlocklistHeader(group = group) + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable { onToggle(!isSelected) }, + shape = RoundedCornerShape(18.dp), + color = + if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + ) { + Row( + modifier = Modifier.padding(horizontal = 13.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = pack.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = + context.getString( + R.string.rsv_blocklist_count_text, + blocklistCount.toString() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Checkbox(checked = isSelected, onCheckedChange = onToggle) + } + } + } +} + +@Composable +internal fun BlocklistAdvancedRow( + group: String, + subGroup: String, + name: String, + entries: Int, + level: Int?, + entryUrl: String?, + isSelected: Boolean, + showHeader: Boolean, + onToggle: (Boolean) -> Unit, + onEntryClick: (String) -> Unit +) { + val context = LocalContext.current + val groupText = if (subGroup.isEmpty()) group else subGroup + val entryText = context.getString(R.string.dc_entries, entries.toString()) + val (chipText, chipBg) = chipColorsForLevel(level) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (showHeader) { + BlocklistHeader(group = group) + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable { onToggle(!isSelected) }, + shape = RoundedCornerShape(18.dp), + color = + if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + ) { + Row( + modifier = Modifier.padding(horizontal = 13.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = groupText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 6.dp) + ) + AssistChip( + onClick = { entryUrl?.let(onEntryClick) ?: Unit }, + enabled = !entryUrl.isNullOrEmpty(), + label = { Text(text = entryText) }, + colors = + AssistChipDefaults.assistChipColors( + containerColor = chipBg, + labelColor = chipText + ) + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Checkbox(checked = isSelected, onCheckedChange = onToggle) + } + } + } +} + +@Composable +private fun BlocklistHeader(group: String) { + val context = LocalContext.current + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 2.dp, bottom = 2.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = RethinkBlocklistManager.getGroupName(context, group), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = RethinkBlocklistManager.getTitleDesc(context, group), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun chipColorsForLevel(level: Int?): Pair { + if (level == null) { + val text = MaterialTheme.colorScheme.onSurface + val bg = MaterialTheme.colorScheme.surface + return text to bg + } + + return when (level) { + 0 -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.tertiaryContainer + 1 -> MaterialTheme.colorScheme.onSurfaceVariant to MaterialTheme.colorScheme.surfaceVariant + 2 -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.onSurface to MaterialTheme.colorScheme.surface + } +} + +internal fun RethinkBlocklistManager.getGroupName(context: Context, group: String): String { + if (group.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { + return context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.label) + } + if (group.equals(RethinkBlocklistManager.SECURITY.name, true)) { + return context.getString(RethinkBlocklistManager.SECURITY.label) + } + if (group.equals(RethinkBlocklistManager.PRIVACY.name, true)) { + return context.getString(RethinkBlocklistManager.PRIVACY.label) + } + return group +} + +internal fun RethinkBlocklistManager.getTitleDesc(context: Context, group: String): String { + if (group.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { + return context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.desc) + } + if (group.equals(RethinkBlocklistManager.SECURITY.name, true)) { + return context.getString(RethinkBlocklistManager.SECURITY.desc) + } + if (group.equals(RethinkBlocklistManager.PRIVACY.name, true)) { + return context.getString(RethinkBlocklistManager.PRIVACY.desc) + } + return "" +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt index 9844f6c0b..053318c16 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt @@ -14,449 +14,724 @@ See the License for the specific language governing permissions and limitations under the License. */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_UI import android.content.Context import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat.isAttachedToWindow -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import com.celzero.bravedns.R import com.celzero.bravedns.database.ConnectionTracker -import com.celzero.bravedns.databinding.ListItemConnTrackBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.FirewallRuleset import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.ui.bottomsheet.ConnTrackerBottomSheet -import com.celzero.bravedns.util.Constants.Companion.EMPTY_PACKAGE_NAME +import com.celzero.bravedns.ui.compose.rememberDrawablePainter import com.celzero.bravedns.util.Constants.Companion.TIME_FORMAT_1 import com.celzero.bravedns.util.KnownPorts import com.celzero.bravedns.util.Protocol import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.getDurationInHumanReadableFormat import com.celzero.bravedns.util.Utilities -import com.celzero.bravedns.util.Utilities.getDefaultIcon import com.celzero.bravedns.util.Utilities.getIcon -import com.google.gson.Gson import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Locale +import kotlin.math.roundToInt + +private const val MAX_BYTES = 500000 // 500 KB +private const val MAX_TIME_TCP = 135 // seconds +private const val MAX_TIME_UDP = 135 // seconds +private const val NO_USER_ID = 0 + +private data class ConnectionRowPalette( + val status: Color, + val statusContainer: Color, + val statusLabel: String, + val surfaceCollapsed: Color, + val surfaceExpanded: Color, + val surfaceSubtle: Color, + val line: Color, + val primaryText: Color, + val secondaryText: Color, + val tagBg: Color, + val tagText: Color, +) + +@Composable +private fun connectionRowPalette(ct: ConnectionTracker): ConnectionRowPalette { + val scheme = MaterialTheme.colorScheme + val allowedGreen = Color(0xFF2FB36B) + val statusColor = if (ct.isBlocked) scheme.error else allowedGreen + val statusContainer = if (ct.isBlocked) scheme.errorContainer.copy(alpha = 0.55f) else allowedGreen.copy(alpha = 0.2f) + + return ConnectionRowPalette( + status = statusColor, + statusContainer = statusContainer, + statusLabel = if (ct.isBlocked) stringResource(R.string.lbl_blocked) else stringResource(R.string.lbl_allowed), + surfaceCollapsed = scheme.surfaceContainerLow, + surfaceExpanded = scheme.surfaceContainer, + surfaceSubtle = scheme.surfaceContainerHighest.copy(alpha = 0.3f), + line = scheme.outlineVariant.copy(alpha = 0.42f), + primaryText = scheme.onSurface, + secondaryText = scheme.onSurfaceVariant, + tagBg = scheme.surfaceContainerHighest.copy(alpha = 0.58f), + tagText = scheme.onSurfaceVariant, + ) +} + +@Composable +fun ConnectionRow( + ct: ConnectionTracker, + index: Int = 0, + itemCount: Int = 1, +) { + val context = LocalContext.current + val palette = connectionRowPalette(ct) + val summary = summaryInfo(context, ct) + val hintColor = hintColor(ct) ?: palette.secondaryText + val protocol = protocolLabel(context, ct.port, ct.protocol) + val time = Utilities.convertLongToTime(ct.timeStamp, TIME_FORMAT_1) + val destination = ct.dnsQuery?.takeIf { it.isNotBlank() } ?: ct.ipAddress + val appDisplay = if (ct.appName.isBlank()) stringResource(R.string.network_log_app_name_unknown) else ct.appName + + var expanded by remember(ct.id) { mutableStateOf(false) } + var showDetails by remember(ct.id) { mutableStateOf(false) } + var appIcon by remember(ct.uid) { mutableStateOf(null) } + var appCount by remember(ct.uid) { mutableStateOf(1) } + + LaunchedEffect(ct.uid, ct.appName, ct.usrId) { + val apps = withContext(Dispatchers.IO) { FirewallManager.getPackageNamesByUid(ct.uid) } + appCount = apps.size + appIcon = if (apps.isEmpty()) null else getIcon(context, apps[0]) + } + + val appName = + when { + ct.usrId != NO_USER_ID -> + stringResource(R.string.about_version_install_source, appDisplay, ct.usrId.toString()) + appCount > 1 -> + stringResource(R.string.ctbs_app_other_apps, appDisplay, "${appCount - 1}") + else -> appDisplay + } + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val rowScale by animateFloatAsState( + targetValue = if (isPressed) 0.988f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy), + label = "connRowScale", + ) + + val chevronAngle by animateFloatAsState( + targetValue = if (expanded) 90f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "connChevron", + ) + + val baseCardColor = if (expanded) palette.surfaceExpanded else palette.surfaceCollapsed + val pressedCardColor = lerp(baseCardColor, MaterialTheme.colorScheme.primaryContainer, 0.16f) + val cardColor by animateColorAsState( + targetValue = if (isPressed) pressedCardColor else baseCardColor, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "connCardColor", + ) + + val shadowElevation by animateDpAsState( + targetValue = + when { + isPressed -> 3.dp + expanded -> 6.dp + else -> 1.dp + }, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "connShadow", + ) + + val stripeAlpha by animateFloatAsState( + targetValue = if (expanded) 1f else 0.9f, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "connStripeAlpha", + ) + + val detailsProgress by animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = tween(durationMillis = 230, easing = FastOutSlowInEasing), + label = "connDetailsProgress", + finishedListener = { value -> if (value == 0f) showDetails = false }, + ) + + LaunchedEffect(expanded) { + if (expanded) showDetails = true + } -class ConnectionTrackerAdapter(private val context: Context) : - PagingDataAdapter( - DIFF_CALLBACK + val cardShape = ListItemDefaults.segmentedShapes(index = index, count = itemCount) + + Surface( + modifier = + Modifier + .fillMaxWidth() + .scale(rowScale) + .clip(cardShape.shape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { expanded = !expanded }, + ), + shape = cardShape.shape, + color = cardColor, + tonalElevation = if (expanded) 2.dp else 0.dp, + shadowElevation = shadowElevation, + border = if (expanded) BorderStroke(1.dp, palette.line.copy(alpha = 0.7f)) else null, ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 26.dp, end = 12.dp, top = 12.dp, bottom = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AppIconSlot( + appIcon = appIcon, + statusColor = palette.statusContainer, + ) - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = destination, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + color = palette.primaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + letterSpacing = (-0.2).sp, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = appName, + fontSize = 11.sp, + color = palette.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + ProtocolTag(type = protocol, bg = palette.tagBg, textColor = palette.tagText) + } + } - override fun areItemsTheSame(old: ConnectionTracker, new: ConnectionTracker): Boolean { - return old.id == new.id + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + StatusLabel(text = palette.statusLabel, color = palette.status) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = time, + fontSize = 10.sp, + color = palette.secondaryText.copy(alpha = 0.92f), + ) + ChevronIcon(angle = chevronAngle, tint = palette.secondaryText) + } + } } - override fun areContentsTheSame(old: ConnectionTracker, new: ConnectionTracker): Boolean { - return old == new + if (showDetails) { + Box( + modifier = + Modifier + .fillMaxWidth() + .accordionReveal(detailsProgress), + ) { + ConnectionDetailsPanel( + ct = ct, + protocol = protocol, + summary = summary, + panelColor = palette.surfaceSubtle, + dividerColor = palette.line, + textColor = palette.secondaryText, + hintColor = hintColor, + ) + } } } - private const val MAX_BYTES = 500000 // 500 KB - private const val MAX_TIME_TCP = 135 // seconds - private const val MAX_TIME_UDP = 135 // seconds - private const val NO_USER_ID = 0 - private const val RTT_SHORT_THRESHOLD_MS = 20 // milliseconds - private const val TAG = "ConnTrackAdapter" + StatusStripe( + color = palette.status.copy(alpha = stripeAlpha), + modifier = + Modifier + .align(Alignment.TopStart) + .fillMaxHeight() + .zIndex(1f), + ) + } } +} - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConnectionTrackerViewHolder { - val itemBinding = - ListItemConnTrackBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false +private fun Modifier.accordionReveal(progress: Float): Modifier { + val p = progress.coerceIn(0f, 1f) + return this + .graphicsLayer { alpha = p } + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val h = (placeable.height * p).roundToInt() + layout(placeable.width, h) { + if (h > 0) placeable.place(0, 0) + } + } +} + +@Composable +private fun StatusStripe(color: Color, modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(start = 10.dp, top = 10.dp, bottom = 10.dp) + .width(5.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(999.dp)) + .background( + brush = Brush.verticalGradient(colors = listOf(color, color.copy(alpha = 0.38f))), + ), + ) +} + +@Composable +private fun AppIconSlot( + appIcon: Drawable?, + statusColor: Color, +) { + val iconDrawable = appIcon + + Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) { + if (iconDrawable != null) { + Crossfade(targetState = iconDrawable, animationSpec = tween(durationMillis = 180), label = "connIcon") { drawable -> + rememberDrawablePainter(drawable)?.let { painter -> + androidx.compose.foundation.Image( + painter = painter, + contentDescription = null, + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(7.dp)), + ) + } + } + } else { + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(10.dp)) + .background(statusColor.copy(alpha = 0.5f)) ) - return ConnectionTrackerViewHolder(itemBinding) + } } +} - override fun onBindViewHolder(holder: ConnectionTrackerViewHolder, position: Int) { - val connTracker: ConnectionTracker? = getItem(position) +@Composable +private fun ProtocolTag(type: String, bg: Color, textColor: Color) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(5.dp)) + .background(bg) + .padding(horizontal = 6.dp, vertical = 0.dp), + ) { + Text( + text = type, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = textColor, + letterSpacing = 0.5.sp, + ) + } +} - if (connTracker == null) { - holder.clear() - return - } - holder.update(connTracker) - holder.setTag(connTracker) +@Composable +private fun StatusLabel(text: String, color: Color) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = + Modifier + .size(5.dp) + .clip(CircleShape) + .background(color), + ) + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = color, + letterSpacing = 0.2.sp, + ) } +} - inner class ConnectionTrackerViewHolder(private val b: ListItemConnTrackBinding) : - RecyclerView.ViewHolder(b.root) { - - fun clear() { - b.connectionResponseTime.text = "" - b.connectionFlag.text = "" - b.connectionIpAddress.text = "" - b.connectionDomain.text = "" - b.connectionAppName.text = "" - b.connectionAppIcon.setImageDrawable(null) - b.connectionDataUsage.text = "" - b.connectionDelay.text = "" - b.connectionStatusIndicator.visibility = View.INVISIBLE - b.connectionSummaryLl.visibility = View.GONE - } +@Composable +private fun ChevronIcon(angle: Float, tint: Color) { + Icon( + painter = painterResource(R.drawable.ic_right_arrow_small), + contentDescription = null, + tint = tint, + modifier = + Modifier + .size(10.dp) + .rotate(angle), + ) +} - fun update(connTracker: ConnectionTracker) { - displayTransactionDetails(connTracker) - displayProtocolDetails(connTracker.port, connTracker.protocol) - displayAppDetails(connTracker) - displaySummaryDetails(connTracker) - // case: when the rule is set to RULE12 but no proxy is set, consider this as error - // handle this as special case, and display the RULE1C hint - // RULE1C is the hint for RULE12 with no proxy set. - val blocked = if (connTracker.blockedByRule == FirewallRuleset.RULE12.id) { - connTracker.proxyDetails.isEmpty() - } else { - connTracker.isBlocked - } - val rule = if (connTracker.blockedByRule == FirewallRuleset.RULE12.id && connTracker.proxyDetails.isEmpty()) { - FirewallRuleset.RULE18.id - } else { - connTracker.blockedByRule - } - displayFirewallRulesetHint(blocked, rule) +@Composable +private fun ConnectionDetailsPanel( + ct: ConnectionTracker, + protocol: String, + summary: Summary, + panelColor: Color, + dividerColor: Color, + textColor: Color, + hintColor: Color, +) { + val context = LocalContext.current + val endpoint = buildString { + append(ct.ipAddress) + if (ct.port > 0) append(":${ct.port}") + } - b.connectionParentLayout.setOnClickListener { openBottomSheet(connTracker) } - } + Column( + modifier = + Modifier + .fillMaxWidth() + .background(panelColor), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + HorizontalDivider(color = dividerColor, thickness = 0.5.dp) - fun setTag(connTracker: ConnectionTracker) { - b.connectionResponseTime.tag = connTracker.timeStamp - b.root.tag = connTracker.timeStamp - } + Column( + modifier = Modifier.padding(start = 26.dp, end = 14.dp, top = 10.dp, bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + DetailTextRow(label = "Transport", value = protocol, tint = textColor) + DetailTextRow(label = "Country", value = countryDisplay(context, ct.flag), tint = textColor) - private fun openBottomSheet(ct: ConnectionTracker) { - if (context !is FragmentActivity) { - Logger.w(LOG_TAG_UI, "$TAG err opening the connection tracker bottomsheet") - return + if (endpoint.isNotBlank()) { + DetailTextRow(label = "Endpoint", value = endpoint, mono = true, tint = MaterialTheme.colorScheme.secondary) } - Logger.vv(LOG_TAG_UI, "$TAG show bottom sheet for ${ct.appName}") - val bottomSheetFragment = ConnTrackerBottomSheet() - // see AppIpRulesAdapter.kt#openBottomSheet() - val bundle = Bundle() - bundle.putString(ConnTrackerBottomSheet.INSTANCE_STATE_IPDETAILS, Gson().toJson(ct)) - bottomSheetFragment.arguments = bundle - bottomSheetFragment.show(context.supportFragmentManager, bottomSheetFragment.tag) - } - - private fun displayTransactionDetails(connTracker: ConnectionTracker) { - val time = Utilities.convertLongToTime(connTracker.timeStamp, TIME_FORMAT_1) - b.connectionResponseTime.text = time - b.connectionFlag.text = connTracker.flag - - if (connTracker.dnsQuery.isNullOrEmpty()) { - b.connectionIpAddress.text = connTracker.ipAddress - b.connectionDomain.visibility = View.GONE - } else { - b.connectionIpAddress.text = connTracker.ipAddress - b.connectionDomain.text = connTracker.dnsQuery - b.connectionDomain.visibility = View.VISIBLE - // marquee is not working for the textview, hence the workaround. - b.connectionDomain.isSelected = true + if (!ct.dnsQuery.isNullOrBlank()) { + DetailTextRow(label = "DNS", value = ct.dnsQuery.orEmpty(), mono = true, tint = textColor) } - } - private fun displayAppDetails(ct: ConnectionTracker) { - io { - uiCtx { - val apps = FirewallManager.getPackageNamesByUid(ct.uid) - val count = apps.count() - - val appName = when { - ct.usrId != NO_USER_ID -> context.getString( - R.string.about_version_install_source, - ct.appName, - ct.usrId.toString() - ) + if (ct.blockedByRule.isNotBlank()) { + DetailTextRow( + label = "Rule", + value = ct.blockedByRule, + tint = if (ct.isBlocked) MaterialTheme.colorScheme.error else textColor, + ) + } - count > 1 -> context.getString( - R.string.ctbs_app_other_apps, - ct.appName, - "${count - 1}" - ) + if (ct.proxyDetails.isNotBlank()) { + DetailTextRow(label = "Proxy", value = ct.proxyDetails, mono = true, tint = textColor) + } - else -> ct.appName - } + if (summary.duration.isNotBlank()) { + DetailTextRow(label = "Duration", value = summary.duration, tint = textColor) + } - b.connectionAppName.text = appName - if (apps.isEmpty() || ct.packageName.isEmpty() || ct.packageName == EMPTY_PACKAGE_NAME) { - loadAppIcon(getDefaultIcon(context)) - } else { - loadAppIcon(getIcon(context, apps[0])) - } - } + if (summary.dataUsage.isNotBlank()) { + DetailTextRow(label = "Usage", value = summary.dataUsage, tint = textColor) } - } - private fun displayProtocolDetails(port: Int, proto: Int) { - // If the protocol is not TCP or UDP, then display the protocol name. - if (Protocol.UDP.protocolType != proto && Protocol.TCP.protocolType != proto) { - b.connLatencyTxt.text = Protocol.getProtocolName(proto).name - return + if (ct.synack > 0) { + DetailTextRow(label = "Latency", value = "${ct.synack}ms", tint = textColor) } - // Instead of displaying the port number, display the service name if it is known. - // https://github.com/celzero/rethink-app/issues/42 - #3 - transport + protocol. - val resolvedPort = KnownPorts.resolvePort(port) - // case: for UDP/443 label it as HTTP3 instead of HTTPS - b.connLatencyTxt.text = - if (port == KnownPorts.HTTPS_PORT && proto == Protocol.UDP.protocolType) { - context.getString(R.string.connection_http3) - } else if (resolvedPort != KnownPorts.PORT_VAL_UNKNOWN) { - resolvedPort.uppercase(Locale.ROOT) - } else { - Protocol.getProtocolName(proto).name - } - } + if (summary.delay.isNotBlank()) { + DetailTextRow(label = "Flags", value = summary.delay, tint = hintColor) + } - private fun displayFirewallRulesetHint(isBlocked: Boolean, ruleName: String?) { - when { - // hint red when blocked - isBlocked -> { - b.connectionStatusIndicator.visibility = View.VISIBLE - val isError = FirewallRuleset.isProxyError(ruleName) - if (isError) { - b.connectionStatusIndicator.setBackgroundColor( - UIUtils.fetchColor(context, R.attr.chipTextNeutral) - ) - } else { - b.connectionStatusIndicator.setBackgroundColor( - ContextCompat.getColor(context, R.color.colorRed_A400) - ) - } - } - // hint white when whitelisted - (FirewallRuleset.shouldShowHint(ruleName)) -> { - b.connectionStatusIndicator.visibility = View.VISIBLE - b.connectionStatusIndicator.setBackgroundColor( - ContextCompat.getColor(context, R.color.primaryLightColorText) - ) - } - // no hints, otherwise - else -> { - b.connectionStatusIndicator.visibility = View.INVISIBLE - } + if (ct.message.isNotBlank()) { + DetailTextRow( + label = "Message", + value = ct.message, + tint = if (ct.isBlocked) MaterialTheme.colorScheme.error else textColor, + ) } } + } +} - private fun displaySummaryDetails(ct: ConnectionTracker) { - io { - val hasCid = VpnController.hasCid(ct.connId, ct.uid) - val connType = ConnectionTracker.ConnType.get(ct.connType) - uiCtx { - b.connectionDataUsage.text = "" - b.connectionDelay.text = "" - if ( - ct.duration == 0 && - ct.downloadBytes == 0L && - ct.uploadBytes == 0L && - ct.message.isEmpty() - ) { - var hasMinSummary = false - if (hasCid) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDataUsage.text = context.getString(R.string.lbl_active) - b.connectionDuration.text = context.getString(R.string.symbol_green_circle) - b.connectionDelay.text = "" - hasMinSummary = true - } else { - b.connectionDataUsage.text = "" - b.connectionDuration.text ="" - } - if (connType.isMetered()) { - b.connectionDelay.text = context.getString(R.string.symbol_currency) - hasMinSummary = true - } else { - b.connectionDelay.text = "" - } - - if (isRpnProxy(ct.rpid)) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_sparkle) - ) - } else if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_key) - ) - hasMinSummary = true - } - if (!hasMinSummary) { - b.connectionSummaryLl.visibility = View.GONE - } - return@uiCtx - } - - b.connectionSummaryLl.visibility = View.VISIBLE - val duration = getDurationInHumanReadableFormat(context, ct.duration) - b.connectionDuration.text = context.getString(R.string.single_argument, duration) - // add unicode for download and upload - val download = - context.getString( - R.string.symbol_download, - Utilities.humanReadableByteCount(ct.downloadBytes, true) - ) - val upload = - context.getString( - R.string.symbol_upload, - Utilities.humanReadableByteCount(ct.uploadBytes, true) - ) - b.connectionDataUsage.text = context.getString(R.string.two_argument, upload, download) - b.connectionDelay.text = "" - if (connType.isMetered()) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_currency) - ) - } - if (isConnectionHeavier(ct)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_heavy) - ) - } - if (isConnectionSlower(ct)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_turtle) - ) - } - // bunny in case rpid as present, key in case of proxy - // bunny and key indicate conn is proxied, so its enough to show one of them - if (isRpnProxy(ct.rpid)) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_sparkle) - ) - } else if (containsRelayProxy(ct.rpid)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_bunny) - ) - } else if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_key) - ) - } +@Composable +private fun DetailTextRow( + label: String, + value: String, + mono: Boolean = false, + tint: Color, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 0.4.sp, + modifier = Modifier.widthIn(min = 72.dp), + ) + Text( + text = value, + fontSize = 11.sp, + color = tint, + fontFamily = if (mono) FontFamily.Monospace else FontFamily.Default, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.End, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} - // rtt -> show rocket if less than 20ms, treat it as rtt - if (isRoundTripShorter(ct.synack, ct.isBlocked)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_rocket) - ) - } +private fun countryDisplay(context: Context, flag: String): String { + val unknown = context.getString(R.string.network_log_app_name_unknown) + val countryName = UIUtils.getCountryNameFromFlag(flag).trim() + val normalizedName = countryName.takeUnless { it.isBlank() || it == "--" } + val normalizedFlag = flag.trim().takeUnless { it.isBlank() || it == "--" } + + return when { + normalizedName != null && normalizedFlag != null -> "$normalizedName $normalizedFlag" + normalizedName != null -> normalizedName + normalizedFlag != null -> normalizedFlag + else -> unknown + } +} - if (b.connectionDelay.text.isEmpty() && b.connectionDataUsage.text.isEmpty()) { - b.connectionSummaryLl.visibility = View.GONE - } +private fun protocolLabel(context: Context, port: Int, proto: Int): String { + if (Protocol.UDP.protocolType != proto && Protocol.TCP.protocolType != proto) { + return Protocol.getProtocolName(proto).name + } - } - } + val resolvedPort = KnownPorts.resolvePort(port) + return if (port == KnownPorts.HTTPS_PORT && proto == Protocol.UDP.protocolType) { + context.getString(R.string.connection_http3) + } else if (resolvedPort != KnownPorts.PORT_VAL_UNKNOWN) { + resolvedPort.uppercase(Locale.ROOT) + } else { + Protocol.getProtocolName(proto).name + } +} +@Composable +private fun hintColor(ct: ConnectionTracker): Color? { + val blocked = + if (ct.blockedByRule == FirewallRuleset.RULE12.id) { + ct.proxyDetails.isEmpty() + } else { + ct.isBlocked } - - private fun isRoundTripShorter(rtt: Long, blocked: Boolean): Boolean { - return rtt in 1..RTT_SHORT_THRESHOLD_MS && !blocked + val rule = + if (ct.blockedByRule == FirewallRuleset.RULE12.id && ct.proxyDetails.isEmpty()) { + FirewallRuleset.RULE18.id + } else { + ct.blockedByRule } - - private fun containsRelayProxy(rpid: String): Boolean { - return rpid.isNotEmpty() + return when { + blocked -> { + val isError = FirewallRuleset.isProxyError(rule) + if (isError) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error } + FirewallRuleset.shouldShowHint(rule) -> MaterialTheme.colorScheme.onSurfaceVariant + else -> null + } +} - private fun isConnectionProxied(ruleName: String?, proxyDetails: String): Boolean { - if (ruleName == null) return false - val rule = FirewallRuleset.getFirewallRule(ruleName) ?: return false - val proxy = ProxyManager.isNotLocalAndRpnProxy(proxyDetails) - // show key symbol in case of proxy error too - val isProxyError = FirewallRuleset.isProxyError(ruleName) - return (FirewallRuleset.isProxied(rule) && proxyDetails.isNotEmpty() && proxy) || isProxyError +private data class Summary( + val dataUsage: String, + val duration: String, + val delay: String, + val showSummary: Boolean +) + +private fun summaryInfo(context: Context, ct: ConnectionTracker): Summary { + val connType = ConnectionTracker.ConnType.get(ct.connType) + var dataUsage = "" + var delay = "" + var duration = "" + + if (ct.duration == 0 && ct.downloadBytes == 0L && ct.uploadBytes == 0L && ct.message.isEmpty()) { + var hasMinSummary = false + if (VpnController.hasCid(ct.connId, ct.uid)) { + dataUsage = context.getString(R.string.lbl_active) + duration = context.getString(R.string.symbol_green_circle) + hasMinSummary = true } - private fun isRpnProxy(pid: String): Boolean { - return pid.isNotEmpty() && ProxyManager.isRpnProxy(pid) + if (connType.isMetered()) { + delay = context.getString(R.string.symbol_currency) + hasMinSummary = true } - private fun isConnectionHeavier(ct: ConnectionTracker): Boolean { - return ct.downloadBytes + ct.uploadBytes > MAX_BYTES + if (isRpnProxy(ct.rpid)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_sparkle)) + } else if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_key)) + hasMinSummary = true } - private fun isConnectionSlower(ct: ConnectionTracker): Boolean { - return (ct.protocol == Protocol.UDP.protocolType && ct.duration > MAX_TIME_UDP) || - (ct.protocol == Protocol.TCP.protocolType && ct.duration > MAX_TIME_TCP) - } + return Summary(dataUsage, duration, delay, hasMinSummary) + } - private fun loadAppIcon(drawable: Drawable?) { - Glide.with(context) - .load(drawable) - .error(getDefaultIcon(context)) - .into(b.connectionAppIcon) - } + duration = context.getString( + R.string.single_argument, + getDurationInHumanReadableFormat(context, ct.duration) + ) + + val download = context.getString( + R.string.symbol_download, + Utilities.humanReadableByteCount(ct.downloadBytes, true) + ) + val upload = context.getString( + R.string.symbol_upload, + Utilities.humanReadableByteCount(ct.uploadBytes, true) + ) + dataUsage = context.getString(R.string.two_argument, upload, download) + + if (connType.isMetered()) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_currency)) + } + if (isConnectionHeavier(ct)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_heavy)) + } + if (isConnectionSlower(ct)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_turtle)) + } + if (isRpnProxy(ct.rpid)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_sparkle)) + } else if (containsRelayProxy(ct.rpid)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_bunny)) + } else if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_key)) + } + if (isRoundTripShorter(ct.synack, ct.isBlocked)) { + delay = context.getString(R.string.ci_desc, delay, context.getString(R.string.symbol_rocket)) } - private fun io(f: suspend () -> Unit) { - val owner = context as? LifecycleOwner ?: return + val showSummary = delay.isNotEmpty() || dataUsage.isNotEmpty() + return Summary(dataUsage, duration, delay, showSummary) +} - owner.lifecycleScope.launch(Dispatchers.IO) { f() } - } +private fun isRoundTripShorter(rtt: Long, blocked: Boolean): Boolean { + return rtt in 1..20 && !blocked +} + +private fun containsRelayProxy(rpid: String): Boolean { + return rpid.isNotEmpty() +} - private suspend fun uiCtx(f: suspend () -> Unit) { - val owner = context as? LifecycleOwner ?: return +private fun isConnectionProxied(ruleName: String?, proxyDetails: String): Boolean { + if (ruleName == null) return false + val rule = FirewallRuleset.getFirewallRule(ruleName) ?: return false + val proxy = ProxyManager.isNotLocalAndRpnProxy(proxyDetails) + val isProxyError = FirewallRuleset.isProxyError(ruleName) + return (FirewallRuleset.isProxied(rule) && proxyDetails.isNotEmpty() && proxy) || isProxyError +} - withContext(Dispatchers.Main.immediate) { - if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - return@withContext - } +private fun isRpnProxy(pid: String): Boolean { + return pid.isNotEmpty() && ProxyManager.isRpnProxy(pid) +} - f() - } - } +private fun isConnectionHeavier(ct: ConnectionTracker): Boolean { + return ct.downloadBytes + ct.uploadBytes > MAX_BYTES +} + +private fun isConnectionSlower(ct: ConnectionTracker): Boolean { + return (ct.protocol == Protocol.UDP.protocolType && ct.duration > MAX_TIME_UDP) || + (ct.protocol == Protocol.TCP.protocolType && ct.duration > MAX_TIME_TCP) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/ConsoleLogAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/ConsoleLogAdapter.kt index f4f08f8ba..0d248d1e2 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/ConsoleLogAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/ConsoleLogAdapter.kt @@ -15,98 +15,65 @@ */ package com.celzero.bravedns.adapter -import Logger -import android.content.Context -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import com.celzero.bravedns.R import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG import com.celzero.bravedns.database.ConsoleLog -import com.celzero.bravedns.databinding.ListItemConsoleLogBinding import com.celzero.bravedns.util.Constants.Companion.TIME_FORMAT_1 -import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities -class ConsoleLogAdapter(private val context: Context) : - PagingDataAdapter(DIFF_CALLBACK) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(old: ConsoleLog, new: ConsoleLog): Boolean { - return old.id == new.id - } - - override fun areContentsTheSame(old: ConsoleLog, new: ConsoleLog): Boolean { - return old == new - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConsoleLogViewHolder { - val itemBinding = - ListItemConsoleLogBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ConsoleLogViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: ConsoleLogViewHolder, position: Int) { - val logInfo = getItem(position) ?: return - holder.update(logInfo) - } - - inner class ConsoleLogViewHolder(private val b: ListItemConsoleLogBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(log: ConsoleLog) { - try { - // SAFETY CHECK: Verify log data is valid - if (log.message.isEmpty()) return - - // update the textview color with the first letter of the log level - val logLevel = log.message.firstOrNull() ?: 'V' - when (logLevel) { - 'V' -> - b.logDetail.setTextColor( - UIUtils.fetchColor(context, R.attr.primaryLightColorText) - ) - - 'D' -> - b.logDetail.setTextColor( - UIUtils.fetchColor(context, R.attr.primaryLightColorText) - ) - - 'I' -> - b.logDetail.setTextColor( - UIUtils.fetchColor(context, R.attr.defaultToggleBtnTxt) - ) - - 'W' -> - b.logDetail.setTextColor( - UIUtils.fetchColor(context, R.attr.firewallWhiteListToggleBtnTxt) - ) - - 'E' -> - b.logDetail.setTextColor( - UIUtils.fetchColor(context, R.attr.accentBad) - ) - - else -> - b.logDetail.setTextColor( - UIUtils.fetchColor(context, R.attr.primaryLightColorText) - ) - } - b.logDetail.text = log.message - if (DEBUG) { - b.logTimestamp.text = "${log.id}\n${Utilities.convertLongToTime(log.timestamp, TIME_FORMAT_1)}" - } else { - b.logTimestamp.text = Utilities.convertLongToTime(log.timestamp, TIME_FORMAT_1) - } - } catch (e: Exception) { - Logger.w("ConsoleLogAdapter", "Error updating view holder: ${e.message}") - } +@Composable +fun ConsoleLogRow(log: ConsoleLog, isDebug: Boolean = DEBUG) { + val context = LocalContext.current + val logLevel = log.message.firstOrNull() ?: 'V' + val colorRes = + when (logLevel) { + 'I' -> R.attr.defaultToggleBtnTxt + 'W' -> R.attr.firewallWhiteListToggleBtnTxt + 'E' -> R.attr.firewallBlockToggleBtnTxt + else -> R.attr.primaryLightColorText + } + val logColor = + when (colorRes) { + R.attr.defaultToggleBtnTxt -> MaterialTheme.colorScheme.onSurfaceVariant + R.attr.firewallWhiteListToggleBtnTxt -> MaterialTheme.colorScheme.tertiary + R.attr.firewallBlockToggleBtnTxt -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant } + val timestamp = + if (isDebug) { + "${log.id}\n${Utilities.convertLongToTime(log.timestamp, TIME_FORMAT_1)}" + } else { + Utilities.convertLongToTime(log.timestamp, TIME_FORMAT_1) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 5.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = log.message, + style = MaterialTheme.typography.bodySmall, + color = logColor, + modifier = Modifier.weight(1f) + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt index 67ff6f290..e8da580f4 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt @@ -16,264 +16,89 @@ limitations under the License. package com.celzero.bravedns.adapter -import android.content.Context -import android.content.DialogInterface -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DnsCryptEndpoint -import com.celzero.bravedns.databinding.DnsCryptEndpointListItemBinding -import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.util.UIUtils -import com.celzero.bravedns.util.UIUtils.clipboardCopy -import com.celzero.bravedns.util.Utilities -import com.celzero.firestack.backend.Backend -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class DnsCryptEndpointAdapter(private val context: Context, private val appConfig: AppConfig) : - PagingDataAdapter( - DIFF_CALLBACK - ) { - var lifecycleOwner: LifecycleOwner? = null - - companion object { - private const val ONE_SEC = 1000L - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: DnsCryptEndpoint, - newConnection: DnsCryptEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected == newConnection.isSelected) - } - - override fun areContentsTheSame( - oldConnection: DnsCryptEndpoint, - newConnection: DnsCryptEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected != newConnection.isSelected) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsCryptEndpointViewHolder { - val itemBinding = - DnsCryptEndpointListItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - lifecycleOwner = parent.findViewTreeLifecycleOwner() - return DnsCryptEndpointViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: DnsCryptEndpointViewHolder, position: Int) { - val dnsCryptEndpoint: DnsCryptEndpoint = getItem(position) ?: return - holder.update(dnsCryptEndpoint) - } - - inner class DnsCryptEndpointViewHolder(private val b: DnsCryptEndpointListItemBinding) : - RecyclerView.ViewHolder(b.root) { - private var statusCheckJob: Job? = null - - fun update(endpoint: DnsCryptEndpoint) { - displayDetails(endpoint) - setupClickListeners(endpoint) - } - - private fun setupClickListeners(endpoint: DnsCryptEndpoint) { - b.root.setOnClickListener { - b.dnsCryptEndpointListActionImage.isChecked = - !b.dnsCryptEndpointListActionImage.isChecked - updateDnsCryptDetails(endpoint) - } - - b.dnsCryptEndpointListActionImage.setOnClickListener { updateDnsCryptDetails(endpoint) } - - b.dnsCryptEndpointListInfoImage.setOnClickListener { - showExplanationOnImageClick(endpoint) - } - } - - private fun displayDetails(endpoint: DnsCryptEndpoint) { - b.dnsCryptEndpointListUrlName.text = endpoint.dnsCryptName - b.dnsCryptEndpointListActionImage.isChecked = endpoint.isSelected - - if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) { - keepSelectedStatusUpdated() - } else if (endpoint.isSelected) { - b.dnsCryptEndpointListUrlExplanation.text = - context.getString(R.string.rt_filter_parent_selected) - } else { - b.dnsCryptEndpointListUrlExplanation.text = "" - } +private const val TAG = "DnsCryptEndpointAdapter" + +@Composable +fun DnsCryptRow(endpoint: DnsCryptEndpoint, appConfig: AppConfig) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val explanation = + rememberDnsStatusExplanation( + key = endpoint.id, + isSelected = endpoint.isSelected, + smartDnsEnabled = appConfig.isSmartDnsEnabled(), + tag = TAG + ) + var infoDialog by remember(endpoint.id) { mutableStateOf(null) } + var deleteDialog by remember(endpoint.id) { mutableStateOf(null) } + + DnsEndpointRow( + title = endpoint.dnsCryptName, + supporting = explanation.ifEmpty { null }, + selected = endpoint.isSelected, + action = if (endpoint.isDeletable()) DnsRowAction.Delete else DnsRowAction.Info, + selection = DnsRowSelection.Radio, + onActionClick = { if (endpoint.isDeletable()) { - b.dnsCryptEndpointListInfoImage.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_fab_uninstall) - ) + deleteDialog = + DnsDeleteDialogModel( + id = endpoint.id, + titleRes = R.string.dns_crypt_custom_url_remove_dialog_title, + messageRes = R.string.dns_crypt_url_remove_dialog_message, + successRes = R.string.dns_crypt_url_remove_success + ) } else { - b.dnsCryptEndpointListInfoImage.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_info) - ) - } - } - - private fun keepSelectedStatusUpdated() { - statusCheckJob = ui { - while (true) { - updateSelectedStatus() - delay(ONE_SEC) - } - } - } - - private fun updateSelectedStatus() { - // if the view is not active then cancel the job - if ( - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false || - bindingAdapterPosition == RecyclerView.NO_POSITION - ) { - statusCheckJob?.cancel() - return - } - - updateDnsStatus() - } - - private fun showExplanationOnImageClick(dnsCryptEndpoint: DnsCryptEndpoint) { - if (dnsCryptEndpoint.isDeletable()) showDeleteDialog(dnsCryptEndpoint.id) - else { - showDialogExplanation( - dnsCryptEndpoint.dnsCryptName, - dnsCryptEndpoint.dnsCryptURL, - dnsCryptEndpoint.dnsCryptExplanation - ) - } - } - - private fun showDeleteDialog(id: Int) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.dns_crypt_custom_url_remove_dialog_title) - builder.setMessage(R.string.dns_crypt_url_remove_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - deleteEndpoint(id) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> } - val alertDialog: AlertDialog = builder.create() - alertDialog.setCancelable(true) - alertDialog.show() - } - - private fun showDialogExplanation(title: String, url: String, message: String?) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(title) - if (message == null) builder.setMessage(url) - else builder.setMessage(url + "\n\n" + cryptDesc(message)) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { - dialogInterface, - _ -> - dialogInterface.dismiss() - } - - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { - _: DialogInterface, - _: Int -> - clipboardCopy(context, url, context.getString(R.string.copy_clipboard_label)) - Utilities.showToastUiCentered( - context, - context.getString(R.string.info_dialog_url_copy_toast_msg), - Toast.LENGTH_SHORT - ) - } - val alertDialog: AlertDialog = builder.create() - alertDialog.setCancelable(true) - alertDialog.show() - } - - private fun cryptDesc(message: String?): String { - if (message.isNullOrEmpty()) return "" - - return try { - // fixme: find a better way to handle this - if (message.contains("R.string.")) { - val m = message.substringAfter("R.string.") - val resId: Int = - context.resources.getIdentifier(m, "string", context.packageName) - context.getString(resId) - } else { - message - } - } catch (_: Exception) { - "" + val description = + if (endpoint.dnsCryptExplanation.isNullOrEmpty()) { + endpoint.dnsCryptURL + } else { + endpoint.dnsCryptURL + "\n\n" + + resolveDnsDescriptionText(context, endpoint.dnsCryptExplanation) + } + infoDialog = + DnsInfoDialogModel( + title = endpoint.dnsCryptName, + message = description, + copyValue = endpoint.dnsCryptURL + ) } - } - - private fun updateDnsCryptDetails(endpoint: DnsCryptEndpoint) { - io { + }, + onSelectionChange = { + launchDnsEndpointSelectionUpdate(scope, context, TAG) { endpoint.isSelected = true appConfig.handleDnscryptChanges(endpoint) } } - - private fun updateDnsStatus() { - io { - val state = VpnController.getDnsStatus(Backend.Preferred) - val status = UIUtils.getDnsStatusStringRes(state) - uiCtx { - b.dnsCryptEndpointListUrlExplanation.text = - context.getString(status).replaceFirstChar(Char::titlecase) - } - } - } - - private fun deleteEndpoint(id: Int) { - io { - appConfig.deleteDnscryptEndpoint(id) - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.dns_crypt_url_remove_success), - Toast.LENGTH_SHORT - ) + ) + + deleteDialog?.let { model -> + DnsDeleteDialog( + model = model, + onDismiss = { deleteDialog = null }, + onConfirm = { id -> + launchDnsEndpointDelete(scope, context, model.successRes) { + appConfig.deleteDnscryptEndpoint(id) } + deleteDialog = null } - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } - - private fun ui(f: suspend () -> Unit): Job? { - return lifecycleOwner?.lifecycleScope?.launch(Dispatchers.Main) { f() } - } + ) + } - private fun io(f: suspend () -> Unit) { - lifecycleOwner?.lifecycleScope?.launch(Dispatchers.IO) { f() } - } + infoDialog?.let { model -> + DnsInfoDialog( + model = model, + onDismiss = { infoDialog = null } + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt index f5660a660..027a16b68 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt @@ -16,243 +16,96 @@ limitations under the License. package com.celzero.bravedns.adapter -import android.content.Context -import android.content.DialogInterface -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DnsCryptRelayEndpoint -import com.celzero.bravedns.databinding.DnsCryptEndpointListItemBinding -import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.util.UIUtils -import com.celzero.bravedns.util.UIUtils.clipboardCopy -import com.celzero.bravedns.util.Utilities -import com.celzero.firestack.backend.Backend -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -class DnsCryptRelayEndpointAdapter( - private val context: Context, - val lifecycleOwner: LifecycleOwner, - private val appConfig: AppConfig -) : - PagingDataAdapter< - DnsCryptRelayEndpoint, - DnsCryptRelayEndpointAdapter.DnsCryptRelayEndpointViewHolder - >(DIFF_CALLBACK) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: DnsCryptRelayEndpoint, - newConnection: DnsCryptRelayEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected == newConnection.isSelected) - } - - override fun areContentsTheSame( - oldConnection: DnsCryptRelayEndpoint, - newConnection: DnsCryptRelayEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected != newConnection.isSelected) - } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): DnsCryptRelayEndpointViewHolder { - val itemBinding = - DnsCryptEndpointListItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return DnsCryptRelayEndpointViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: DnsCryptRelayEndpointViewHolder, position: Int) { - val dnsCryptRelayEndpoint: DnsCryptRelayEndpoint = getItem(position) ?: return - holder.update(dnsCryptRelayEndpoint) - } - - inner class DnsCryptRelayEndpointViewHolder(private val b: DnsCryptEndpointListItemBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(endpoint: DnsCryptRelayEndpoint) { - displayDetails(endpoint) - setupClickListener(endpoint) - } - - private fun setupClickListener(endpoint: DnsCryptRelayEndpoint) { - b.root.setOnClickListener { - b.dnsCryptEndpointListActionImage.isChecked = - !b.dnsCryptEndpointListActionImage.isChecked - updateDNSCryptRelayDetails(endpoint, b.dnsCryptEndpointListActionImage.isChecked) - } - - b.dnsCryptEndpointListActionImage.setOnClickListener { - updateDNSCryptRelayDetails(endpoint, b.dnsCryptEndpointListActionImage.isChecked) - } - - b.dnsCryptEndpointListInfoImage.setOnClickListener { promptUser(endpoint) } +private const val TAG = "DnsCryptRelayEndpointAdapter" + +@Composable +fun RelayRow(endpoint: DnsCryptRelayEndpoint, appConfig: AppConfig) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isSelected by remember(endpoint.id) { mutableStateOf(endpoint.isSelected) } + val explanation = + rememberDnsStatusExplanation( + key = endpoint.id, + isSelected = isSelected, + smartDnsEnabled = appConfig.isSmartDnsEnabled(), + tag = TAG, + pollIntervalMs = 1500L, + requireTunnel = false, + selectedFallbackText = null + ) + var infoDialog by remember(endpoint.id) { mutableStateOf(null) } + var deleteDialog by remember(endpoint.id) { mutableStateOf(null) } + + val updateSelection: (Boolean) -> Unit = { checked -> + isSelected = checked + launchDnsEndpointSelectionUpdate(scope, context, TAG) { + endpoint.isSelected = checked + appConfig.handleDnsrelayChanges(endpoint) } + } - private fun displayDetails(endpoint: DnsCryptRelayEndpoint) { - b.dnsCryptEndpointListUrlName.text = endpoint.dnsCryptRelayName - if (endpoint.isSelected && !appConfig.isSmartDnsEnabled()) { - updateSelectedStatus() - } else { - b.dnsCryptEndpointListUrlExplanation.text = "" - } - - b.dnsCryptEndpointListActionImage.isChecked = endpoint.isSelected + DnsEndpointRow( + title = endpoint.dnsCryptRelayName, + supporting = explanation.ifEmpty { null }, + selected = isSelected, + action = if (endpoint.isDeletable()) DnsRowAction.Delete else DnsRowAction.Info, + selection = DnsRowSelection.Checkbox, + onActionClick = { if (endpoint.isDeletable()) { - b.dnsCryptEndpointListInfoImage.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_fab_uninstall) - ) + deleteDialog = + DnsDeleteDialogModel( + id = endpoint.id, + titleRes = R.string.dns_crypt_relay_remove_dialog_title, + messageRes = R.string.dns_crypt_relay_remove_dialog_message, + successRes = R.string.dns_crypt_relay_remove_success + ) } else { - b.dnsCryptEndpointListInfoImage.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_info) - ) - } - } - - private fun updateSelectedStatus() { - io { - // always use the id as Dnsx.Preffered as it is the primary dns id for now - val state = VpnController.getDnsStatus(Backend.Preferred) - val status = UIUtils.getDnsStatusStringRes(state) - uiCtx { - b.dnsCryptEndpointListUrlExplanation.text = - context.getString(status).replaceFirstChar(Char::titlecase) - } - } - } - - private fun promptUser(endpoint: DnsCryptRelayEndpoint) { - if (endpoint.isDeletable()) showDeleteDialog(endpoint.id) - else { - showDialogExplanation( - endpoint.dnsCryptRelayName, - endpoint.dnsCryptRelayURL, - endpoint.dnsCryptRelayExplanation - ) - } - } - - private fun showDialogExplanation(title: String, url: String, message: String?) { - val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim) - builder.setTitle(title) - if (message != null) builder.setMessage(url + "\n\n" + relayDesc(message)) - else builder.setMessage(url) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { - dialogInterface, - _ -> - dialogInterface.dismiss() - } - - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { - _: DialogInterface, - _: Int -> - clipboardCopy(context, url, context.getString(R.string.copy_clipboard_label)) - Utilities.showToastUiCentered( - context, - context.getString(R.string.info_dialog_url_copy_toast_msg), - Toast.LENGTH_SHORT - ) - } - builder.create().show() - } - - private fun relayDesc(message: String?): String { - if (message.isNullOrEmpty()) return "" - - return try { - // fixme: find a better way to handle this - if (message.contains("R.string.")) { - val m = message.substringAfter("R.string.") - val resId: Int = - context.resources.getIdentifier(m, "string", context.packageName) - context.getString(resId) - } else { - message - } - } catch (_: Exception) { - "" - } - } - - private fun showDeleteDialog(id: Int) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.dns_crypt_relay_remove_dialog_title) - builder.setMessage(R.string.dns_crypt_relay_remove_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - deleteEndpoint(id) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> } - builder.create().show() - } - - private fun updateDNSCryptRelayDetails( - endpoint: DnsCryptRelayEndpoint, - isSelected: Boolean - ) { - - io { - if (isSelected && !appConfig.isDnscryptRelaySelectable()) { - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.dns_crypt_relay_error_toast), - Toast.LENGTH_LONG - ) - b.dnsCryptEndpointListActionImage.isChecked = false + val description = + if (endpoint.dnsCryptRelayExplanation.isNullOrEmpty()) { + endpoint.dnsCryptRelayURL + } else { + endpoint.dnsCryptRelayURL + "\n\n" + + resolveDnsDescriptionText(context, endpoint.dnsCryptRelayExplanation) } - return@io - } - - endpoint.isSelected = isSelected - appConfig.handleDnsrelayChanges(endpoint) - } - } - - private fun deleteEndpoint(id: Int) { - io { - appConfig.deleteDnscryptRelayEndpoint(id) - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.dns_crypt_relay_remove_success), - Toast.LENGTH_SHORT + infoDialog = + DnsInfoDialogModel( + title = endpoint.dnsCryptRelayName, + message = description, + copyValue = endpoint.dnsCryptRelayURL ) + } + }, + onSelectionChange = updateSelection + ) + + deleteDialog?.let { model -> + DnsDeleteDialog( + model = model, + onDismiss = { deleteDialog = null }, + onConfirm = { id -> + launchDnsEndpointDelete(scope, context, model.successRes) { + appConfig.deleteDnscryptRelayEndpoint(id) } + deleteDialog = null } - } - - private fun io(f: suspend () -> Unit) { - lifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { f() } - } + ) + } - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } + infoDialog?.let { model -> + DnsInfoDialog( + model = model, + onDismiss = { infoDialog = null } + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointRowComponents.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointRowComponents.kt new file mode 100644 index 000000000..1bd8bf2a9 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointRowComponents.kt @@ -0,0 +1,170 @@ +/* +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.adapter + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +internal enum class DnsRowAction { + Info, + Edit, + Delete +} + +internal enum class DnsRowSelection { + Radio, + Checkbox +} + +@Composable +internal fun DnsEndpointRow( + title: String, + supporting: String?, + selected: Boolean, + action: DnsRowAction, + selection: DnsRowSelection = DnsRowSelection.Radio, + onActionClick: () -> Unit, + onSelectionChange: (Boolean) -> Unit +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + shape = RoundedCornerShape(16.dp), + color = + if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.32f) + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onSelectionChange( + if (selection == DnsRowSelection.Radio) true else !selected + ) + } + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + if (!supporting.isNullOrEmpty()) { + Text( + text = supporting, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + DnsEndpointActionButton( + action = action, + onClick = onActionClick + ) + + when (selection) { + DnsRowSelection.Radio -> { + RadioButton( + selected = selected, + onClick = { onSelectionChange(true) } + ) + } + DnsRowSelection.Checkbox -> { + Checkbox( + checked = selected, + onCheckedChange = onSelectionChange + ) + } + } + } + } +} + +@Composable +private fun DnsEndpointActionButton( + action: DnsRowAction, + onClick: () -> Unit +) { + val containerColor = + when (action) { + DnsRowAction.Delete -> MaterialTheme.colorScheme.errorContainer + DnsRowAction.Edit -> MaterialTheme.colorScheme.tertiaryContainer + DnsRowAction.Info -> MaterialTheme.colorScheme.secondaryContainer + } + val contentColor = + when (action) { + DnsRowAction.Delete -> MaterialTheme.colorScheme.onErrorContainer + DnsRowAction.Edit -> MaterialTheme.colorScheme.onTertiaryContainer + DnsRowAction.Info -> MaterialTheme.colorScheme.onSecondaryContainer + } + val icon = + when (action) { + DnsRowAction.Delete -> Icons.Rounded.DeleteOutline + DnsRowAction.Edit -> Icons.Rounded.Edit + DnsRowAction.Info -> Icons.Rounded.MoreHoriz + } + + Surface( + shape = CircleShape, + color = containerColor, + modifier = Modifier.size(32.dp), + onClick = onClick + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(18.dp) + ) + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointShared.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointShared.kt new file mode 100644 index 000000000..5271a2277 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsEndpointShared.kt @@ -0,0 +1,218 @@ +/* +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.adapter + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.celzero.bravedns.R +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkMultiActionDialog +import com.celzero.bravedns.util.UIUtils.clipboardCopy +import com.celzero.bravedns.util.UIUtils.getDnsStatusStringRes +import com.celzero.bravedns.util.Utilities +import com.celzero.firestack.backend.Backend +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal data class DnsInfoDialogModel( + val title: String, + val message: String, + val copyValue: String? = null, + val copyToastRes: Int = R.string.info_dialog_url_copy_toast_msg, + val confirmTextRes: Int = R.string.dns_info_positive, + val copyTextRes: Int = R.string.dns_info_neutral +) + +internal data class DnsDeleteDialogModel( + val id: Int, + val titleRes: Int, + val messageRes: Int, + val successRes: Int +) + +@Composable +internal fun DnsInfoDialog( + model: DnsInfoDialogModel, + onDismiss: () -> Unit +) { + val context = LocalContext.current + RethinkMultiActionDialog( + onDismissRequest = onDismiss, + title = model.title, + message = model.message, + primaryText = context.getString(model.confirmTextRes), + onPrimary = onDismiss, + secondaryText = + model.copyValue?.takeIf { it.isNotEmpty() }?.let { + context.getString(model.copyTextRes) + }, + onSecondary = { + val copyValue = model.copyValue + if (copyValue.isNullOrEmpty()) return@RethinkMultiActionDialog + clipboardCopy( + context, + copyValue, + context.getString(R.string.copy_clipboard_label) + ) + Utilities.showToastUiCentered( + context, + context.getString(model.copyToastRes), + Toast.LENGTH_SHORT + ) + } + ) +} + +@Composable +internal fun DnsDeleteDialog( + model: DnsDeleteDialogModel, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit +) { + val context = LocalContext.current + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = context.getString(model.titleRes), + message = context.getString(model.messageRes), + confirmText = context.getString(R.string.lbl_delete), + dismissText = context.getString(R.string.lbl_cancel), + isConfirmDestructive = true, + onConfirm = { onConfirm(model.id) }, + onDismiss = onDismiss + ) +} + +@Composable +internal fun rememberDnsStatusExplanation( + key: Any, + isSelected: Boolean, + smartDnsEnabled: Boolean, + tag: String, + pollIntervalMs: Long = 1000L, + requireTunnel: Boolean = true, + selectedFallbackText: ((Context) -> String)? = { + it.getString(R.string.rt_filter_parent_selected) + }, + statusTextMapper: (Context, Int) -> String = { context, statusRes -> + context.getString(statusRes).replaceFirstChar(Char::titlecase) + } +): String { + val context = LocalContext.current + var explanation by remember(key) { mutableStateOf("") } + + LaunchedEffect(key, isSelected, smartDnsEnabled) { + if (isSelected && !smartDnsEnabled && (!requireTunnel || VpnController.hasTunnel())) { + while (isActive) { + val status = + runCatching { + withContext(Dispatchers.IO) { + val state = VpnController.getDnsStatus(Backend.Preferred) + getDnsStatusStringRes(state) + } + }.getOrElse { + Napier.e("$tag failed to read dns status", it) + R.string.rt_filter_parent_selected + } + explanation = statusTextMapper(context, status) + delay(pollIntervalMs) + } + } else if (isSelected && selectedFallbackText != null) { + explanation = selectedFallbackText(context) + } else { + explanation = "" + } + } + + return explanation +} + +internal fun resolveDnsDescriptionText(context: Context, message: String?): String { + if (message.isNullOrEmpty()) return "" + + return try { + if (message.contains("R.string.")) { + val key = message.substringAfter("R.string.") + val resId = context.resources.getIdentifier(key, "string", context.packageName) + if (resId == 0) message else context.getString(resId) + } else { + message + } + } catch (_: Exception) { + "" + } +} + +internal fun launchDnsEndpointSelectionUpdate( + scope: CoroutineScope, + context: Context, + tag: String, + apply: suspend () -> Unit +) { + scope.launch(Dispatchers.IO) { + runCatching { apply() }.onFailure { + Napier.e("$tag failed to update endpoint", it) + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.status_failing), + Toast.LENGTH_SHORT + ) + } + } + } +} + +internal fun launchDnsEndpointDelete( + scope: CoroutineScope, + context: Context, + successRes: Int, + apply: suspend () -> Unit +) { + scope.launch(Dispatchers.IO) { + runCatching { apply() }.onSuccess { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + context.getString(successRes), + Toast.LENGTH_SHORT + ) + } + }.onFailure { + Napier.e("dns endpoint delete failed", it) + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.status_failing), + Toast.LENGTH_SHORT + ) + } + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt index 555204f9b..d61eb8880 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt @@ -14,524 +14,910 @@ See the License for the specific language governing permissions and limitations under the License. */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_DNS -import Logger.LOG_TAG_UI import android.content.Context import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade -import com.bumptech.glide.request.target.CustomViewTarget +import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.DrawableCrossFadeFactory import com.bumptech.glide.request.transition.Transition import com.celzero.bravedns.R -import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG -import com.celzero.bravedns.adapter.DnsLogAdapter.DnsLogViewHolder import com.celzero.bravedns.database.DnsLog -import com.celzero.bravedns.databinding.ListItemDnsLogBinding import com.celzero.bravedns.glide.FavIconDownloader import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.ProxyManager -import com.celzero.bravedns.ui.bottomsheet.DnsBlocklistBottomSheet +import com.celzero.bravedns.ui.compose.rememberDrawablePainter import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Constants.Companion.MAX_ENDPOINT -import com.celzero.bravedns.util.UIUtils.fetchColor -import com.celzero.bravedns.util.Utilities.getDefaultIcon +import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities.getIcon import com.celzero.firestack.backend.Backend -import com.google.gson.Gson +import io.github.aakira.napier.Napier +import kotlin.math.roundToInt + +private data class DnsRowPalette( + val status: Color, + val statusContainer: Color, + val statusLabel: String, + val surfaceCollapsed: Color, + val surfaceExpanded: Color, + val surfaceSubtle: Color, + val line: Color, + val primaryText: Color, + val secondaryText: Color, + val tagBg: Color, + val tagText: Color, +) + +@Composable +private fun dnsRowPalette(log: DnsLog): DnsRowPalette { + val scheme = MaterialTheme.colorScheme + val allowedGreen = Color(0xFF2FB36B) + val statusColor = when { + log.isBlocked -> scheme.error + determineMaybeBlocked(log) -> scheme.error.copy(alpha = 0.9f) + else -> allowedGreen + } -class DnsLogAdapter(val context: Context, val loadFavIcon: Boolean, val isRethinkDns: Boolean) : - PagingDataAdapter(DIFF_CALLBACK) { + val statusContainer = when { + log.isBlocked -> scheme.errorContainer.copy(alpha = 0.55f) + determineMaybeBlocked(log) -> scheme.errorContainer.copy(alpha = 0.48f) + else -> allowedGreen.copy(alpha = 0.18f) + } - companion object { - private const val TAG = "DnsLogAdapter" - private const val RTT_SHORT_THRESHOLD_MS = 10 // milliseconds + return DnsRowPalette( + status = statusColor, + statusContainer = statusContainer, + statusLabel = + if (log.isBlocked) { + stringResource(R.string.lbl_blocked) + } else { + stringResource(R.string.lbl_allowed) + }, + surfaceCollapsed = scheme.surfaceContainerLow, + surfaceExpanded = scheme.surfaceContainer, + surfaceSubtle = scheme.surfaceContainerHighest.copy(alpha = 0.32f), + line = scheme.outlineVariant.copy(alpha = 0.45f), + primaryText = scheme.onSurface, + secondaryText = scheme.onSurfaceVariant, + tagBg = scheme.surfaceContainerHighest.copy(alpha = 0.6f), + tagText = scheme.onSurfaceVariant, + ) +} - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { +@Composable +fun DnsLogRow( + log: DnsLog, + loadFavIcon: Boolean, + isRethinkDns: Boolean, + onShowBlocklist: (DnsLog) -> Unit, + index: Int = 0, + itemCount: Int = 1, +) { + val context = LocalContext.current + val palette = dnsRowPalette(log) + val dnsType = dnsTypeName(context, log, isRethinkDns) + val hint = unicodeHint(context, log, isRethinkDns) + val appLabel = log.appName.ifEmpty { + stringResource(R.string.network_log_app_name_unknown) + } - override fun areItemsTheSame(prev: DnsLog, curr: DnsLog) = - prev.id == curr.id + var appIcon by remember(log.packageName) { mutableStateOf(null) } + var favIcon by remember(log.queryStr) { mutableStateOf(null) } + var showFav by remember(log.queryStr, loadFavIcon) { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + var showDetails by remember { mutableStateOf(false) } + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val rowScale by animateFloatAsState( + targetValue = if (isPressed) 0.988f else 1f, + animationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + dampingRatio = Spring.DampingRatioNoBouncy + ), + label = "dnsRowScale" + ) + + val chevronAngle by animateFloatAsState( + targetValue = if (expanded) 90f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "dnsChevron" + ) + + val baseCardColor = if (expanded) palette.surfaceExpanded else palette.surfaceCollapsed + val pressedCardColor = lerp(baseCardColor, MaterialTheme.colorScheme.primaryContainer, 0.2f) + val cardColor by animateColorAsState( + targetValue = if (isPressed) pressedCardColor else baseCardColor, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "dnsCardColor" + ) + + val shadowElevation by animateDpAsState( + targetValue = + when { + isPressed -> 3.dp + expanded -> 7.dp + else -> 1.dp + }, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "dnsCardShadow" + ) + + val stripeAlpha by animateFloatAsState( + targetValue = if (expanded) 1f else 0.9f, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "dnsStripeAlpha" + ) + + val detailsProgress by animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "dnsDetailsProgress", + finishedListener = { value -> + if (value == 0f) showDetails = false + } + ) - override fun areContentsTheSame(prev: DnsLog, curr: DnsLog): Boolean { - return prev == curr - } + LaunchedEffect(log.packageName) { + appIcon = + if (log.packageName.isEmpty() || log.packageName == Constants.EMPTY_PACKAGE_NAME) { + null + } else { + getIcon(context, log.packageName) } } - override fun onBindViewHolder(holder: DnsLogViewHolder, position: Int) { - val log: DnsLog = getItem(position) ?: return - - holder.clear() - holder.update(log) - holder.setTag(log) + LaunchedEffect(log.queryStr, loadFavIcon, log.groundedQuery()) { + showFav = false + favIcon = null } - override fun getItemViewType(position: Int): Int { - return R.layout.list_item_dns_log + LaunchedEffect(log.queryStr, loadFavIcon, log.groundedQuery()) { + if (!loadFavIcon || log.groundedQuery()) return@LaunchedEffect + displayFavIcon( + context = context, + log = log, + loadFavIcon = true, + onShowFlag = { showFav = false; favIcon = null }, + onShowFav = { d -> showFav = true; favIcon = d }, + ) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsLogViewHolder { - val binding = ListItemDnsLogBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return DnsLogViewHolder(binding) + LaunchedEffect(expanded) { + if (expanded) showDetails = true } - inner class DnsLogViewHolder(private val b: ListItemDnsLogBinding): RecyclerView.ViewHolder(b.root) { - fun clear() { - b.dnsWallTime.text = "" - b.dnsFlag.text = "" - b.dnsQuery.text = "" - b.dnsAppName.text = "" - b.dnsIps.text = "" - b.dnsAppIcon.setImageDrawable(null) - b.dnsTypeName.text = "" - b.dnsQueryType.text = "" - b.dnsUnicodeHint.text = "" - b.dnsStatusIndicator.visibility = View.INVISIBLE - b.dnsSummaryLl.visibility = View.GONE - } + val cardShape = ListItemDefaults.segmentedShapes(index = index, count = itemCount) + + Surface( + modifier = + Modifier + .fillMaxWidth() + .scale(rowScale) + .clip(cardShape.shape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { expanded = !expanded }, + ), + shape = cardShape.shape, + color = cardColor, + tonalElevation = if (expanded) 2.dp else 0.dp, + shadowElevation = shadowElevation, + border = + if (expanded) { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) + } else { + null + }, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 26.dp, end = 12.dp, top = 12.dp, bottom = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AppIconSlot( + showFav = showFav, + favIcon = favIcon, + appIcon = appIcon, + statusColor = palette.statusContainer, + ) - fun setTag(log: DnsLog?) { - if (log == null) return + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = log.queryStr, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + color = palette.primaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + letterSpacing = (-0.2).sp, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = appLabel, + fontSize = 11.sp, + color = palette.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + DnsTypeTag(type = dnsType, bg = palette.tagBg, textColor = palette.tagText) + } + } - b.dnsWallTime.tag = log.time - b.root.tag = log.time - } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + StatusLabel(text = palette.statusLabel, color = palette.status) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = log.wallTime(), + fontSize = 10.sp, + color = palette.secondaryText.copy(alpha = 0.92f), + ) + ChevronIcon(angle = chevronAngle, tint = palette.secondaryText) + } + } + } + + if (showDetails) { + Box( + modifier = + Modifier + .fillMaxWidth() + .accordionReveal(detailsProgress), + ) { + DetailPanel( + log = log, + dnsType = dnsType, + hint = hint, + statusColor = palette.status, + context = context, + panelColor = palette.surfaceSubtle, + dividerColor = palette.line, + textColor = palette.secondaryText, + onShowBlocklist = onShowBlocklist, + ) + } + } + } - fun update(log: DnsLog) { - displayTransactionDetails(log) - displayAppDetails(log) - displayLogEntryHint(log) - displayIcon(log) - displayUnicodeIfNeeded(log) - displayDnsType(log) - b.dnsParentLayout.setOnClickListener { openBottomSheet(log) } + StatusStripe( + color = palette.status.copy(alpha = stripeAlpha), + modifier = + Modifier + .align(Alignment.TopStart) + .fillMaxHeight() + .zIndex(1f), + ) } + } +} - private fun openBottomSheet(log: DnsLog) { - if (context !is FragmentActivity) { - Logger.w(LOG_TAG_UI, "$TAG err opening dns log btm sheet, no ctx to activity") - return +private fun Modifier.accordionReveal(progress: Float): Modifier { + val p = progress.coerceIn(0f, 1f) + return this + .graphicsLayer { alpha = p } + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val h = (placeable.height * p).roundToInt() + layout(placeable.width, h) { + if (h > 0) placeable.place(0, 0) } - - val bottomSheetFragment = DnsBlocklistBottomSheet() - val bundle = Bundle() - bundle.putString(DnsBlocklistBottomSheet.INSTANCE_STATE_DNSLOGS, Gson().toJson(log)) - bottomSheetFragment.arguments = bundle - bottomSheetFragment.show(context.supportFragmentManager, bottomSheetFragment.tag) } +} +@Composable +private fun StatusStripe(color: Color, modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(start = 10.dp, top = 10.dp, bottom = 10.dp) + .width(5.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(999.dp)) + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + color, + color.copy(alpha = 0.38f), + ), + ), + ), + ) +} - private fun displayLogEntryHint(log: DnsLog) { - if (log.isBlocked) { - b.dnsStatusIndicator.visibility = View.VISIBLE - b.dnsStatusIndicator.setBackgroundColor( - ContextCompat.getColor(context, R.color.colorRed_A400) - ) - } else if (determineMaybeBlocked(log)) { - b.dnsStatusIndicator.visibility = View.VISIBLE - val color = fetchColor(context, R.attr.chipTextNeutral) - b.dnsStatusIndicator.setBackgroundColor(color) - } else { - b.dnsStatusIndicator.visibility = View.INVISIBLE +@Composable +private fun AppIconSlot( + showFav: Boolean, + favIcon: Drawable?, + appIcon: Drawable?, + statusColor: Color, +) { + val iconDrawable = if (showFav && favIcon != null) favIcon else appIcon + + Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) { + if (iconDrawable != null) { + Crossfade(targetState = iconDrawable, animationSpec = tween(durationMillis = 180), label = "dnsIcon") { drawable -> + rememberDrawablePainter(drawable)?.let { painter -> + androidx.compose.foundation.Image( + painter = painter, + contentDescription = null, + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(7.dp)), + ) + } } + } else { + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(10.dp)) + .background(statusColor.copy(alpha = 0.5f)) + ) } + } +} - private fun determineMaybeBlocked(log: DnsLog): Boolean { - return log.upstreamBlock || log.blockLists.isNotEmpty() - } +@Composable +private fun DnsTypeTag(type: String, bg: Color, textColor: Color) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(5.dp)) + .background(bg) + .padding(horizontal = 6.dp, vertical = 0.dp), + ) { + Text( + text = type, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = textColor, + letterSpacing = 0.5.sp, + ) + } +} - private fun displayTransactionDetails(log: DnsLog) { - b.dnsWallTime.text = log.wallTime() +@Composable +private fun StatusLabel(text: String, color: Color) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = + Modifier + .size(5.dp) + .clip(CircleShape) + .background(color), + ) + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = color, + letterSpacing = 0.2.sp, + ) + } +} - b.dnsQuery.text = log.queryStr - b.dnsIps.text = log.responseIps.split(",").firstOrNull() ?: "" - b.dnsIps.visibility = View.VISIBLE - // marquee is not working for the textview, hence the workaround. - b.dnsIps.isSelected = true +@Composable +private fun ChevronIcon(angle: Float, tint: Color) { + Icon( + painter = painterResource(R.drawable.ic_right_arrow_small), + contentDescription = null, + tint = tint, + modifier = + Modifier + .size(10.dp) + .rotate(angle), + ) +} - b.dnsLatency.text = context.getString(R.string.dns_query_latency, log.latency.toString()) - b.dnsQueryType.text = log.typeName - } +@Composable +private fun DetailPanel( + log: DnsLog, + dnsType: String, + hint: String, + statusColor: Color, + context: Context, + panelColor: Color, + dividerColor: Color, + textColor: Color, + onShowBlocklist: (DnsLog) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(panelColor), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + HorizontalDivider( + color = dividerColor, + thickness = 0.5.dp, + ) + + Column( + modifier = Modifier.padding(start = 26.dp, end = 14.dp, top = 10.dp, bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + DetailLatencyRow(latency = log.latency) + + Spacer(Modifier.height(8.dp)) + + DetailTextRow( + label = "Transport", + value = dnsType, + tint = textColor, + ) - private fun displayUnicodeIfNeeded(log: DnsLog) { - if (DEBUG) { - val msg = log.msg.split(";").firstOrNull() ?: "" - if (msg.isNotEmpty() && msg == Backend.OriginInternal) { - b.dnsUnicodeHint.text = context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - "🪃" - ) + val unknownLabel = context.getString(R.string.network_log_app_name_unknown) + val countryName = UIUtils.getCountryNameFromFlag(log.flag).trim() + val normalizedCountryName = countryName.takeUnless { it.isBlank() || it == "--" } + val normalizedFlag = log.flag.trim().takeUnless { it.isBlank() || it == "--" } + val countryDisplay = + when { + normalizedCountryName != null && normalizedFlag != null -> "$normalizedCountryName $normalizedFlag" + normalizedCountryName != null -> normalizedCountryName + normalizedFlag != null -> normalizedFlag + else -> unknownLabel } - } - - // no need to show Unicode hints for failed transactions as the hints are relevant only - // for complete transactions and can be misleading in case of failed transactions - if (Transaction.Status.COMPLETE.name != log.status) { - return - } + DetailTextRow( + label = "Country", + value = countryDisplay, + tint = textColor, + ) - // rtt -> show rocket if less than 20ms, treat it as rtt - if (isRoundTripShorter(log.latency, log.isBlocked)) { - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_rocket) - ) - } - // bunny in case rpid as present, key in case of proxy - // bunny and key indicate conn is proxied, so its enough to show one of them - if (containsRelayProxy(log.relayIP)) { - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_bunny) - ) - } else if (isConnectionProxied(log.proxyId)) { - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_key) - ) + if (log.responseIps.isNotBlank()) { + val ips = log.responseIps.split(",").map { it.trim() }.filter { it.isNotEmpty() } + DetailTextRow( + label = context.getString(R.string.response_ip_label).ifEmpty { "IP" }, + value = ips.joinToString(" · "), + mono = true, + tint = MaterialTheme.colorScheme.secondary, + ) } - // show star if RethinkDNS or RPN is used - if (isRethinkUsed(log)) { - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - getRethinkUnicode(log) - ) - } else if (isGoosOrSystemUsed(log)) { - // show duck icon in case of system or goos transport - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_duck) - ) - } else if (isDefaultResolverUsed(log)) { - // show globe icon in case of default or bootstrap resolver - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_diamond) - ) - } else if (containsMultipleIPs(log)) { - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_heavy) - ) + if (log.serverIP.isNotBlank()) { + DetailTextRow( + label = context.getString(R.string.resolver_label).ifEmpty { "Resolver" }, + value = log.serverIP, + mono = true, + tint = textColor, + ) } - if (dnssecIndicatorRequired(log)) { - if (dnssecOk(log)) { - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_lock) - ) - } else { - b.dnsUnicodeHint.text = - context.getString( - R.string.ci_desc, - b.dnsUnicodeHint.text, - context.getString(R.string.symbol_unlock) - ) - } + if (log.dnssecOk || log.dnssecValid) { + val dnssecOkay = log.dnssecOk && log.dnssecValid + DetailTextRow( + label = "DNSSEC", + value = if (dnssecOkay) "✓ Valid" else "⚠ Unverified", + tint = if (dnssecOkay) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.tertiary, + ) } - if (b.dnsUnicodeHint.text.isEmpty() && b.dnsQueryType.text.isEmpty()) { - b.dnsSummaryLl.visibility = View.GONE - } else { - b.dnsSummaryLl.visibility = View.VISIBLE + if (hint.isNotEmpty()) { + DetailTextRow(label = "Flags", value = hint, tint = textColor) } - } - private fun dnssecIndicatorRequired(log: DnsLog): Boolean { - // dnssec indicator is shown only for complete transactions - if (log.status != Transaction.Status.COMPLETE.name) { - return false + if (log.blockLists.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + BlocklistRow(log = log, statusColor = statusColor, onShowBlocklist = onShowBlocklist) } - - return log.dnssecOk || log.dnssecValid } + } +} - private fun dnssecOk(log: DnsLog): Boolean { - // dnssec ok is true only when both dnssecOk and dnssecValid are true - return log.dnssecOk && log.dnssecValid +@Composable +private fun DetailLatencyRow(latency: Long) { + val scheme = MaterialTheme.colorScheme + val successGreen = Color(0xFF2FB36B) + val (barColor, label) = + when { + latency in 1..10 -> successGreen to "${latency}ms · fast" + latency in 11..50 -> scheme.tertiary to "${latency}ms · ok" + latency > 50 -> scheme.error to "${latency}ms · slow" + else -> scheme.onSurfaceVariant to "${latency}ms" } - - private fun isRoundTripShorter(rtt: Long, blocked: Boolean): Boolean { - return rtt in 1..RTT_SHORT_THRESHOLD_MS && !blocked + val fraction = (latency.toFloat() / 100f).coerceIn(0.04f, 1f) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = "Latency", + fontSize = 10.sp, + color = scheme.onSurfaceVariant, + letterSpacing = 0.4.sp, + ) + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = barColor, + ) } - private fun containsRelayProxy(rpid: String): Boolean { - return rpid.isNotEmpty() + Box( + modifier = + Modifier + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(scheme.outlineVariant.copy(alpha = 0.35f)), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .clip(RoundedCornerShape(2.dp)) + .background( + Brush.horizontalGradient( + listOf(barColor.copy(alpha = 0.7f), barColor), + ), + ), + ) } + } +} - private fun isConnectionProxied(proxy: String?): Boolean { - if (proxy.isNullOrEmpty()) return false - - return ProxyManager.isNotLocalAndRpnProxy(proxy) - } +@Composable +private fun DetailTextRow( + label: String, + value: String, + mono: Boolean = false, + tint: Color, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 0.4.sp, + modifier = Modifier.widthIn(min = 64.dp), + ) + Text( + text = value, + fontSize = 11.sp, + color = tint, + fontFamily = if (mono) FontFamily.Monospace else FontFamily.Default, + fontWeight = FontWeight.Medium, + textAlign = androidx.compose.ui.text.style.TextAlign.End, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} - private fun containsMultipleIPs(log: DnsLog): Boolean { - return log.responseIps.split(",").size > 1 +@Composable +private fun BlocklistRow(log: DnsLog, statusColor: Color, onShowBlocklist: (DnsLog) -> Unit) { + val scheme = MaterialTheme.colorScheme + val count = log.blockLists.split(",").filter { it.isNotEmpty() }.size + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(9.dp)) + .background(scheme.errorContainer.copy(alpha = 0.38f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = scheme.error.copy(alpha = 0.16f)), + onClick = { onShowBlocklist(log) }, + ) + .padding(horizontal = 10.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .size(5.dp) + .clip(CircleShape) + .background(statusColor), + ) + Text( + text = "$count blocklist${if (count != 1) "s" else ""} matched", + fontSize = 11.sp, + color = scheme.error, + fontWeight = FontWeight.SemiBold, + ) } + Icon( + painter = painterResource(R.drawable.ic_right_arrow_small), + contentDescription = null, + tint = scheme.error.copy(alpha = 0.65f), + modifier = Modifier.size(10.dp), + ) + } +} - private fun isRethinkUsed(log: DnsLog): Boolean { - if (log.status != Transaction.Status.COMPLETE.name) { - return false - } +private fun determineMaybeBlocked(log: DnsLog): Boolean = + log.upstreamBlock || log.blockLists.isNotEmpty() - // now the rethink dns is added as preferred in the backend, instead of separate - // id, so match it with Preferred and BlockFree - return if (isRethinkDns) { - (log.resolverId.contains(Backend.Preferred) || - log.resolverId.contains(Backend.BlockFree)) - } else { - false - } +private fun unicodeHint(context: Context, log: DnsLog, isRethinkDns: Boolean): String { + var hint = "" + if (isRoundTripShorter(log.latency, log.isBlocked)) { + hint = context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_rocket)) + } + if (containsRelayProxy(log.relayIP)) { + hint = context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_bunny)) + } else if (isConnectionProxied(log.proxyId)) { + hint = context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_key)) + } + if (isRethinkUsed(log, isRethinkDns)) { + hint = context.getString(R.string.ci_desc, hint, getRethinkUnicode(context, log)) + } else if (isGoosOrSystemUsed(log)) { + hint = context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_duck)) + } else if (isDefaultResolverUsed(log)) { + hint = context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_diamond)) + } else if (containsMultipleIPs(log)) { + hint = context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_heavy)) + } + if (dnssecIndicatorRequired(log)) { + hint = if (dnssecOk(log)) { + context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_lock)) + } else { + context.getString(R.string.ci_desc, hint, context.getString(R.string.symbol_unlock)) } + } + return hint +} - private fun isGoosOrSystemUsed(log: DnsLog): Boolean { - if (log.status != Transaction.Status.COMPLETE.name) { - return false +private fun dnsTypeName(context: Context, log: DnsLog, isRethinkDns: Boolean): String = + when (Transaction.TransportType.fromOrdinal(log.dnsType)) { + Transaction.TransportType.DOH -> + if (isRethinkDns && isRethinkUsed(log, isRethinkDns)) { + context.getString(R.string.lbl_rdns) + } else { + context.getString(R.string.other_dns_list_tab1) } + Transaction.TransportType.DNS_CRYPT -> context.getString(R.string.lbl_dc_abbr) + Transaction.TransportType.DNS_PROXY -> context.getString(R.string.lbl_dp) + Transaction.TransportType.DOT -> context.getString(R.string.lbl_dot) + Transaction.TransportType.ODOH -> context.getString(R.string.lbl_odoh) + } - return log.resolverId.contains(Backend.Goos) || log.resolverId.contains(Backend.System) - } +private fun dnssecIndicatorRequired(log: DnsLog) = + log.status == Transaction.Status.COMPLETE.name && (log.dnssecOk || log.dnssecValid) - private fun isDefaultResolverUsed(log: DnsLog): Boolean { - if (log.status != Transaction.Status.COMPLETE.name) { - return false - } +private fun dnssecOk(log: DnsLog) = log.dnssecOk && log.dnssecValid - // ideally bootstrap will not be sent from go-tun, just in case check for it - return log.resolverId.contains(Backend.Default) || log.resolverId.contains(Backend.Bootstrap) - } +private fun isRoundTripShorter(rtt: Long, blocked: Boolean) = rtt in 1..10 && !blocked - private fun getRethinkUnicode(log: DnsLog): String { - // resolver check for rethink dns is done before calling this method - if (log.relayIP.endsWith(Backend.RPN) || log.relayIP == Backend.Auto) return context.getString( - R.string.symbol_sparkle - ) +private fun containsRelayProxy(rpid: String) = rpid.isNotEmpty() - return if (log.serverIP.contains(MAX_ENDPOINT)) { - context.getString(R.string.symbol_max) - } else { - context.getString(R.string.symbol_sky) - } - } +private fun isConnectionProxied(proxy: String?): Boolean { + if (proxy.isNullOrEmpty()) return false + return ProxyManager.isNotLocalAndRpnProxy(proxy) +} - private fun displayAppDetails(log: DnsLog) { - if (log.appName.isEmpty()) { - b.dnsAppName.text = context.getString(R.string.network_log_app_name_unknown).uppercase() - } else { - b.dnsAppName.text = log.appName - } - if (log.packageName.isEmpty() || log.packageName == Constants.EMPTY_PACKAGE_NAME) { - loadAppIcon(getDefaultIcon(context)) - } else { - loadAppIcon(getIcon(context, log.packageName)) - } - return - } +private fun containsMultipleIPs(log: DnsLog) = log.responseIps.split(",").size > 1 - private fun loadAppIcon(drawable: Drawable?) { - Glide.with(context) - .load(drawable) - .error(getDefaultIcon(context)) - .into(b.dnsAppIcon) - } +private fun isRethinkUsed(log: DnsLog, isRethinkDns: Boolean): Boolean { + if (log.status != Transaction.Status.COMPLETE.name) return false + return isRethinkDns && + (log.resolverId.contains(Backend.Preferred) || log.resolverId.contains(Backend.BlockFree)) +} - private fun displayIcon(log: DnsLog) { - b.dnsFlag.text = log.flag - b.dnsFlag.visibility = View.VISIBLE - b.dnsFavIcon.visibility = View.GONE - if (!loadFavIcon || log.groundedQuery()) { - clearFavIcon() - return - } +private fun isGoosOrSystemUsed(log: DnsLog): Boolean { + if (log.status != Transaction.Status.COMPLETE.name) return false + return log.resolverId.contains(Backend.Goos) || log.resolverId.contains(Backend.System) +} - // no need to check in glide cache if the value is available in failed cache - if ( - FavIconDownloader.isUrlAvailableInFailedCache(log.queryStr.dropLast(1)) != null - ) { - hideFavIcon() - showFlag() - } else { - // Glide will cache the icons against the urls. To extract the fav icon from the - // cache, first verify that the cache is available with the next dns url. - // If it is not available then glide will throw an error, do the duckduckgo - // url check in that case. - displayNextDnsFavIcon(log) - } - } +private fun isDefaultResolverUsed(log: DnsLog): Boolean { + if (log.status != Transaction.Status.COMPLETE.name) return false + return log.resolverId.contains(Backend.Default) || log.resolverId.contains(Backend.Bootstrap) +} - private fun displayDnsType(log: DnsLog) { - val type = Transaction.TransportType.fromOrdinal(log.dnsType) - when (type) { - Transaction.TransportType.DOH -> { - if (isRethinkDns && isRethinkUsed(log)) { - b.dnsTypeName.text = context.getString(R.string.lbl_rdns) - } else { - b.dnsTypeName.text = context.getString(R.string.other_dns_list_tab1) - } - } - Transaction.TransportType.DNS_CRYPT -> { - b.dnsTypeName.text = context.getString(R.string.lbl_dc_abbr) - } - Transaction.TransportType.DNS_PROXY -> { - b.dnsTypeName.text = context.getString(R.string.lbl_dp) - } - Transaction.TransportType.DOT -> { - b.dnsTypeName.text = context.getString(R.string.lbl_dot) - } - Transaction.TransportType.ODOH -> { - b.dnsTypeName.text = context.getString(R.string.lbl_odoh) - } - } - } +private fun getRethinkUnicode(context: Context, log: DnsLog): String { + if (log.relayIP.endsWith(Backend.RPN) || log.relayIP == Backend.Auto) { + return context.getString(R.string.symbol_sparkle) + } + return if (log.serverIP.contains(MAX_ENDPOINT)) { + context.getString(R.string.symbol_max) + } else { + context.getString(R.string.symbol_sky) + } +} - private fun clearFavIcon() { - Glide.with(context.applicationContext).clear(b.dnsFavIcon) - } +private fun displayFavIcon( + context: Context, + log: DnsLog, + loadFavIcon: Boolean, + onShowFlag: () -> Unit, + onShowFav: (Drawable) -> Unit, +) { + if (!loadFavIcon || log.groundedQuery()) { + onShowFlag() + return + } + if (FavIconDownloader.isUrlAvailableInFailedCache(log.queryStr.dropLast(1)) != null) { + onShowFlag() + return + } + displayNextDnsFavIcon(context, log, onShowFlag, onShowFav) +} - private fun displayNextDnsFavIcon(log: DnsLog) { - val trim = log.queryStr.dropLastWhile { it == '.' } - // url to check if the icon is cached from nextdns - val nextDnsUrl = FavIconDownloader.constructFavIcoUrlNextDns(trim) - // url to check if the icon is cached from duckduckgo - val duckduckGoUrl = FavIconDownloader.constructFavUrlDuckDuckGo(trim) - // subdomain to check if the icon is cached from duckduckgo - val duckduckgoDomainURL = FavIconDownloader.getDomainUrlFromFdqnDuckduckgo(trim) - try { - val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() - Glide.with(context.applicationContext) - .load(nextDnsUrl) - .onlyRetrieveFromCache(true) - .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) - .error( - // on error, check if the icon is stored in the name of duckduckgo url - displayDuckduckgoFavIcon(duckduckGoUrl, duckduckgoDomainURL) - ) - .transition(withCrossFade(factory)) - .into( - object : CustomViewTarget(b.dnsFavIcon) { - override fun onLoadFailed(errorDrawable: Drawable?) { - showFlag() - hideFavIcon() - } - - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - hideFlag() - showFavIcon(resource) - } - - override fun onResourceCleared(placeholder: Drawable?) { - hideFavIcon() - showFlag() - } - } - ) - } catch (_: Exception) { - Logger.d(LOG_TAG_DNS, "err loading icon, load flag instead") - displayDuckduckgoFavIcon(duckduckGoUrl, duckduckgoDomainURL) - } - } +private fun displayNextDnsFavIcon( + context: Context, + log: DnsLog, + onShowFlag: () -> Unit, + onShowFav: (Drawable) -> Unit, +) { + val trim = log.queryStr.dropLastWhile { it == '.' } + val nextDnsUrl = FavIconDownloader.constructFavIcoUrlNextDns(trim) + val duckduckGoUrl = FavIconDownloader.constructFavUrlDuckDuckGo(trim) + val duckDomainUrl = FavIconDownloader.getDomainUrlFromFdqnDuckduckgo(trim) + + try { + val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() + Glide.with(context.applicationContext) + .load(nextDnsUrl) + .onlyRetrieveFromCache(true) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .transition(withCrossFade(factory)) + .into( + object : CustomTarget() { + override fun onLoadFailed(e: Drawable?) = + displayDuckduckgoFavIcon( + context, + duckduckGoUrl, + duckDomainUrl, + onShowFlag, + onShowFav, + ) - /** - * Loads the fav icons from the cache, the icons are cached by favIconDownloader. On - * failure, will check if there is a icon for top level domain is available in cache. Else, - * will show the Flag. - * - * This method will be executed only when show fav icon setting is turned on. - */ - private fun displayDuckduckgoFavIcon(url: String, subDomainURL: String) { - try { - val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() - Glide.with(context.applicationContext) - .load(url) - .onlyRetrieveFromCache(true) - .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) - .error( - Glide.with(context.applicationContext) - .load(subDomainURL) - .onlyRetrieveFromCache(true) - ) - .transition(withCrossFade(factory)) - .into( - object : CustomViewTarget(b.dnsFavIcon) { - override fun onLoadFailed(errorDrawable: Drawable?) { - showFlag() - hideFavIcon() - } - - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - hideFlag() - showFavIcon(resource) - } - - override fun onResourceCleared(placeholder: Drawable?) { - hideFavIcon() - showFlag() - } - } - ) - } catch (_: Exception) { - Logger.d(LOG_TAG_DNS, "$TAG err loading icon, load flag instead") - showFlag() - hideFavIcon() - } - } + override fun onResourceReady(r: Drawable, t: Transition?) = onShowFav(r) - private fun showFavIcon(drawable: Drawable) { - b.dnsFavIcon.visibility = View.VISIBLE - b.dnsFavIcon.setImageDrawable(drawable) - } + override fun onLoadCleared(p: Drawable?) = onShowFlag() + }, + ) + } catch (_: Exception) { + Napier.d("err loading icon, load flag instead") + displayDuckduckgoFavIcon(context, duckduckGoUrl, duckDomainUrl, onShowFlag, onShowFav) + } +} - private fun hideFavIcon() { - b.dnsFavIcon.visibility = View.GONE - b.dnsFavIcon.setImageDrawable(null) - } +private fun displayDuckduckgoFavIcon( + context: Context, + url: String, + subDomainURL: String, + onShowFlag: () -> Unit, + onShowFav: (Drawable) -> Unit, +) { + try { + val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() + Glide.with(context.applicationContext) + .load(url) + .onlyRetrieveFromCache(true) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .error( + Glide.with(context.applicationContext).load(subDomainURL).onlyRetrieveFromCache(true), + ) + .transition(withCrossFade(factory)) + .into( + object : CustomTarget() { + override fun onLoadFailed(e: Drawable?) = onShowFlag() - private fun showFlag() { - b.dnsFlag.visibility = View.VISIBLE - } + override fun onResourceReady(r: Drawable, t: Transition?) = onShowFav(r) - private fun hideFlag() { - b.dnsFlag.visibility = View.GONE - } + override fun onLoadCleared(p: Drawable?) = onShowFlag() + }, + ) + } catch (_: Exception) { + Napier.d("err loading icon, load flag instead") + onShowFlag() } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt index deadb345f..ed424ec11 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt @@ -16,219 +16,118 @@ limitations under the License. package com.celzero.bravedns.adapter -import android.content.Context -import android.content.DialogInterface -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.appcompat.content.res.AppCompatResources -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DnsProxyEndpoint -import com.celzero.bravedns.databinding.DnsProxyListItemBinding import com.celzero.bravedns.service.FirewallManager -import com.celzero.bravedns.util.UIUtils.clipboardCopy -import com.celzero.bravedns.util.Utilities -import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class DnsProxyEndpointAdapter( - private val context: Context, - val lifecycleOwner: LifecycleOwner, - private val appConfig: AppConfig -) : - PagingDataAdapter( - DIFF_CALLBACK - ) { +private const val TAG = "DnsProxyEndpointAdapter" - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: DnsProxyEndpoint, - newConnection: DnsProxyEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected == newConnection.isSelected) - } - - override fun areContentsTheSame( - oldConnection: DnsProxyEndpoint, - newConnection: DnsProxyEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected != newConnection.isSelected) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsProxyEndpointViewHolder { - val itemBinding = - DnsProxyListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return DnsProxyEndpointViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: DnsProxyEndpointViewHolder, position: Int) { - val dnsProxyEndpoint: DnsProxyEndpoint = getItem(position) ?: return - holder.update(dnsProxyEndpoint) - } - - inner class DnsProxyEndpointViewHolder(private val b: DnsProxyListItemBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(endpoint: DnsProxyEndpoint) { - displayDetails(endpoint) - setupClickListeners(endpoint) - } - - private fun setupClickListeners(endpoint: DnsProxyEndpoint) { - b.root.setOnClickListener { updateDnsProxyDetails(endpoint) } +@Composable +fun DnsProxyEndpointRow(endpoint: DnsProxyEndpoint, appConfig: AppConfig) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var explanation by remember(endpoint.id) { mutableStateOf("") } + var infoDialog by remember(endpoint.id) { mutableStateOf(null) } + var deleteDialog by remember(endpoint.id) { mutableStateOf(null) } - b.dnsProxyListActionImage.setOnClickListener { promptUser(endpoint) } - - b.dnsProxyListCheckImage.setOnClickListener { updateDnsProxyDetails(endpoint) } - - b.root.setOnClickListener { updateDnsProxyDetails(endpoint) } - - b.dnsProxyListActionImage.setOnClickListener { promptUser(endpoint) } - - b.dnsProxyListCheckImage.setOnClickListener { updateDnsProxyDetails(endpoint) } - } - - private fun displayDetails(endpoint: DnsProxyEndpoint) { - b.dnsProxyListUrlName.text = endpoint.proxyName - b.dnsProxyListCheckImage.isChecked = endpoint.isSelected - - io { - val appInfo = FirewallManager.getAppInfoByPackage(endpoint.proxyAppName) - uiCtx { - val appName = - if ( - endpoint.proxyName != - context.getString(R.string.cd_custom_dns_proxy_default_app) - ) { - appInfo?.appName - ?: context.getString(R.string.cd_custom_dns_proxy_default_app) - } else { - endpoint.proxyAppName - ?: context.getString(R.string.cd_custom_dns_proxy_default_app) - } - - b.dnsProxyListUrlExplanation.text = - endpoint.getExplanationText(context, appName) - } + LaunchedEffect(endpoint.id, endpoint.proxyName, endpoint.proxyAppName) { + val appName = + withContext(Dispatchers.IO) { + FirewallManager.getAppInfoByPackage(endpoint.proxyAppName)?.appName } - - if (endpoint.isDeletable()) { - b.dnsProxyListActionImage.setImageDrawable( - AppCompatResources.getDrawable(context, R.drawable.ic_fab_uninstall) - ) + val defaultName = context.getString(R.string.cd_custom_dns_proxy_default_app) + val resolvedAppName = + if (endpoint.proxyName != defaultName) { + appName ?: defaultName } else { - b.dnsProxyListActionImage.setImageDrawable( - AppCompatResources.getDrawable(context, R.drawable.ic_info) - ) + endpoint.proxyAppName ?: defaultName } - } + explanation = endpoint.getExplanationText(context, resolvedAppName) } - private fun promptUser(endpoint: DnsProxyEndpoint) { - if (endpoint.isDeletable()) showDeleteDialog(endpoint) - else { - io { - val app = FirewallManager.getAppInfoByPackage(endpoint.getPackageName())?.appName - uiCtx { - showDetailsDialog( - endpoint.proxyName, - endpoint.proxyIP, - endpoint.proxyPort.toString(), - app + DnsEndpointRow( + title = endpoint.proxyName, + supporting = explanation.ifEmpty { null }, + selected = endpoint.isSelected, + action = if (endpoint.isDeletable()) DnsRowAction.Delete else DnsRowAction.Info, + selection = DnsRowSelection.Radio, + onActionClick = { + if (endpoint.isDeletable()) { + deleteDialog = + DnsDeleteDialogModel( + id = endpoint.id, + titleRes = R.string.dns_proxy_remove_dialog_title, + messageRes = R.string.dns_proxy_remove_dialog_message, + successRes = R.string.dns_proxy_remove_success ) + } else { + scope.launch(Dispatchers.IO) { + val app = + FirewallManager.getAppInfoByPackage(endpoint.getPackageName())?.appName + val message = + if (!app.isNullOrEmpty()) { + context.getString( + R.string.dns_proxy_dialog_message, + app, + endpoint.proxyIP, + endpoint.proxyPort.toString() + ) + } else { + context.getString( + R.string.dns_proxy_dialog_message_no_app, + endpoint.proxyIP, + endpoint.proxyPort.toString() + ) + } + withContext(Dispatchers.Main) { + infoDialog = + DnsInfoDialogModel( + title = endpoint.proxyName, + message = message, + copyValue = endpoint.proxyIP, + copyToastRes = R.string.info_dialog_copy_toast_msg + ) + } } } - } - } - - private fun showDetailsDialog(title: String, ip: String?, port: String, app: String?) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(title) - - if (!app.isNullOrEmpty()) { - builder.setMessage(context.getString(R.string.dns_proxy_dialog_message, app, ip, port)) - } else { - builder.setMessage( - context.getString(R.string.dns_proxy_dialog_message_no_app, ip, port) - ) - } - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { - dialogInterface, - _ -> - dialogInterface.dismiss() - } - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { - _: DialogInterface, - _: Int -> - if (ip != null) { - clipboardCopy(context, ip, context.getString(R.string.copy_clipboard_label)) - Utilities.showToastUiCentered( - context, - context.getString(R.string.info_dialog_copy_toast_msg), - Toast.LENGTH_SHORT - ) - } else { - // no op: Copy functionality is for the Ip of the endpoint, no operation needed - // when the ip is not available for endpoint. + }, + onSelectionChange = { + launchDnsEndpointSelectionUpdate(scope, context, TAG) { + endpoint.isSelected = true + appConfig.handleDnsProxyChanges(endpoint) } } - builder.create().show() - } - - private fun showDeleteDialog(dnsProxyEndpoint: DnsProxyEndpoint) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.dns_proxy_remove_dialog_title) - builder.setMessage(R.string.dns_proxy_remove_dialog_message) - - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - deleteProxyEndpoint(dnsProxyEndpoint.id) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> } - builder.create().show() - } - - private fun updateDnsProxyDetails(endpoint: DnsProxyEndpoint) { - io { - endpoint.isSelected = true - appConfig.handleDnsProxyChanges(endpoint) - } - } - - private fun deleteProxyEndpoint(id: Int) { - io { - appConfig.deleteDnsProxyEndpoint(id) - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.dns_proxy_remove_success), - Toast.LENGTH_SHORT - ) + ) + + deleteDialog?.let { model -> + DnsDeleteDialog( + model = model, + onDismiss = { deleteDialog = null }, + onConfirm = { id -> + launchDnsEndpointDelete(scope, context, model.successRes) { + appConfig.deleteDnsProxyEndpoint(id) + } + deleteDialog = null } - } - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } + ) } - private fun io(f: suspend () -> Unit) { - lifecycleOwner.lifecycleScope.launch { withContext(Dispatchers.IO) { f() } } + infoDialog?.let { model -> + DnsInfoDialog( + model = model, + onDismiss = { infoDialog = null } + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt index 7c5c330c1..c20b3796a 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt @@ -16,256 +16,99 @@ limitations under the License. package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_DNS -import android.content.Context -import android.content.DialogInterface -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DoTEndpoint -import com.celzero.bravedns.databinding.ListItemEndpointBinding -import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.util.UIUtils.clipboardCopy -import com.celzero.bravedns.util.UIUtils.getDnsStatusStringRes -import com.celzero.bravedns.util.Utilities -import com.celzero.firestack.backend.Backend -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -class DoTEndpointAdapter(private val context: Context, private val appConfig: AppConfig) : - PagingDataAdapter(DIFF_CALLBACK) { - - var lifecycleOwner: LifecycleOwner? = null - - companion object { - private const val ONE_SEC = 1000L - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: DoTEndpoint, - newConnection: DoTEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected == newConnection.isSelected) - } - - override fun areContentsTheSame( - oldConnection: DoTEndpoint, - newConnection: DoTEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected != newConnection.isSelected) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DoTEndpointViewHolder { - val itemBinding = - ListItemEndpointBinding.inflate(LayoutInflater.from(parent.context), parent, false) - lifecycleOwner = parent.findViewTreeLifecycleOwner() - return DoTEndpointViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: DoTEndpointViewHolder, position: Int) { - val endpoint: DoTEndpoint = getItem(position) ?: return - holder.update(endpoint) - } - - inner class DoTEndpointViewHolder(private val b: ListItemEndpointBinding) : - RecyclerView.ViewHolder(b.root) { - private var statusCheckJob: Job? = null - - fun update(endpoint: DoTEndpoint) { - displayDetails(endpoint) - setupClickListeners(endpoint) - } - - private fun setupClickListeners(endpoint: DoTEndpoint) { - b.root.setOnClickListener { updateConnection(endpoint) } - b.endpointInfoImg.setOnClickListener { showExplanationOnImageClick(endpoint) } - b.endpointCheck.setOnClickListener { updateConnection(endpoint) } - } - - private fun displayDetails(endpoint: DoTEndpoint) { - if (endpoint.isSecure) { - b.endpointName.text = endpoint.name - } else { - b.endpointName.text = - context.getString( - R.string.ci_desc, - endpoint.name, - context.getString(R.string.lbl_insecure) - ) - } - b.endpointCheck.isChecked = endpoint.isSelected - - if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) { - keepSelectedStatusUpdated() - } else if (endpoint.isSelected) { - b.endpointDesc.text = context.getString(R.string.rt_filter_parent_selected) - } else { - b.endpointDesc.text = "" - } - - // Shows either the info/delete icon for the DoH entries. - showIcon(endpoint) - } - - private fun keepSelectedStatusUpdated() { - statusCheckJob = ui { - while (true) { - updateSelectedStatus() - delay(ONE_SEC) - } - } - } - - private fun updateSelectedStatus() { - // if the view is not active then cancel the job - if ( - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false || - bindingAdapterPosition == RecyclerView.NO_POSITION - ) { - statusCheckJob?.cancel() - return - } - - updateDnsStatus() - } - - private fun updateDnsStatus() { - io { - val state = VpnController.getDnsStatus(Backend.Preferred) - val status = getDnsStatusStringRes(state) - uiCtx { - b.endpointDesc.text = context.getString(status).replaceFirstChar(Char::titlecase) - } - } +private const val TAG = "DoTEndpointAdapter" + +@Composable +fun DoTEndpointRow(endpoint: DoTEndpoint, appConfig: AppConfig) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val explanation = + rememberDnsStatusExplanation( + key = endpoint.id, + isSelected = endpoint.isSelected, + smartDnsEnabled = appConfig.isSmartDnsEnabled(), + tag = TAG + ) + var infoDialog by remember(endpoint.id) { mutableStateOf(null) } + var deleteDialog by remember(endpoint.id) { mutableStateOf(null) } + + val name = + if (endpoint.isSecure) { + endpoint.name + } else { + context.getString( + R.string.ci_desc, + endpoint.name, + context.getString(R.string.lbl_insecure) + ) } - private fun showIcon(endpoint: DoTEndpoint) { + DnsEndpointRow( + title = name, + supporting = explanation.ifEmpty { null }, + selected = endpoint.isSelected, + action = if (endpoint.isDeletable()) DnsRowAction.Delete else DnsRowAction.Info, + selection = DnsRowSelection.Radio, + onActionClick = { if (endpoint.isDeletable()) { - b.endpointInfoImg.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_fab_uninstall) - ) + deleteDialog = + DnsDeleteDialogModel( + id = endpoint.id, + titleRes = R.string.doh_custom_url_remove_dialog_title, + messageRes = R.string.dot_custom_url_remove_dialog_message, + successRes = R.string.doh_custom_url_remove_success + ) } else { - b.endpointInfoImg.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_info) - ) + val description = + if (endpoint.desc.isNullOrEmpty()) { + endpoint.url + } else { + endpoint.url + "\n\n" + resolveDnsDescriptionText(context, endpoint.desc) + } + infoDialog = + DnsInfoDialogModel( + title = endpoint.name, + message = description, + copyValue = endpoint.url + ) } - } - - private fun updateConnection(endpoint: DoTEndpoint) { - Logger.d( - LOG_TAG_DNS, - "on dot change - ${endpoint.name}, ${endpoint.url}, ${endpoint.isSelected}" - ) - io { + }, + onSelectionChange = { + launchDnsEndpointSelectionUpdate(scope, context, TAG) { endpoint.isSelected = true appConfig.handleDoTChanges(endpoint) } } + ) - private fun deleteEndpoint(id: Int) { - io { - appConfig.deleteDoTEndpoint(id) - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.doh_custom_url_remove_success), - Toast.LENGTH_SHORT - ) + deleteDialog?.let { model -> + DnsDeleteDialog( + model = model, + onDismiss = { deleteDialog = null }, + onConfirm = { id -> + launchDnsEndpointDelete(scope, context, model.successRes) { + appConfig.deleteDoTEndpoint(id) } + deleteDialog = null } - } - - private fun showExplanationOnImageClick(endpoint: DoTEndpoint) { - if (endpoint.isDeletable()) showDeleteDialog(endpoint.id) - else showDoTMetadataDialog(endpoint.name, endpoint.url, endpoint.desc) - } - - private fun showDoTMetadataDialog(title: String, url: String, message: String?) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(title) - builder.setMessage(url + "\n\n" + getDnsDesc(message)) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { - dialogInterface, - _ -> - dialogInterface.dismiss() - } - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { - _: DialogInterface, - _: Int -> - clipboardCopy(context, url, context.getString(R.string.copy_clipboard_label)) - Utilities.showToastUiCentered( - context, - context.getString(R.string.info_dialog_url_copy_toast_msg), - Toast.LENGTH_SHORT - ) - } - builder.create().show() - } - - private fun getDnsDesc(message: String?): String { - if (message.isNullOrEmpty()) return "" - - return try { - if (message.contains("R.string.")) { - val m = message.substringAfter("R.string.") - val resId: Int = - context.resources.getIdentifier(m, "string", context.packageName) - context.getString(resId) - } else { - message - } - } catch (_: Exception) { - "" - } - } - - private fun showDeleteDialog(id: Int) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.dot_custom_url_remove_dialog_title) - builder.setMessage(R.string.dot_custom_url_remove_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - deleteEndpoint(id) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - // no-op - } - builder.create().show() - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } - - private fun ui(f: suspend () -> Unit): Job? { - return lifecycleOwner?.lifecycleScope?.launch { withContext(Dispatchers.Main) { f() } } - } + ) + } - private fun io(f: suspend () -> Unit) { - lifecycleOwner?.lifecycleScope?.launch { withContext(Dispatchers.IO) { f() } } - } + infoDialog?.let { model -> + DnsInfoDialog( + model = model, + onDismiss = { infoDialog = null } + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt index 1622404d8..d931b7429 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt @@ -16,252 +16,99 @@ limitations under the License. package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_DNS -import android.content.Context -import android.content.DialogInterface -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DoHEndpoint -import com.celzero.bravedns.databinding.ListItemEndpointBinding -import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.util.UIUtils.clipboardCopy -import com.celzero.bravedns.util.UIUtils.getDnsStatusStringRes -import com.celzero.bravedns.util.Utilities -import com.celzero.firestack.backend.Backend -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -class DohEndpointAdapter(private val context: Context, private val appConfig: AppConfig) : - PagingDataAdapter(DIFF_CALLBACK) { - - var lifecycleOwner: LifecycleOwner? = null - - companion object { - private const val ONE_SEC = 1000L - private const val TAG = "DohEndpointAdapter" - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: DoHEndpoint, - newConnection: DoHEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected == newConnection.isSelected) - } - - override fun areContentsTheSame( - oldConnection: DoHEndpoint, - newConnection: DoHEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected != newConnection.isSelected) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DoHEndpointViewHolder { - val itemBinding = - ListItemEndpointBinding.inflate(LayoutInflater.from(parent.context), parent, false) - lifecycleOwner = parent.findViewTreeLifecycleOwner() - return DoHEndpointViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: DoHEndpointViewHolder, position: Int) { - val doHEndpoint: DoHEndpoint = getItem(position) ?: return - holder.update(doHEndpoint) - } - - inner class DoHEndpointViewHolder(private val b: ListItemEndpointBinding) : - RecyclerView.ViewHolder(b.root) { - private var statusCheckJob: Job? = null - - fun update(endpoint: DoHEndpoint) { - displayDetails(endpoint) - setupClickListeners(endpoint) - } - - private fun setupClickListeners(endpoint: DoHEndpoint) { - b.root.setOnClickListener { updateConnection(endpoint) } - b.endpointInfoImg.setOnClickListener { showExplanationOnImageClick(endpoint) } - b.endpointCheck.setOnClickListener { updateConnection(endpoint) } - } - - private fun displayDetails(endpoint: DoHEndpoint) { - if (endpoint.isSecure) { - b.endpointName.text = endpoint.dohName - } else { - b.endpointName.text = - context.getString( - R.string.ci_desc, - endpoint.dohName, - context.getString(R.string.lbl_insecure) - ) - } - b.endpointCheck.isChecked = endpoint.isSelected - if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) { - keepSelectedStatusUpdated() - } else if (endpoint.isSelected) { - b.endpointDesc.text = context.getString(R.string.rt_filter_parent_selected) - } else { - b.endpointDesc.text = "" - } - - // Shows either the info/delete icon for the DoH entries. - showIcon(endpoint) - } - - private fun keepSelectedStatusUpdated() { - statusCheckJob = ui { - while (true) { - updateSelectedStatus() - delay(ONE_SEC) - } - } - } - - private fun updateSelectedStatus() { - // if the view is not active then cancel the job - if ( - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false || - bindingAdapterPosition == RecyclerView.NO_POSITION - ) { - statusCheckJob?.cancel() - return - } - - updateDnsStatus() - } - - private fun updateDnsStatus() { - io { - // always use the id as Dnsx.Preffered as it is the primary dns id for now - val state = VpnController.getDnsStatus(Backend.Preferred) - val status = getDnsStatusStringRes(state) - uiCtx { - b.endpointDesc.text = - context.getString(status).replaceFirstChar(Char::titlecase) - } - } - } - - private fun showIcon(endpoint: DoHEndpoint) { +private const val TAG = "DohEndpointAdapter" + +@Composable +fun DoHEndpointRow(endpoint: DoHEndpoint, appConfig: AppConfig) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val explanation = + rememberDnsStatusExplanation( + key = endpoint.id, + isSelected = endpoint.isSelected, + smartDnsEnabled = appConfig.isSmartDnsEnabled(), + tag = TAG + ) + var infoDialog by remember(endpoint.id) { mutableStateOf(null) } + var deleteDialog by remember(endpoint.id) { mutableStateOf(null) } + + val name = + if (endpoint.isSecure) { + endpoint.dohName + } else { + context.getString( + R.string.ci_desc, + endpoint.dohName, + context.getString(R.string.lbl_insecure) + ) + } + DnsEndpointRow( + title = name, + supporting = explanation.ifEmpty { null }, + selected = endpoint.isSelected, + action = if (endpoint.isDeletable()) DnsRowAction.Delete else DnsRowAction.Info, + selection = DnsRowSelection.Radio, + onActionClick = { if (endpoint.isDeletable()) { - b.endpointInfoImg.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_fab_uninstall) - ) + deleteDialog = + DnsDeleteDialogModel( + id = endpoint.id, + titleRes = R.string.doh_custom_url_remove_dialog_title, + messageRes = R.string.doh_custom_url_remove_dialog_message, + successRes = R.string.doh_custom_url_remove_success + ) } else { - b.endpointInfoImg.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_info) - ) + val description = + if (endpoint.dohExplanation.isNullOrEmpty()) { + endpoint.dohURL + } else { + endpoint.dohURL + "\n\n" + + resolveDnsDescriptionText(context, endpoint.dohExplanation) + } + infoDialog = + DnsInfoDialogModel( + title = endpoint.dohName, + message = description, + copyValue = endpoint.dohURL + ) } - } - - private fun updateConnection(endpoint: DoHEndpoint) { - Logger.d(LOG_TAG_DNS, "$TAG update doh; ${endpoint.dohName}, ${endpoint.dohURL}, ${endpoint.isSelected}") - io { + }, + onSelectionChange = { + launchDnsEndpointSelectionUpdate(scope, context, TAG) { endpoint.isSelected = true appConfig.handleDoHChanges(endpoint) } } + ) - private fun deleteEndpoint(id: Int) { - io { - Logger.i(LOG_TAG_DNS, "$TAG delete endpoint; $id") - appConfig.deleteDohEndpoint(id) - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.doh_custom_url_remove_success), - Toast.LENGTH_SHORT - ) - } - } - } - - private fun showExplanationOnImageClick(endpoint: DoHEndpoint) { - if (endpoint.isDeletable()) showDeleteDnsDialog(endpoint.id) - else showDohMetadataDialog(endpoint.dohName, endpoint.dohURL, endpoint.dohExplanation) - } - - private fun showDohMetadataDialog(title: String, url: String, message: String?) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(title) - builder.setMessage(url + "\n\n" + getDnsDesc(message)) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialogInterface, _ -> - dialogInterface.dismiss() - } - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { _: DialogInterface, _: Int -> - clipboardCopy(context, url, context.getString(R.string.copy_clipboard_label)) - Utilities.showToastUiCentered( - context, - context.getString(R.string.info_dialog_url_copy_toast_msg), - Toast.LENGTH_SHORT - ) - } - builder.create().show() - } - - private fun getDnsDesc(message: String?): String { - if (message.isNullOrEmpty()) return "" - - return try { - if (message.contains("R.string.")) { - val m = message.substringAfter("R.string.") - val resId: Int = - context.resources.getIdentifier(m, "string", context.packageName) - context.getString(resId) - } else { - message + deleteDialog?.let { model -> + DnsDeleteDialog( + model = model, + onDismiss = { deleteDialog = null }, + onConfirm = { id -> + launchDnsEndpointDelete(scope, context, model.successRes) { + appConfig.deleteDohEndpoint(id) } - } catch (_: Exception) { - "" - } - } - - private fun showDeleteDnsDialog(id: Int) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.doh_custom_url_remove_dialog_title) - builder.setMessage(R.string.doh_custom_url_remove_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - deleteEndpoint(id) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - // no-op + deleteDialog = null } - builder.create().show() - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } - - private fun ui(f: suspend () -> Unit): Job? { - return lifecycleOwner?.lifecycleScope?.launch { withContext(Dispatchers.Main) { f() } } - } + ) + } - private fun io(f: suspend () -> Unit) { - lifecycleOwner?.lifecycleScope?.launch { withContext(Dispatchers.IO) { f() } } - } + infoDialog?.let { model -> + DnsInfoDialog( + model = model, + onDismiss = { infoDialog = null } + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt index c76385dd0..5e0a44d77 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt @@ -15,163 +15,106 @@ */ package com.celzero.bravedns.adapter -import android.content.Context import android.content.Intent -import android.graphics.drawable.Drawable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConnection -import com.celzero.bravedns.databinding.ListItemStatisticsSummaryBinding import com.celzero.bravedns.service.FirewallManager -import com.celzero.bravedns.ui.activity.AppInfoActivity -import com.celzero.bravedns.ui.activity.DomainConnectionsActivity -import com.celzero.bravedns.ui.activity.NetworkLogsActivity +import com.celzero.bravedns.ui.HomeScreenActivity +import com.celzero.bravedns.ui.compose.statistics.StatisticsSummaryItem import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Utilities import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class DomainConnectionsAdapter(private val context: Context, private val type: DomainConnectionsActivity.InputType) : - PagingDataAdapter( - DIFF_CALLBACK - ) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: AppConnection, - newConnection: AppConnection - ): Boolean { - return (oldConnection == newConnection) - } - - override fun areContentsTheSame( - oldConnection: AppConnection, - newConnection: AppConnection - ): Boolean { - return (oldConnection == newConnection) - } - } +@Composable +fun ConnectionRow(dc: AppConnection) { + val context = LocalContext.current + val fallbackName = if (dc.appOrDnsName.isNullOrEmpty()) { + context.getString(R.string.network_log_app_name_unnamed, "(${dc.uid})") + } else { + dc.appOrDnsName } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): DomainConnectionsViewHolder { - val itemBinding = - ListItemStatisticsSummaryBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false + val totalUsageText = if (dc.downloadBytes != null && dc.uploadBytes != null) { + val download = + context.getString( + R.string.symbol_download, + Utilities.humanReadableByteCount(dc.downloadBytes, true) ) - return DomainConnectionsViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: DomainConnectionsViewHolder, position: Int) { - val appNetworkActivity = getItem(position) ?: return - holder.bind(appNetworkActivity) + val upload = + context.getString( + R.string.symbol_upload, + Utilities.humanReadableByteCount(dc.uploadBytes, true) + ) + context.getString(R.string.two_argument, upload, download) + } else { + null } - inner class DomainConnectionsViewHolder(private val b: ListItemStatisticsSummaryBinding) : - RecyclerView.ViewHolder(b.root) { + val scope = rememberCoroutineScope() + var title by remember(dc.uid, dc.appOrDnsName) { mutableStateOf(fallbackName.orEmpty()) } + var icon by remember(dc.uid) { mutableStateOf(Utilities.getDefaultIcon(context)) } + var isUnknown by remember(dc.uid) { mutableStateOf(true) } - fun bind(dc: AppConnection) { - io { + LaunchedEffect(dc.uid, dc.appOrDnsName) { + val resolved = + withContext(Dispatchers.IO) { val appInfo = FirewallManager.getAppInfoByUid(dc.uid) - uiCtx { - if (dc.appOrDnsName.isNullOrEmpty()) { - b.ssDataUsage.text = appInfo?.appName ?: context.getString( - R.string.network_log_app_name_unnamed, - "(${dc.uid})" - ) - } else { - b.ssDataUsage.text = dc.appOrDnsName - } - b.ssIcon.visibility = View.VISIBLE - b.ssFlag.visibility = View.GONE - loadAppIcon( - Utilities.getIcon( - context, - appInfo?.packageName.orEmpty(), - appInfo?.appName.orEmpty() - ) - ) - } - } - if (dc.downloadBytes == null || dc.uploadBytes == null) { - return - } - - val download = - context.getString( - R.string.symbol_download, - Utilities.humanReadableByteCount(dc.downloadBytes, true) - ) - val upload = - context.getString( - R.string.symbol_upload, - Utilities.humanReadableByteCount(dc.uploadBytes, true) - ) - val total = context.getString(R.string.two_argument, upload, download) - b.ssName.text = total - b.ssCount.text = dc.count.toString() - - b.ssProgress.visibility = View.GONE - - b.ssContainer.setOnClickListener { - io { - if (isUnknownApp(dc)) { - uiCtx { - val intent = Intent(context, NetworkLogsActivity::class.java) - intent.putExtra(Constants.VIEW_PAGER_SCREEN_TO_LOAD, NetworkLogsActivity.Tabs.NETWORK_LOGS.screen) - intent.putExtra(Constants.SEARCH_QUERY, dc.appOrDnsName) - context.startActivity(intent) - } - } else { - uiCtx { - val intent = Intent(context, AppInfoActivity::class.java) - intent.putExtra(AppInfoActivity.INTENT_UID, dc.uid) - context.startActivity(intent) - } - } + val displayName = if (dc.appOrDnsName.isNullOrEmpty()) { + appInfo?.appName ?: fallbackName.orEmpty() + } else { + dc.appOrDnsName } + val resolvedIcon = + Utilities.getIcon( + context, + appInfo?.packageName ?: "", + appInfo?.appName ?: "" + ) ?: Utilities.getDefaultIcon(context) + Triple(appInfo == null, displayName, resolvedIcon) } - } - - private suspend fun isUnknownApp(appConnection: AppConnection): Boolean { - val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid) - return appInfo == null - } + isUnknown = resolved.first + title = resolved.second + icon = resolved.third + } - private fun loadAppIcon(drawable: Drawable?) { - ui { - Glide.with(context) - .load(drawable) - .error(Utilities.getDefaultIcon(context)) - .into(b.ssIcon) + val onClick = { + scope.launch(Dispatchers.IO) { + if (isUnknown) { + // Navigate to network logs via HomeScreenActivity + val intent = Intent(context, HomeScreenActivity::class.java) + intent.putExtra(HomeScreenActivity.EXTRA_NAV_TARGET, HomeScreenActivity.NAV_TARGET_NETWORK_LOGS) + intent.putExtra(Constants.SEARCH_QUERY, dc.appOrDnsName) + withContext(Dispatchers.Main) { context.startActivity(intent) } + } else { + val intent = Intent(context, HomeScreenActivity::class.java) + intent.putExtra(HomeScreenActivity.EXTRA_NAV_TARGET, HomeScreenActivity.NAV_TARGET_APP_INFO) + intent.putExtra(HomeScreenActivity.EXTRA_APP_INFO_UID, dc.uid) + withContext(Dispatchers.Main) { context.startActivity(intent) } } } + Unit } - private fun io(f: suspend () -> Unit) { - (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } - } - - private fun ui(f: suspend () -> Unit) { - (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.Main) { f() } - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } + StatisticsSummaryItem( + title = title, + subtitle = totalUsageText, + countText = dc.count.toString(), + iconDrawable = icon, + flagText = null, + showProgress = false, + progress = 0f, + progressColor = Color.Transparent, + showIndicator = true, + onClick = onClick + ) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/EventsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/EventsAdapter.kt index 5ae92cf35..c556c82c5 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/EventsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/EventsAdapter.kt @@ -15,184 +15,398 @@ */ package com.celzero.bravedns.adapter -import android.animation.ObjectAnimator import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator import android.widget.Toast -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import com.celzero.bravedns.R import com.celzero.bravedns.database.Event +import com.celzero.bravedns.database.EventSource import com.celzero.bravedns.database.Severity -import com.celzero.bravedns.databinding.ItemEventBinding import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -class EventsAdapter(private val context: Context) : - PagingDataAdapter(EventDiffCallback()) { +fun copyEventToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Event Message", text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() +} - companion object { - private const val ANIMATION_DURATION = 300L - private const val ROTATION_EXPANDED = 180f - private const val ROTATION_COLLAPSED = 0f - } +@Composable +fun EventCard( + event: Event, + onCopy: (String) -> Unit, + query: String = "", + position: EventCardPosition = EventCardPosition.Single +) { + val hasDetails = !event.details.isNullOrBlank() + var expanded by remember(event.id) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.985f else 1f, + animationSpec = tween(durationMillis = 120), + label = "event_card_scale" + ) - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventViewHolder { - val binding = ItemEventBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return EventViewHolder(binding) + val accent = event.severity.toAccentColor() + val timestampText = remember(event.timestamp) { formatEventTimestamp(event.timestamp) } + val highlightColor = MaterialTheme.colorScheme.primary + val highlightedMessage = remember(event.message, query, highlightColor) { + buildHighlightedText(event.message, query, highlightColor) } - - override fun onBindViewHolder(holder: EventViewHolder, position: Int) { - val event = getItem(position) - if (event != null) { - holder.bind(event) - } + val highlightedDetails = remember(event.details, query, highlightColor) { + buildHighlightedText(event.details.orEmpty(), query, highlightColor) } - inner class EventViewHolder(private val binding: ItemEventBinding) : - RecyclerView.ViewHolder(binding.root) { + val shape = eventShapeFor(position) - private var isExpanded = false + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 0.dp, + modifier = Modifier + .fillMaxWidth() + .scale(scale) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = { + if (hasDetails) { + expanded = !expanded + } + }, + onLongClick = { onCopy(event.toClipboardText()) } + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + EventSeverityIcon( + severity = event.severity, + accentColor = accent, + modifier = Modifier.size(40.dp) + ) - fun bind(event: Event) { - // Set tag for scroll header - binding.root.tag = event.timestamp + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = highlightedMessage, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = timestampText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } - // Reset expansion state for recycled views - isExpanded = false - binding.detailsContainer.visibility = View.GONE - binding.expandIcon.rotation = ROTATION_COLLAPSED + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + CompactEventBadge( + text = event.severity.name, + containerColor = accent.copy(alpha = 0.14f), + contentColor = accent + ) + CompactEventBadge( + text = event.source.toDisplayLabel(), + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + CompactEventBadge( + text = event.eventType.name.toDisplayLabel(), + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + if (event.userAction) { + CompactEventBadge( + text = "User", + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f), + contentColor = MaterialTheme.colorScheme.primary + ) + } + } - // Set severity indicator color and icon - setSeverityIndicator(event.severity) + AnimatedVisibility(visible = expanded && hasDetails) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f), + modifier = Modifier.padding(top = 4.dp) + ) + Text( + text = highlightedDetails, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 8, + overflow = TextOverflow.Ellipsis + ) + } + } + } - // Set event type - binding.eventTypeChip.text = event.eventType.name.replace("_", " ") + IconButton( + onClick = { onCopy(event.toClipboardText()) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_copy), + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) + ) + } + } + } +} - // Set severity badge - binding.severityBadge.text = event.severity.name - setSeverityBadgeColor(event.severity) +enum class EventCardPosition { + First, + Middle, + Last, + Single +} - // Set timestamp - binding.timestampText.text = formatTimestamp(event.timestamp) +private fun eventShapeFor(position: EventCardPosition): RoundedCornerShape { + return when (position) { + EventCardPosition.First -> + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = 10.dp, + bottomEnd = 10.dp + ) + EventCardPosition.Middle -> RoundedCornerShape(10.dp) + EventCardPosition.Last -> + RoundedCornerShape( + topStart = 10.dp, + topEnd = 10.dp, + bottomStart = 18.dp, + bottomEnd = 18.dp + ) + EventCardPosition.Single -> RoundedCornerShape(18.dp) + } +} - // Set source - binding.sourceText.text = event.source.name +@Composable +private fun EventSeverityIcon( + severity: Severity, + accentColor: Color, + modifier: Modifier = Modifier +) { + val icon = when (severity) { + Severity.LOW -> Icons.Filled.CheckCircle + Severity.MEDIUM -> Icons.Filled.Info + Severity.HIGH -> Icons.Filled.Warning + Severity.CRITICAL -> Icons.Filled.Error + } - // Show user action indicator if applicable - binding.userActionIcon.visibility = if (event.userAction) View.VISIBLE else View.GONE + Surface( + shape = RoundedCornerShape(12.dp), + color = accentColor.copy(alpha = 0.12f), + modifier = modifier + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = severity.name, + tint = accentColor, + modifier = Modifier.size(22.dp) + ) + } + } +} - // Set message - binding.messageText.text = event.message +@Composable +private fun CompactEventBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = containerColor + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = contentColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + maxLines = 1 + ) + } +} - // Handle details - if (!event.details.isNullOrBlank()) { - binding.detailsText.text = event.details - // Make card clickable to expand - binding.root.setOnClickListener { - toggleExpansion() - } - } else { - binding.root.setOnClickListener(null) - binding.expandIcon.visibility = View.GONE - } +@Composable +private fun EventBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + shape = RoundedCornerShape(10.dp), + color = containerColor + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = contentColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + maxLines = 1 + ) + } +} - // Long press to copy message - binding.root.setOnLongClickListener { - copyToClipboard(event.message) - true - } +private fun Event.toClipboardText(): String { + return buildString { + append(message) + details?.takeIf { it.isNotBlank() }?.let { + append("\n\n") + append(it) } + } +} - private fun setSeverityIndicator(severity: Severity) { - val color = when (severity) { - Severity.LOW -> 0xFF4CAF50.toInt() // Green - Severity.MEDIUM -> 0xFFFFC107.toInt() // Amber/Yellow - Severity.HIGH -> 0xFFFF9800.toInt() // Orange - Severity.CRITICAL -> 0xFFF44336.toInt() // Red - } - binding.severityIndicator.setBackgroundColor(color) - - val iconRes = when (severity) { - Severity.LOW -> R.drawable.ic_tick_normal - Severity.MEDIUM -> R.drawable.ic_app_info_accent - Severity.HIGH -> R.drawable.ic_block_accent - Severity.CRITICAL -> R.drawable.ic_block - } - binding.severityIcon.setImageResource(iconRes) - binding.severityIcon.setColorFilter(color) - } +private fun EventSource.toDisplayLabel(): String { + return name.toDisplayLabel() +} - private fun setSeverityBadgeColor(severity: Severity) { - val color = when (severity) { - Severity.LOW -> 0xFF4CAF50.toInt() // Green - Severity.MEDIUM -> 0xFFFFC107.toInt() // Amber/Yellow - Severity.HIGH -> 0xFFFF9800.toInt() // Orange - Severity.CRITICAL -> 0xFFF44336.toInt() // Red - } - binding.severityBadge.setBackgroundColor(color) - } +private fun String.toDisplayLabel(): String { + return lowercase(Locale.getDefault()) + .replace('_', ' ') + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } +} - private fun formatTimestamp(timestamp: Long): String { - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - return sdf.format(Date(timestamp)) - } +private fun Severity.toAccentColor(): Color { + return when (this) { + Severity.LOW -> Color(0xFF2E7D32) + Severity.MEDIUM -> Color(0xFFAD7F00) + Severity.HIGH -> Color(0xFFB85C00) + Severity.CRITICAL -> Color(0xFFB3261E) + } +} - private fun toggleExpansion() { - isExpanded = !isExpanded +private fun formatEventTimestamp(timestamp: Long): String { + val formatter = SimpleDateFormat("dd MMM, HH:mm:ss", Locale.getDefault()) + return formatter.format(Date(timestamp)) +} - // Animate expand icon rotation - val rotation = if (isExpanded) ROTATION_EXPANDED else ROTATION_COLLAPSED - ObjectAnimator.ofFloat(binding.expandIcon, "rotation", rotation).apply { - duration = ANIMATION_DURATION - interpolator = AccelerateDecelerateInterpolator() - start() - } +private fun buildHighlightedText( + text: String, + query: String, + highlightColor: Color +): AnnotatedString { + if (text.isBlank() || query.isBlank()) return AnnotatedString(text) - // Animate details container visibility - if (isExpanded) { - binding.detailsContainer.visibility = View.VISIBLE - binding.detailsContainer.alpha = 0f - binding.detailsContainer.animate() - .alpha(1f) - .setDuration(ANIMATION_DURATION) - .setInterpolator(AccelerateDecelerateInterpolator()) - .start() - } else { - binding.detailsContainer.animate() - .alpha(0f) - .setDuration(ANIMATION_DURATION) - .setInterpolator(AccelerateDecelerateInterpolator()) - .withEndAction { - binding.detailsContainer.visibility = View.GONE - } - .start() - } - } + val ranges = mutableListOf>() + val tokens = query.split(Regex("\\s+")).map { it.trim() }.filter { it.isNotBlank() }.distinct() - private fun copyToClipboard(text: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Event Message", text) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + tokens.forEach { token -> + var startIndex = 0 + while (startIndex < text.length) { + val index = text.indexOf(token, startIndex = startIndex, ignoreCase = true) + if (index < 0) break + ranges += index to (index + token.length) + startIndex = index + token.length } } - class EventDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Event, newItem: Event): Boolean { - return oldItem.id == newItem.id + if (ranges.isEmpty()) return AnnotatedString(text) + + val merged = ranges + .sortedBy { it.first } + .fold(mutableListOf>()) { acc, range -> + if (acc.isEmpty()) { + acc += range + return@fold acc + } + val last = acc.last() + if (range.first <= last.second) { + acc[acc.lastIndex] = last.first to maxOf(last.second, range.second) + } else { + acc += range + } + acc } - override fun areContentsTheSame(oldItem: Event, newItem: Event): Boolean { - return oldItem == newItem + return buildAnnotatedString { + append(text) + merged.forEach { (start, end) -> + addStyle( + style = SpanStyle(color = highlightColor, fontWeight = FontWeight.Bold), + start = start, + end = end + ) } } } - diff --git a/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt index dbc9b5e6b..7e4785394 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt @@ -16,487 +16,886 @@ package com.celzero.bravedns.adapter import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Drawable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageView -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide +import android.util.LruCache +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MobileOff +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.rounded.WifiOff +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.database.AppInfo import com.celzero.bravedns.database.EventSource import com.celzero.bravedns.database.EventType import com.celzero.bravedns.database.Severity -import com.celzero.bravedns.databinding.ListItemFirewallAppBinding import com.celzero.bravedns.service.EventLogger import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.FirewallManager.updateFirewallStatus import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.service.ProxyManager.ID_NONE -import com.celzero.bravedns.ui.activity.AppInfoActivity -import com.celzero.bravedns.ui.activity.AppInfoActivity.Companion.INTENT_UID +import com.celzero.bravedns.ui.HomeScreenActivity +import com.celzero.bravedns.ui.compose.apps.DiagonalWipeIcon import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getIcon -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit - -class FirewallAppListAdapter( - private val context: Context, - private val lifecycleOwner: LifecycleOwner, - private val eventLogger: EventLogger -) : PagingDataAdapter(DIFF_CALLBACK), SectionedAdapter { - - private val packageManager: PackageManager = context.packageManager - // private val systemAppColor: Int by lazy { UIUtils.fetchColor(context, R.attr.textColorAccentBad) } - // private val userAppColor: Int by lazy { UIUtils.fetchColor(context, R.attr.primaryTextColor) } - - companion object { - private const val ALPHA_FULL = 1f - private const val ALPHA_DISABLED = 0.4f - - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - newConnection: AppInfo, - oldConnection: AppInfo - ): Boolean { - return oldConnection.uid == newConnection.uid && - oldConnection.packageName == newConnection.packageName - } +import androidx.compose.ui.res.stringResource - override fun areContentsTheSame( - oldConnection: AppInfo, - newConnection: AppInfo - ): Boolean { - return oldConnection == newConnection - } - } - } +enum class FirewallRowPosition { + First, + Middle, + Last, + Single +} - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppListViewHolder { - val itemBinding = - ListItemFirewallAppBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return AppListViewHolder(itemBinding) +@Composable +fun FirewallAppRow( + appInfo: AppInfo, + eventLogger: EventLogger, + searchQuery: String = "", + rowPosition: FirewallRowPosition = FirewallRowPosition.Single, + onAppClick: ((Int) -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var dialogState by remember(appInfo.uid) { mutableStateOf(null) } + val packageManager = context.packageManager + var appStatus by remember(appInfo.uid, appInfo.firewallStatus) { + mutableStateOf(FirewallManager.FirewallStatus.getStatus(appInfo.firewallStatus)) } - - override fun onBindViewHolder(holder: AppListViewHolder, position: Int) { - val appInfo: AppInfo = getItem(position) ?: return - holder.update(appInfo) + var connStatus by remember(appInfo.uid, appInfo.connectionStatus) { + mutableStateOf(FirewallManager.ConnectionStatus.getStatus(appInfo.connectionStatus)) + } + var appIcon by + remember(appInfo.packageName) { + mutableStateOf(FirewallAppIconCache.get(appInfo.packageName)) + } + var proxyEnabled by remember(appInfo.uid) { mutableStateOf(false) } + val isSelfApp = appInfo.packageName == context.packageName + val tombstoned = appInfo.tombstoneTs > 0 + val nameAlpha = if (appInfo.hasInternetPermission(packageManager)) 1f else 0.4f + + LaunchedEffect(appInfo.uid, appInfo.firewallStatus, appInfo.connectionStatus) { + appStatus = FirewallManager.FirewallStatus.getStatus(appInfo.firewallStatus) + connStatus = FirewallManager.ConnectionStatus.getStatus(appInfo.connectionStatus) } - inner class AppListViewHolder(private val b: ListItemFirewallAppBinding) : - RecyclerView.ViewHolder(b.root) { + LaunchedEffect(appInfo.uid, appInfo.packageName, appInfo.appName, appInfo.isProxyExcluded) { + if (appIcon == null) { + val icon = + withContext(Dispatchers.IO) { + getIcon(context, appInfo.packageName, appInfo.appName) + } + appIcon = icon + FirewallAppIconCache.put(appInfo.packageName, icon) + } + val proxyId = ProxyManager.getProxyIdForApp(appInfo.uid) + proxyEnabled = !appInfo.isProxyExcluded && proxyId.isNotEmpty() && proxyId != ID_NONE + } - fun update(appInfo: AppInfo) { - displayDetails(appInfo) - setupClickListeners(appInfo) + val hasDataUsage = appInfo.uploadBytes > 0L || appInfo.downloadBytes > 0L + val dataUsageText = if (hasDataUsage) buildDataUsageText(context, appInfo) else "" + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "rowScale" + ) + val highlightedAppName = + buildHighlightedText( + text = appInfo.appName, + query = searchQuery, + highlightColor = MaterialTheme.colorScheme.primary + ) + val statusText = + if (isSelfApp) { + "" + } else { + getFirewallText(context, appStatus, connStatus) } + val statusColor = getStatusColor(appStatus, connStatus) + val accentColor by animateColorAsState( + targetValue = statusColor, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "accentColor" + ) + val wifiIcon = wifiIconRes(appStatus, connStatus, isSelfApp) + val mobileIcon = mobileIconRes(appStatus, connStatus, isSelfApp) + val wifiBlocked = wifiIcon == R.drawable.ic_firewall_wifi_off + val mobileBlocked = mobileIcon == R.drawable.ic_firewall_data_off + val wifiTint = + if (wifiBlocked) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val mobileTint = + if (mobileBlocked) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val wifiCircleMotion = rememberBlockCircleMotion(blocked = wifiBlocked, labelPrefix = "wifi") + val mobileCircleMotion = rememberBlockCircleMotion(blocked = mobileBlocked, labelPrefix = "mobile") + + val shape = rowShapeFor(rowPosition) + + Surface( + modifier = Modifier + .fillMaxWidth() + .scale(scale) + .clip(shape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { + onAppClick?.invoke(appInfo.uid) ?: openAppDetailActivity(context, appInfo.uid) + } + ), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.spacingMd, + vertical = 9.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val iconPainter = + rememberDrawablePainter(appIcon) + ?: rememberDrawablePainter(Utilities.getDefaultIcon(context)) + iconPainter?.let { painter -> + Image( + painter = painter, + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + ) + } - private fun displayDetails(appInfo: AppInfo) { - io { - val appStatus = FirewallManager.appStatus(appInfo.uid) - val connStatus = FirewallManager.connectionStatus(appInfo.uid) - uiCtx { - b.firewallAppLabelTv.text = appInfo.appName - // setting the appname with different color for system and user apps - // causes conflict with the firewall status like blocked and isolated - // so removing the color change for now - /* if (appInfo.isSystemApp) { - b.firewallAppLabelTv.setTextColor(systemAppColor) - } else { - b.firewallAppLabelTv.setTextColor(userAppColor) - } */ - b.firewallAppToggleOther.text = getFirewallText(appStatus, connStatus) - displayIcon( - getIcon(context, appInfo.packageName, appInfo.appName), b.firewallAppIconIv) - // set the alpha based on internet permission - if (appInfo.hasInternetPermission(packageManager)) { - b.firewallAppLabelTv.alpha = ALPHA_FULL - b.firewallAppIconIv.alpha = ALPHA_FULL - } else { - b.firewallAppLabelTv.alpha = ALPHA_DISABLED - b.firewallAppIconIv.alpha = ALPHA_DISABLED + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = highlightedAppName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = nameAlpha), + textDecoration = if (tombstoned) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (statusText.isNotBlank()) { + Surface( + shape = RoundedCornerShape(Dimensions.buttonCornerRadius), + color = accentColor.copy(alpha = 0.12f) + ) { + Text( + text = statusText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = accentColor, + modifier = Modifier.padding( + horizontal = 6.dp, + vertical = 2.dp + ) + ) + } } - b.firewallAppToggleWifi.visibility = View.VISIBLE - b.firewallAppToggleMobileData.visibility = View.VISIBLE - // strike through the app name if the app is tombstoned - if (appInfo.tombstoneTs > 0) { - b.firewallAppLabelTv.paint.isStrikeThruText = true - b.firewallAppLabelTv.alpha = ALPHA_DISABLED - b.firewallAppIconIv.alpha = ALPHA_DISABLED - } else { - b.firewallAppLabelTv.paint.isStrikeThruText = false + } + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + if (proxyEnabled) { + Surface( + shape = RoundedCornerShape(Dimensions.buttonCornerRadius), + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.75f) + ) { + Text( + text = "Proxy", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding( + horizontal = 6.dp, + vertical = 1.dp + ) + ) + } } - displayConnectionStatus(appStatus, connStatus) - displayDataUsage(appInfo) - maybeDisplayProxyStatus(appInfo) } - } - } - - private fun displayDataUsage(appInfo: AppInfo) { - val u = Utilities.humanReadableByteCount(appInfo.uploadBytes, true) - val uploadBytes = context.getString(R.string.symbol_upload, u) - val d = Utilities.humanReadableByteCount(appInfo.downloadBytes, true) - val downloadBytes = context.getString(R.string.symbol_download, d) - b.firewallAppDataUsage.text = - context.getString(R.string.two_argument, uploadBytes, downloadBytes) - } - - private fun maybeDisplayProxyStatus(appInfo: AppInfo) { - if (appInfo.isProxyExcluded) { - return + if (hasDataUsage) { + Text( + text = dataUsageText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = Dimensions.Opacity.MEDIUM + ) + ) + } } - // show key icon in drawable right of b.firewallAppDataUsage - val proxy = ProxyManager.getProxyIdForApp(appInfo.uid) - if (proxy.isEmpty() || (proxy.size == 1 && proxy[0] == ID_NONE)) { - return + if (wifiIcon != null) { + Box( + modifier = Modifier.size(34.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + alpha = wifiCircleMotion.alpha + scaleX = wifiCircleMotion.scale + scaleY = wifiCircleMotion.scale + } + .clip(CircleShape) + .background(MaterialTheme.colorScheme.errorContainer) + ) + IconButton( + onClick = { + handleWifiToggle( + scope = scope, + eventLogger = eventLogger, + appInfo = appInfo, + onShowDialog = { packageList -> + dialogState = + FirewallAppDialogState( + packageList, + appInfo, + isWifi = true + ) + }, + onStatusUpdated = { newAppStatus, newConnStatus -> + appStatus = newAppStatus + connStatus = newConnStatus + } + ) + }, + modifier = Modifier.fillMaxSize().clip(CircleShape) + ) { + DiagonalWipeIcon( + blocked = wifiBlocked, + allowedIcon = Icons.Rounded.Wifi, + blockedIcon = Icons.Rounded.WifiOff, + allowedTint = MaterialTheme.colorScheme.onSurfaceVariant, + blockedTint = wifiTint, + contentDescription = stringResource(R.string.firewall_rule_block_unmetered), + modifier = Modifier.size(16.dp) + ) + } + } } - b.firewallAppLabelTv.append(context.getString(R.string.symbol_key)) - } - private fun getFirewallText( - aStat: FirewallManager.FirewallStatus, - cStat: FirewallManager.ConnectionStatus - ): String { - return when (aStat) { - FirewallManager.FirewallStatus.NONE -> - when (cStat) { - FirewallManager.ConnectionStatus.ALLOW -> - context.getString(R.string.firewall_status_allow) - FirewallManager.ConnectionStatus.METERED -> - context.getString(R.string.firewall_status_block_metered) - FirewallManager.ConnectionStatus.UNMETERED -> - context.getString(R.string.firewall_status_block_unmetered) - FirewallManager.ConnectionStatus.BOTH -> - context.getString(R.string.firewall_status_blocked) + if (mobileIcon != null) { + Box( + modifier = Modifier.size(34.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + alpha = mobileCircleMotion.alpha + scaleX = mobileCircleMotion.scale + scaleY = mobileCircleMotion.scale + } + .clip(CircleShape) + .background(MaterialTheme.colorScheme.errorContainer) + ) + IconButton( + onClick = { + handleMobileToggle( + scope = scope, + eventLogger = eventLogger, + appInfo = appInfo, + onShowDialog = { packageList -> + dialogState = + FirewallAppDialogState( + packageList, + appInfo, + isWifi = false + ) + }, + onStatusUpdated = { newAppStatus, newConnStatus -> + appStatus = newAppStatus + connStatus = newConnStatus + } + ) + }, + modifier = Modifier.fillMaxSize().clip(CircleShape) + ) { + DiagonalWipeIcon( + blocked = mobileBlocked, + allowedIcon = Icons.Rounded.PhoneAndroid, + blockedIcon = Icons.Rounded.MobileOff, + allowedTint = MaterialTheme.colorScheme.onSurfaceVariant, + blockedTint = mobileTint, + contentDescription = stringResource(R.string.lbl_mobile_data), + modifier = Modifier.size(16.dp) + ) } - FirewallManager.FirewallStatus.EXCLUDE -> - context.getString(R.string.firewall_status_excluded) - FirewallManager.FirewallStatus.ISOLATE -> - context.getString(R.string.firewall_status_isolate) - FirewallManager.FirewallStatus.BYPASS_UNIVERSAL -> - context.getString(R.string.firewall_status_whitelisted) - FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> - context.getString(R.string.firewall_status_bypass_dns_firewall) - FirewallManager.FirewallStatus.UNTRACKED -> - context.getString(R.string.firewall_status_unknown) + } } } + } - private fun displayConnectionStatus( - firewallStatus: FirewallManager.FirewallStatus, - connStatus: FirewallManager.ConnectionStatus - ) { - when (firewallStatus) { - FirewallManager.FirewallStatus.NONE -> { - when (connStatus) { - FirewallManager.ConnectionStatus.ALLOW -> { - showWifiEnabled() - showMobileDataEnabled() - } - FirewallManager.ConnectionStatus.UNMETERED -> { - showWifiDisabled() - showMobileDataEnabled() - } - FirewallManager.ConnectionStatus.METERED -> { - showWifiEnabled() - showMobileDataDisabled() - } - FirewallManager.ConnectionStatus.BOTH -> { - showWifiDisabled() - showMobileDataDisabled() - } + dialogState?.let { state -> + val count = state.packageList.count() + RethinkConfirmDialog( + onDismissRequest = { dialogState = null }, + title = + stringResource( + R.string.ctbs_block_other_apps, + state.appInfo.appName, + count.toString() + ), + text = { + Column(verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs)) { + state.packageList.forEach { name -> + Text(text = name, style = MaterialTheme.typography.bodyMedium) } } - FirewallManager.FirewallStatus.EXCLUDE -> { - showMobileDataUnused() - showWifiUnused() - } - FirewallManager.FirewallStatus.BYPASS_UNIVERSAL -> { - showMobileDataUnused() - showWifiUnused() - } - FirewallManager.FirewallStatus.ISOLATE -> { - showMobileDataUnused() - showWifiUnused() - } - FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> { - showMobileDataUnused() - showWifiUnused() - } - else -> { - showWifiEnabled() - showMobileDataEnabled() + }, + confirmText = stringResource(R.string.lbl_proceed), + dismissText = stringResource(R.string.ctbs_dialog_negative_btn), + onConfirm = { + scope.launch(Dispatchers.IO) { + val updatedConnStatus = + if (state.isWifi) { + toggleWifi(eventLogger, state.appInfo) + } else { + toggleMobileData(eventLogger, state.appInfo) + } + withContext(Dispatchers.Main) { + appStatus = FirewallManager.FirewallStatus.NONE + connStatus = updatedConnStatus + } } + dialogState = null + }, + onDismiss = { dialogState = null } + ) + } +} + +@Composable +private fun getStatusColor( + appStatus: FirewallManager.FirewallStatus, + connStatus: FirewallManager.ConnectionStatus +): Color { + return when (appStatus) { + FirewallManager.FirewallStatus.NONE -> + when (connStatus) { + FirewallManager.ConnectionStatus.ALLOW -> MaterialTheme.colorScheme.primary + FirewallManager.ConnectionStatus.METERED -> MaterialTheme.colorScheme.error + FirewallManager.ConnectionStatus.UNMETERED -> MaterialTheme.colorScheme.error + FirewallManager.ConnectionStatus.BOTH -> MaterialTheme.colorScheme.error } - } - private fun showMobileDataDisabled() { - b.firewallAppToggleMobileData.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_off)) - } + FirewallManager.FirewallStatus.EXCLUDE -> MaterialTheme.colorScheme.tertiary + FirewallManager.FirewallStatus.ISOLATE -> MaterialTheme.colorScheme.error + FirewallManager.FirewallStatus.BYPASS_UNIVERSAL -> MaterialTheme.colorScheme.tertiary + FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> MaterialTheme.colorScheme.tertiary + FirewallManager.FirewallStatus.UNTRACKED -> MaterialTheme.colorScheme.onSurfaceVariant + } +} - private fun showMobileDataEnabled() { - b.firewallAppToggleMobileData.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_on)) - } +private data class FirewallAppDialogState( + val packageList: List, + val appInfo: AppInfo, + val isWifi: Boolean +) + +private data class BlockCircleMotion( + val alpha: Float, + val scale: Float +) + +@Composable +private fun rememberBlockCircleMotion(blocked: Boolean, labelPrefix: String): BlockCircleMotion { + val transition = updateTransition(targetState = blocked, label = "${labelPrefix}BlockCircle") + + val alpha by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + tween(durationMillis = 240, easing = FastOutSlowInEasing) + } else { + tween(durationMillis = 170, easing = FastOutLinearInEasing) + } + }, + label = "${labelPrefix}BlockCircleAlpha" + ) { isBlocked -> + if (isBlocked) 0.44f else 0f + } - private fun showWifiDisabled() { - b.firewallAppToggleWifi.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_off)) - } + val scale by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + spring( + dampingRatio = 0.80f, + stiffness = 620f + ) + } else { + tween(durationMillis = 190, easing = FastOutLinearInEasing) + } + }, + label = "${labelPrefix}BlockCircleScale" + ) { isBlocked -> + if (isBlocked) 1f else 0.72f + } - private fun showWifiEnabled() { - b.firewallAppToggleWifi.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_on)) - } + return BlockCircleMotion(alpha = alpha, scale = scale) +} - private fun showMobileDataUnused() { - b.firewallAppToggleMobileData.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_on_grey)) - } +private fun buildDataUsageText(context: Context, appInfo: AppInfo): String { + val u = Utilities.humanReadableByteCount(appInfo.uploadBytes, true) + val uploadBytes = context.getString(R.string.symbol_upload, u) + val d = Utilities.humanReadableByteCount(appInfo.downloadBytes, true) + val downloadBytes = context.getString(R.string.symbol_download, d) + return context.getString(R.string.two_argument, uploadBytes, downloadBytes) +} - private fun showWifiUnused() { - b.firewallAppToggleWifi.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_on_grey)) - } +private fun getFirewallText( + context: Context, + aStat: FirewallManager.FirewallStatus, + cStat: FirewallManager.ConnectionStatus +): String { + return when (aStat) { + FirewallManager.FirewallStatus.NONE -> + when (cStat) { + FirewallManager.ConnectionStatus.ALLOW -> "" + FirewallManager.ConnectionStatus.METERED -> + context.getString(R.string.lbl_blocked) + + FirewallManager.ConnectionStatus.UNMETERED -> + context.getString(R.string.lbl_blocked) + + FirewallManager.ConnectionStatus.BOTH -> + context.getString(R.string.lbl_blocked) + } + + FirewallManager.FirewallStatus.EXCLUDE -> + context.getString(R.string.fapps_firewall_filter_excluded) + + FirewallManager.FirewallStatus.ISOLATE -> + context.getString(R.string.fapps_firewall_filter_isolate) + + FirewallManager.FirewallStatus.BYPASS_UNIVERSAL -> + context.getString(R.string.fapps_firewall_filter_bypass_universal) + + FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> + context.getString(R.string.fapps_firewall_filter_bypass_universal) + + FirewallManager.FirewallStatus.UNTRACKED -> + context.getString(R.string.network_log_app_name_unknown) + } +} - private fun displayIcon(drawable: Drawable?, mIconImageView: ImageView) { - ui { - Glide.with(context) - .load(drawable) - .error(Utilities.getDefaultIcon(context)) - .into(mIconImageView) +private fun wifiIconRes( + firewallStatus: FirewallManager.FirewallStatus, + connStatus: FirewallManager.ConnectionStatus, + isSelfApp: Boolean +): Int? { + if (isSelfApp) return null + return when (firewallStatus) { + FirewallManager.FirewallStatus.NONE -> + when (connStatus) { + FirewallManager.ConnectionStatus.ALLOW -> R.drawable.ic_firewall_wifi_on + FirewallManager.ConnectionStatus.UNMETERED -> R.drawable.ic_firewall_wifi_off + FirewallManager.ConnectionStatus.METERED -> R.drawable.ic_firewall_wifi_on + FirewallManager.ConnectionStatus.BOTH -> R.drawable.ic_firewall_wifi_off } - } - private fun setupClickListeners(appInfo: AppInfo) { + FirewallManager.FirewallStatus.EXCLUDE, + FirewallManager.FirewallStatus.BYPASS_UNIVERSAL, + FirewallManager.FirewallStatus.ISOLATE, + FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> + R.drawable.ic_firewall_wifi_on_grey + + else -> R.drawable.ic_firewall_wifi_on + } +} - b.firewallAppTextLl.setOnClickListener { - enableAfterDelay(TimeUnit.SECONDS.toMillis(1L), b.firewallAppTextLl) - openAppDetailActivity(appInfo.uid) +private fun mobileIconRes( + firewallStatus: FirewallManager.FirewallStatus, + connStatus: FirewallManager.ConnectionStatus, + isSelfApp: Boolean +): Int? { + if (isSelfApp) return null + return when (firewallStatus) { + FirewallManager.FirewallStatus.NONE -> + when (connStatus) { + FirewallManager.ConnectionStatus.ALLOW -> R.drawable.ic_firewall_data_on + FirewallManager.ConnectionStatus.UNMETERED -> R.drawable.ic_firewall_data_on + FirewallManager.ConnectionStatus.METERED -> R.drawable.ic_firewall_data_off + FirewallManager.ConnectionStatus.BOTH -> R.drawable.ic_firewall_data_off } - b.firewallAppIconIv.setOnClickListener { - enableAfterDelay(TimeUnit.SECONDS.toMillis(1L), b.firewallAppIconIv) - openAppDetailActivity(appInfo.uid) + FirewallManager.FirewallStatus.EXCLUDE, + FirewallManager.FirewallStatus.BYPASS_UNIVERSAL, + FirewallManager.FirewallStatus.ISOLATE, + FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> + R.drawable.ic_firewall_data_on_grey + + else -> R.drawable.ic_firewall_data_on + } +} + +private fun handleWifiToggle( + scope: CoroutineScope, + eventLogger: EventLogger, + appInfo: AppInfo, + onShowDialog: (List) -> Unit, + onStatusUpdated: (FirewallManager.FirewallStatus, FirewallManager.ConnectionStatus) -> Unit +) { + scope.launch(Dispatchers.IO) { + val appNames = FirewallManager.getAppNamesByUid(appInfo.uid) + if (appNames.count() > 1) { + withContext(Dispatchers.Main) { + onShowDialog(appNames) } + return@launch + } + val updatedConnStatus = toggleWifi(eventLogger, appInfo) + withContext(Dispatchers.Main) { + onStatusUpdated(FirewallManager.FirewallStatus.NONE, updatedConnStatus) + } + } +} - b.firewallAppDetailsLl.setOnClickListener { - enableAfterDelay(TimeUnit.SECONDS.toMillis(1L), b.firewallAppIconIv) - openAppDetailActivity(appInfo.uid) +private fun handleMobileToggle( + scope: CoroutineScope, + eventLogger: EventLogger, + appInfo: AppInfo, + onShowDialog: (List) -> Unit, + onStatusUpdated: (FirewallManager.FirewallStatus, FirewallManager.ConnectionStatus) -> Unit +) { + scope.launch(Dispatchers.IO) { + val appNames = FirewallManager.getAppNamesByUid(appInfo.uid) + if (appNames.count() > 1) { + withContext(Dispatchers.Main) { + onShowDialog(appNames) } + return@launch + } + val updatedConnStatus = toggleMobileData(eventLogger, appInfo) + withContext(Dispatchers.Main) { + onStatusUpdated(FirewallManager.FirewallStatus.NONE, updatedConnStatus) + } + } +} - b.firewallAppToggleWifi.setOnClickListener { - enableAfterDelay(TimeUnit.SECONDS.toMillis(1L), b.firewallAppToggleWifi) - io { - val appNames = FirewallManager.getAppNamesByUid(appInfo.uid) - val connStatus = FirewallManager.connectionStatus(appInfo.uid) - uiCtx { - if (appNames.count() > 1) { - showDialog(appNames, appInfo, isWifi = true, connStatus) - return@uiCtx - } - ioCtx { toggleWifi(appInfo, connStatus) } - } - } +private suspend fun toggleMobileData( + eventLogger: EventLogger, + appInfo: AppInfo +): FirewallManager.ConnectionStatus { + return FirewallToggleLock.withLock { + val connStatus = FirewallManager.connectionStatus(appInfo.uid) + val updatedConnStatus = nextConnStatusForMobileToggle(connStatus) + when (connStatus) { + FirewallManager.ConnectionStatus.METERED -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.ALLOW + ) } - b.firewallAppToggleMobileData.setOnClickListener { - enableAfterDelay(TimeUnit.SECONDS.toMillis(1L), b.firewallAppToggleMobileData) - io { - val appNames = FirewallManager.getAppNamesByUid(appInfo.uid) - val connStatus = FirewallManager.connectionStatus(appInfo.uid) - uiCtx { - if (appNames.count() > 1) { - showDialog(appNames, appInfo, isWifi = false, connStatus) - return@uiCtx - } - ioCtx { toggleMobileData(appInfo, connStatus) } - } - } + FirewallManager.ConnectionStatus.UNMETERED -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.BOTH + ) } - } - private suspend fun toggleMobileData( - appInfo: AppInfo, - connStatus: FirewallManager.ConnectionStatus - ) { - if (appInfo.packageName == context.packageName) { - return + FirewallManager.ConnectionStatus.BOTH -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.UNMETERED + ) } - when (connStatus) { - FirewallManager.ConnectionStatus.METERED -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW) - } - FirewallManager.ConnectionStatus.UNMETERED -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.BOTH) - } - FirewallManager.ConnectionStatus.BOTH -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.UNMETERED) - } - FirewallManager.ConnectionStatus.ALLOW -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.METERED) - } + + FirewallManager.ConnectionStatus.ALLOW -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.METERED + ) } - logEvent("UID: ${appInfo.uid}, App: ${appInfo.appName}, New FW status: ${FirewallManager.connectionStatus(appInfo.uid)}") } + logEvent( + eventLogger, + "UID: ${appInfo.uid}, App: ${appInfo.appName}, New FW status: ${ + FirewallManager.connectionStatus( + appInfo.uid + ) + }" + ) + updatedConnStatus + } +} - private suspend fun toggleWifi( - appInfo: AppInfo, - connStatus: FirewallManager.ConnectionStatus - ) { - when (connStatus) { - FirewallManager.ConnectionStatus.METERED -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.BOTH) - } - FirewallManager.ConnectionStatus.UNMETERED -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW) - } - FirewallManager.ConnectionStatus.BOTH -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.METERED) - } - FirewallManager.ConnectionStatus.ALLOW -> { - updateFirewallStatus( - appInfo.uid, - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.UNMETERED) - } +private suspend fun toggleWifi( + eventLogger: EventLogger, + appInfo: AppInfo +): FirewallManager.ConnectionStatus { + return FirewallToggleLock.withLock { + val connStatus = FirewallManager.connectionStatus(appInfo.uid) + val updatedConnStatus = nextConnStatusForWifiToggle(connStatus) + when (connStatus) { + FirewallManager.ConnectionStatus.METERED -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.BOTH + ) + } + + FirewallManager.ConnectionStatus.UNMETERED -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.ALLOW + ) + } + + FirewallManager.ConnectionStatus.BOTH -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.METERED + ) } - logEvent("UID: ${appInfo.uid}, App: ${appInfo.appName}, New FW status: ${FirewallManager.connectionStatus(appInfo.uid)}") - } - private fun openAppDetailActivity(uid: Int) { - val intent = Intent(context, AppInfoActivity::class.java) - intent.putExtra(INTENT_UID, uid) - context.startActivity(intent) + FirewallManager.ConnectionStatus.ALLOW -> { + updateFirewallStatus( + appInfo.uid, + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.UNMETERED + ) + } } + logEvent( + eventLogger, + "UID: ${appInfo.uid}, App: ${appInfo.appName}, New FW status: ${ + FirewallManager.connectionStatus( + appInfo.uid + ) + }" + ) + updatedConnStatus + } +} - private fun showDialog( - packageList: List, - appInfo: AppInfo, - isWifi: Boolean, - connStatus: FirewallManager.ConnectionStatus - ) { +private fun nextConnStatusForMobileToggle( + connStatus: FirewallManager.ConnectionStatus +): FirewallManager.ConnectionStatus { + return when (connStatus) { + FirewallManager.ConnectionStatus.METERED -> FirewallManager.ConnectionStatus.ALLOW + FirewallManager.ConnectionStatus.UNMETERED -> FirewallManager.ConnectionStatus.BOTH + FirewallManager.ConnectionStatus.BOTH -> FirewallManager.ConnectionStatus.UNMETERED + FirewallManager.ConnectionStatus.ALLOW -> FirewallManager.ConnectionStatus.METERED + } +} - val builderSingle = MaterialAlertDialogBuilder(context) - - builderSingle.setIcon(R.drawable.ic_firewall_block_grey) - val count = packageList.count() - builderSingle.setTitle( - context.getString( - R.string.ctbs_block_other_apps, appInfo.appName, count.toString())) - - val arrayAdapter = - ArrayAdapter(context, android.R.layout.simple_list_item_activated_1) - arrayAdapter.addAll(packageList) - builderSingle.setCancelable(false) - - builderSingle.setItems(packageList.toTypedArray(), null) - - builderSingle - .setPositiveButton(context.getString(R.string.lbl_proceed)) { - _: DialogInterface, - _: Int -> - io { - if (isWifi) { - toggleWifi(appInfo, connStatus) - return@io - } +private fun nextConnStatusForWifiToggle( + connStatus: FirewallManager.ConnectionStatus +): FirewallManager.ConnectionStatus { + return when (connStatus) { + FirewallManager.ConnectionStatus.METERED -> FirewallManager.ConnectionStatus.BOTH + FirewallManager.ConnectionStatus.UNMETERED -> FirewallManager.ConnectionStatus.ALLOW + FirewallManager.ConnectionStatus.BOTH -> FirewallManager.ConnectionStatus.METERED + FirewallManager.ConnectionStatus.ALLOW -> FirewallManager.ConnectionStatus.UNMETERED + } +} - toggleMobileData(appInfo, connStatus) - } - } - .setNeutralButton(context.getString(R.string.ctbs_dialog_negative_btn)) { - _: DialogInterface, - _: Int -> - } +private fun openAppDetailActivity(context: Context, uid: Int) { + val intent = Intent(context, HomeScreenActivity::class.java) + intent.putExtra(HomeScreenActivity.EXTRA_NAV_TARGET, HomeScreenActivity.NAV_TARGET_APP_INFO) + intent.putExtra(HomeScreenActivity.EXTRA_APP_INFO_UID, uid) + context.startActivity(intent) +} - val alertDialog: AlertDialog = builderSingle.create() - alertDialog.listView.setOnItemClickListener { _, _, _, _ -> } - alertDialog.show() +private fun logEvent(eventLogger: EventLogger, details: String) { + eventLogger.log( + EventType.FW_RULE_MODIFIED, + Severity.LOW, + "App list, rule change", + EventSource.UI, + false, + details + ) +} + +private fun buildHighlightedText( + text: String, + query: String, + highlightColor: Color +): AnnotatedString { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank() || text.isBlank()) return AnnotatedString(text) + + val terms = + normalizedQuery + .split(Regex("\\s+")) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + if (terms.isEmpty()) return AnnotatedString(text) + + val normalizedText = text.lowercase() + val ranges = mutableListOf() + + terms.forEach { term -> + val normalizedTerm = term.lowercase() + var from = 0 + while (from < normalizedText.length) { + val start = normalizedText.indexOf(normalizedTerm, from) + if (start == -1) break + ranges.add(start until (start + normalizedTerm.length)) + from = start + normalizedTerm.length } } - private fun enableAfterDelay(delay: Long, vararg views: View) { - for (v in views) v.isEnabled = false + if (ranges.isEmpty()) return AnnotatedString(text) - Utilities.delay(delay, lifecycleOwner.lifecycleScope) { - for (v in views) v.isEnabled = true + val mergedRanges = ranges.sortedBy { it.first }.fold(mutableListOf()) { acc, range -> + val last = acc.lastOrNull() + if (last == null || range.first > last.last + 1) { + acc.add(range) + } else { + acc[acc.lastIndex] = last.first..maxOf(last.last, range.last) } + acc } - private fun logEvent(details: String) { - eventLogger.log(EventType.FW_RULE_MODIFIED, Severity.LOW, "App list, rule change", EventSource.UI, false, details) + return buildAnnotatedString { + append(text) + mergedRanges.forEach { range -> + addStyle( + style = SpanStyle(color = highlightColor), + start = range.first, + end = range.last + 1 + ) + } } +} - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } +private fun rowShapeFor(position: FirewallRowPosition): RoundedCornerShape { + return when (position) { + FirewallRowPosition.First -> + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = 10.dp, + bottomEnd = 10.dp + ) + + FirewallRowPosition.Middle -> RoundedCornerShape(10.dp) + FirewallRowPosition.Last -> + RoundedCornerShape( + topStart = 10.dp, + topEnd = 10.dp, + bottomStart = 18.dp, + bottomEnd = 18.dp + ) + + FirewallRowPosition.Single -> RoundedCornerShape(18.dp) } +} - private fun ui(f: suspend () -> Unit) { - lifecycleOwner.lifecycleScope.launch { withContext(Dispatchers.Main) { f() } } - } +private object FirewallAppIconCache { + private const val CACHE_SIZE = 256 + private val cache = LruCache(CACHE_SIZE) - private fun io(f: suspend () -> Unit) { - lifecycleOwner.lifecycleScope.launch { withContext(Dispatchers.IO) { f() } } - } + fun get(packageName: String): Drawable? = cache.get(packageName) - private suspend fun ioCtx(f: suspend () -> Unit) { - withContext(Dispatchers.IO) { f() } + fun put(packageName: String, icon: Drawable?) { + if (packageName.isBlank() || icon == null) return + cache.put(packageName, icon) } +} - override fun getSectionName(position: Int): String { - // Check if position is valid to prevent IndexOutOfBoundsException - if (position !in 0.. withLock(block: suspend () -> T): T { + return mutex.withLock { block() } } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt index 4818c3782..37601f674 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt @@ -15,190 +15,28 @@ */ package com.celzero.bravedns.adapter -import android.content.Context -import android.content.res.ColorStateList -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.celzero.bravedns.R +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.database.RethinkLocalFileTag -import com.celzero.bravedns.databinding.ListItemRethinkBlocklistAdvBinding -import com.celzero.bravedns.service.RethinkBlocklistManager -import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment -import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.UIUtils.openUrl -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -class LocalAdvancedViewAdapter(val context: Context) : - PagingDataAdapter( - DIFF_CALLBACK - ) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: RethinkLocalFileTag, - newConnection: RethinkLocalFileTag - ): Boolean { - return oldConnection == newConnection - } - - override fun areContentsTheSame( - oldConnection: RethinkLocalFileTag, - newConnection: RethinkLocalFileTag - ): Boolean { - return oldConnection == newConnection - } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): RethinkLocalFileTagViewHolder { - val itemBinding = - ListItemRethinkBlocklistAdvBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return RethinkLocalFileTagViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: RethinkLocalFileTagViewHolder, position: Int) { - val filetag: RethinkLocalFileTag = getItem(position) ?: return - - holder.update(filetag, position) - } - - inner class RethinkLocalFileTagViewHolder(private val b: ListItemRethinkBlocklistAdvBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(filetag: RethinkLocalFileTag, position: Int) { - b.root.tag = getGroupName(filetag.group) - displayHeaderIfNeeded(filetag, position) - displayMetaData(filetag) - - b.crpCheckBox.setOnClickListener { toggleCheckbox(b.crpCheckBox.isChecked, filetag) } - - b.crpCard.setOnClickListener { toggleCheckbox(!b.crpCheckBox.isChecked, filetag) } - - b.crpDescEntriesTv.setOnClickListener { openUrl(context, filetag.url[0]) } - } - - private fun displayMetaData(filetag: RethinkLocalFileTag) { - b.crpLabelTv.text = filetag.vname - if (filetag.subg.isEmpty()) { - b.crpDescGroupTv.text = filetag.group - } else { - b.crpDescGroupTv.text = filetag.subg - } - - setEntries(filetag) - b.crpCheckBox.isChecked = filetag.isSelected - setCardBackground(filetag.isSelected) - } - - private fun setEntries(filetag: RethinkLocalFileTag) { - b.crpDescEntriesTv.text = - context.getString(R.string.dc_entries, filetag.entries.toString()) - - if (filetag.level.isNullOrEmpty()) return - - val level = filetag.level?.get(0) ?: return - when (level) { - 0 -> { - val color = fetchColor(context, R.attr.chipTextPositive) - val bgColor = fetchColor(context, R.attr.chipBgColorPositive) - b.crpDescEntriesTv.setTextColor(color) - b.crpDescEntriesTv.chipBackgroundColor = ColorStateList.valueOf(bgColor) - } - 1 -> { - val color = fetchColor(context, R.attr.chipTextNeutral) - val bgColor = fetchColor(context, R.attr.chipBgColorNeutral) - b.crpDescEntriesTv.setTextColor(color) - b.crpDescEntriesTv.chipBackgroundColor = ColorStateList.valueOf(bgColor) - } - 2 -> { - val color = fetchColor(context, R.attr.chipTextNegative) - val bgColor = fetchColor(context, R.attr.chipBgColorNegative) - b.crpDescEntriesTv.setTextColor(color) - b.crpDescEntriesTv.chipBackgroundColor = ColorStateList.valueOf(bgColor) - } - else -> { - /* no-op */ - } - } - } - - // handle the group name (filetag.json) - private fun getGroupName(group: String): String { - return if (group.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.label) - } else if (group.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.label) - } else if (group.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.label) - } else { - "" - } - } - - private fun setCardBackground(isSelected: Boolean) { - if (isSelected) { - b.crpCard.setCardBackgroundColor(fetchColor(context, R.attr.selectedCardBg)) - } else { - b.crpCard.setCardBackgroundColor(fetchColor(context, R.attr.background)) - } - } - - private fun toggleCheckbox(isSelected: Boolean, filetag: RethinkLocalFileTag) { - b.crpCheckBox.isChecked = isSelected - setCardBackground(isSelected) - setFileTag(filetag, isSelected) - } - - private fun setFileTag(filetag: RethinkLocalFileTag, selected: Boolean) { - io { - filetag.isSelected = selected - RethinkBlocklistManager.updateFiletagLocal(filetag) - val list = RethinkBlocklistManager.getSelectedFileTagsLocal().toSet() - RethinkBlocklistFragment.updateFileTagList(list) - } - } - - private fun displayHeaderIfNeeded(filetag: RethinkLocalFileTag, position: Int) { - if (position == 0 || getItem(position - 1)?.group != filetag.group) { - b.crpTitleLl.visibility = View.VISIBLE - b.crpBlocktypeHeadingTv.text = getGroupName(filetag.group) - b.crpBlocktypeDescTv.text = getTitleDesc(filetag.group) - return - } - - b.crpTitleLl.visibility = View.GONE - } - - private fun getTitleDesc(title: String): String { - return if (title.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.desc) - } else if (title.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.desc) - } else if (title.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.desc) - } else { - "" - } - } - - private fun io(f: suspend () -> Unit) { - CoroutineScope(Dispatchers.IO).launch { f() } - } - } +@Composable +fun LocalAdvancedBlocklistRow( + filetag: RethinkLocalFileTag, + showHeader: Boolean, + onToggle: (Boolean) -> Unit +) { + val context = LocalContext.current + BlocklistAdvancedRow( + group = filetag.group, + subGroup = filetag.subg, + name = filetag.vname, + entries = filetag.entries, + level = filetag.level?.firstOrNull(), + entryUrl = filetag.url.firstOrNull(), + isSelected = filetag.isSelected, + showHeader = showHeader, + onToggle = onToggle, + onEntryClick = { url -> openUrl(context, url) } + ) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt index 5d4dea1e7..41dfd5872 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt @@ -15,185 +15,25 @@ */ package com.celzero.bravedns.adapter -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.cardview.widget.CardView -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.celzero.bravedns.R +import androidx.compose.runtime.Composable import com.celzero.bravedns.database.LocalBlocklistPacksMap -import com.celzero.bravedns.databinding.ListItemRethinkBlocklistSimpleBinding -import com.celzero.bravedns.service.RethinkBlocklistManager -import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment -import com.celzero.bravedns.util.UIUtils.fetchColor -import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class LocalSimpleViewAdapter(val context: Context) : - PagingDataAdapter( - DIFF_CALLBACK - ) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: LocalBlocklistPacksMap, - newConnection: LocalBlocklistPacksMap - ): Boolean { - return oldConnection == newConnection - } - - override fun areContentsTheSame( - oldConnection: LocalBlocklistPacksMap, - newConnection: LocalBlocklistPacksMap - ): Boolean { - return oldConnection == newConnection - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RethinkSimpleViewHolder { - val itemBinding = - ListItemRethinkBlocklistSimpleBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return RethinkSimpleViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: RethinkSimpleViewHolder, position: Int) { - val map: LocalBlocklistPacksMap = getItem(position) ?: return - - holder.update(map, position) - } - - inner class RethinkSimpleViewHolder(private val b: ListItemRethinkBlocklistSimpleBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(map: LocalBlocklistPacksMap, position: Int) { - b.root.tag = getGroupName(map.group) - displayMetaData(map, position) - setupClickListener(map) - } - - private fun setupClickListener(map: LocalBlocklistPacksMap) { - b.crpCheckBox.setOnClickListener { toggleCheckbox(b.crpCheckBox.isChecked, map) } - - b.crpCard.setOnClickListener { toggleCheckbox(!b.crpCheckBox.isChecked, map) } - } - - private fun setCardBackground(card: CardView, isSelected: Boolean) { - if (isSelected) { - card.setCardBackgroundColor(fetchColor(context, R.attr.selectedCardBg)) - } else { - card.setCardBackgroundColor(fetchColor(context, R.attr.background)) - } - } - - private fun toggleCheckbox(isSelected: Boolean, map: LocalBlocklistPacksMap) { - b.crpCheckBox.isChecked = isSelected - setCardBackground(b.crpCard, isSelected) - setFileTag(map.blocklistIds.toMutableList(), if (isSelected) 1 else 0) - } - - private fun setFileTag(tagIds: MutableList, selected: Int) { - io { - RethinkBlocklistManager.updateFiletagsLocal(tagIds.toSet(), selected) - val selectedTags = RethinkBlocklistManager.getSelectedFileTagsLocal().toSet() - RethinkBlocklistFragment.updateFileTagList(selectedTags) - ui { notifyDataSetChanged() } - } - } - - private fun displayMetaData(map: LocalBlocklistPacksMap, position: Int) { - setCardBackground(b.crpCard, false) - - // check to show the title and desc, as of now these values are predefined so checking - // with those pre defined values. - if (position == 0 || getItem(position - 1)?.group != map.group) { - b.crpTitleLl.visibility = View.VISIBLE - b.crpBlocktypeHeadingTv.text = getGroupName(map.group) - b.crpBlocktypeDescTv.text = getTitleDesc(map.group) - } else { - b.crpTitleLl.visibility = View.GONE - } - - b.crpLabelTv.text = map.pack.replaceFirstChar(Char::titlecase) - b.crpDescGroupTv.text = - context.getString( - R.string.rsv_blocklist_count_text, - map.blocklistIds.size.toString() - ) - - val selectedTags = RethinkBlocklistFragment.getSelectedFileTags() - // enable the check box if the stamp contains all the values - b.crpCheckBox.isChecked = selectedTags.containsAll(map.blocklistIds) - setCardBackground(b.crpCard, b.crpCheckBox.isChecked) - - // show level indicator - showLevelIndicator(b.crpLevelIndicator, map.level) - } - - private fun showLevelIndicator(mIconIndicator: TextView, level: Int) { - when (level) { - 0 -> { - val color = fetchToggleBtnColors(context, R.color.firewallNoRuleToggleBtnBg) - mIconIndicator.setBackgroundColor(color) - } - 1 -> { - val color = fetchToggleBtnColors(context, R.color.firewallWhiteListToggleBtnTxt) - mIconIndicator.setBackgroundColor(color) - } - 2 -> { - val color = fetchToggleBtnColors(context, R.color.firewallBlockToggleBtnTxt) - mIconIndicator.setBackgroundColor(color) - } - else -> { - /* no-op */ - } - } - } - - private fun getTitleDesc(title: String): String { - return if (title.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.desc) - } else if (title.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.desc) - } else if (title.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.desc) - } else { - "" - } - } - - // handle the group name (filetag.json) - private fun getGroupName(group: String): String { - return if (group.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.label) - } else if (group.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.label) - } else if (group.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.label) - } else { - "" - } - } - - private fun io(f: suspend () -> Unit) { - CoroutineScope(Dispatchers.IO).launch { f() } - } - - private fun ui(f: () -> Unit) { - CoroutineScope(Dispatchers.Main).launch { f() } - } - } +import com.celzero.bravedns.ui.rethink.RethinkBlocklistState + +@Composable +fun LocalSimpleBlocklistRow( + map: LocalBlocklistPacksMap, + showHeader: Boolean, + onToggle: (Boolean) -> Unit +) { + val selectedTags = RethinkBlocklistState.getSelectedFileTags() + val isSelected = selectedTags.containsAll(map.blocklistIds) + + BlocklistSimpleRow( + group = map.group, + pack = map.pack, + blocklistCount = map.blocklistIds.size, + isSelected = isSelected, + showHeader = showHeader, + onToggle = onToggle + ) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt index 2b6e0da7f..73439ed4b 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt @@ -16,260 +16,84 @@ limitations under the License. package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_DNS -import android.content.Context -import android.content.DialogInterface -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.ODoHEndpoint -import com.celzero.bravedns.databinding.ListItemEndpointBinding -import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.util.UIUtils.clipboardCopy -import com.celzero.bravedns.util.UIUtils.getDnsStatusStringRes -import com.celzero.bravedns.util.Utilities -import com.celzero.firestack.backend.Backend -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -class ODoHEndpointAdapter(private val context: Context, private val appConfig: AppConfig) : - PagingDataAdapter(DIFF_CALLBACK) { - - var lifecycleOwner: LifecycleOwner? = null - - companion object { - private const val ONE_SEC = 1000L - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: ODoHEndpoint, - newConnection: ODoHEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected == newConnection.isSelected) - } - - override fun areContentsTheSame( - oldConnection: ODoHEndpoint, - newConnection: ODoHEndpoint - ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.isSelected != newConnection.isSelected) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ODoHEndpointViewHolder { - val itemBinding = - ListItemEndpointBinding.inflate(LayoutInflater.from(parent.context), parent, false) - lifecycleOwner = parent.findViewTreeLifecycleOwner() - return ODoHEndpointViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: ODoHEndpointViewHolder, position: Int) { - val endpoint: ODoHEndpoint = getItem(position) ?: return - holder.update(endpoint) - } - - inner class ODoHEndpointViewHolder(private val b: ListItemEndpointBinding) : - RecyclerView.ViewHolder(b.root) { - private var statusCheckJob: Job? = null - - fun update(endpoint: ODoHEndpoint) { - displayDetails(endpoint) - setupClickListeners(endpoint) - } - - private fun setupClickListeners(endpoint: ODoHEndpoint) { - b.root.setOnClickListener { updateConnection(endpoint) } - b.endpointInfoImg.setOnClickListener { showExplanationOnImageClick(endpoint) } - b.endpointCheck.setOnClickListener { updateConnection(endpoint) } - } - - private fun displayDetails(endpoint: ODoHEndpoint) { - b.endpointName.text = endpoint.name - b.endpointCheck.isChecked = endpoint.isSelected - - if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) { - keepSelectedStatusUpdated() - } else if (endpoint.isSelected) { - b.endpointDesc.text = context.getString(R.string.rt_filter_parent_selected) - } else { - b.endpointDesc.text = "" - } - - // Shows either the info/delete icon for the DoH entries. - showIcon(endpoint) - } - - private fun keepSelectedStatusUpdated() { - statusCheckJob = ui { - while (true) { - updateSelectedStatus() - delay(ONE_SEC) - } - } - } - - private fun updateSelectedStatus() { - // if the view is not active then cancel the job - if ( - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false || - bindingAdapterPosition == RecyclerView.NO_POSITION - ) { - statusCheckJob?.cancel() - return - } - - updateDnsStatus() - - } - - private fun updateDnsStatus() { - io { - // always use the id as Dnsx.Preffered as it is the primary dns id for now - val state = VpnController.getDnsStatus(Backend.Preferred) - val status = getDnsStatusStringRes(state) - uiCtx { - b.endpointDesc.text = context.getString(status).replaceFirstChar(Char::titlecase) - } - } - } - - private fun showIcon(endpoint: ODoHEndpoint) { +private const val TAG = "ODoHEndpointAdapter" + +@Composable +fun ODoHEndpointRow(endpoint: ODoHEndpoint, appConfig: AppConfig) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val explanation = + rememberDnsStatusExplanation( + key = endpoint.id, + isSelected = endpoint.isSelected, + smartDnsEnabled = appConfig.isSmartDnsEnabled(), + tag = TAG + ) + var infoDialog by remember(endpoint.id) { mutableStateOf(null) } + var deleteDialog by remember(endpoint.id) { mutableStateOf(null) } + + DnsEndpointRow( + title = endpoint.name, + supporting = explanation.ifEmpty { null }, + selected = endpoint.isSelected, + action = if (endpoint.isDeletable()) DnsRowAction.Delete else DnsRowAction.Info, + selection = DnsRowSelection.Radio, + onActionClick = { if (endpoint.isDeletable()) { - b.endpointInfoImg.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_fab_uninstall) - ) + deleteDialog = + DnsDeleteDialogModel( + id = endpoint.id, + titleRes = R.string.dot_custom_url_remove_dialog_title, + messageRes = R.string.dot_custom_url_remove_dialog_message, + successRes = R.string.doh_custom_url_remove_success + ) } else { - b.endpointInfoImg.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_info) - ) + infoDialog = + DnsInfoDialogModel( + title = endpoint.name, + message = + endpoint.proxy + "\n\n" + endpoint.resolver + "\n\n" + + resolveDnsDescriptionText(context, endpoint.desc), + copyValue = endpoint.resolver + ) } - } - - private fun updateConnection(endpoint: ODoHEndpoint) { - Logger.d( - LOG_TAG_DNS, - "on-ODoH change ${endpoint.name}, ${endpoint.proxy}, ${endpoint.resolver}, ${endpoint.isSelected}" - ) - io { + }, + onSelectionChange = { + launchDnsEndpointSelectionUpdate(scope, context, TAG) { endpoint.isSelected = true appConfig.handleODoHChanges(endpoint) } } + ) - private fun deleteEndpoint(id: Int) { - io { - appConfig.deleteODoHEndpoint(id) - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.doh_custom_url_remove_success), - Toast.LENGTH_SHORT - ) - } - } - } - - private fun showExplanationOnImageClick(endpoint: ODoHEndpoint) { - if (endpoint.isDeletable()) showDeleteDialog(endpoint.id) - else - showDoTMetadataDialog( - endpoint.name, - endpoint.proxy, - endpoint.resolver, - endpoint.desc - ) - } - - private fun showDoTMetadataDialog( - title: String, - proxy: String, - resolver: String, - message: String? - ) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(title) - builder.setMessage(proxy + "\n\n" + resolver + "\n\n" + getDnsDesc(message)) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { - dialogInterface, - _ -> - dialogInterface.dismiss() - } - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { - _: DialogInterface, - _: Int -> - clipboardCopy(context, resolver, context.getString(R.string.copy_clipboard_label)) - Utilities.showToastUiCentered( - context, - context.getString(R.string.info_dialog_url_copy_toast_msg), - Toast.LENGTH_SHORT - ) - } - builder.create().show() - } - - private fun getDnsDesc(message: String?): String { - if (message.isNullOrEmpty()) return "" - - return try { - if (message.contains("R.string.")) { - val m = message.substringAfter("R.string.") - val resId: Int = - context.resources.getIdentifier(m, "string", context.packageName) - context.getString(resId) - } else { - message + deleteDialog?.let { model -> + DnsDeleteDialog( + model = model, + onDismiss = { deleteDialog = null }, + onConfirm = { id -> + launchDnsEndpointDelete(scope, context, model.successRes) { + appConfig.deleteODoHEndpoint(id) } - } catch (_: Exception) { - "" - } - } - - private fun showDeleteDialog(id: Int) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.dot_custom_url_remove_dialog_title) - builder.setMessage(R.string.dot_custom_url_remove_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - deleteEndpoint(id) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - // no-op + deleteDialog = null } - builder.create().show() - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } - - private fun ui(f: suspend () -> Unit): Job? { - return lifecycleOwner?.lifecycleScope?.launch { withContext(Dispatchers.Main) { f() } } - } + ) + } - private fun io(f: suspend () -> Unit) { - lifecycleOwner?.lifecycleScope?.launch { withContext(Dispatchers.IO) { f() } } - } + infoDialog?.let { model -> + DnsInfoDialog( + model = model, + onDismiss = { infoDialog = null } + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt index 1cd2f01bc..ad21374ee 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt @@ -15,28 +15,48 @@ */ package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_PROXY import android.content.Context import android.content.Intent import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.Toast -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import com.celzero.bravedns.R import com.celzero.bravedns.database.EventSource import com.celzero.bravedns.database.EventType import com.celzero.bravedns.database.Severity import com.celzero.bravedns.database.WgConfigFiles -import com.celzero.bravedns.databinding.ListItemWgOneInterfaceBinding import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.EventLogger import com.celzero.bravedns.service.ProxyManager @@ -47,470 +67,530 @@ import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_ACTIVE import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_FULL import com.celzero.bravedns.service.WireguardManager.ERR_CODE_WG_INVALID import com.celzero.bravedns.service.WireguardManager.WG_UPTIME_THRESHOLD -import com.celzero.bravedns.ui.activity.WgConfigDetailActivity -import com.celzero.bravedns.ui.activity.WgConfigDetailActivity.Companion.INTENT_EXTRA_WG_TYPE -import com.celzero.bravedns.ui.activity.WgConfigEditorActivity.Companion.INTENT_EXTRA_WG_ID +import com.celzero.bravedns.ui.compose.wireguard.WgType import com.celzero.bravedns.util.UIUtils -import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.Utilities import com.celzero.firestack.backend.RouterStats +import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import androidx.compose.ui.res.stringResource -class OneWgConfigAdapter(private val context: Context, private val listener: DnsStatusListener, private val eventLogger: EventLogger) : - PagingDataAdapter(DIFF_CALLBACK) { - private var lifecycleOwner: LifecycleOwner? = null +private const val DELAY_MS = 1500L - interface DnsStatusListener { - fun onDnsStatusChanged() +data class ProtocolChips( + val ipv4: Boolean = false, + val ipv6: Boolean = false, + val splitTunnel: Boolean = false +) { + fun hasAny(): Boolean { + return ipv4 || ipv6 || splitTunnel } +} - companion object { - private const val DELAY_MS = 1500L - private const val TAG = "OneWgCfgAdapter" - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: WgConfigFiles, - newConnection: WgConfigFiles - ): Boolean { - return oldConnection == newConnection - } - - override fun areContentsTheSame( - oldConnection: WgConfigFiles, - newConnection: WgConfigFiles - ): Boolean { - return oldConnection == newConnection - } - } +data class OneWgUiState( + val isActive: Boolean, + val statusText: String, + val appsText: String, + val showAppsCount: Boolean, + val showActiveLayout: Boolean, + val uptimeText: String, + val rxtxText: String, + val strokeColor: Color, + val strokeWidth: Dp +) + +@Composable +fun OneWgConfigRow( + config: WgConfigFiles, + eventLogger: EventLogger, + onDnsStatusChanged: () -> Unit, + onConfigDetailClick: (Int, WgType) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + var isChecked by remember(config.id, config.isActive) { + mutableStateOf(config.isActive && VpnController.hasTunnel()) } - - override fun onBindViewHolder(holder: WgInterfaceViewHolder, position: Int) { - val wgConfigFiles: WgConfigFiles = getItem(position) ?: return - holder.update(wgConfigFiles) + var statusText by remember(config.id) { + mutableStateOf(context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase)) } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WgInterfaceViewHolder { - val itemBinding = - ListItemWgOneInterfaceBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - if (lifecycleOwner == null) { - lifecycleOwner = parent.findViewTreeLifecycleOwner() - } - return WgInterfaceViewHolder(itemBinding) - } - - override fun onViewDetachedFromWindow(holder: WgInterfaceViewHolder) { - super.onViewDetachedFromWindow(holder) - holder.cancelJobIfAny() - } - - inner class WgInterfaceViewHolder(private val b: ListItemWgOneInterfaceBinding) : - RecyclerView.ViewHolder(b.root) { - private var job: Job? = null - - fun update(config: WgConfigFiles) { - b.interfaceNameText.text = config.name - b.interfaceNameText.isSelected = true - b.interfaceIdText.text = context.getString(R.string.single_argument_parenthesis, config.id.toString()) - val isWgActive = config.isActive && VpnController.hasTunnel() - b.oneWgCheck.isChecked = isWgActive - setupClickListeners(config) - if (isWgActive) { - keepStatusUpdated(config) - } else { - cancelJobIfAny() - disableInterface() - } - } - - fun cancelJobIfAny() { - if (job?.isActive == true) { - job?.cancel() + var appsText by remember(config.id) { mutableStateOf("") } + var showAppsCount by remember(config.id) { mutableStateOf(false) } + var showActiveLayout by remember(config.id) { mutableStateOf(false) } + var uptimeText by remember(config.id) { mutableStateOf("") } + var rxtxText by remember(config.id) { mutableStateOf("") } + val errorColor = MaterialTheme.colorScheme.error + val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant + val tertiaryColor = MaterialTheme.colorScheme.tertiary + var strokeColor by remember(config.id, errorColor) { mutableStateOf(errorColor) } + var strokeWidth by remember(config.id) { mutableStateOf(0.dp) } + var protocolChips by remember(config.id) { mutableStateOf(ProtocolChips()) } + var inProgress by remember(config.id) { mutableStateOf(false) } + + LaunchedEffect(config.id, config.isActive) { + protocolChips = withContext(Dispatchers.IO) { computeProtocolChips(config) } + while (isActive) { + if (!lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + delay(DELAY_MS) + continue } - } - - private fun keepStatusUpdated(config: WgConfigFiles) { - job = io { - while (true) { - updateStatus(config) - delay(DELAY_MS) + val uiState = + withContext(Dispatchers.IO) { + computeOneWgStatusUi( + context = context, + config = config, + errorColor = errorColor, + onSurfaceVariantColor = onSurfaceVariantColor, + tertiaryColor = tertiaryColor + ) } - } + isChecked = uiState.isActive + statusText = uiState.statusText + appsText = uiState.appsText + showAppsCount = uiState.showAppsCount + showActiveLayout = uiState.showActiveLayout + uptimeText = uiState.uptimeText + rxtxText = uiState.rxtxText + strokeColor = uiState.strokeColor + strokeWidth = uiState.strokeWidth + delay(DELAY_MS) } + } - private fun updateProtocolChip(pair: Pair) { - if (b.protocolInfoChipGroup.isVisible) return - - if (!pair.first && !pair.second) { - b.protocolInfoChipGroup.visibility = View.GONE - return - } - b.protocolInfoChipGroup.visibility = View.VISIBLE - if (pair.first) { - b.protocolInfoChipIpv4.visibility = View.VISIBLE - } else { - b.protocolInfoChipIpv4.visibility = View.GONE - } - if (pair.second) { - b.protocolInfoChipIpv6.visibility = View.VISIBLE - } else { - b.protocolInfoChipIpv6.visibility = View.GONE - } + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { launchOneWgConfigDetail(context, config.id, onConfigDetailClick) }, + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + border = if (strokeWidth > 0.dp) { + BorderStroke(strokeWidth, strokeColor) + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) } + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = config.name, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + context.getString( + R.string.single_argument_parenthesis, + config.id.toString() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + if (isChecked && protocolChips.hasAny()) { + Row( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (protocolChips.ipv4) { + WgChip(text = stringResource(R.string.settings_ip_text_ipv4)) + } + if (protocolChips.ipv6) { + WgChip(text = context.getString(R.string.settings_ip_text_ipv6)) + } + if (protocolChips.splitTunnel) { + WgChip(text = context.getString(R.string.lbl_split)) + } + } + } + } - private fun updateSplitTunnelChip(isSplitTunnel: Boolean) { - if (isSplitTunnel) { - b.chipSplitTunnel.visibility = View.VISIBLE - } else { - b.chipSplitTunnel.visibility = View.GONE + Checkbox( + checked = isChecked, + onCheckedChange = { checked -> + if (inProgress) return@Checkbox + inProgress = true + scope.launch(Dispatchers.IO) { + val success = + if (checked) { + enableOneWgIfPossible(context, config, onDnsStatusChanged, eventLogger) + } else { + disableOneWgIfPossible(context, config, onDnsStatusChanged, eventLogger) + } + withContext(Dispatchers.Main) { + isChecked = if (checked) success else !success + inProgress = false + } + } + } + ) } - } - private suspend fun updateStatus(config: WgConfigFiles) { - // if the view is not active then cancel the job - if ( - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false - ) { - job?.cancel() - return - } + Text( + text = statusText, + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 4.dp) + ) - if (config.isActive && !VpnController.hasTunnel()) { - // Fix: disableInterface() modifies UI, must run on main thread - uiCtx { disableInterface() } - return + if (showAppsCount) { + Text( + text = appsText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) } - val id = ProxyManager.ID_WG_BASE + config.id - val statusPair = VpnController.getProxyStatusById(id) - val pair = VpnController.getSupportedIpVersion(id) - val c = WireguardManager.getConfigById(config.id) - val stats = VpnController.getProxyStats(id) - val dnsStatusId = VpnController.getDnsStatus(ProxyManager.ID_WG_BASE + config.id) - val isSplitTunnel = - if (c?.getPeers()?.isNotEmpty() == true) { - VpnController.isSplitTunnelProxy(id, pair) - } else { - false + if (showActiveLayout) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = uptimeText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + Text( + text = rxtxText, + style = MaterialTheme.typography.bodySmall + ) } - uiCtx { - updateStatusUi(config, statusPair, dnsStatusId, stats) - updateProtocolChip(pair) - updateSplitTunnelChip(isSplitTunnel) } } + } +} - private fun isDnsError(statusId: Long?): Boolean { - if (statusId == null) return true - - val s = Transaction.Status.fromId(statusId) - return s == Transaction.Status.BAD_QUERY || s == Transaction.Status.BAD_RESPONSE || s == Transaction.Status.NO_RESPONSE || s == Transaction.Status.SEND_FAIL || s == Transaction.Status.CLIENT_ERROR || s == Transaction.Status.INTERNAL_ERROR || s == Transaction.Status.TRANSPORT_ERROR +suspend fun computeProtocolChips(config: WgConfigFiles): ProtocolChips { + val id = ProxyManager.ID_WG_BASE + config.id + val pair = VpnController.getSupportedIpVersion(id) + val cfg = WireguardManager.getConfigById(config.id) + val splitTunnel = + if (cfg?.getPeers()?.isNotEmpty() == true) { + VpnController.isSplitTunnelProxy(id, pair) + } else { + false } + return ProtocolChips( + ipv4 = pair.first, + ipv6 = pair.second, + splitTunnel = splitTunnel + ) +} - private fun updateStatusUi(config: WgConfigFiles, statusPair: Pair, dnsStatusId: Long?, stats: RouterStats?) { - if (config.isActive && VpnController.hasTunnel()) { - b.interfaceDetailCard.strokeWidth = 2 - b.oneWgCheck.isChecked = true - b.interfaceAppsCount.visibility = View.VISIBLE - b.interfaceAppsCount.text = context.getString(R.string.one_wg_apps_added) - - if (dnsStatusId != null) { - // check for dns failure cases and update the UI - if (isDnsError(dnsStatusId)) { - b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.chipTextNegative) - b.interfaceStatus.text = - context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) - } else { - // if dns status is not failing, then update the proxy status - updateProxyStatusUi(statusPair, stats) - } - } else { - // in one wg mode, if dns status should be available, this is a fallback case - updateProxyStatusUi(statusPair, stats) - } +suspend fun computeOneWgStatusUi( + context: Context, + config: WgConfigFiles, + errorColor: Color, + onSurfaceVariantColor: Color, + tertiaryColor: Color +): OneWgUiState { + if (config.isActive && !VpnController.hasTunnel()) { + return OneWgUiState( + isActive = false, + statusText = context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase), + appsText = "", + showAppsCount = false, + showActiveLayout = false, + uptimeText = "", + rxtxText = "", + strokeColor = errorColor, + strokeWidth = 0.dp + ) + } - b.interfaceActiveLayout.visibility = View.VISIBLE - val rxtx = getRxTx(stats) - val time = getUpTime(stats) - - if (time.isNotEmpty()) { - val t = context.getString(R.string.logs_card_duration, time) - b.interfaceActiveUptime.text = - context.getString( - R.string.two_argument_space, - context.getString(R.string.lbl_active), - t - ) - } else { - b.interfaceActiveUptime.text = context.getString(R.string.lbl_active) - } - b.interfaceActiveRxTx.text = rxtx - } else { - disableInterface() - } + if (!config.isActive || !VpnController.hasTunnel()) { + return OneWgUiState( + isActive = false, + statusText = context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase), + appsText = "", + showAppsCount = false, + showActiveLayout = false, + uptimeText = "", + rxtxText = "", + strokeColor = errorColor, + strokeWidth = 0.dp + ) + } + + val id = ProxyManager.ID_WG_BASE + config.id + val statusPair = VpnController.getProxyStatusById(id) + val stats = VpnController.getProxyStats(id) + val dnsStatusId = VpnController.getDnsStatus(id) + val statusText = + if (dnsStatusId != null && isOneWgDnsError(dnsStatusId)) { + context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + } else { + getOneWgStatusText( + context, + UIUtils.ProxyStatus.entries.find { it.id == statusPair.first }, + getOneWgHandshakeTime(stats).toString(), + stats, + statusPair.second + ) } - private fun getStrokeColorForStatus(status: UIUtils.ProxyStatus?, stats: RouterStats?): Int{ - val now = System.currentTimeMillis() - val lastOk = stats?.lastOK ?: 0L - val since = stats?.since ?: 0L - val isFailing = now - since > WG_UPTIME_THRESHOLD && lastOk == 0L - return when (status) { - UIUtils.ProxyStatus.TOK -> if (isFailing) R.attr.chipTextNeutral else R.attr.accentGood - UIUtils.ProxyStatus.TUP, UIUtils.ProxyStatus.TZZ, UIUtils.ProxyStatus.TNT -> R.attr.chipTextNeutral - else -> R.attr.chipTextNegative // TKO, TEND - } + val strokeColor = + if (dnsStatusId != null && isOneWgDnsError(dnsStatusId)) { + errorColor + } else { + val status = + UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } + getOneWgStrokeColorForStatus( + status = status, + stats = stats, + errorColor = errorColor, + onSurfaceVariantColor = onSurfaceVariantColor, + tertiaryColor = tertiaryColor + ) } - private fun getStatusText( - status: UIUtils.ProxyStatus?, - handshakeTime: String? = null, - stats: RouterStats?, - errMsg: String? = null - ): String { - if (status == null) { - val txt = if (errMsg != null) { - context.getString(R.string.status_waiting) + " ($errMsg)" - } else { - context.getString(R.string.status_waiting) - } - return txt.replaceFirstChar(Char::titlecase) - } + val rxtx = getOneWgRxTx(context, stats) + val time = getOneWgUpTime(stats) + val uptimeText = + if (time.isNotEmpty()) { + val t = context.getString(R.string.logs_card_duration, time) + context.getString( + R.string.two_argument_space, + context.getString(R.string.lbl_active), + t + ) + } else { + context.getString(R.string.lbl_active) + } - val now = System.currentTimeMillis() - val lastOk = stats?.lastOK ?: 0L - val since = stats?.since ?: 0L - if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { - return context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) - } + return OneWgUiState( + isActive = true, + statusText = statusText, + appsText = context.getString(R.string.one_wg_apps_added), + showAppsCount = true, + showActiveLayout = true, + uptimeText = uptimeText, + rxtxText = rxtx, + strokeColor = strokeColor, + strokeWidth = 2.dp + ) +} - val baseText = context.getString(UIUtils.getProxyStatusStringRes(status.id)) - .replaceFirstChar(Char::titlecase) +private fun isOneWgDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || + s == Transaction.Status.BAD_RESPONSE || + s == Transaction.Status.NO_RESPONSE || + s == Transaction.Status.SEND_FAIL || + s == Transaction.Status.CLIENT_ERROR || + s == Transaction.Status.INTERNAL_ERROR || + s == Transaction.Status.TRANSPORT_ERROR +} - return if (stats?.lastOK != 0L && handshakeTime != null) { - context.getString(R.string.about_version_install_source, baseText, handshakeTime) +private fun getOneWgStrokeColorForStatus( + status: UIUtils.ProxyStatus?, + stats: RouterStats?, + errorColor: Color, + onSurfaceVariantColor: Color, + tertiaryColor: Color +): Color { + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + val isFailing = now - since > WG_UPTIME_THRESHOLD && lastOk == 0L + return when (status) { + UIUtils.ProxyStatus.TOK -> + if (isFailing) { + onSurfaceVariantColor } else { - baseText + tertiaryColor } - } + UIUtils.ProxyStatus.TUP, + UIUtils.ProxyStatus.TZZ, + UIUtils.ProxyStatus.TNT -> onSurfaceVariantColor + else -> errorColor + } +} - private fun updateProxyStatusUi(statusPair: Pair, stats: RouterStats?) { - val status = - UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } // Convert to enum +private fun getOneWgStatusText( + context: Context, + status: UIUtils.ProxyStatus?, + handshakeTime: String? = null, + stats: RouterStats?, + errMsg: String? = null +): String { + if (status == null) { + val txt = + if (errMsg != null) { + context.getString(R.string.status_waiting) + " ($errMsg)" + } else { + context.getString(R.string.status_waiting) + } + return txt.replaceFirstChar(Char::titlecase) + } - val handshakeTime = getHandshakeTime(stats).toString() + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { + return context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + } - val strokeColor = getStrokeColorForStatus(status, stats) - b.interfaceDetailCard.strokeColor = fetchColor(context, strokeColor) - val statusText = getStatusText(status, handshakeTime, stats, statusPair.second) - b.interfaceStatus.text = statusText - } + val baseText = + context.getString(UIUtils.getProxyStatusStringRes(status.id)) + .replaceFirstChar(Char::titlecase) + return if (stats?.lastOK != 0L && handshakeTime != null) { + context.getString(R.string.about_version_install_source, baseText, handshakeTime) + } else { + baseText + } +} - private fun disableInterface() { - b.interfaceDetailCard.strokeWidth = 0 - b.protocolInfoChipGroup.visibility = View.GONE - b.interfaceAppsCount.visibility = View.GONE - b.oneWgCheck.isChecked = false - b.interfaceActiveLayout.visibility = View.GONE - b.interfaceStatus.text = - context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) - } +private fun getOneWgUpTime(stats: RouterStats?): CharSequence { + if (stats == null) return "" + if (stats.since <= 0L) return "" + val now = System.currentTimeMillis() + return DateUtils.getRelativeTimeSpanString( + stats.since, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) +} - private fun getUpTime(stats: RouterStats?): CharSequence { - if (stats == null) { - return "" - } - if (stats.since <= 0L) { - return "" - } - val now = System.currentTimeMillis() - // returns a string describing 'time' as a time relative to 'now' - return DateUtils.getRelativeTimeSpanString( - stats.since, - now, - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - } +private fun getOneWgRxTx(context: Context, stats: RouterStats?): String { + if (stats == null) return "" + val rx = + context.getString( + R.string.symbol_download, + Utilities.humanReadableByteCount(stats.rx, true) + ) + val tx = + context.getString( + R.string.symbol_upload, + Utilities.humanReadableByteCount(stats.tx, true) + ) + return context.getString(R.string.two_argument_space, tx, rx) +} - private fun getRxTx(stats: RouterStats?): String { - if (stats == null) return "" - val rx = - context.getString( - R.string.symbol_download, - Utilities.humanReadableByteCount(stats.rx, true) - ) - val tx = - context.getString( - R.string.symbol_upload, - Utilities.humanReadableByteCount(stats.tx, true) - ) - return context.getString(R.string.two_argument_space, tx, rx) - } +private fun getOneWgHandshakeTime(stats: RouterStats?): CharSequence { + if (stats == null) return "" + if (stats.lastOK == 0L) return "" + val now = System.currentTimeMillis() + return DateUtils.getRelativeTimeSpanString( + stats.lastOK, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) +} - private fun getHandshakeTime(stats: RouterStats?): CharSequence { - if (stats == null) { - return "" - } - if (stats.lastOK == 0L) { - return "" - } - val now = System.currentTimeMillis() - // returns a string describing 'time' as a time relative to 'now' - return DateUtils.getRelativeTimeSpanString( - stats.lastOK, - now, - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE +suspend fun enableOneWgIfPossible(context: Context, config: WgConfigFiles, onDnsStatusChanged: () -> Unit, eventLogger: EventLogger): Boolean { + if (!VpnController.hasTunnel()) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + + context.getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG ) } + return false + } - fun setupClickListeners(config: WgConfigFiles) { - b.interfaceDetailCard.setOnClickListener { launchConfigDetail(config.id) } - - b.oneWgCheck.setOnClickListener { - val isChecked = b.oneWgCheck.isChecked - io { - if (isChecked) { - enableWgIfPossible(config) - } else { - disableWgIfPossible(config) - } - } - } + if (!WireguardManager.canEnableProxy()) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_FULL + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) } + return false + } - private suspend fun enableWgIfPossible(config: WgConfigFiles) { - if (!VpnController.hasTunnel()) { - Logger.i(LOG_TAG_PROXY, "$TAG VPN not active, cannot enable WireGuard") - uiCtx { - Utilities.showToastUiCentered( - context, - ERR_CODE_VPN_NOT_ACTIVE + - context.getString(R.string.settings_socks5_vpn_disabled_error), - Toast.LENGTH_LONG - ) - // reset the check box - b.oneWgCheck.isChecked = false - } - return - } - - if (!WireguardManager.canEnableProxy()) { - Logger.i(LOG_TAG_PROXY, "not in DNS+Firewall mode, cannot enable WireGuard") - uiCtx { - // reset the check box - b.oneWgCheck.isChecked = false - Utilities.showToastUiCentered( - context, - ERR_CODE_VPN_NOT_FULL + - context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - } - return - } - - if (WireguardManager.isAnyOtherOneWgEnabled(config.id)) { - Logger.i(LOG_TAG_PROXY, "another WireGuard is already enabled") - uiCtx { - // reset the check box - b.oneWgCheck.isChecked = false - Utilities.showToastUiCentered( - context, - ERR_CODE_OTHER_WG_ACTIVE + - context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - } - return - } - - if (!WireguardManager.isValidConfig(config.id)) { - Logger.i(LOG_TAG_PROXY, "invalid WireGuard config") - uiCtx { - // reset the check box - b.oneWgCheck.isChecked = false - Utilities.showToastUiCentered( - context, - ERR_CODE_WG_INVALID + context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - } - return - } - - Logger.i(LOG_TAG_PROXY, "enabling WireGuard, id: ${config.id}") - WireguardManager.updateOneWireGuardConfig(config.id, owg = true) - config.oneWireGuard = true - WireguardManager.enableConfig(config.toImmutable()) - uiCtx { listener.onDnsStatusChanged() } - logEvent("One-WireGuard enabled", "WG ID: ${config.id}") + if (WireguardManager.isAnyOtherOneWgEnabled(config.id)) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_OTHER_WG_ACTIVE + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) } + return false + } - private suspend fun disableWgIfPossible(config: WgConfigFiles) { - if (!VpnController.hasTunnel()) { - Logger.i(LOG_TAG_PROXY, "VPN not active, cannot disable WireGuard") - uiCtx { - // reset the check box - b.oneWgCheck.isChecked = true - Utilities.showToastUiCentered( - context, - ERR_CODE_VPN_NOT_ACTIVE + - context.getString(R.string.settings_socks5_vpn_disabled_error), - Toast.LENGTH_LONG - ) - } - return - } - - Logger.i(LOG_TAG_PROXY, "disabling WireGuard, id: ${config.id}") - WireguardManager.updateOneWireGuardConfig(config.id, owg = false) - config.oneWireGuard = false - WireguardManager.disableConfig(config.toImmutable()) - uiCtx { listener.onDnsStatusChanged() } - logEvent("One-WireGuard disabled", "WG ID: ${config.id}") + if (!WireguardManager.isValidConfig(config.id)) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_WG_INVALID + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) } + return false + } - private fun launchConfigDetail(id: Int) { - if (!VpnController.hasTunnel()) { - Utilities.showToastUiCentered( - context, - context.getString(R.string.ssv_toast_start_rethink), - Toast.LENGTH_SHORT - ) - return - } + WireguardManager.updateOneWireGuardConfig(config.id, owg = true) + config.oneWireGuard = true + WireguardManager.enableConfig(config.toImmutable()) + withContext(Dispatchers.Main) { onDnsStatusChanged() } + logOneWgEvent(eventLogger, "One-WireGuard enabled", "WG ID: ${config.id}") + return true +} - val intent = Intent(context, WgConfigDetailActivity::class.java) - intent.putExtra(INTENT_EXTRA_WG_ID, id) - intent.putExtra(INTENT_EXTRA_WG_TYPE, WgConfigDetailActivity.WgType.ONE_WG.value) - context.startActivity(intent) +suspend fun disableOneWgIfPossible(context: Context, config: WgConfigFiles, onDnsStatusChanged: () -> Unit, eventLogger: EventLogger): Boolean { + if (!VpnController.hasTunnel()) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + + context.getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) } + return false } - private fun logEvent(msg: String, details: String) { - eventLogger.log(EventType.PROXY_SWITCH, Severity.LOW, msg, EventSource.UI, false, details) - } + WireguardManager.updateOneWireGuardConfig(config.id, owg = false) + config.oneWireGuard = false + WireguardManager.disableConfig(config.toImmutable()) + withContext(Dispatchers.Main) { onDnsStatusChanged() } + logOneWgEvent(eventLogger, "One-WireGuard disabled", "WG ID: ${config.id}") + return true +} - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } +private fun launchOneWgConfigDetail(context: Context, id: Int, onConfigDetailClick: (Int, WgType) -> Unit) { + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.ssv_toast_start_rethink), + Toast.LENGTH_SHORT + ) + return } - private fun io(f: suspend () -> Unit): Job? { - return lifecycleOwner?.lifecycleScope?.launch(Dispatchers.IO) { f() } - } + onConfigDetailClick(id, WgType.ONE_WG) +} + +private fun logOneWgEvent(eventLogger: EventLogger, msg: String, details: String) { + eventLogger.log(EventType.PROXY_SWITCH, Severity.LOW, msg, EventSource.UI, false, details) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt index 5ae3b1965..b6b837916 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt @@ -15,191 +15,28 @@ */ package com.celzero.bravedns.adapter -import android.content.Context -import android.content.res.ColorStateList -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.celzero.bravedns.R +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.database.RethinkRemoteFileTag -import com.celzero.bravedns.databinding.ListItemRethinkBlocklistAdvBinding -import com.celzero.bravedns.service.RethinkBlocklistManager -import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment -import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.UIUtils.openUrl -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -class RemoteAdvancedViewAdapter(val context: Context) : - PagingDataAdapter< - RethinkRemoteFileTag, - RemoteAdvancedViewAdapter.RethinkRemoteFileTagViewHolder - >(DIFF_CALLBACK) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: RethinkRemoteFileTag, - newConnection: RethinkRemoteFileTag - ): Boolean { - return oldConnection == newConnection - } - - override fun areContentsTheSame( - oldConnection: RethinkRemoteFileTag, - newConnection: RethinkRemoteFileTag - ): Boolean { - return oldConnection == newConnection - } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): RethinkRemoteFileTagViewHolder { - val itemBinding = - ListItemRethinkBlocklistAdvBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return RethinkRemoteFileTagViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: RethinkRemoteFileTagViewHolder, position: Int) { - val filetag: RethinkRemoteFileTag = getItem(position) ?: return - - holder.update(filetag, position) - } - - inner class RethinkRemoteFileTagViewHolder(private val b: ListItemRethinkBlocklistAdvBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(filetag: RethinkRemoteFileTag, position: Int) { - b.root.tag = getGroupName(filetag.group) - displayHeaderIfNeeded(filetag, position) - displayMetaData(filetag) - - b.crpCheckBox.setOnClickListener { toggleCheckbox(b.crpCheckBox.isChecked, filetag) } - - b.crpCard.setOnClickListener { toggleCheckbox(!b.crpCheckBox.isChecked, filetag) } - - b.crpDescEntriesTv.setOnClickListener { openUrl(context, filetag.url[0]) } - } - - private fun displayMetaData(filetag: RethinkRemoteFileTag) { - b.crpLabelTv.text = filetag.vname - - if (filetag.subg.isEmpty()) { - b.crpDescGroupTv.text = filetag.group - } else { - b.crpDescGroupTv.text = filetag.subg - } - setEntries(filetag) - - b.crpCheckBox.isChecked = filetag.isSelected - setCardBackground(filetag.isSelected) - } - - private fun setEntries(filetag: RethinkRemoteFileTag) { - b.crpDescEntriesTv.text = - context.getString(R.string.dc_entries, filetag.entries.toString()) - - if (filetag.level.isNullOrEmpty()) return - - val level = filetag.level?.get(0) ?: return - when (level) { - 0 -> { - val color = fetchColor(context, R.attr.chipTextPositive) - val bgColor = fetchColor(context, R.attr.chipBgColorPositive) - b.crpDescEntriesTv.setTextColor(color) - b.crpDescEntriesTv.chipBackgroundColor = ColorStateList.valueOf(bgColor) - } - 1 -> { - val color = fetchColor(context, R.attr.chipTextNeutral) - val bgColor = fetchColor(context, R.attr.chipBgColorNeutral) - b.crpDescEntriesTv.setTextColor(color) - b.crpDescEntriesTv.chipBackgroundColor = ColorStateList.valueOf(bgColor) - } - 2 -> { - val color = fetchColor(context, R.attr.chipTextNegative) - val bgColor = fetchColor(context, R.attr.chipBgColorNegative) - b.crpDescEntriesTv.setTextColor(color) - b.crpDescEntriesTv.chipBackgroundColor = ColorStateList.valueOf(bgColor) - } - else -> { - /* no-op */ - } - } - } - - private fun getTitleDesc(title: String): String { - return if (title.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.desc) - } else if (title.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.desc) - } else if (title.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.desc) - } else { - "" - } - } - - private fun setCardBackground(isSelected: Boolean) { - if (isSelected) { - b.crpCard.setCardBackgroundColor(fetchColor(context, R.attr.selectedCardBg)) - } else { - b.crpCard.setCardBackgroundColor(fetchColor(context, R.attr.background)) - } - } - - private fun toggleCheckbox(isSelected: Boolean, filetag: RethinkRemoteFileTag) { - b.crpCheckBox.isChecked = isSelected - setCardBackground(isSelected) - setFileTag(filetag, isSelected) - } - - private fun setFileTag(filetag: RethinkRemoteFileTag, selected: Boolean) { - io { - filetag.isSelected = selected - RethinkBlocklistManager.updateFiletagRemote(filetag) - val list = RethinkBlocklistManager.getSelectedFileTagsRemote().toSet() - RethinkBlocklistFragment.updateFileTagList(list) - } - } - - private fun displayHeaderIfNeeded(filetag: RethinkRemoteFileTag, position: Int) { - if (position == 0 || getItem(position - 1)?.group != filetag.group) { - b.crpTitleLl.visibility = View.VISIBLE - b.crpBlocktypeHeadingTv.text = getGroupName(filetag.group) - b.crpBlocktypeDescTv.text = getTitleDesc(filetag.group) - return - } - - b.crpTitleLl.visibility = View.GONE - } - - private fun getGroupName(group: String): String { - return if (group.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.label) - } else if (group.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.label) - } else if (group.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.label) - } else { - "" - } - } - - private fun io(f: suspend () -> Unit) { - CoroutineScope(Dispatchers.IO).launch { f() } - } - } +@Composable +fun RemoteAdvancedBlocklistRow( + filetag: RethinkRemoteFileTag, + showHeader: Boolean, + onToggle: (Boolean) -> Unit +) { + val context = LocalContext.current + BlocklistAdvancedRow( + group = filetag.group, + subGroup = filetag.subg, + name = filetag.vname, + entries = filetag.entries, + level = filetag.level?.firstOrNull(), + entryUrl = filetag.url.firstOrNull(), + isSelected = filetag.isSelected, + showHeader = showHeader, + onToggle = onToggle, + onEntryClick = { url -> openUrl(context, url) } + ) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt index c65b0d5e7..0e1898127 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt @@ -15,184 +15,25 @@ */ package com.celzero.bravedns.adapter -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.cardview.widget.CardView -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.celzero.bravedns.R +import androidx.compose.runtime.Composable import com.celzero.bravedns.database.RemoteBlocklistPacksMap -import com.celzero.bravedns.databinding.ListItemRethinkBlocklistSimpleBinding -import com.celzero.bravedns.service.RethinkBlocklistManager -import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment -import com.celzero.bravedns.util.UIUtils.fetchColor -import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class RemoteSimpleViewAdapter(val context: Context) : - PagingDataAdapter( - DIFF_CALLBACK - ) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: RemoteBlocklistPacksMap, - newConnection: RemoteBlocklistPacksMap - ): Boolean { - return oldConnection == newConnection - } - - override fun areContentsTheSame( - oldConnection: RemoteBlocklistPacksMap, - newConnection: RemoteBlocklistPacksMap - ): Boolean { - return oldConnection == newConnection - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RethinkSimpleViewHolder { - val itemBinding = - ListItemRethinkBlocklistSimpleBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return RethinkSimpleViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: RethinkSimpleViewHolder, position: Int) { - val map: RemoteBlocklistPacksMap = getItem(position) ?: return - holder.update(map, position) - } - - inner class RethinkSimpleViewHolder(private val b: ListItemRethinkBlocklistSimpleBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(map: RemoteBlocklistPacksMap, position: Int) { - b.root.tag = getGroupName(map.group) - displayMetaData(map, position) - setupClickListener(map) - } - - private fun setupClickListener(map: RemoteBlocklistPacksMap) { - b.crpCheckBox.setOnClickListener { toggleCheckbox(b.crpCheckBox.isChecked, map) } - - b.crpCard.setOnClickListener { toggleCheckbox(!b.crpCheckBox.isChecked, map) } - } - - private fun setCardBackground(card: CardView, isSelected: Boolean) { - if (isSelected) { - card.setCardBackgroundColor(fetchColor(context, R.attr.selectedCardBg)) - } else { - card.setCardBackgroundColor(fetchColor(context, R.attr.background)) - } - } - - private fun toggleCheckbox(isSelected: Boolean, map: RemoteBlocklistPacksMap) { - b.crpCheckBox.isChecked = isSelected - setCardBackground(b.crpCard, isSelected) - setFileTag(map.blocklistIds.toMutableList(), if (isSelected) 1 else 0) - } - - private fun setFileTag(tagIds: MutableList, selected: Int) { - io { - RethinkBlocklistManager.updateFiletagsRemote(tagIds.toSet(), selected) - val selectedTags = RethinkBlocklistManager.getSelectedFileTagsRemote().toSet() - RethinkBlocklistFragment.updateFileTagList(selectedTags) - ui { notifyDataSetChanged() } - } - } - - private fun displayMetaData(map: RemoteBlocklistPacksMap, position: Int) { - setCardBackground(b.crpCard, false) - - // check to show the title and desc, as of now these values are predefined so checking - // with those pre defined values. - if (position == 0 || getItem(position - 1)?.group != map.group) { - b.crpTitleLl.visibility = View.VISIBLE - b.crpBlocktypeHeadingTv.text = getGroupName(map.group) - b.crpBlocktypeDescTv.text = getTitleDesc(map.group) - } else { - b.crpTitleLl.visibility = View.GONE - } - - b.crpLabelTv.text = map.pack.replaceFirstChar(Char::titlecase) - b.crpDescGroupTv.text = - context.getString( - R.string.rsv_blocklist_count_text, - map.blocklistIds.size.toString() - ) - - val selectedTags = RethinkBlocklistFragment.getSelectedFileTags() - // enable the check box if the stamp contains all the values - b.crpCheckBox.isChecked = selectedTags.containsAll(map.blocklistIds) - setCardBackground(b.crpCard, b.crpCheckBox.isChecked) - - // show level indicator - showLevelIndicator(b.crpLevelIndicator, map.level) - } - - private fun showLevelIndicator(mIconIndicator: TextView, level: Int) { - when (level) { - 0 -> { - val color = fetchToggleBtnColors(context, R.color.firewallNoRuleToggleBtnBg) - mIconIndicator.setBackgroundColor(color) - } - 1 -> { - val color = fetchToggleBtnColors(context, R.color.firewallWhiteListToggleBtnTxt) - mIconIndicator.setBackgroundColor(color) - } - 2 -> { - val color = fetchToggleBtnColors(context, R.color.firewallBlockToggleBtnTxt) - mIconIndicator.setBackgroundColor(color) - } - else -> { - /* no-op */ - } - } - } - - private fun getTitleDesc(title: String): String { - return if (title.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.desc) - } else if (title.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.desc) - } else if (title.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.desc) - } else { - "" - } - } - - // handle the group name (filetag.json) - private fun getGroupName(group: String): String { - return if (group.equals(RethinkBlocklistManager.PARENTAL_CONTROL.name, true)) { - context.getString(RethinkBlocklistManager.PARENTAL_CONTROL.label) - } else if (group.equals(RethinkBlocklistManager.SECURITY.name, true)) { - context.getString(RethinkBlocklistManager.SECURITY.label) - } else if (group.equals(RethinkBlocklistManager.PRIVACY.name, true)) { - context.getString(RethinkBlocklistManager.PRIVACY.label) - } else { - "" - } - } - - private fun io(f: suspend () -> Unit) { - CoroutineScope(Dispatchers.IO).launch { f() } - } - - private fun ui(f: () -> Unit) { - CoroutineScope(Dispatchers.Main).launch { f() } - } - } +import com.celzero.bravedns.ui.rethink.RethinkBlocklistState + +@Composable +fun RemoteSimpleBlocklistRow( + map: RemoteBlocklistPacksMap, + showHeader: Boolean, + onToggle: (Boolean) -> Unit +) { + val selectedTags = RethinkBlocklistState.getSelectedFileTags() + val isSelected = selectedTags.containsAll(map.blocklistIds) + + BlocklistSimpleRow( + group = map.group, + pack = map.pack, + blocklistCount = map.blocklistIds.size, + isSelected = isSelected, + showHeader = showHeader, + onToggle = onToggle + ) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt index e649f5b8b..84b6182e7 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt @@ -16,208 +16,99 @@ package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_DNS import android.content.Context -import android.content.DialogInterface -import android.content.Intent -import android.view.LayoutInflater -import android.view.ViewGroup import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.RethinkDnsEndpoint -import com.celzero.bravedns.databinding.RethinkEndpointListItemBinding -import com.celzero.bravedns.service.RethinkBlocklistManager import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.ui.activity.ConfigureRethinkBasicActivity -import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.ui.compose.theme.RethinkMultiActionDialog import com.celzero.bravedns.util.UIUtils.clipboardCopy import com.celzero.bravedns.util.Utilities -import com.celzero.firestack.backend.Backend -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import com.celzero.bravedns.ui.compose.dns.ConfigureRethinkScreenType -class RethinkEndpointAdapter(private val context: Context, private val appConfig: AppConfig) : - PagingDataAdapter( - DIFF_CALLBACK - ) { +private const val TAG = "RethinkEndpointAdapter" - var lifecycleOwner: LifecycleOwner? = null - - companion object { - private const val ONE_SEC = 1000L - private const val TAG = "RethinkEndpointAdapter" - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: RethinkDnsEndpoint, - newConnection: RethinkDnsEndpoint - ): Boolean { - return (oldConnection.url == newConnection.url && - oldConnection.isActive == newConnection.isActive) - } - - override fun areContentsTheSame( - oldConnection: RethinkDnsEndpoint, - newConnection: RethinkDnsEndpoint - ): Boolean { - return (oldConnection.url == newConnection.url && - oldConnection.isActive != newConnection.isActive) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RethinkEndpointViewHolder { - val itemBinding = - RethinkEndpointListItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - lifecycleOwner = parent.findViewTreeLifecycleOwner() - return RethinkEndpointViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: RethinkEndpointViewHolder, position: Int) { - val doHEndpoint: RethinkDnsEndpoint = getItem(position) ?: return - holder.update(doHEndpoint) - } - - inner class RethinkEndpointViewHolder(private val b: RethinkEndpointListItemBinding) : - RecyclerView.ViewHolder(b.root) { - private var statusCheckJob: Job? = null - - fun update(endpoint: RethinkDnsEndpoint) { - displayDetails(endpoint) - setupClickListeners(endpoint) - } - - private fun setupClickListeners(endpoint: RethinkDnsEndpoint) { - b.root.setOnClickListener { updateConnection(endpoint) } - b.rethinkEndpointListActionImage.setOnClickListener { showDohMetadataDialog(endpoint) } - b.rethinkEndpointListCheckImage.setOnClickListener { updateConnection(endpoint) } - } - - private fun displayDetails(endpoint: RethinkDnsEndpoint) { - b.rethinkEndpointListUrlName.text = endpoint.name - b.rethinkEndpointListCheckImage.isChecked = endpoint.isActive - - // Shows either the info/delete icon for the DoH entries. - showIcon(endpoint) - - if (endpoint.isActive && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) { - keepSelectedStatusUpdated(endpoint) - } else if (endpoint.isActive) { - b.rethinkEndpointListUrlExplanation.text = - context.getString(R.string.rt_filter_parent_selected) - } else { - b.rethinkEndpointListUrlExplanation.text = "" - } - } - - private fun keepSelectedStatusUpdated(endpoint: RethinkDnsEndpoint) { - statusCheckJob = io { - while (true) { - updateBlocklistStatusText(endpoint) - delay(ONE_SEC) - } - } - } - - private suspend fun updateBlocklistStatusText(endpoint: RethinkDnsEndpoint) { - // if the view is not active then cancel the job - if ( - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false || - bindingAdapterPosition == RecyclerView.NO_POSITION - ) { - statusCheckJob?.cancel() - return - } - - updateDnsStatus(endpoint) - } +private sealed class RethinkDialogState { + data class Info(val endpoint: RethinkDnsEndpoint) : RethinkDialogState() +} - private suspend fun updateDnsStatus(endpoint: RethinkDnsEndpoint) { - val state = VpnController.getDnsStatus(Backend.Preferred) - val status = UIUtils.getDnsStatusStringRes(state) - uiCtx { - // show the status as it is if it is not connected +@Composable +fun RethinkEndpointRow( + endpoint: RethinkDnsEndpoint, + appConfig: AppConfig, + onEditConfiguration: (ConfigureRethinkScreenType, String, String) -> Unit = { _, _, _ -> } +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val explanation = + rememberDnsStatusExplanation( + key = "${endpoint.url}:${endpoint.blocklistCount}", + isSelected = endpoint.isActive, + smartDnsEnabled = appConfig.isSmartDnsEnabled(), + tag = TAG, + statusTextMapper = { ctx, status -> if (status != R.string.dns_connected) { - b.rethinkEndpointListUrlExplanation.text = - context.getString(status).replaceFirstChar(Char::titlecase) - return@uiCtx - } - - if (endpoint.blocklistCount > 0) { - b.rethinkEndpointListUrlExplanation.text = - context.getString( - R.string.dns_connected_rethink_plus, - endpoint.blocklistCount.toString() - ) + ctx.getString(status).replaceFirstChar(Char::titlecase) + } else if (endpoint.blocklistCount > 0) { + ctx.getString( + R.string.dns_connected_rethink_plus, + endpoint.blocklistCount.toString() + ) } else { - b.rethinkEndpointListUrlExplanation.text = context.getString(status) + ctx.getString(status) } } - - } - - private fun showIcon(endpoint: RethinkDnsEndpoint) { - if (endpoint.isEditable(context)) { - b.rethinkEndpointListActionImage.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_edit_icon) - ) - } else { - b.rethinkEndpointListActionImage.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_info) - ) - } - } - - private fun updateConnection(endpoint: RethinkDnsEndpoint) { - Logger.d( - LOG_TAG_DNS, - "$TAG rdns update; ${endpoint.name}, ${endpoint.url}, ${endpoint.isActive}" - ) - - io { + ) + var dialogState by remember(endpoint.url) { mutableStateOf(null) } + + DnsEndpointRow( + title = endpoint.name, + supporting = explanation.ifEmpty { null }, + selected = endpoint.isActive, + action = if (endpoint.isEditable(context)) DnsRowAction.Edit else DnsRowAction.Info, + selection = DnsRowSelection.Radio, + onActionClick = { dialogState = RethinkDialogState.Info(endpoint) }, + onSelectionChange = { + launchDnsEndpointSelectionUpdate(scope, context, TAG) { endpoint.isActive = true appConfig.handleRethinkChanges(endpoint) } } - - private fun showDohMetadataDialog(endpoint: RethinkDnsEndpoint) { - val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim) - builder.setTitle(endpoint.name) - builder.setMessage(endpoint.url + "\n\n" + endpoint.desc) - builder.setCancelable(true) - if (endpoint.isEditable(context)) { - builder.setPositiveButton(context.getString(R.string.rt_edit_dialog_positive)) { _, _ -> - openEditConfiguration(endpoint) - } + ) + + dialogState?.let { state -> + val info = state as RethinkDialogState.Info + val editEnabled = info.endpoint.isEditable(context) + val positiveText = + if (editEnabled) { + context.getString(R.string.rt_edit_dialog_positive) } else { - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialogInterface, _ -> - dialogInterface.dismiss() - } + context.getString(R.string.dns_info_positive) } - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { _: DialogInterface, _: Int -> + RethinkMultiActionDialog( + onDismissRequest = { dialogState = null }, + title = info.endpoint.name, + message = info.endpoint.url + "\n\n" + info.endpoint.desc, + primaryText = positiveText, + onPrimary = { + dialogState = null + if (editEnabled) { + openEditConfiguration(context, endpoint, onEditConfiguration) + } + }, + secondaryText = context.getString(R.string.dns_info_neutral), + onSecondary = { clipboardCopy( context, - endpoint.url, + info.endpoint.url, context.getString(R.string.copy_clipboard_label) ) Utilities.showToastUiCentered( @@ -226,36 +117,23 @@ class RethinkEndpointAdapter(private val context: Context, private val appConfig Toast.LENGTH_SHORT ) } - builder.create().show() - } - - private fun openEditConfiguration(endpoint: RethinkDnsEndpoint) { - - if (!VpnController.hasTunnel()) { - Utilities.showToastUiCentered( - context, - context.getString(R.string.ssv_toast_start_rethink), - Toast.LENGTH_SHORT - ) - return - } - - val intent = Intent(context, ConfigureRethinkBasicActivity::class.java) - intent.putExtra( - ConfigureRethinkBasicActivity.RETHINK_BLOCKLIST_TYPE, - RethinkBlocklistManager.RethinkBlocklistType.REMOTE - ) - intent.putExtra(ConfigureRethinkBasicActivity.RETHINK_BLOCKLIST_NAME, endpoint.name) - intent.putExtra(ConfigureRethinkBasicActivity.RETHINK_BLOCKLIST_URL, endpoint.url) - context.startActivity(intent) - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } + ) + } +} - private fun io(f: suspend () -> Unit): Job? { - return lifecycleOwner?.lifecycleScope?.launch { withContext(Dispatchers.IO) { f() } } - } +private fun openEditConfiguration( + context: Context, + endpoint: RethinkDnsEndpoint, + onEditConfiguration: (ConfigureRethinkScreenType, String, String) -> Unit +) { + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.ssv_toast_start_rethink), + Toast.LENGTH_SHORT + ) + return } + + onEditConfiguration(ConfigureRethinkScreenType.REMOTE, endpoint.name, endpoint.url) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt index 904fa8b2f..c7cbc8760 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt @@ -16,32 +16,43 @@ limitations under the License. package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_UI + import android.content.Context import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.celzero.bravedns.R import com.celzero.bravedns.database.ConnectionTracker import com.celzero.bravedns.database.RethinkLog -import com.celzero.bravedns.databinding.ListItemConnTrackBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.FirewallRuleset import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.ui.bottomsheet.ConnTrackerBottomSheet import com.celzero.bravedns.util.Constants.Companion.TIME_FORMAT_1 import com.celzero.bravedns.util.KnownPorts import com.celzero.bravedns.util.Protocol @@ -49,386 +60,360 @@ import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.getDurationInHumanReadableFormat import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getIcon -import com.google.gson.Gson +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Locale -class RethinkLogAdapter(private val context: Context) : - PagingDataAdapter(DIFF_CALLBACK) { - - companion object { - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldConnection: RethinkLog, newConnection: RethinkLog) = - oldConnection.id == newConnection.id - - override fun areContentsTheSame( - oldConnection: RethinkLog, - newConnection: RethinkLog - ) = oldConnection == newConnection - } - - private const val MAX_BYTES = 500000 // 500 KB - private const val MAX_TIME_TCP = 135 // seconds - private const val MAX_TIME_UDP = 135 // seconds - private const val RTT_SHORT_THRESHOLD_MS = 20 // milliseconds - - const val DNS_IP_TEMPLATE_V4 = "10.111.222.3" - const val DNS_IP_TEMPLATE_V6 = "fd66:f83a:c650::3" - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RethinkLogViewHolder { - val itemBinding = - ListItemConnTrackBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return RethinkLogViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: RethinkLogViewHolder, position: Int) { - val log: RethinkLog = getItem(position) ?: return - holder.update(log) - holder.setTag(log) - } - - inner class RethinkLogViewHolder(private val b: ListItemConnTrackBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(log: RethinkLog) { - displayTransactionDetails(log) - displayProtocolDetails(log.port, log.protocol) - displayAppDetails(log) - displaySummaryDetails(log) - // case: when the rule is set to RULE12 but no proxy is set, consider this as error - // handle this as special case, and display the RULE1C hint - // RULE1C is the hint for RULE12 with no proxy set. - val blocked = if (log.blockedByRule == FirewallRuleset.RULE12.id) { - log.proxyDetails.isEmpty() - } else { - log.isBlocked - } - val rule = if (log.blockedByRule == FirewallRuleset.RULE12.id && log.proxyDetails.isEmpty()) { - FirewallRuleset.RULE18.id - } else { - log.blockedByRule - } - displayFirewallRulesetHint(log.isBlocked, rule) - - b.connectionParentLayout.setOnClickListener { openBottomSheet(log) } +private const val MAX_BYTES = 500000 // 500 KB +private const val MAX_TIME_TCP = 135 // seconds +private const val MAX_TIME_UDP = 135 // seconds + +const val DNS_IP_TEMPLATE_V4 = "10.111.222.3" +const val DNS_IP_TEMPLATE_V6 = "fd66:f83a:c650::3" + +@Composable +fun RethinkLogRow( + log: RethinkLog, + onShowConnTracker: (ConnectionTracker) -> Unit +) { + val context = LocalContext.current + val time = Utilities.convertLongToTime(log.timeStamp, TIME_FORMAT_1) + val protocolLabel = protocolLabel(context, log.port, log.protocol) + val indicatorColor = hintColor(context, log) + val summary = summaryInfo(context, log) + val flag = log.flag + val ipAddress = + if (log.ipAddress == DNS_IP_TEMPLATE_V4 || log.ipAddress == DNS_IP_TEMPLATE_V6) { + stringResource(R.string.dns_mode_info_title) + } else { + log.ipAddress } - fun setTag(log: RethinkLog) { - b.connectionResponseTime.tag = log.timeStamp - b.root.tag = log.timeStamp - } + var appName by remember(log.uid, log.appName) { mutableStateOf(log.appName) } + var appIcon by remember(log.uid) { mutableStateOf(null) } - private fun openBottomSheet(log: RethinkLog) { - if (context !is FragmentActivity) { - Logger.w(LOG_TAG_UI, "err opening the connection tracker bottomsheet") - return - } - // ToDo: get rid of rethink btm sht if not required - val bottomSheetFragment = ConnTrackerBottomSheet() - // see AppIpRulesAdapter.kt#openBottomSheet() - val bundle = Bundle() - bundle.putString(ConnTrackerBottomSheet.INSTANCE_STATE_IPDETAILS, Gson().toJson(log)) - bottomSheetFragment.arguments = bundle - bottomSheetFragment.show(context.supportFragmentManager, bottomSheetFragment.tag) + LaunchedEffect(log.uid, log.appName) { + val apps = + withContext(Dispatchers.IO) { FirewallManager.getPackageNamesByUid(log.uid) } + if (apps.isEmpty()) { + appIcon = Utilities.getDefaultIcon(context) + appName = log.appName + return@LaunchedEffect } - private fun displayTransactionDetails(log: RethinkLog) { - val time = Utilities.convertLongToTime(log.timeStamp, TIME_FORMAT_1) - b.connectionResponseTime.text = time - b.connectionFlag.text = log.flag - - if (log.ipAddress == DNS_IP_TEMPLATE_V4 || log.ipAddress == DNS_IP_TEMPLATE_V6) { - b.connectionIpAddress.text = context.getString(R.string.dns_mode_info_title) + val count = apps.count() + appName = + if (count > 1) { + context.getString( + R.string.ctbs_app_other_apps, + log.appName, + (count - 1).toString() + ) } else { - b.connectionIpAddress.text = log.ipAddress + log.appName } + appIcon = Utilities.getIcon(context, apps[0], "") + } - if (log.dnsQuery.isNullOrEmpty()) { - b.connectionDomain.visibility = View.GONE - } else { - b.connectionDomain.text = log.dnsQuery - b.connectionDomain.visibility = View.VISIBLE - // marquee is not working for the textview, hence the workaround. - b.connectionDomain.isSelected = true - } - } - - private fun displayAppDetails(log: RethinkLog) { - b.connectionAppName.text = log.appName - - io { - val apps = FirewallManager.getPackageNamesByUid(log.uid) - uiCtx { - if (apps.isEmpty()) { - loadAppIcon(Utilities.getDefaultIcon(context)) - return@uiCtx - } - - val count = apps.count() - val appName = - if (count > 1) { - context.getString( - R.string.ctbs_app_other_apps, - log.appName, - (count).minus(1).toString() - ) - } else { - log.appName - } - - b.connectionAppName.text = appName - loadAppIcon(getIcon(context, apps[0], /*No app name */ "")) - } + val dnsQuery = log.dnsQuery + + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { onShowConnTracker(toConnectionTracker(log)) } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = + Modifier + .width(1.5.dp) + .fillMaxHeight() + .background(indicatorColor ?: Color.Transparent) + ) + val iconDrawable = appIcon ?: Utilities.getDefaultIcon(context) + val iconPainter = rememberDrawablePainter(iconDrawable) + iconPainter?.let { painter -> + Image( + painter = painter, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) } - } - - private fun displayProtocolDetails(port: Int, proto: Int) { - // Instead of showing the port name and protocol, now the ports are resolved with - // known ports(reserved port and protocol identifiers). - // https://github.com/celzero/rethink-app/issues/42 - #3 - transport + protocol. - val resolvedPort = KnownPorts.resolvePort(port) - // case: for UDP/443 label it as HTTP3 instead of HTTPS - b.connLatencyTxt.text = - if (port == KnownPorts.HTTPS_PORT && proto == Protocol.UDP.protocolType) { - context.getString(R.string.connection_http3) - } else if (resolvedPort != KnownPorts.PORT_VAL_UNKNOWN) { - resolvedPort.uppercase(Locale.ROOT) - } else { - Protocol.getProtocolName(proto).name - } - } - - private fun displayFirewallRulesetHint(isBlocked: Boolean, ruleName: String) { - when { - // hint red when blocked - isBlocked -> { - b.connectionStatusIndicator.visibility = View.VISIBLE - val isError = FirewallRuleset.isProxyError(ruleName) - if (isError) { - b.connectionStatusIndicator.setBackgroundColor( - UIUtils.fetchColor(context, R.attr.chipTextNeutral) - ) - } else { - b.connectionStatusIndicator.setBackgroundColor( - ContextCompat.getColor(context, R.color.colorRed_A400) - ) - } - } - // hint white when whitelisted - (FirewallRuleset.shouldShowHint(ruleName)) -> { - b.connectionStatusIndicator.visibility = View.VISIBLE - b.connectionStatusIndicator.setBackgroundColor( - ContextCompat.getColor(context, R.color.primaryLightColorText) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = appName, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + Text( + text = protocolLabel, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp) + ) + Text( + text = flag, + style = MaterialTheme.typography.titleMedium ) } - // no hints, otherwise - else -> { - b.connectionStatusIndicator.visibility = View.INVISIBLE + Text( + text = ipAddress, + style = MaterialTheme.typography.bodyMedium + ) + if (!dnsQuery.isNullOrEmpty()) { + Text( + text = dnsQuery, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) } } } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = time, style = MaterialTheme.typography.bodySmall) + Text( + text = summary.duration, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = summary.delay, + style = MaterialTheme.typography.bodySmall + ) + } + if (summary.showSummary) { + Text( + text = summary.dataUsage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + Spacer(modifier = Modifier.fillMaxWidth()) + } +} - private fun displaySummaryDetails(log: RethinkLog) { - io { - val connType = ConnectionTracker.ConnType.get(log.connType) - val hasCid = VpnController.hasCid(log.connId, log.uid) - uiCtx { - b.connectionDataUsage.text = "" - b.connectionDelay.text = "" - if ( - log.duration == 0 && - log.downloadBytes == 0L && - log.uploadBytes == 0L && - log.message.isEmpty() - ) { - var hasMinSummary = false - if (hasCid) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDataUsage.text = context.getString(R.string.lbl_active) - b.connectionDuration.text = context.getString(R.string.symbol_green_circle) - b.connectionDelay.text = "" - hasMinSummary = true - } else { - b.connectionDataUsage.text = "" - b.connectionDuration.text ="" - } - if (connType.isMetered()) { - b.connectionDelay.text = context.getString(R.string.symbol_currency) - hasMinSummary = true - } else { - b.connectionDelay.text = "" - } - - if (isRpnProxy(log.rpid)) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_sparkle) - ) - } else if (isConnectionProxied(log.blockedByRule, log.proxyDetails)) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_key) - ) - hasMinSummary = true - } - if (!hasMinSummary) { - b.connectionSummaryLl.visibility = View.GONE - } - return@uiCtx - } - - b.connectionSummaryLl.visibility = View.VISIBLE - val duration = getDurationInHumanReadableFormat(context, log.duration) - b.connectionDuration.text = context.getString(R.string.single_argument, duration) - // add unicode for download and upload - val download = - context.getString( - R.string.symbol_download, - Utilities.humanReadableByteCount(log.downloadBytes, true) - ) - val upload = - context.getString( - R.string.symbol_upload, - Utilities.humanReadableByteCount(log.uploadBytes, true) - ) - b.connectionDataUsage.text = context.getString(R.string.two_argument, upload, download) - b.connectionDelay.text = "" - if (connType.isMetered()) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_currency) - ) - } - if (isConnectionHeavier(log)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_heavy) - ) - } - if (isConnectionSlower(log)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_turtle) - ) - } - // bunny in case rpid as present, key in case of proxy - // bunny and key indicate conn is proxied, so its enough to show one of them - if (isRpnProxy(log.rpid)) { - b.connectionSummaryLl.visibility = View.VISIBLE - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_sparkle) - ) - } else if (containsRelayProxy(log.rpid)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_bunny) - ) - } else if (isConnectionProxied(log.blockedByRule, log.proxyDetails)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_key) - ) - } - - // rtt -> show rocket if less than 20ms, treat it as rtt - if (isRoundTripShorter(log.synack, log.isBlocked)) { - b.connectionDelay.text = - context.getString( - R.string.ci_desc, - b.connectionDelay.text, - context.getString(R.string.symbol_rocket) - ) - } +private fun protocolLabel(context: Context, port: Int, proto: Int): String { + val resolvedPort = KnownPorts.resolvePort(port) + return if (port == KnownPorts.HTTPS_PORT && proto == Protocol.UDP.protocolType) { + context.getString(R.string.connection_http3) + } else if (resolvedPort != KnownPorts.PORT_VAL_UNKNOWN) { + resolvedPort.uppercase(Locale.ROOT) + } else { + Protocol.getProtocolName(proto).name + } +} - if (b.connectionDelay.text.isEmpty() && b.connectionDataUsage.text.isEmpty()) { - b.connectionSummaryLl.visibility = View.GONE - } - } - } +@Composable +private fun hintColor(context: Context, log: RethinkLog): Color? { + val blocked = + if (log.blockedByRule == FirewallRuleset.RULE12.id) { + log.proxyDetails.isEmpty() + } else { + log.isBlocked } - - private fun isRoundTripShorter(rtt: Long, blocked: Boolean): Boolean { - return rtt in 1..RTT_SHORT_THRESHOLD_MS && !blocked + val rule = + if (log.blockedByRule == FirewallRuleset.RULE12.id && log.proxyDetails.isEmpty()) { + FirewallRuleset.RULE18.id + } else { + log.blockedByRule } - - private fun containsRelayProxy(rpid: String): Boolean { - return rpid.isNotEmpty() + return when { + blocked -> { + val isError = FirewallRuleset.isProxyError(rule) + if (isError) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + } } - - private fun isConnectionProxied(ruleName: String?, proxyDetails: String): Boolean { - if (ruleName == null) return false - val rule = FirewallRuleset.getFirewallRule(ruleName) ?: return false - val proxy = ProxyManager.isNotLocalAndRpnProxy(proxyDetails) - // show key symbol in case of proxy error too - val isProxyError = FirewallRuleset.isProxyError(ruleName) - return (FirewallRuleset.isProxied(rule) && proxyDetails.isNotEmpty() && proxy) || isProxyError + FirewallRuleset.shouldShowHint(rule) -> { + MaterialTheme.colorScheme.onSurfaceVariant } + else -> null + } +} - private fun isRpnProxy(pid: String): Boolean { - return pid.isNotEmpty() && ProxyManager.isRpnProxy(pid) +data class LogSummary(val dataUsage: String, val duration: String, val delay: String, val showSummary: Boolean) + +private fun summaryInfo(context: Context, log: RethinkLog): LogSummary { + val connType = ConnectionTracker.ConnType.get(log.connType) + var dataUsage = "" + var delay = "" + var duration = "" + var showSummary = false + + if (log.duration == 0 && log.downloadBytes == 0L && log.uploadBytes == 0L && log.message.isEmpty()) { + var hasMinSummary = false + if (VpnController.hasCid(log.connId, log.uid)) { + dataUsage = context.getString(R.string.lbl_active) + duration = context.getString(R.string.symbol_green_circle) + hasMinSummary = true } - private fun isConnectionHeavier(log: RethinkLog): Boolean { - return log.downloadBytes + log.uploadBytes > MAX_BYTES + if (connType.isMetered()) { + delay = context.getString(R.string.symbol_currency) + hasMinSummary = true } - private fun isConnectionSlower(log: RethinkLog): Boolean { - return (log.protocol == Protocol.UDP.protocolType && log.duration > MAX_TIME_UDP) || - (log.protocol == Protocol.TCP.protocolType && log.duration > MAX_TIME_TCP) + if (isRpnProxy(log.rpid)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_sparkle) + ) + } else if (isConnectionProxied(log.blockedByRule, log.proxyDetails)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_key) + ) + hasMinSummary = true } + showSummary = hasMinSummary + return LogSummary(dataUsage, duration, delay, showSummary) + } - private fun loadAppIcon(drawable: Drawable?) { - Glide.with(context) - .load(drawable) - .error(Utilities.getDefaultIcon(context)) - .into(b.connectionAppIcon) - } + showSummary = true + duration = context.getString(R.string.single_argument, getDurationInHumanReadableFormat(context, log.duration)) + val download = + context.getString( + R.string.symbol_download, + Utilities.humanReadableByteCount(log.downloadBytes, true) + ) + val upload = + context.getString( + R.string.symbol_upload, + Utilities.humanReadableByteCount(log.uploadBytes, true) + ) + dataUsage = context.getString(R.string.two_argument, upload, download) + + if (connType.isMetered()) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_currency) + ) + } + if (isConnectionHeavier(log)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_heavy) + ) + } + if (isConnectionSlower(log)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_turtle) + ) } + if (isRpnProxy(log.rpid)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_sparkle) + ) + } else if (containsRelayProxy(log.rpid)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_bunny) + ) + } else if (isConnectionProxied(log.blockedByRule, log.proxyDetails)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_key) + ) + } + if (isRoundTripShorter(log.synack, log.isBlocked)) { + delay = + context.getString( + R.string.ci_desc, + delay, + context.getString(R.string.symbol_rocket) + ) + } + showSummary = delay.isNotEmpty() || dataUsage.isNotEmpty() + return LogSummary(dataUsage, duration, delay, showSummary) +} + +private fun isRoundTripShorter(rtt: Long, blocked: Boolean): Boolean { + return rtt in 1..20 && !blocked +} - private fun io(f: suspend () -> Unit) { - val owner = context as? LifecycleOwner ?: return +private fun containsRelayProxy(rpid: String): Boolean { + return rpid.isNotEmpty() +} - owner.lifecycleScope.launch(Dispatchers.IO) { f() } - } +private fun isConnectionProxied(ruleName: String?, proxyDetails: String): Boolean { + if (ruleName == null) return false + val rule = FirewallRuleset.getFirewallRule(ruleName) ?: return false + val proxy = ProxyManager.isNotLocalAndRpnProxy(proxyDetails) + val isProxyError = FirewallRuleset.isProxyError(ruleName) + return (FirewallRuleset.isProxied(rule) && proxyDetails.isNotEmpty() && proxy) || isProxyError +} - private suspend fun uiCtx(f: suspend () -> Unit) { - val owner = context as? LifecycleOwner ?: return +private fun isRpnProxy(pid: String): Boolean { + return pid.isNotEmpty() && ProxyManager.isRpnProxy(pid) +} - withContext(Dispatchers.Main.immediate) { - if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - return@withContext - } +private fun isConnectionHeavier(log: RethinkLog): Boolean { + return log.downloadBytes + log.uploadBytes > MAX_BYTES +} - f() - } - } +private fun isConnectionSlower(log: RethinkLog): Boolean { + return (log.protocol == Protocol.UDP.protocolType && log.duration > MAX_TIME_UDP) || + (log.protocol == Protocol.TCP.protocolType && log.duration > MAX_TIME_TCP) +} + +private fun toConnectionTracker(log: RethinkLog): ConnectionTracker { + val tracker = ConnectionTracker() + tracker.appName = log.appName + tracker.uid = log.uid + tracker.usrId = log.usrId + tracker.ipAddress = log.ipAddress + tracker.port = log.port + tracker.protocol = log.protocol + tracker.isBlocked = log.isBlocked + tracker.blockedByRule = log.blockedByRule + tracker.blocklists = log.blocklists + tracker.proxyDetails = log.proxyDetails + tracker.flag = log.flag + tracker.dnsQuery = log.dnsQuery + tracker.timeStamp = log.timeStamp + tracker.connId = log.connId + tracker.downloadBytes = log.downloadBytes + tracker.uploadBytes = log.uploadBytes + tracker.duration = log.duration + tracker.synack = log.synack + tracker.rpid = log.rpid + tracker.message = log.message + tracker.connType = log.connType + return tracker } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt index 4d90dc11a..477474a65 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt @@ -15,34 +15,50 @@ */ package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_PROXY -import Logger.LOG_TAG_UI import android.content.Context import android.content.Intent import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.Toast -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import com.celzero.bravedns.R -import com.celzero.bravedns.adapter.OneWgConfigAdapter.DnsStatusListener import com.celzero.bravedns.database.EventSource import com.celzero.bravedns.database.EventType import com.celzero.bravedns.database.Severity import com.celzero.bravedns.database.WgConfigFiles -import com.celzero.bravedns.database.WgConfigFilesImmutable -import com.celzero.bravedns.databinding.ListItemWgGeneralInterfaceBinding import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.EventLogger import com.celzero.bravedns.service.ProxyManager -import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.service.WireguardManager import com.celzero.bravedns.service.WireguardManager.ERR_CODE_OTHER_WG_ACTIVE @@ -50,653 +66,573 @@ import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_ACTIVE import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_FULL import com.celzero.bravedns.service.WireguardManager.ERR_CODE_WG_INVALID import com.celzero.bravedns.service.WireguardManager.WG_UPTIME_THRESHOLD -import com.celzero.bravedns.ui.activity.WgConfigDetailActivity -import com.celzero.bravedns.ui.activity.WgConfigEditorActivity.Companion.INTENT_EXTRA_WG_ID +import com.celzero.bravedns.ui.compose.wireguard.WgType 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 com.celzero.bravedns.wireguard.WgInterface -import com.celzero.firestack.backend.Backend import com.celzero.firestack.backend.RouterStats +import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class WgConfigAdapter(private val context: Context, private val listener: DnsStatusListener, private val splitDns: Boolean, private val eventLogger: EventLogger) : - PagingDataAdapter(DIFF_CALLBACK) { - private var lifecycleOwner: LifecycleOwner? = null - - companion object { - private const val DELAY_MS = 1500L - private const val TAG = "WgCfgAdapter" - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame( - oldConnection: WgConfigFiles, - newConnection: WgConfigFiles - ): Boolean { - return oldConnection == newConnection - } - - override fun areContentsTheSame( - oldConnection: WgConfigFiles, - newConnection: WgConfigFiles - ): Boolean { - return oldConnection == newConnection - } - } +private const val DELAY_MS = 1500L + +data class WgChips( + val ipv4: Boolean = false, + val ipv6: Boolean = false, + val splitTunnel: Boolean = false, + val amnezia: Boolean = false, + val hopSrc: Boolean = false, + val hopping: Boolean = false, + val properties: String = "" +) { + fun hasAny(): Boolean { + return ipv4 || + ipv6 || + splitTunnel || + amnezia || + hopSrc || + hopping || + properties.isNotEmpty() } +} - override fun onBindViewHolder(holder: WgInterfaceViewHolder, position: Int) { - val item = getItem(position) - val wgConfigFiles: WgConfigFiles = item ?: return - holder.update(wgConfigFiles) +data class WgUiState( + val isActive: Boolean, + val statusText: String, + val appsText: String, + val showActiveLayout: Boolean, + val uptimeText: String, + val rxtxText: String, + val strokeColor: Color, + val strokeWidth: Dp +) + +@Composable +fun WgConfigRow( + config: WgConfigFiles, + eventLogger: EventLogger, + onDnsStatusChanged: () -> Unit, + onConfigDetailClick: (Int, WgType) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + var isChecked by remember(config.id, config.isActive) { + mutableStateOf(config.isActive && VpnController.hasTunnel()) } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WgInterfaceViewHolder { - val itemBinding = - ListItemWgGeneralInterfaceBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - if (lifecycleOwner == null) { - lifecycleOwner = parent.findViewTreeLifecycleOwner() - } - return WgInterfaceViewHolder(itemBinding) + var statusText by remember(config.id) { + mutableStateOf(context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase)) } - - override fun onViewDetachedFromWindow(holder: WgInterfaceViewHolder) { - super.onViewDetachedFromWindow(holder) - holder.cancelJobIfAny() - } - - inner class WgInterfaceViewHolder(private val b: ListItemWgGeneralInterfaceBinding) : - RecyclerView.ViewHolder(b.root) { - private var job: Job? = null - - fun update(config: WgConfigFiles) { - b.interfaceNameText.text = config.name - b.interfaceNameText.isSelected = true - b.interfaceIdText.text = context.getString(R.string.single_argument_parenthesis, config.id.toString()) - b.interfaceSwitch.isChecked = config.isActive && VpnController.hasTunnel() - setupClickListeners(config) - val appsCount = ProxyManager.getAppCountForProxy(ID_WG_BASE + config.id) - updateUi(config, appsCount) - updateStatusJob(config) - updateHopSrcChip(config.id) - updateAmneziaChip(config) - updateHoppingChip(config.id) - } - - private fun updateStatusJob(config: WgConfigFiles) { - if (config.isActive && VpnController.hasTunnel()) { - job = updateProxyStatusContinuously(config) - } else { - cancelJobIfAny() - disableInactiveConfig(config) - } - } - - private fun disableInactiveConfig(config: WgConfigFiles) { - if (config.isLockdown) { - b.protocolInfoChipGroup.visibility = View.GONE - b.interfaceActiveLayout.visibility = View.GONE - b.interfaceStatus.visibility = View.GONE - } else { - b.interfaceAppsCount.visibility = View.GONE - b.interfaceActiveLayout.visibility = View.GONE - b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.background) - b.interfaceDetailCard.strokeWidth = 0 - b.interfaceSwitch.isChecked = false - b.protocolInfoChipGroup.visibility = View.GONE - b.interfaceStatus.visibility = View.VISIBLE - b.interfaceStatus.text = - context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) - updateProtocolChip(Pair(false, false)) - updateSplitTunnelChip(false) - updateHopSrcChip(config.id) - updateAmneziaChip(config) - updateHoppingChip(config.id) - } - } - - private fun updateProxyStatusContinuously(config: WgConfigFiles): Job? { - return io { - while (true) { - updateStatus(config) - delay(DELAY_MS) + var appsText by remember(config.id) { mutableStateOf("") } + var showActiveLayout by remember(config.id) { mutableStateOf(false) } + var uptimeText by remember(config.id) { mutableStateOf("") } + var rxtxText by remember(config.id) { mutableStateOf("") } + val errorColor = MaterialTheme.colorScheme.error + val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant + val tertiaryColor = MaterialTheme.colorScheme.tertiary + var strokeColor by remember(config.id, errorColor) { mutableStateOf(errorColor) } + var strokeWidth by remember(config.id) { mutableStateOf(0.dp) } + var chips by remember(config.id) { mutableStateOf(WgChips()) } + var inProgress by remember(config.id) { mutableStateOf(false) } + + LaunchedEffect( + config.id, + config.isActive, + config.isCatchAll, + config.useOnlyOnMetered, + config.ssidEnabled + ) { + chips = withContext(Dispatchers.IO) { computeChips(context, config) } + while (isActive) { + if (!lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + delay(DELAY_MS) + continue + } + val uiState = + withContext(Dispatchers.IO) { + computeStatusUi( + context = context, + config = config, + errorColor = errorColor, + onSurfaceVariantColor = onSurfaceVariantColor, + tertiaryColor = tertiaryColor + ) } - } - } - - private fun updateProtocolChip(pair: Pair?) { - if (pair == null) return - - if (!pair.first && !pair.second) { - b.protocolInfoChipIpv4.visibility = View.GONE - b.protocolInfoChipIpv6.visibility = View.GONE - return - } - b.protocolInfoChipGroup.visibility = View.VISIBLE - b.protocolInfoChipIpv4.visibility = View.GONE - b.protocolInfoChipIpv6.visibility = View.GONE - if (pair.first) { - b.protocolInfoChipIpv4.visibility = View.VISIBLE - b.protocolInfoChipIpv4.text = context.getString(R.string.settings_ip_text_ipv4) - } else { - b.protocolInfoChipIpv4.visibility = View.GONE - } - if (pair.second) { - b.protocolInfoChipIpv6.visibility = View.VISIBLE - b.protocolInfoChipIpv6.text = context.getString(R.string.settings_ip_text_ipv6) - } else { - b.protocolInfoChipIpv6.visibility = View.GONE - } + isChecked = uiState.isActive + statusText = uiState.statusText + appsText = uiState.appsText + showActiveLayout = uiState.showActiveLayout + uptimeText = uiState.uptimeText + rxtxText = uiState.rxtxText + strokeColor = uiState.strokeColor + strokeWidth = uiState.strokeWidth + delay(DELAY_MS) } + } - private fun updateSplitTunnelChip(isSplitTunnel: Boolean) { - if (isSplitTunnel) { - b.chipSplitTunnel.visibility = View.VISIBLE - } else { - b.chipSplitTunnel.visibility = View.GONE - } + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { launchConfigDetail(context, config.id, onConfigDetailClick) }, + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + border = if (strokeWidth > 0.dp) { + BorderStroke(strokeWidth, strokeColor) + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) } + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = config.name, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + context.getString( + R.string.single_argument_parenthesis, + config.id.toString() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + if (chips.hasAny()) { + Row( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (chips.ipv4) { + WgChip(text = context.getString(R.string.settings_ip_text_ipv4)) + } + if (chips.ipv6) { + WgChip(text = context.getString(R.string.settings_ip_text_ipv6)) + } + if (chips.splitTunnel) { + WgChip(text = context.getString(R.string.lbl_split)) + } + if (chips.amnezia) { + WgChip(text = context.getString(R.string.lbl_amnezia)) + } + if (chips.hopSrc) { + WgChip(text = context.getString(R.string.lbl_hopping)) + } + if (chips.hopping) { + WgChip(text = context.getString(R.string.cd_dns_crypt_relay_heading)) + } + if (chips.properties.isNotEmpty()) { + WgChip(text = chips.properties) + } + } + } + } - private fun updateHopSrcChip(id: Int) { - val sid = ID_WG_BASE + id - val hop = WgHopManager.getMapBySrc(sid) - if (hop.isNotEmpty()) { - b.protocolInfoChipGroup.visibility = View.VISIBLE - b.chipHopSrc.visibility = View.VISIBLE - b.chipHopSrc.text = context.getString( - R.string.two_argument_space, - context.getString(R.string.symbol_bunny), - context.getString(R.string.lbl_hopping) + Switch( + checked = isChecked, + onCheckedChange = { checked -> + if (inProgress) return@Switch + inProgress = true + scope.launch(Dispatchers.IO) { + val success = + if (checked) { + enableWgIfPossible(context, config, onDnsStatusChanged, eventLogger) + } else { + disableWgIfPossible(context, config, onDnsStatusChanged, eventLogger) + } + withContext(Dispatchers.Main) { + isChecked = if (checked) success else !success + inProgress = false + } + } + } ) - } else { - b.chipHopSrc.visibility = View.GONE } - } - private fun updateHoppingChip(id: Int) { - val sid = ID_WG_BASE + id - val hops = WgHopManager.getMapByHop(sid) - if (hops.isNotEmpty()) { - b.protocolInfoChipGroup.visibility = View.VISIBLE - b.chipHopping.visibility = View.VISIBLE - val hopContentTxt = context.getString( - R.string.two_argument_colon, context.getString(R.string.lbl_hop), - hops.joinToString { it.src }) - b.chipHopping.text = context.getString( - R.string.two_argument_space, - context.getString(R.string.symbol_satellite), - hopContentTxt - ) - } else { - b.chipHopping.visibility = View.GONE - } - } + Text( + text = statusText, + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 4.dp) + ) - fun cancelJobIfAny() { - if (job?.isActive == true) { - job?.cancel() - } - } + Text( + text = appsText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) - private suspend fun updateStatus(config: WgConfigFiles) { - val id = ID_WG_BASE + config.id - val statusId = VpnController.getProxyStatusById(id) - val pair = VpnController.getSupportedIpVersion(id) - val c = WireguardManager.getConfigById(config.id) - val stats = VpnController.getProxyStats(id) - val dnsStatusId = if (splitDns) { - VpnController.getDnsStatus(id) - } else { - null - } - val isSplitTunnel = - if (c?.getPeers()?.isNotEmpty() == true) { - VpnController.isSplitTunnelProxy(id, pair) - } else { - false + if (showActiveLayout) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = uptimeText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + Text( + text = rxtxText, + style = MaterialTheme.typography.bodySmall + ) } - - // if the view is not active then cancel the job - if ( - lifecycleOwner != null && - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false - ) { - cancelJobIfAny() - return - } - uiCtx { - updateStatusUi(config, statusId, dnsStatusId, stats) - updateProtocolChip(pair) - updateSplitTunnelChip(isSplitTunnel) } } + } +} - private fun updateAmneziaChip(config: WgConfigFiles) { - val c = WireguardManager.getConfigById(config.id) ?: return +@Composable +fun WgChip(text: String) { + AssistChip(onClick = {}, label = { Text(text = text) }) +} - c.getInterface()?.let { - if (isAmneziaConfig(it)) { - b.protocolInfoChipGroup.visibility = View.VISIBLE - b.chipAmnezia.visibility = View.VISIBLE - } else { - b.chipAmnezia.visibility = View.GONE - } - } +suspend fun computeChips(context: Context, config: WgConfigFiles): WgChips { + val id = ProxyManager.ID_WG_BASE + config.id + val pair = VpnController.getSupportedIpVersion(id) + val cfg = WireguardManager.getConfigById(config.id) + val splitTunnel = + if (cfg?.getPeers()?.isNotEmpty() == true) { + VpnController.isSplitTunnelProxy(id, pair) + } else { + false } + val hopSrc = WgHopManager.getMapBySrc(id).isNotEmpty() + val hopping = WgHopManager.isAlreadyHop(id) + val properties = buildString { + if (config.isCatchAll) append(context.getString(R.string.symbol_lightening)) + if (config.useOnlyOnMetered) append(context.getString(R.string.symbol_mobile)) + if (config.ssidEnabled) append(context.getString(R.string.symbol_id)) + } + val amnezia = cfg?.getInterface()?.isAmnezia() == true + return WgChips( + ipv4 = pair.first, + ipv6 = pair.second, + splitTunnel = splitTunnel, + amnezia = amnezia, + hopSrc = hopSrc, + hopping = hopping, + properties = properties + ) +} - private fun isAmneziaConfig(c: WgInterface): Boolean { - // TODO: should we add more checks here? - // consider the config values jc, jmin, jmax, h1, h2, h3, h4, s1, s2 - return c.getJc().isPresent || c.getJmin().isPresent || c.getJmax().isPresent || - c.getH1().isPresent || c.getH2().isPresent || c.getH3().isPresent || - c.getH4().isPresent || c.getS1().isPresent || c.getS2().isPresent +suspend fun computeStatusUi( + context: Context, + config: WgConfigFiles, + errorColor: Color, + onSurfaceVariantColor: Color, + tertiaryColor: Color +): WgUiState { + val proxyId = ProxyManager.ID_WG_BASE + config.id + val appCount = ProxyManager.getAppsCountForProxy(proxyId) + val appsText = + if (config.isCatchAll) { + context.getString(R.string.routing_remaining_apps) + } else { + context.getString(R.string.add_remove_apps, appCount.toString()) } - private fun updateUi(mapping: WgConfigFiles, appsCount: Int) { - b.interfaceAppsCount.visibility = View.VISIBLE - b.chipProperties.text = "" - if (mapping.isCatchAll) { - b.chipProperties.visibility = View.VISIBLE - b.chipProperties.text = context.getString(R.string.symbol_lightening) - } - if (mapping.isLockdown) { - if (!mapping.isActive) { - b.interfaceDetailCard.strokeWidth = 2 - b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.accentBad) - } - 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 (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) - ) - } + if (config.isActive && !VpnController.hasTunnel()) { + return WgUiState( + isActive = false, + statusText = context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase), + appsText = appsText, + showActiveLayout = false, + uptimeText = "", + rxtxText = "", + strokeColor = errorColor, + strokeWidth = 0.dp + ) + } - val visible = if (b.chipProperties.text.isNotEmpty()) View.VISIBLE else View.GONE - b.chipProperties.visibility = visible + if (!config.isActive || !VpnController.hasTunnel()) { + return WgUiState( + isActive = false, + statusText = context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase), + appsText = appsText, + showActiveLayout = false, + uptimeText = "", + rxtxText = "", + strokeColor = errorColor, + strokeWidth = 0.dp + ) + } - if (!mapping.isActive) { - // no need to update the apps count if the config is disabled - b.interfaceAppsCount.visibility = View.GONE - b.interfaceActiveLayout.visibility = View.GONE - } else if (mapping.isCatchAll) { - b.interfaceAppsCount.text = context.getString(R.string.routing_remaining_apps) - b.interfaceAppsCount.setTextColor(fetchColor(context, R.attr.primaryLightColorText)) - } else { - b.interfaceAppsCount.text = context.getString(R.string.firewall_card_status_active, appsCount.toString()) - if (appsCount == 0) { - b.interfaceAppsCount.setTextColor(fetchColor(context, R.attr.accentBad)) - } else { - b.interfaceAppsCount.setTextColor(fetchColor(context, R.attr.primaryLightColorText)) - } - } + val statusPair = VpnController.getProxyStatusById(proxyId) + val stats = VpnController.getProxyStats(proxyId) + val dnsStatusId = VpnController.getDnsStatus(proxyId) + val statusText = + if (dnsStatusId != null && isDnsError(dnsStatusId)) { + context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + } else { + getStatusText( + context, + UIUtils.ProxyStatus.entries.find { it.id == statusPair.first }, + getHandshakeTime(stats).toString(), + stats, + statusPair.second + ) } - private fun updateStatusUi(config: WgConfigFiles, statusPair: Pair, dnsStatusId: Long?, stats: RouterStats?) { - if (config.isActive) { - b.interfaceSwitch.isChecked = true - b.interfaceDetailCard.strokeWidth = 2 - b.interfaceStatus.visibility = View.VISIBLE - b.interfaceActiveLayout.visibility = View.VISIBLE - val time = getUpTime(stats) - val rxtx = getRxTx(stats) - if (time.isNotEmpty()) { - val t = context.getString(R.string.logs_card_duration, time) - b.interfaceActiveUptime.text = - context.getString( - R.string.two_argument_space, - context.getString(R.string.lbl_active), - t - ) - } else { - b.interfaceActiveUptime.text = context.getString(R.string.lbl_active) - } - b.interfaceActiveRxTx.text = rxtx - - if (dnsStatusId != null) { - // check for dns failure cases and update the UI - if (isDnsError(dnsStatusId) && statusPair.first != Backend.TPU) { - b.interfaceDetailCard.strokeColor = - fetchColor(context, R.attr.chipTextNegative) - val humanReadableLastOk = getHumanReadableLastOk(stats).toString() - // show last ok time if available - if (humanReadableLastOk.isEmpty()) { - b.interfaceStatus.text = context.getString( - R.string.status_failing - ).replaceFirstChar(Char::titlecase) - } else { - b.interfaceStatus.text = context.getString( - R.string.about_version_install_source, - context.getString(R.string.status_failing) - .replaceFirstChar(Char::titlecase), humanReadableLastOk - ) - } - Logger.d( - LOG_TAG_UI, - "$TAG DNS failing, status updated to failing with stroke color chipTextNegative, lastok:${stats?.lastOK}, since:${stats?.since}, humanReadableLastOk:$humanReadableLastOk" - ) - } else { - // if dns status is not failing, then update the proxy status - updateProxyStatusUi(statusPair, stats) - } - } else { - // in one wg mode, if dns status should be available, this is a fallback case - updateProxyStatusUi(statusPair, stats) - } - } else { - b.interfaceActiveLayout.visibility = View.GONE - b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.background) - b.interfaceDetailCard.strokeWidth = 0 - b.interfaceSwitch.isChecked = false - b.interfaceAppsCount.visibility = View.GONE - b.interfaceStatus.visibility = View.VISIBLE - b.interfaceStatus.text = - context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) - } + val strokeColor = + if (dnsStatusId != null && isDnsError(dnsStatusId)) { + errorColor + } else { + val status = + UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } + getStrokeColorForStatus( + status = status, + stats = stats, + errorColor = errorColor, + onSurfaceVariantColor = onSurfaceVariantColor, + tertiaryColor = tertiaryColor + ) } - private fun getStrokeColorForStatus(status: UIUtils.ProxyStatus?, stats: RouterStats?): Int { - val now = System.currentTimeMillis() - val lastOk = stats?.lastOK ?: 0L - val since = stats?.since ?: 0L - val isFailing = now - since > WG_UPTIME_THRESHOLD && lastOk == 0L - return when (status) { - UIUtils.ProxyStatus.TOK -> if (isFailing) R.attr.chipTextNegative else R.attr.accentGood - // treat TNT as neutral, for v055u (until fixed in go), as there is a scenario - // where idle is behaving as waiting - UIUtils.ProxyStatus.TUP, UIUtils.ProxyStatus.TZZ, UIUtils.ProxyStatus.TNT -> R.attr.chipTextNeutral - else -> R.attr.chipTextNegative // TKO, TEND - } + val rxtx = getRxTx(context, stats) + val time = getUpTime(stats) + val uptimeText = + if (time.isNotEmpty()) { + val t = context.getString(R.string.logs_card_duration, time) + context.getString( + R.string.two_argument_space, + context.getString(R.string.lbl_active), + t + ) + } else { + context.getString(R.string.lbl_active) } - private fun getStatusText( - status: UIUtils.ProxyStatus?, - humanReadableLastOk: String? = null, - stats: RouterStats?, - errMsg: String? = null - ): String { - if (status == null) { - val txt = if (!errMsg.isNullOrEmpty()) { - context.getString(R.string.status_waiting) + " ($errMsg)" - } else { - context.getString(R.string.status_waiting) - } - return txt.replaceFirstChar(Char::titlecase) - } + return WgUiState( + isActive = true, + statusText = statusText, + appsText = appsText, + showActiveLayout = true, + uptimeText = uptimeText, + rxtxText = rxtx, + strokeColor = strokeColor, + strokeWidth = 2.dp + ) +} - // no need to check for lastOk/since for paused wg - if (status == UIUtils.ProxyStatus.TPU) { - return context.getString(UIUtils.getProxyStatusStringRes(status.id)) - .replaceFirstChar(Char::titlecase) - } +private fun isDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || + s == Transaction.Status.BAD_RESPONSE || + s == Transaction.Status.NO_RESPONSE || + s == Transaction.Status.SEND_FAIL || + s == Transaction.Status.CLIENT_ERROR || + s == Transaction.Status.INTERNAL_ERROR || + s == Transaction.Status.TRANSPORT_ERROR +} - val now = System.currentTimeMillis() - val lastOk = stats?.lastOK ?: 0L - val since = stats?.since ?: 0L - if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { - return context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) +private fun getStrokeColorForStatus( + status: UIUtils.ProxyStatus?, + stats: RouterStats?, + errorColor: Color, + onSurfaceVariantColor: Color, + tertiaryColor: Color +): Color { + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + val isFailing = now - since > WG_UPTIME_THRESHOLD && lastOk == 0L + return when (status) { + UIUtils.ProxyStatus.TOK -> + if (isFailing) { + onSurfaceVariantColor + } else { + tertiaryColor } + UIUtils.ProxyStatus.TUP, + UIUtils.ProxyStatus.TZZ, + UIUtils.ProxyStatus.TNT -> onSurfaceVariantColor + else -> errorColor + } +} - val baseText = context.getString(UIUtils.getProxyStatusStringRes(status.id)) - .replaceFirstChar(Char::titlecase) - - return if (stats?.lastOK != 0L && humanReadableLastOk != null) { - context.getString(R.string.about_version_install_source, baseText, humanReadableLastOk) +fun getStatusText( + context: Context, + status: UIUtils.ProxyStatus?, + handshakeTime: String? = null, + stats: RouterStats?, + errMsg: String? = null +): String { + if (status == null) { + val txt = + if (errMsg != null) { + context.getString(R.string.status_waiting) + " ($errMsg)" } else { - baseText + context.getString(R.string.status_waiting) } - } - - private fun updateProxyStatusUi(statusPair: Pair, stats: RouterStats?) { - val status = UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } // Convert to enum + return txt.replaceFirstChar(Char::titlecase) + } - val humanReadableLastOk = getHumanReadableLastOk(stats).toString() + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { + return context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + } - val strokeColor = getStrokeColorForStatus(status, stats) - b.interfaceDetailCard.strokeColor = fetchColor(context, strokeColor) - val statusText = getStatusText(status, humanReadableLastOk, stats, statusPair.second) + val baseText = + context.getString(UIUtils.getProxyStatusStringRes(status.id)) + .replaceFirstChar(Char::titlecase) + return if (stats?.lastOK != 0L && handshakeTime != null) { + context.getString(R.string.about_version_install_source, baseText, handshakeTime) + } else { + baseText + } +} - b.interfaceStatus.text = statusText - Logger.d(LOG_TAG_UI, "$TAG status updated to $statusText (${status?.id} - ${status?.name}) with stroke color $strokeColor, lastok:${stats?.lastOK}, since:${stats?.since}, humanReadableLastOk:$humanReadableLastOk") - } +fun getUpTime(stats: RouterStats?): CharSequence { + if (stats == null) return "" + if (stats.since <= 0L) return "" + val now = System.currentTimeMillis() + return DateUtils.getRelativeTimeSpanString( + stats.since, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) +} - private fun isDnsError(statusId: Long?): Boolean { - if (statusId == null) return true +fun getRxTx(context: Context, stats: RouterStats?): String { + if (stats == null) return "" + val rx = + context.getString( + R.string.symbol_download, + Utilities.humanReadableByteCount(stats.rx, true) + ) + val tx = + context.getString( + R.string.symbol_upload, + Utilities.humanReadableByteCount(stats.tx, true) + ) + return context.getString(R.string.two_argument_space, tx, rx) +} - val s = Transaction.Status.fromId(statusId) - return s == Transaction.Status.BAD_QUERY || s == Transaction.Status.BAD_RESPONSE || s == Transaction.Status.NO_RESPONSE || s == Transaction.Status.SEND_FAIL || s == Transaction.Status.CLIENT_ERROR || s == Transaction.Status.INTERNAL_ERROR || s == Transaction.Status.TRANSPORT_ERROR - } +fun getHandshakeTime(stats: RouterStats?): CharSequence { + if (stats == null) return "" + if (stats.lastOK == 0L) return "" + val now = System.currentTimeMillis() + return DateUtils.getRelativeTimeSpanString( + stats.lastOK, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) +} - private fun getRxTx(stats: RouterStats?): String { - if (stats == null) return "" - val rx = - context.getString( - R.string.symbol_download, - Utilities.humanReadableByteCount(stats.rx, true) - ) - val tx = - context.getString( - R.string.symbol_upload, - Utilities.humanReadableByteCount(stats.tx, true) - ) - return context.getString(R.string.two_argument_space, tx, rx) +suspend fun enableWgIfPossible(context: Context, config: WgConfigFiles, onDnsStatusChanged: () -> Unit, eventLogger: EventLogger): Boolean { + if (!VpnController.hasTunnel()) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + + context.getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) } + return false + } - private fun getUpTime(stats: RouterStats?): CharSequence { - if (stats == null) { - return "" - } - if (stats.since <= 0L) { - return "" - } - val now = System.currentTimeMillis() - // returns a string describing 'time' as a time relative to 'now' - return DateUtils.getRelativeTimeSpanString( - stats.since, - now, - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE + if (!WireguardManager.canEnableProxy()) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_FULL + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG ) } + return false + } - private fun getHumanReadableLastOk(stats: RouterStats?): CharSequence { - if (stats == null) { - return "" - } - if (stats.lastOK <= 0L) { - return "" - } - val now = System.currentTimeMillis() - // returns a string describing 'time' as a time relative to 'now' - return DateUtils.getRelativeTimeSpanString( - stats.lastOK, - now, - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE + if (WireguardManager.oneWireGuardEnabled()) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_OTHER_WG_ACTIVE + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG ) } + return false + } - fun setupClickListeners(config: WgConfigFiles) { - b.interfaceDetailCard.setOnClickListener { launchConfigDetail(config.id) } - - b.interfaceSwitch.setOnCheckedChangeListener(null) - b.interfaceSwitch.setOnClickListener { - val cfg = config.toImmutable() - io { - if (b.interfaceSwitch.isChecked) { - enableWgIfPossible(cfg) - } else { - disableWgIfPossible(cfg) - } - } - } + if (!WireguardManager.isValidConfig(config.id)) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + ERR_CODE_WG_INVALID + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) } + return false + } - private suspend fun disableWgIfPossible(cfg: WgConfigFilesImmutable) { - if (!VpnController.hasTunnel()) { - Logger.i(LOG_TAG_PROXY, "$TAG VPN not active, cannot enable WireGuard") - uiCtx { - Utilities.showToastUiCentered( - context, - ERR_CODE_VPN_NOT_ACTIVE + - context.getString(R.string.settings_socks5_vpn_disabled_error), - Toast.LENGTH_LONG - ) - // reset the check box - b.interfaceSwitch.isChecked = true - } - return - } + WireguardManager.enableConfig(config.toImmutable()) + withContext(Dispatchers.Main) { onDnsStatusChanged() } + logEvent(eventLogger, "WireGuard enabled", "WG ID: ${config.id}") + return true +} - if (WireguardManager.canDisableConfig(cfg)) { - WireguardManager.disableConfig(cfg) - logEvent("Wireguard disable", "Disabled WireGuard config: ${cfg.name} (id: ${cfg.id})") +suspend fun disableWgIfPossible(context: Context, config: WgConfigFiles, onDnsStatusChanged: () -> Unit, eventLogger: EventLogger): Boolean { + val canDisable = WireguardManager.canDisableConfig(config.toImmutable()) + if (!canDisable) { + val msgRes = + if (WgHopManager.isWgEitherHopOrSrc(config.id)) { + R.string.wireguard_disable_failure_relay } else { - if (cfg.isCatchAll) { - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.wireguard_disable_failure), - Toast.LENGTH_LONG - ) - b.interfaceSwitch.isChecked = true - } - } else { - uiCtx { - Utilities.showToastUiCentered( - context, - context.getString(R.string.wireguard_disable_failure_relay), - Toast.LENGTH_LONG - ) - b.interfaceSwitch.isChecked = true - } - } - } - - uiCtx { listener.onDnsStatusChanged() } - } - - private suspend fun enableWgIfPossible(cfg: WgConfigFilesImmutable) { - - if (!VpnController.hasTunnel()) { - Logger.i(LOG_TAG_PROXY, "$TAG VPN not active, cannot enable WireGuard") - uiCtx { - Utilities.showToastUiCentered( - context, - ERR_CODE_VPN_NOT_ACTIVE + - context.getString(R.string.settings_socks5_vpn_disabled_error), - Toast.LENGTH_LONG - ) - // reset the check box - b.interfaceSwitch.isChecked = false - } - return - } - - if (!WireguardManager.canEnableProxy()) { - Logger.i(LOG_TAG_PROXY, "$TAG not in DNS+Firewall mode, cannot enable WireGuard") - uiCtx { - // reset the check box - b.interfaceSwitch.isChecked = false - Utilities.showToastUiCentered( - context, - ERR_CODE_VPN_NOT_FULL + - context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - } - return + R.string.wireguard_disable_failure } - - if (WireguardManager.oneWireGuardEnabled()) { - // this should not happen, ui is disabled if one wireGuard is enabled - Logger.w(LOG_TAG_PROXY, "$TAG one wireGuard is already enabled") - uiCtx { - // reset the check box - b.interfaceSwitch.isChecked = false - Utilities.showToastUiCentered( - context, - ERR_CODE_OTHER_WG_ACTIVE + - context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - } - return - } - - if (!WireguardManager.isValidConfig(cfg.id)) { - Logger.i(LOG_TAG_PROXY, "$TAG invalid WireGuard config") - uiCtx { - // reset the check box - b.interfaceSwitch.isChecked = false - Utilities.showToastUiCentered( - context, - ERR_CODE_WG_INVALID + context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - } - return - } - - WireguardManager.enableConfig(cfg) - logEvent("Wireguard enable", "Enabled WireGuard config: ${cfg.name} (id: ${cfg.id})") - uiCtx { listener.onDnsStatusChanged() } - } - - private fun launchConfigDetail(id: Int) { - if (!VpnController.hasTunnel()) { - Utilities.showToastUiCentered( - context, - context.getString(R.string.ssv_toast_start_rethink), - Toast.LENGTH_SHORT - ) - return - } - - val intent = Intent(context, WgConfigDetailActivity::class.java) - intent.putExtra(INTENT_EXTRA_WG_ID, id) - intent.putExtra( - WgConfigDetailActivity.INTENT_EXTRA_WG_TYPE, - WgConfigDetailActivity.WgType.DEFAULT.value - ) - context.startActivity(intent) + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered(context, context.getString(msgRes), Toast.LENGTH_LONG) } + return false } - private fun logEvent(msg: String, details: String) { - eventLogger.log(EventType.PROXY_SWITCH, Severity.LOW, msg, EventSource.UI, false, details) - } + WireguardManager.disableConfig(config.toImmutable()) + withContext(Dispatchers.Main) { onDnsStatusChanged() } + logEvent(eventLogger, "WireGuard disabled", "WG ID: ${config.id}") + return true +} - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } +private fun launchConfigDetail(context: Context, id: Int, onConfigDetailClick: (Int, WgType) -> Unit) { + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.ssv_toast_start_rethink), + Toast.LENGTH_SHORT + ) + return } - private fun io(f: suspend () -> Unit): Job? { - if (lifecycleOwner == null) { - return null - } - return lifecycleOwner?.lifecycleScope?.launch(Dispatchers.IO) { f() } - } + onConfigDetailClick(id, WgType.DEFAULT) +} + +private fun logEvent(eventLogger: EventLogger, msg: String, details: String) { + eventLogger.log(EventType.PROXY_SWITCH, Severity.LOW, msg, EventSource.UI, false, details) } 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 a66b09f02..e00f5596d 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt @@ -15,23 +15,43 @@ */ 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 androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.celzero.bravedns.R -import com.celzero.bravedns.databinding.ListItemWgHopBinding import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE 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.Config import com.celzero.bravedns.wireguard.WgHopManager @@ -40,373 +60,277 @@ 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, - private val hopables: List, - private var selectedId: Int -) : RecyclerView.Adapter() { - - companion object { - private const val TAG = "HopAdapter" - private const val HOP_TEST_DELAY_MS = 2000L // 2 seconds - } - - 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 { - return hopables.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(hopables[position]) - } - - override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { - super.onAttachedToRecyclerView(recyclerView) - isAttached = true +private const val TAG = "HopAdapter" + +@Composable +fun HopRow( + context: Context, + srcId: Int, + config: Config, + isActive: Boolean, + selectedId: Int, + onSelectedIdChange: (Int) -> Unit +) { + var isChecked by remember { mutableStateOf(config.getId() == selectedId) } + var inProgress by remember { mutableStateOf(false) } + var statusText by remember { mutableStateOf("") } + var chips by remember { mutableStateOf(HopChips()) } + val scope = rememberCoroutineScope() + + LaunchedEffect(config.getId(), selectedId) { + isChecked = config.getId() == selectedId } - override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { - super.onDetachedFromRecyclerView(recyclerView) - isAttached = false + LaunchedEffect(config.getId(), selectedId) { + statusText = computeStatusText(context, srcId, config, selectedId) + chips = computeChips(context, config) } - inner class HopViewHolder(private val b: ListItemWgHopBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(config: Config) { - val mapping = WireguardManager.getConfigFilesById(config.getId()) ?: return - b.wgHopListNameTv.text = config.getName() + " (" + config.getId() + ")" - b.wgHopListCheckbox.isChecked = config.getId() == selectedId - setCardStroke(config.getId() == selectedId, mapping.isActive) - showChips(config) - updateStatusUi(config) - setupClickListeners(config, mapping.isActive) + val strokeColor = + if (isChecked && isActive) { + MaterialTheme.colorScheme.tertiary + } else if (isChecked) { + MaterialTheme.colorScheme.error + } else { + Color.Transparent } - - private fun updateStatusUi(config: Config) { - io { - 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 = ID_WG_BASE + srcConfig.getId() - val hop = 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) + val strokeWidth = if (isChecked) 2.dp else 0.dp + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp) + .clickable(enabled = !inProgress) { + scope.launch { + inProgress = true + val targetChecked = !isChecked + val res = + handleHop( + context = context, + srcId = srcId, + config = config, + isChecked = targetChecked, + isActive = isActive, + selectedId = selectedId, + onSelectedIdChange = onSelectedIdChange + ) + if (res.first) { + isChecked = targetChecked + statusText = computeStatusText(context, srcId, config, selectedId) } else { - b.wgHopListDescTv.text = statusPair.second + isChecked = false } + inProgress = false } - return@io + }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = if (strokeWidth > 0.dp) BorderStroke(strokeWidth, strokeColor) else null + ) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = config.getName() + " (" + config.getId() + ")", + style = MaterialTheme.typography.titleMedium + ) + Text(text = statusText, style = MaterialTheme.typography.bodySmall) } - 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) + Checkbox( + checked = isChecked, + onCheckedChange = { checked -> + if (inProgress) return@Checkbox + scope.launch { + inProgress = true + val res = + handleHop( + context = context, + srcId = srcId, + config = config, + isChecked = checked, + isActive = isActive, + selectedId = selectedId, + onSelectedIdChange = onSelectedIdChange + ) + if (res.first) { + isChecked = checked + statusText = computeStatusText(context, srcId, config, selectedId) + } else { + isChecked = false + } + inProgress = false + } } - } + ) } - } - private fun showChips(config: Config) { - io { - val id = ID_WG_BASE + config.getId() - val pair = VpnController.getSupportedIpVersion(id) - val isSplitTunnel = if (config.getPeers()?.isNotEmpty() == true) { - VpnController.isSplitTunnelProxy(id, pair) - } else { - false - } - uiCtx { - updatePropertiesChip(config) - updateAmzChip(config) - updateProtocolChip(pair) - updateSplitTunnelChip(isSplitTunnel) - updateHopSrcChip(config) - updateHoppingChip(config) + if (chips.hasAny()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 6.dp)) { + if (chips.ipv4) HopChip(text = context.getString(R.string.settings_ip_text_ipv4)) + if (chips.ipv6) HopChip(text = context.getString(R.string.settings_ip_text_ipv6)) + if (chips.splitTunnel) HopChip(text = context.getString(R.string.lbl_split)) + if (chips.amnezia) HopChip(text = context.getString(R.string.lbl_amnezia)) + if (chips.hopSrc) HopChip(text = context.getString(R.string.lbl_hopping)) + if (chips.hopping) HopChip(text = context.getString(R.string.cd_dns_crypt_relay_heading)) + if (chips.properties.isNotEmpty()) HopChip(text = chips.properties) } } - } - private fun updatePropertiesChip(config: Config) { - val mapping = WireguardManager.getConfigFilesById(config.getId()) ?: return - if (!mapping.isCatchAll && !mapping.isLockdown && !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.isLockdown) { - 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 (inProgress) { + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } - 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 updateAmzChip(config: Config) { - config.getInterface()?.let { - if (it.isAmnezia()) { - b.chipGroup.visibility = View.VISIBLE - b.chipAmnezia.visibility = View.VISIBLE - } else { - b.chipAmnezia.visibility = View.GONE - } - } - } +@Composable +private fun HopChip(text: String) { + AssistChip(onClick = {}, label = { Text(text = text) }) +} - private fun updateProtocolChip(pair: Pair?) { - if (pair == null) return +private data class HopChips( + val ipv4: Boolean = false, + val ipv6: Boolean = false, + val splitTunnel: Boolean = false, + val amnezia: Boolean = false, + val hopSrc: Boolean = false, + val hopping: Boolean = false, + val properties: String = "" +) { + fun hasAny(): Boolean { + return ipv4 || ipv6 || splitTunnel || amnezia || hopSrc || hopping || properties.isNotEmpty() + } +} - 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 suspend fun computeStatusText( + context: Context, + srcId: Int, + config: Config, + selectedId: Int +): String { + val map = WireguardManager.getConfigFilesById(config.getId()) + if (map == null) return context.getString(R.string.config_invalid_desc) + if (selectedId == config.getId()) { + val srcConfig = WireguardManager.getConfigById(srcId) + if (srcConfig == null) return context.getString(R.string.lbl_inactive) + val src = ID_WG_BASE + srcConfig.getId() + val hop = ID_WG_BASE + config.getId() + val statusPair = VpnController.hopStatus(src, hop) + return if (statusPair.first != null) { + context.getString(UIUtils.getProxyStatusStringRes(statusPair.first)) + } else { + statusPair.second } + } + return if (map.isActive) context.getString(R.string.lbl_active) else context.getString(R.string.lbl_inactive) +} - private fun updateHopSrcChip(config: Config) { - val id = ID_WG_BASE + config.getId() - val hop = WgHopManager.getMapBySrc(id) - if (hop.isNotEmpty()) { - b.chipGroup.visibility = View.VISIBLE - b.chipHopSrc.visibility = View.VISIBLE +private suspend fun computeChips(context: Context, config: Config): HopChips { + return withContext(Dispatchers.IO) { + val id = ID_WG_BASE + config.getId() + val pair = VpnController.getSupportedIpVersion(id) + val isSplitTunnel = + if (config.getPeers()?.isNotEmpty() == true) { + VpnController.isSplitTunnelProxy(id, pair) } else { - b.chipHopSrc.visibility = View.GONE + false } - } - - private fun updateHoppingChip(config: Config) { - val id = 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 + val hopSrc = WgHopManager.getMapBySrc(id).isNotEmpty() + val hopping = WgHopManager.isAlreadyHop(id) + val properties = buildString { + val mapping = WireguardManager.getConfigFilesById(config.getId()) + if (mapping != null) { + if (mapping.isCatchAll) append(context.getString(R.string.symbol_lightening)) + if (mapping.useOnlyOnMetered) append(context.getString(R.string.symbol_mobile)) + if (mapping.ssidEnabled) append(context.getString(R.string.symbol_id)) } } + val amnezia = config.getInterface()?.isAmnezia() == true + HopChips( + ipv4 = pair.first, + ipv6 = pair.second, + splitTunnel = isSplitTunnel, + amnezia = amnezia, + hopSrc = hopSrc, + hopping = hopping, + properties = properties + ) + } +} - private fun setupClickListeners(config: Config, isActive: Boolean) { - b.wgHopListCard.setOnClickListener { - io { handleHop(config, !b.wgHopListCheckbox.isChecked, isActive) } - } +private suspend fun handleHop( + context: Context, + srcId: Int, + config: Config, + isChecked: Boolean, + isActive: Boolean, + selectedId: Int, + onSelectedIdChange: (Int) -> Unit +): Pair { + 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 { Utilities.showToastUiCentered(context, context.getString(R.string.config_invalid_desc), Toast.LENGTH_LONG) } + return false to context.getString(R.string.config_invalid_desc) + } - b.wgHopListCheckbox.setOnClickListener { - io { handleHop(config, b.wgHopListCheckbox.isChecked, isActive) } - } + if (mapping.useOnlyOnMetered || mapping.ssidEnabled) { + uiCtx { + Utilities.showToastUiCentered( + context, + context.getString(R.string.hop_error_toast_msg_3), + Toast.LENGTH_LONG + ) } + return false to context.getString(R.string.hop_error_toast_msg_3) + } - private suspend fun handleHop(config: Config, isChecked: Boolean, isActive: Boolean) { - 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 = ID_WG_BASE + srcConfig.getId() - val hop = 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(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(HOP_TEST_DELAY_MS) - 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, isActive) - } - notifyDataSetChanged() + Logger.d(LOG_TAG_UI, "$TAG; init, hop: ${srcConfig.getId()} -> ${config.getId()}, isChecked? $isChecked") + val src = ID_WG_BASE + srcConfig.getId() + val hop = 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(ID_WG_BASE.length).toIntOrNull() ?: return@forEach + res = WgHopManager.removeHop(srcConfig.getId(), id).first } } - - 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 + if (res) { + onSelectedIdChange(-1) } - - 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) + } + delay(2000) + if (isChecked) { + val hopTestRes = VpnController.testHop(src, hop) + if (!hopTestRes.first) { + uiCtx { + Utilities.showToastUiCentered( + context, + hopTestRes.second ?: context.getString(R.string.unknown_error), + Toast.LENGTH_LONG + ) } - b.wgHopListCard.strokeColor = strokeColor + return false to (hopTestRes.second ?: context.getString(R.string.unknown_error)) } } - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } + val res = if (!isChecked) { + onSelectedIdChange(-1) + WgHopManager.removeHop(srcConfig.getId(), config.getId()) + } else { + onSelectedIdChange(config.getId()) + WgHopManager.hop(srcConfig.getId(), config.getId()) } - - private fun io(f: suspend () -> Unit) { - (context as LifecycleOwner).lifecycleScope.launch { withContext(Dispatchers.IO) { f() } } + uiCtx { + Utilities.showToastUiCentered(context, res.second, Toast.LENGTH_LONG) } + return res +} + +private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } } 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 2390c72b7..2da55988b 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt @@ -15,307 +15,178 @@ */ package com.celzero.bravedns.adapter -import Logger -import Logger.LOG_TAG_PROXY -import android.content.Context -import android.content.DialogInterface -import android.content.pm.PackageManager + import android.graphics.drawable.Drawable -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.celzero.bravedns.R +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.Image +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp 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.util.UIUtils import com.celzero.bravedns.util.Utilities.getDefaultIcon import com.celzero.bravedns.util.Utilities.getIcon -import com.celzero.bravedns.util.Utilities.showToastUiCentered -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class WgIncludeAppsAdapter( - private val context: Context, - private val proxyId: String, - private val proxyName: String -) : - PagingDataAdapter( - DIFF_CALLBACK - ) { - private val packageManager: PackageManager = context.packageManager - - companion object { - - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - // 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) - } - - override fun areContentsTheSame( - oldConnection: ProxyApplicationMapping, - newConnection: ProxyApplicationMapping - ): Boolean { - return false - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): IncludedAppInfoViewHolder { - val itemBinding = - ListItemWgIncludeAppsBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return IncludedAppInfoViewHolder(itemBinding) - } - - override fun onBindViewHolder(holder: IncludedAppInfoViewHolder, position: Int) { - val apps: ProxyApplicationMapping = getItem(position) ?: return - // Double-check position validity to prevent IndexOutOfBoundsException - if (position !in 0.. Unit +) { + val context = LocalContext.current + val packageManager = context.packageManager + var isProxyExcluded by remember(mapping.uid, mapping.packageName) { mutableStateOf(false) } + var hasInternetPerm by remember(mapping.uid, mapping.packageName) { mutableStateOf(true) } + var iconDrawable by remember(mapping.uid, mapping.packageName) { mutableStateOf(null) } + var isIncluded by + remember(mapping.uid, mapping.packageName, mapping.proxyId) { + mutableStateOf(false) } - holder.update(apps) - } - inner class IncludedAppInfoViewHolder(private val b: ListItemWgIncludeAppsBinding) : - RecyclerView.ViewHolder(b.root) { - - fun update(mapping: ProxyApplicationMapping) { - // capture item identity before async operations to prevent incorrect UI updates - // when ViewHolder is recycled for a different item during fast scrolling - val itemUid = mapping.uid - val itemPackageName = mapping.packageName - val itemAppName = mapping.appName - val itemProxyId = proxyId - - io { - // all proxies assigned to this uid and package - val proxyIdsForApp = - ProxyManager.getProxyIdsForApp(mapping.uid, mapping.packageName) - val isIncludedInCurrent = proxyIdsForApp.contains(itemProxyId) - val isProxyExcluded = FirewallManager.isAppExcludedFromProxy(itemUid) - val hasInternetPerm = mapping.hasInternetPermission(packageManager) - val iconDrawable = getIcon(context, itemPackageName, itemAppName) - Logger.d(LOG_TAG_PROXY, "INCLUDE(${mapping.appName}): $isIncludedInCurrent, $isProxyExcluded, $proxyName, $proxyId, $proxyIdsForApp, $isIncludedInCurrent") - 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" - ) - 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" - ) - return@uiCtx - } - - if (isProxyExcluded) { - b.wgIncludeAppListContainer.isEnabled = false - b.wgIncludeAppListCheckbox.isChecked = false - b.wgIncludeCard.isClickable = false - b.wgIncludeCard.isFocusable = false - b.wgIncludeAppListCheckbox.isClickable = false - b.wgIncludeAppListCheckbox.isFocusable = false - } else { - b.wgIncludeAppListContainer.isEnabled = true - b.wgIncludeCard.isClickable = true - b.wgIncludeCard.isFocusable = true - b.wgIncludeAppListCheckbox.isClickable = true - b.wgIncludeAppListCheckbox.isFocusable = true - } - - b.wgIncludeAppListApkLabelTv.text = itemAppName - b.wgIncludeAppListApkLabelTv.alpha = if (hasInternetPerm) 1.0f else 0.4f - - // checkbox state purely based on membership in this proxyId - b.wgIncludeAppListCheckbox.isChecked = isIncludedInCurrent && !isProxyExcluded - setCardBackground(isIncludedInCurrent && !isProxyExcluded) - - // description text logic: show only other proxies (exclude current proxyId) - setupClickListeners(mapping, isProxyExcluded) - displayIcon(iconDrawable) - } - } - } - - private fun setupClickListeners(mapping: ProxyApplicationMapping, isProxyExcluded: Boolean) { - b.wgIncludeCard.setOnClickListener { - val isIncluded = !b.wgIncludeAppListCheckbox.isChecked - b.wgIncludeAppListCheckbox.isChecked = isIncluded - Logger.i( - LOG_TAG_PROXY, - "wgIncludeAppListContainer- ${mapping.appName}, $isIncluded" - ) - updateInterfaceDetails(mapping, isIncluded && !isProxyExcluded) - } - - b.wgIncludeAppListCheckbox.setOnCheckedChangeListener(null) - b.wgIncludeAppListCheckbox.setOnClickListener { - val isIncluded = b.wgIncludeAppListCheckbox.isChecked - Logger.i( - LOG_TAG_PROXY, - "wgIncludeAppListCheckbox- ${mapping.appName}, $isIncluded" - ) - updateInterfaceDetails(mapping, isIncluded && !isProxyExcluded) - } + LaunchedEffect(mapping.uid, mapping.proxyId, mapping.packageName) { + isProxyExcluded = withContext(Dispatchers.IO) { + FirewallManager.isAppExcludedFromProxy(mapping.uid) } - - private fun displayIcon(drawable: Drawable?) { - Glide.with(context) - .load(drawable) - .error(getDefaultIcon(context)) - .into(b.wgIncludeAppListApkIconIv) + hasInternetPerm = mapping.hasInternetPermission(packageManager) + iconDrawable = withContext(Dispatchers.IO) { + getIcon(context, mapping.packageName, mapping.appName) } - private fun setCardBackground(isSelected: Boolean) { - if (isSelected) { - b.wgIncludeCard.setCardBackgroundColor( - UIUtils.fetchColor(context, R.attr.selectedCardBg) - ) - } else { - b.wgIncludeCard.setCardBackgroundColor( - UIUtils.fetchColor(context, R.attr.background) - ) - } - } + isIncluded = mapping.proxyId == proxyId && mapping.proxyId.isNotEmpty() && !isProxyExcluded + } - 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 { - showToastUiCentered( - context, - context.getString(R.string.exclude_apps_from_proxy_failure_toast), - Toast.LENGTH_LONG - ) - } - return@io - } - uiCtx { - if (appUidList.count() > 1) { - showDialog(appUidList, mapping, include) - } else { - updateProxyIdForApp(mapping, include) - } - } - } + val isClickable = !isProxyExcluded + val containerColor = + when { + isProxyExcluded -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f) + isIncluded -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f) + else -> MaterialTheme.colorScheme.surfaceContainerLow } - - private fun updateProxyIdForApp(mapping: ProxyApplicationMapping, include: Boolean) { - io { - if (include) { - addProxyToApp(mapping.uid, mapping.packageName, proxyId, proxyName) - Logger.i(LOG_TAG_PROXY, "Included app: ${mapping.uid}, $proxyId, $proxyName") - } else { - removeProxyFromApp(mapping.uid, mapping.packageName, proxyId) - Logger.i(LOG_TAG_PROXY, "Removed app: ${mapping.uid}, $proxyId, $proxyName") - } - refresh() - } + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "includeRowScale" + ) + val contentAlpha = if (hasInternetPerm && !isProxyExcluded) 1f else 0.5f + val titleColor = + when { + isIncluded -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface } - - private fun showDialog( - packageList: List, - mapping: ProxyApplicationMapping, - included: Boolean - ) { - val positiveTxt: String - - val builderSingle = MaterialAlertDialogBuilder(context) - - builderSingle.setIcon(R.drawable.ic_firewall_exclude_on) - - val count = packageList.count() - val title = - if (included) { - positiveTxt = context.getString(R.string.lbl_include) - context.getString(R.string.wg_apps_dialog_title_include, count.toString()) - } else { - positiveTxt = context.getString(R.string.lbl_remove) - context.getString(R.string.wg_apps_dialog_title_exclude, count.toString()) - } - - builderSingle.setTitle(title) - val arrayAdapter = - ArrayAdapter(context, android.R.layout.simple_list_item_activated_1) - 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 -> - // 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) - } - } - } - refresh() + val shape = shapeFor(position) + + Surface( + modifier = + Modifier + .fillMaxWidth() + .scale(scale) + .clip(shape) + .padding( + top = if (position == CardPosition.Middle || position == CardPosition.Last) 2.dp else 0.dp + ), + shape = shape, + color = containerColor + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .toggleable( + value = isIncluded, + enabled = isClickable, + role = Role.Checkbox, + interactionSource = interactionSource, + indication = null + ) { checked -> + if (checked == isIncluded || !isClickable) return@toggleable + isIncluded = checked + onInterfaceUpdate(mapping, checked) } - } - .setNeutralButton(context.getString(R.string.ctbs_dialog_negative_btn)) { _: DialogInterface, _: Int -> - } - - val alertDialog: AlertDialog = builderSingle.show() - alertDialog.listView.setOnItemClickListener { _, _, _, _ -> } - alertDialog.setCancelable(false) + .padding(horizontal = Dimensions.spacingMd, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val iconPainter = + rememberDrawablePainter(iconDrawable) + ?: rememberDrawablePainter(getDefaultIcon(context)) + iconPainter?.let { painter -> + Image( + painter = painter, + contentDescription = null, + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + ) + } + Text( + text = mapping.appName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = titleColor.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = isIncluded, + enabled = isClickable, + onCheckedChange = null + ) } } +} - 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() } } +private fun shapeFor(position: CardPosition): RoundedCornerShape { + return when (position) { + CardPosition.Single -> RoundedCornerShape(18.dp) + CardPosition.First -> RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = 10.dp, + bottomEnd = 10.dp + ) + CardPosition.Middle -> RoundedCornerShape(10.dp) + CardPosition.Last -> RoundedCornerShape( + topStart = 10.dp, + topEnd = 10.dp, + bottomStart = 18.dp, + bottomEnd = 18.dp + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt index 98ccfe679..55cc1bd88 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt @@ -15,134 +15,190 @@ */ package com.celzero.bravedns.adapter -import android.app.Activity import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.celzero.bravedns.R -import com.celzero.bravedns.databinding.ListItemWgPeersBinding import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog import com.celzero.bravedns.ui.dialog.WgAddPeerDialog -import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities.tos import com.celzero.bravedns.wireguard.Peer -import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class WgPeersAdapter( - val context: Context, - private var themeId: Int, - private val configId: Int, - private var peers: MutableList -) : RecyclerView.Adapter() { - - override fun onBindViewHolder(holder: WgPeersViewHolder, position: Int) { - val peer: Peer = peers[position] - holder.update(peer) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WgPeersViewHolder { - val itemBinding = - ListItemWgPeersBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return WgPeersViewHolder(itemBinding) - } - - override fun getItemCount(): Int { - return peers.size - } +@Composable +fun WgPeerRow( + context: Context, + configId: Int, + wgPeer: Peer, + onPeerChanged: () -> Unit +) { + val scope = rememberCoroutineScope() + val showDeleteDialog = remember(wgPeer.getPublicKey()) { mutableStateOf(false) } + var showEditDialog by remember(wgPeer.getPublicKey()) { mutableStateOf(false) } + val endpoint = + if (wgPeer.getEndpoint().isPresent) { + wgPeer.getEndpoint().get().toString() + } else { + null + } + val allowedIps = + if (wgPeer.getAllowedIps().isNotEmpty()) { + wgPeer.getAllowedIps().joinToString { it.toString() } + } else { + null + } + val keepAlive = + if (wgPeer.persistentKeepalive.isPresent) { + UIUtils.getDurationInHumanReadableFormat( + context, + wgPeer.persistentKeepalive.get() + ) + } else { + null + } - inner class WgPeersViewHolder(private val b: ListItemWgPeersBinding) : - RecyclerView.ViewHolder(b.root) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) + ) { + Column( + modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 14.dp, bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.lbl_peer), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { + showEditDialog = true + }) { + Icon( + painter = painterResource(id = R.drawable.ic_edit_icon_grey), + contentDescription = null + ) + } + IconButton(onClick = { + showDeleteDialog.value = true + }) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = null + ) + } + } - fun update(wgPeer: Peer) { - if (wgPeer.getEndpoint().isPresent) { - b.endpointText.text = wgPeer.getEndpoint().get().toString() - } else { - b.endpointText.visibility = View.GONE - b.endpointLabel.visibility = View.GONE + LabelValue( + label = stringResource(id = R.string.lbl_public_key), + value = wgPeer.getPublicKey().base64().tos().orEmpty() + ) + if (!allowedIps.isNullOrEmpty()) { + LabelValue( + label = stringResource(id = R.string.lbl_allowed_ips), + value = allowedIps + ) } - if (wgPeer.getAllowedIps().isNotEmpty()) { - b.allowedIpsText.text = wgPeer.getAllowedIps().joinToString { it.toString() } - } else { - b.allowedIpsText.visibility = View.GONE - b.allowedIpsLabel.visibility = View.GONE + if (!endpoint.isNullOrEmpty()) { + LabelValue( + label = stringResource(id = R.string.parse_error_inet_endpoint), + value = endpoint + ) } - if (wgPeer.persistentKeepalive.isPresent) { - b.persistentKeepaliveText.text = - UIUtils.getDurationInHumanReadableFormat( - context, - wgPeer.persistentKeepalive.get() - ) - } else { - b.persistentKeepaliveText.visibility = View.GONE - b.persistentKeepaliveLabel.visibility = View.GONE + if (!keepAlive.isNullOrEmpty()) { + LabelValue( + label = stringResource(id = R.string.lbl_persistent_keepalive), + value = keepAlive + ) } - b.publicKeyText.text = wgPeer.getPublicKey().base64() - - b.peerEdit.setOnClickListener { openEditPeerDialog(wgPeer) } - b.peerDelete.setOnClickListener { showDeleteInterfaceDialog(wgPeer) } - } - } - - private fun openEditPeerDialog(wgPeer: Peer) { - // send 0 as peerId to indicate that it is a new peer - if (Themes.isFrostTheme(themeId)) { - themeId = R.style.App_Dialog_NoDim - } - val addPeerDialog = WgAddPeerDialog(context as Activity, themeId, configId, wgPeer) - addPeerDialog.setCanceledOnTouchOutside(false) - addPeerDialog.show() - addPeerDialog.setOnDismissListener { dataChanged() } - } - - fun dataChanged() { - peers.clear() - io { - val p = WireguardManager.getPeers(configId) - peers.addAll(p) - uiCtx { this?.notifyDataSetChanged() } } } - private fun showDeleteInterfaceDialog(wgPeer: Peer) { - val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim) - val delText = + if (showDeleteDialog.value) { + val deleteTitle = context.getString( R.string.two_argument_space, context.getString(R.string.config_delete_dialog_title), context.getString(R.string.lbl_peer) ) - builder.setTitle(delText) - builder.setMessage(context.getString(R.string.config_delete_dialog_desc)) - builder.setCancelable(true) - - builder.setPositiveButton(delText) { _, _ -> deletePeer(wgPeer) } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - // no-op - } - builder.create().show() + RethinkConfirmDialog( + onDismissRequest = { showDeleteDialog.value = false }, + title = deleteTitle, + message = context.getString(R.string.config_delete_dialog_desc), + confirmText = deleteTitle, + dismissText = context.getString(R.string.lbl_cancel), + isConfirmDestructive = true, + onConfirm = { + showDeleteDialog.value = false + deletePeer(context, scope, configId, wgPeer, onPeerChanged) + }, + onDismiss = { showDeleteDialog.value = false } + ) } - private fun deletePeer(wgPeer: Peer) { - io { - WireguardManager.deletePeer(configId, wgPeer) - peers = WireguardManager.getPeers(configId) - uiCtx { this.notifyDataSetChanged() } - } + if (showEditDialog) { + WgAddPeerDialog( + configId = configId, + wgPeer = wgPeer, + onDismiss = { + showEditDialog = false + onPeerChanged() + } + ) } +} - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } +@Composable +private fun LabelValue(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Text(text = value, style = MaterialTheme.typography.bodySmall) } +} - private fun io(f: suspend () -> Unit) { - (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } +private fun deletePeer( + context: Context, + scope: kotlinx.coroutines.CoroutineScope, + configId: Int, + wgPeer: Peer, + onPeerChanged: () -> Unit +) { + scope.launch(Dispatchers.IO) { + WireguardManager.deletePeer(configId, wgPeer) + withContext(Dispatchers.Main) { onPeerChanged() } } } diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt index 350903d4f..f7078ba3a 100644 --- a/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt +++ b/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt @@ -35,7 +35,7 @@ import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.download.BlocklistDownloadHelper import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.RethinkBlocklistManager -import com.celzero.bravedns.ui.activity.AppLockActivity +import com.celzero.bravedns.ui.HomeScreenActivity import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS import com.celzero.bravedns.util.Constants.Companion.LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME @@ -451,7 +451,7 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame if (Utilities.isAtleastO()) { val name: CharSequence = context.getString(R.string.notif_channel_download) - val description = context.resources.getString(R.string.notif_channed_desc_download) + val description = context.getString(R.string.notif_channed_desc_download) val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(DOWNLOAD_NOTIFICATION_TAG, name, importance) channel.description = description @@ -492,7 +492,7 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame private fun getPendingIntent(context: Context): PendingIntent { return Utilities.getActivityPendingIntent( context, - Intent(context, AppLockActivity::class.java), + Intent(context, HomeScreenActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, mutable = false ) diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/OkHttpDebugLogging.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/OkHttpDebugLogging.kt index 32543a714..66f0eab90 100644 --- a/app/src/full/java/com/celzero/bravedns/customdownloader/OkHttpDebugLogging.kt +++ b/app/src/full/java/com/celzero/bravedns/customdownloader/OkHttpDebugLogging.kt @@ -25,6 +25,7 @@ import java.util.logging.Level import java.util.logging.LogRecord import java.util.logging.Logger import java.util.logging.SimpleFormatter +import java.util.Locale import kotlin.reflect.KClass object OkHttpDebugLogging { @@ -41,7 +42,12 @@ object OkHttpDebugLogging { formatter = object : SimpleFormatter() { override fun format(record: LogRecord) = - String.format("[%1\$tF %1\$tT] %2\$s %n", record.millis, record.message) + String.format( + Locale.US, + "[%1\$tF %1\$tT] %2\$s %n", + record.millis, + record.message + ) } } diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt b/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt index 5a9c2d73e..2d8686460 100644 --- a/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt +++ b/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt @@ -203,6 +203,12 @@ class WorkScheduler(val context: Context) { ) } + fun cancelBlocklistUpdateCheckJob() { + Logger.i(LOG_TAG_SCHEDULER, "Cancel all the work related to blocklist update check") + WorkManager.getInstance(context.applicationContext) + .cancelAllWorkByTag(BLOCKLIST_UPDATE_CHECK_JOB_TAG) + } + fun scheduleDataUsageJob() { Logger.i(LOG_TAG_SCHEDULER, "Data usage job scheduled") val workRequest = diff --git a/app/src/full/java/com/celzero/bravedns/ui/HomeDialogComponents.kt b/app/src/full/java/com/celzero/bravedns/ui/HomeDialogComponents.kt new file mode 100644 index 000000000..f0a672bc4 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/HomeDialogComponents.kt @@ -0,0 +1,130 @@ +/* + * 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 + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkMultiActionDialog + +@Composable +internal fun HomeConfirmDialog( + title: String, + message: String, + confirmText: String, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, + dismissText: String? = null, + onDismiss: (() -> Unit)? = null, + isConfirmDestructive: Boolean = false +) { + RethinkConfirmDialog( + onDismissRequest = onDismissRequest, + title = title, + message = message, + confirmText = confirmText, + dismissText = dismissText, + onConfirm = onConfirm, + onDismiss = onDismiss ?: onDismissRequest, + isConfirmDestructive = isConfirmDestructive + ) +} + +@Composable +internal fun HomeAlwaysOnStopDialog( + title: String, + message: String, + stopText: String, + openSettingsText: String, + cancelText: String, + onStop: () -> Unit, + onOpenSettings: () -> Unit, + onCancel: () -> Unit +) { + RethinkMultiActionDialog( + onDismissRequest = {}, + title = title, + message = message, + primaryText = stopText, + onPrimary = onStop, + secondaryText = openSettingsText, + onSecondary = onOpenSettings, + tertiaryText = cancelText, + onTertiary = onCancel + ) +} + +@Composable +internal fun HomeStatsDialog( + title: String, + displayText: String, + dismissText: String, + copyText: String, + onDismissRequest: () -> Unit, + onDismiss: () -> Unit, + onCopy: () -> Unit +) { + RethinkMultiActionDialog( + onDismissRequest = onDismissRequest, + title = title, + text = { + SelectionContainer { + Column( + modifier = + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(Dimensions.spacingSm) + ) { + Text(text = displayText, style = MaterialTheme.typography.bodySmall) + } + } + }, + primaryText = dismissText, + onPrimary = onDismiss, + secondaryText = copyText, + onSecondary = onCopy + ) +} + +@Composable +internal fun HomeNewFeaturesDialog( + title: String, + dismissText: String, + contactText: String, + onDismissRequest: () -> Unit, + onDismiss: () -> Unit, + onContact: () -> Unit, + content: @Composable () -> Unit +) { + RethinkMultiActionDialog( + onDismissRequest = onDismissRequest, + title = title, + text = content, + primaryText = dismissText, + onPrimary = onDismiss, + secondaryText = contactText, + onSecondary = onContact + ) +} 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 6453a1ebb..58378ddd6 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt @@ -15,34 +15,110 @@ */ package com.celzero.bravedns.ui + import Logger import Logger.LOG_TAG_APP_UPDATE import Logger.LOG_TAG_BACKUP_RESTORE import Logger.LOG_TAG_DOWNLOAD import Logger.LOG_TAG_UI +import Logger.LOG_TAG_VPN +import android.Manifest import android.app.UiModeManager +import android.app.Activity import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.graphics.Color as AndroidColor import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.database.Cursor +import android.net.VpnService import android.net.Uri import android.os.Bundle +import android.os.Build import android.os.SystemClock -import android.view.View +import android.provider.Settings +import android.view.Gravity +import android.view.WindowManager import android.widget.Toast -import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.SystemBarStyle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.core.net.toUri -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding +import androidx.core.view.WindowCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavOptions -import androidx.navigation.fragment.NavHostFragment import androidx.work.BackoffPolicy import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder @@ -52,37 +128,71 @@ import androidx.work.WorkRequest import com.celzero.bravedns.BuildConfig import com.celzero.bravedns.NonStoreAppUpdater import com.celzero.bravedns.R +import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG import com.celzero.bravedns.backup.BackupHelper import com.celzero.bravedns.backup.BackupHelper.Companion.BACKUP_FILE_EXTN import com.celzero.bravedns.backup.BackupHelper.Companion.INTENT_RESTART_APP import com.celzero.bravedns.backup.BackupHelper.Companion.INTENT_SCHEME import com.celzero.bravedns.backup.RestoreAgent import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.data.SummaryStatisticsType +import com.celzero.bravedns.database.AppDatabase import com.celzero.bravedns.database.AppInfoRepository +import com.celzero.bravedns.database.EventDao import com.celzero.bravedns.database.RefreshDatabase +import com.celzero.bravedns.scheduler.BugReportZipper +import com.celzero.bravedns.scheduler.EnhancedBugReport +import com.celzero.bravedns.scheduler.WorkScheduler import com.celzero.bravedns.service.AppUpdater import com.celzero.bravedns.service.BraveVPNService -import com.celzero.bravedns.service.FirewallManager -import com.celzero.bravedns.service.InAppMessageProvider +import com.celzero.bravedns.service.EventLogger import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.RethinkBlocklistManager import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.service.WireguardManager -import com.celzero.bravedns.ui.activity.MiscSettingsActivity -import com.celzero.bravedns.ui.activity.PauseActivity -import com.celzero.bravedns.ui.activity.WelcomeActivity + +import com.celzero.bravedns.ui.compose.dns.ConfigureRethinkScreenType +import com.celzero.bravedns.ui.compose.navigation.HomeNavRequest +import com.celzero.bravedns.ui.compose.navigation.CustomRulesMode +import com.celzero.bravedns.ui.compose.navigation.CustomRulesTab +import com.celzero.bravedns.ui.compose.navigation.HomeRoute +import com.celzero.bravedns.ui.compose.wireguard.WgType +import com.celzero.bravedns.ui.compose.navigation.HomeScreenRoot +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkModalBottomSheet +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkTheme +import com.celzero.bravedns.ui.compose.theme.RethinkColorPreset +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.ui.compose.settings.AppLockScreen +import com.celzero.bravedns.ui.compose.settings.AppLockResult +import com.celzero.bravedns.ui.compose.home.PauseScreen +import com.celzero.bravedns.util.BioMetricType +import androidx.lifecycle.asFlow import com.celzero.bravedns.util.Constants -import com.celzero.bravedns.util.Constants.Companion.ALPHA_UPDATE_CHECK_URL import com.celzero.bravedns.util.Constants.Companion.MAX_ENDPOINT import com.celzero.bravedns.util.Constants.Companion.PKG_NAME_PLAY_STORE +import com.celzero.bravedns.util.Constants.Companion.RETHINKDNS_SPONSOR_LINK +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel import com.celzero.bravedns.util.FirebaseErrorReporting import com.celzero.bravedns.util.FirebaseErrorReporting.TOKEN_LENGTH import com.celzero.bravedns.util.FirebaseErrorReporting.TOKEN_REGENERATION_PERIOD_DAYS import com.celzero.bravedns.util.NewSettingsManager import com.celzero.bravedns.util.RemoteFileTagUtil -import com.celzero.bravedns.util.Themes -import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.UIUtils +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.celzero.bravedns.util.QrCodeFromFileScanner +import com.celzero.bravedns.util.TunnelImporter +import com.google.zxing.qrcode.QRCodeReader +import com.celzero.bravedns.util.UIUtils.openNetworkSettings import com.celzero.bravedns.util.UIUtils.openUrl +import com.celzero.bravedns.util.UIUtils.openVpnProfile +import com.celzero.bravedns.util.UIUtils.sendEmailIntent +import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getPackageMetadata import com.celzero.bravedns.util.Utilities.getRandomString @@ -91,29 +201,151 @@ import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.util.Utilities.isPlayStoreFlavour import com.celzero.bravedns.util.Utilities.isWebsiteFlavour import com.celzero.bravedns.util.Utilities.showToastUiCentered +import com.celzero.bravedns.util.disableFrostTemporarily import com.celzero.bravedns.util.handleFrostEffectIfNeeded -import com.google.android.material.bottomnavigation.BottomNavigationView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar +import com.celzero.bravedns.viewmodel.AppConnectionsViewModel +import com.celzero.bravedns.viewmodel.CustomDomainViewModel +import com.celzero.bravedns.viewmodel.CustomIpViewModel +import com.celzero.bravedns.viewmodel.CheckoutViewModel +import com.celzero.bravedns.viewmodel.DomainConnectionsViewModel +import com.celzero.bravedns.viewmodel.EventsViewModel + import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.android.ext.android.get import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.text.SimpleDateFormat import java.util.Calendar +import java.util.Date +import java.util.Locale import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.milliseconds +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream -class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { +class HomeScreenActivity : AppCompatActivity() { private val persistentState by inject() + private val appInfoDb by inject() private val appUpdateManager by inject() - private val inAppMessageProvider by inject() private val rdb by inject() private val appConfig by inject() + private val workScheduler by inject() + private val appDatabase by inject() + private val eventDao by inject() + private val eventLogger by inject() + + private val homeViewModel by viewModel() + private val summaryViewModel by viewModel() + private val aboutViewModel by viewModel() + private val detailedStatsViewModel by viewModel() + private val domainConnectionsViewModel by viewModel() + private val eventsViewModel by viewModel() + private val appInfoIpRulesViewModel by viewModel() + private val appInfoDomainRulesViewModel by viewModel() + private val appInfoNetworkLogsViewModel by viewModel() + private val consoleLogViewModel by inject() + private val consoleLogRepository by inject() + private val proxyAppsMappingViewModel by viewModel() + private val dnsSettingsViewModel by viewModel() + private val appDownloadManager by inject() + private val rethinkEndpointViewModel by viewModel() + private val remoteFileTagViewModel by viewModel() + private val localFileTagViewModel by viewModel() + private val remoteBlocklistPacksMapViewModel by viewModel() + private val localBlocklistPacksMapViewModel by viewModel() + private val appInfoViewModel by viewModel() + private val connectionTrackerViewModel by viewModel() + private val dnsLogViewModel by viewModel() + private val rethinkLogViewModel by viewModel() + private val connectionTrackerRepository by inject() + private val dnsLogRepository by inject() + private val rethinkLogRepository by inject() + + // ConfigureOtherDns ViewModels + private val dohViewModel by viewModel() + private val dotViewModel by viewModel() + private val dnsProxyViewModel by viewModel() + private val dnsCryptViewModel by viewModel() + private val dnsCryptRelayViewModel by viewModel() + private val oDohViewModel by viewModel() + private val checkoutViewModel: CheckoutViewModel? by lazy { + runCatching { get() }.getOrNull() + } + private val wgConfigViewModel by viewModel() + // TODO: see if this can be replaced with a more robust solution // keep track of when app went to background private var appInBackground = false + private var showBugReportSheet by mutableStateOf(false) + private var homeDialogState by mutableStateOf(null) + private var snackbarHostState: SnackbarHostState? = null + private var homeNavRequest by mutableStateOf(null) + + private lateinit var startForResult: androidx.activity.result.ActivityResultLauncher + private lateinit var notificationPermissionResult: androidx.activity.result.ActivityResultLauncher + + // WireGuard Import Launchers + private val tunnelFileImportResultLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { data -> + if (data == null) return@registerForActivityResult + val contentResolver = contentResolver ?: return@registerForActivityResult + lifecycleScope.launch { + if (QrCodeFromFileScanner.validContentType(contentResolver, data)) { + try { + val qrCodeFromFileScanner = + QrCodeFromFileScanner(contentResolver, QRCodeReader()) + val result = qrCodeFromFileScanner.scan(data) + if (result != null) { + withContext(Dispatchers.Main) { + TunnelImporter.importTunnel(result.text) { + showToastUiCentered( + this@HomeScreenActivity, + it.toString(), + Toast.LENGTH_LONG + ) + } + } + } else { + val message = resources.getString(R.string.invalid_file_error) + showToastUiCentered(this@HomeScreenActivity, message, Toast.LENGTH_LONG) + } + } catch (e: Exception) { + val message = resources.getString(R.string.invalid_file_error) + showToastUiCentered(this@HomeScreenActivity, message, Toast.LENGTH_LONG) + } + } else { + TunnelImporter.importTunnel(contentResolver, data) { + showToastUiCentered( + this@HomeScreenActivity, + it.toString(), + Toast.LENGTH_LONG + ) + } + } + } + } + + private val qrImportResultLauncher = + registerForActivityResult(ScanContract()) { result -> + val qrCode = result.contents + if (qrCode != null) { + lifecycleScope.launch { + TunnelImporter.importTunnel(qrCode) { + showToastUiCentered( + this@HomeScreenActivity, + it.toString(), + Toast.LENGTH_LONG + ) + } + } + } + } // TODO - #324 - Usage of isDarkTheme() in all activities. private fun Context.isDarkThemeOn(): Boolean { @@ -122,50 +354,343 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { } override fun onCreate(savedInstanceState: Bundle?) { - theme.applyStyle(getCurrentTheme(isDarkThemeOn(), persistentState.theme), true) super.onCreate(savedInstanceState) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + lightScrim = 0x00000000, + darkScrim = 0x00000000 + ), + navigationBarStyle = SystemBarStyle.auto( + lightScrim = 0x00000000, + darkScrim = 0x00000000 + ) + ) + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = AndroidColor.TRANSPARENT + window.navigationBarColor = AndroidColor.TRANSPARENT + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.clearFlags( + WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS or + WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION + ) + window.navigationBarDividerColor = AndroidColor.TRANSPARENT + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false - if (isAtleastO_MR1()) { - Logger.vv(LOG_TAG_UI, "Setting up window insets for Android 27+") - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.nav_view)) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.updatePadding(bottom = systemBars.bottom) // Add bottom padding to keep icons visible - insets - WindowInsetsCompat.CONSUMED - } - } - - if (isAtleastQ()) { - val controller = WindowInsetsControllerCompat(window, window.decorView) - controller.isAppearanceLightNavigationBars = Themes.isActivityLightTheme(isDarkThemeOn(), persistentState.theme) - window.isNavigationBarContrastEnforced = false - } - // do not launch on board activity when app is running on TV - if (persistentState.firstTimeLaunch && !isAppRunningOnTv()) { - launchOnboardActivity() - return - } + val resolvedThemePreference = + Themes.resolveThemePreference(isDarkThemeOn(), persistentState.theme) - handleFrostEffectIfNeeded(persistentState.theme) + val homeStartDestination = + if (persistentState.firstTimeLaunch && !isAppRunningOnTv()) { + HomeRoute.Welcome + } else { + HomeRoute.Home + } - updateNewVersion() + handleFrostEffectIfNeeded(resolvedThemePreference) - setupNavigationItemSelectedListener() + registerForActivityResult() + updateNewVersion() // handle intent receiver for backup/restore handleIntent() + handleNavigationIntent(intent) initUpdateCheck() observeAppState() - handleOnBackPressed() - NewSettingsManager.handleNewSettings() regenerateFirebaseTokenIfNeeded() + + appConfig.getBraveModeObservable().postValue(appConfig.getBraveMode().mode) + + setContent { + var composeThemePreference by remember { mutableStateOf(persistentState.theme) } + var composeThemeColorPreset by remember { mutableStateOf(persistentState.themeColorPreset) } + + val colorPreset = RethinkColorPreset.fromId(composeThemeColorPreset) + RethinkTheme( + themePreference = composeThemePreference, + colorPreset = colorPreset + ) { + val hostState = remember { SnackbarHostState() } + DisposableEffect(hostState) { + snackbarHostState = hostState + onDispose { + if (snackbarHostState == hostState) { + snackbarHostState = null + } + } + } + val homeState by homeViewModel.uiState.collectAsStateWithLifecycle() + val aboutState by aboutViewModel.uiState.collectAsStateWithLifecycle() + + var isUnlocked by remember { mutableStateOf(false) } + val vpnState by remember { VpnController.connectionStatus.asFlow() }.collectAsStateWithLifecycle( + initialValue = null + ) + + if (vpnState == BraveVPNService.State.PAUSED) { + PauseScreen(onFinish = { }) + } else if (isUnlocked) { + HomeScreenRoot( + homeUiState = homeState, + onHomeStartStopClick = { handleMainScreenBtnClickEvent() }, + onHomeDnsClick = { navigateToDnsDetailIfAllowed() }, + onHomeFirewallClick = { homeNavRequest = HomeNavRequest.FirewallSettings }, + onHomeProxyClick = { + if (appConfig.isWireGuardEnabled()) { + homeNavRequest = HomeNavRequest.WgMain + } else { + homeNavRequest = HomeNavRequest.ProxySettings + } + }, + onHomeLogsClick = { homeNavRequest = HomeNavRequest.NetworkLogs }, + onHomeAppsClick = { homeNavRequest = HomeNavRequest.AppList }, + onHomeSponsorClick = { + // promptForAppSponsorship() + }, + summaryViewModel = summaryViewModel, + onOpenDetailedStats = { type -> openDetailedStatsUi(type) }, + startDestination = homeStartDestination, + isDebug = DEBUG, + onConfigureAppsClick = { homeNavRequest = HomeNavRequest.AppList }, + onConfigureDnsClick = { navigateToDnsDetailIfAllowed() }, + onConfigureFirewallClick = { + homeNavRequest = HomeNavRequest.FirewallSettings + }, + onFirewallUniversalClick = { + homeNavRequest = HomeNavRequest.UniversalFirewallSettings + }, + onFirewallCustomIpClick = { + homeNavRequest = + HomeNavRequest.CustomRules( + uid = UID_EVERYBODY, + tab = CustomRulesTab.IP, + mode = CustomRulesMode.APP_SPECIFIC + ) + }, + onFirewallAppWiseIpClick = { openAppWiseIpScreen() }, + onConfigureProxyClick = { homeNavRequest = HomeNavRequest.ProxySettings }, + onConfigureNetworkClick = { + homeNavRequest = HomeNavRequest.TunnelSettings + }, + onConfigureOthersClick = { homeNavRequest = HomeNavRequest.MiscSettings }, + onConfigureLogsClick = { homeNavRequest = HomeNavRequest.NetworkLogs }, + onConfigureAntiCensorshipClick = { + homeNavRequest = HomeNavRequest.AntiCensorship + }, + onConfigureAdvancedClick = { + homeNavRequest = HomeNavRequest.AdvancedSettings + }, + aboutUiState = aboutState, + onSponsorClick = { openUrl(this, RETHINKDNS_SPONSOR_LINK) }, + onTelegramClick = { + openUrl( + this, + getString(R.string.about_telegram_link) + ) + }, + onBugReportClick = { aboutViewModel.triggerBugReport() }, + onWhatsNewClick = { showNewFeaturesDialog() }, + onAppUpdateClick = { checkForUpdate(AppUpdater.UserPresent.INTERACTIVE) }, + onContributorsClick = { showContributors() }, + onTranslateClick = { + openUrl( + this, + getString(R.string.about_translate_link) + ) + }, + onWebsiteClick = { openUrl(this, getString(R.string.about_website_link)) }, + onGithubClick = { openUrl(this, getString(R.string.about_github_link)) }, + onFaqClick = { openUrl(this, getString(R.string.about_faq_link)) }, + onDocsClick = { openUrl(this, getString(R.string.about_docs_link)) }, + onPrivacyPolicyClick = { + openUrl( + this, + getString(R.string.about_privacy_policy_link) + ) + }, + onTermsOfServiceClick = { + openUrl( + this, + getString(R.string.about_terms_link) + ) + }, + onLicenseClick = { openUrl(this, getString(R.string.about_license_link)) }, + onTwitterClick = { + openUrl( + this, + getString(R.string.about_twitter_handle) + ) + }, + onEmailClick = { disableFrostTemporarily(); sendEmailIntent(this) }, + onRedditClick = { openUrl(this, getString(R.string.about_reddit_handle)) }, + onElementClick = { openUrl(this, getString(R.string.about_matrix_handle)) }, + onMastodonClick = { + openUrl( + this, + getString(R.string.about_mastodom_handle) + ) + }, + onGeneralSettingsClick = { homeNavRequest = HomeNavRequest.MiscSettings }, + onAppInfoClick = { UIUtils.openAndroidAppInfo(this, packageName) }, + onVpnProfileClick = { openVpnProfile(this) }, + onNotificationClick = { openNotificationSettings() }, + onStatsClick = { openStatsDialog() }, + onDbStatsClick = { openDatabaseDumpDialog() }, + onFlightRecordClick = { initiateFlightRecord() }, + onEventLogsClick = { openEventLogs() }, + onTokenClick = { copyTokenToClipboard() }, + onTokenDoubleTap = { aboutViewModel.generateNewToken() }, + onFossClick = { openUrl(this, getString(R.string.about_foss_link)) }, + onFlossFundsClick = { + openUrl( + this, + getString(R.string.about_floss_fund_link) + ) + }, + snackbarHostState = hostState, + detailedStatsViewModel = detailedStatsViewModel, + domainConnectionsViewModel = domainConnectionsViewModel, + eventsViewModel = eventsViewModel, + eventDao = eventDao, + appInfoEventLogger = eventLogger, + appInfoIpRulesViewModel = appInfoIpRulesViewModel, + appInfoDomainRulesViewModel = appInfoDomainRulesViewModel, + appInfoNetworkLogsViewModel = appInfoNetworkLogsViewModel, + persistentState = persistentState, + appConfig = appConfig, + onOpenVpnProfile = { UIUtils.openVpnProfile(this@HomeScreenActivity) }, + onRefreshDatabase = { lifecycleScope.launch { rdb.refresh(RefreshDatabase.ACTION_REFRESH_INTERACTIVE) } }, + onThemeModeChanged = { composeThemePreference = it }, + onThemeColorChanged = { composeThemeColorPreset = it }, + consoleLogViewModel = consoleLogViewModel, + consoleLogRepository = consoleLogRepository, + onShareConsoleLogs = { + homeNavRequest = HomeNavRequest.NetworkLogs + }, // Fallback to Activity for complex share + onConsoleLogsDeleteComplete = { + showToastUiCentered( + this@HomeScreenActivity, + getString(R.string.config_add_success_toast), + Toast.LENGTH_SHORT + ) + }, + proxyAppsMappingViewModel = proxyAppsMappingViewModel, + dnsSettingsViewModel = dnsSettingsViewModel, + appDownloadManager = appDownloadManager, + onDnsCustomDnsClick = { startCustomDnsActivity() }, + onDnsLocalBlocklistConfigureClick = { startLocalBlocklistConfigureActivity() }, + onDnsRethinkPlusDnsClick = { startRethinkPlusDnsActivity() }, + homeNavRequest = homeNavRequest, + onHomeNavConsumed = { homeNavRequest = null }, + rethinkEndpointViewModel = rethinkEndpointViewModel, + remoteFileTagViewModel = remoteFileTagViewModel, + localFileTagViewModel = localFileTagViewModel, + remoteBlocklistPacksMapViewModel = remoteBlocklistPacksMapViewModel, + localBlocklistPacksMapViewModel = localBlocklistPacksMapViewModel, + appInfoViewModel = appInfoViewModel, + refreshDatabase = rdb, + connectionTrackerViewModel = connectionTrackerViewModel, + dnsLogViewModel = dnsLogViewModel, + rethinkLogViewModel = rethinkLogViewModel, + connectionTrackerRepository = connectionTrackerRepository, + dnsLogRepository = dnsLogRepository, + rethinkLogRepository = rethinkLogRepository, + onConfigureOtherDns = { index -> + homeNavRequest = HomeNavRequest.ConfigureOtherDns(index) + }, + // ConfigureOtherDns ViewModels + dohViewModel = dohViewModel, + dotViewModel = dotViewModel, + dnsProxyViewModel = dnsProxyViewModel, + dnsCryptViewModel = dnsCryptViewModel, + dnsCryptRelayViewModel = dnsCryptRelayViewModel, + oDohViewModel = oDohViewModel, + // UniversalFirewallSettings callbacks + onNavigateToLogs = { searchQuery -> + homeNavRequest = HomeNavRequest.NetworkLogs + }, + onOpenAccessibilitySettings = { + startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + }, + // WireGuard dependencies + wgConfigViewModel = wgConfigViewModel, + // Checkout dependencies + checkoutViewModel = checkoutViewModel, + onNavigateToProxy = { homeNavRequest = HomeNavRequest.ProxySettings }, + // WgMain callbacks + onWgCreateClick = { + homeNavRequest = HomeNavRequest.WgConfigEditor( + com.celzero.bravedns.service.WireguardManager.INVALID_CONF_ID, + com.celzero.bravedns.ui.compose.wireguard.WgType.DEFAULT + ) + }, + onWgImportClick = { launchFileImport() }, + onWgQrScanClick = { launchQrScanner() }, + appDatabase = appDatabase + ) + } else { + AppLockScreen( + persistentState = persistentState, + onAuthResult = { result -> + when (result) { + AppLockResult.Success, AppLockResult.NotRequired -> { + isUnlocked = true + } + + AppLockResult.Failure -> { + finish() + } + + AppLockResult.Pending -> { + // Waiting for user interaction + } + } + } + ) + } + + + if (showBugReportSheet) { + BugReportFilesSheet(onDismiss = { showBugReportSheet = false }) + } + HomeDialogHost() + } + } + + // enable in-app messaging, will be used to show in-app messages in case of billing issues + //enableInAppMessaging() + } + + + /*private fun enableInAppMessaging() { + initiateBillingIfNeeded() + // enable in-app messaging + InAppBillingHandler.enableInAppMessaging(this) + Logger.v(LOG_IAB, "enableInAppMessaging: enabled") + } + + private fun initiateBillingIfNeeded() { + if (InAppBillingHandler.isBillingClientSetup()) { + Logger.i(LOG_IAB, "ensureBillingSetup: billing client already setup") + return + } + + InAppBillingHandler.initiate(this.applicationContext) + Logger.i(LOG_IAB, "ensureBillingSetup: billing client initiated") + }*/ + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleNavigationIntent(intent) + Logger.v(LOG_TAG_UI, "home screen activity received new intent") } override fun onResume() { @@ -175,9 +700,6 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { appInBackground = false Logger.d(LOG_TAG_UI, "app restored from background, maintaining activity stack") } - // Show any pending Play Billing in-app messages (payment recovery, grace-period - // notices, etc.). This is a no-op on non-Play flavors. - inAppMessageProvider.showMessages(this) } // check if app running on TV @@ -200,10 +722,8 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { intent.scheme?.equals(INTENT_SCHEME) == true && intent.data?.path?.contains(BACKUP_FILE_EXTN) == true ) { - Logger.i(LOG_TAG_UI, "handleIntent: backup intent") handleRestoreProcess(intent.data) } else if (intent.scheme?.equals(INTENT_SCHEME) == true) { - Logger.i(LOG_TAG_UI, "handleIntent: restore intent") showToastUiCentered( this, getString(R.string.brbs_restore_no_uri_toast), @@ -215,6 +735,82 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { } } + private fun handleNavigationIntent(intent: Intent?) { + if (intent == null) return + handleNotificationAction(intent) + val target = intent.getStringExtra(EXTRA_NAV_TARGET) ?: return + when (target) { + NAV_TARGET_DOMAIN_CONNECTIONS -> { + val typeValue = intent.getIntExtra(EXTRA_DC_TYPE, 0) + val flag = intent.getStringExtra(EXTRA_DC_FLAG).orEmpty() + val domain = intent.getStringExtra(EXTRA_DC_DOMAIN).orEmpty() + val asn = intent.getStringExtra(EXTRA_DC_ASN).orEmpty() + val ip = intent.getStringExtra(EXTRA_DC_IP).orEmpty() + val isBlocked = intent.getBooleanExtra(EXTRA_DC_IS_BLOCKED, false) + val timeCategoryValue = intent.getIntExtra(EXTRA_DC_TIME_CATEGORY, 0) + val type = + com.celzero.bravedns.ui.compose.logs.DomainConnectionsInputType.fromValue( + typeValue + ) + val timeCategory = + DomainConnectionsViewModel.TimeCategory.fromValue(timeCategoryValue) + ?: DomainConnectionsViewModel.TimeCategory.ONE_HOUR + homeNavRequest = + HomeNavRequest.DomainConnections( + type = type, + flag = flag, + domain = domain, + asn = asn, + ip = ip, + isBlocked = isBlocked, + timeCategory = timeCategory + ) + } + + NAV_TARGET_APP_INFO -> { + val uid = intent.getIntExtra(EXTRA_APP_INFO_UID, Constants.INVALID_UID) + if (uid == Constants.INVALID_UID) return + homeNavRequest = HomeNavRequest.AppInfo(uid = uid) + } + + NAV_TARGET_NETWORK_LOGS -> { + // Navigate to network logs screen + homeNavRequest = HomeNavRequest.NetworkLogs + } + + NAV_TARGET_WG_MAIN -> { + homeNavRequest = HomeNavRequest.WgMain + } + } + } + + private fun handleNotificationAction(intent: Intent) { + if (intent.extras == null) return + + val accessibility = intent.getStringExtra(Constants.NOTIF_INTENT_EXTRA_ACCESSIBILITY_NAME) + if (Constants.NOTIF_INTENT_EXTRA_ACCESSIBILITY_VALUE == accessibility) { + homeDialogState = HomeDialog.AccessibilityCrash + return + } + + val newApp = intent.getStringExtra(Constants.NOTIF_INTENT_EXTRA_NEW_APP_NAME) + if (Constants.NOTIF_INTENT_EXTRA_NEW_APP_VALUE == newApp) { + val uid = + intent.getIntExtra(Constants.NOTIF_INTENT_EXTRA_APP_UID, Constants.INVALID_UID) + if (uid > 0) { + homeNavRequest = HomeNavRequest.AppInfo(uid = uid) + } + return + } + + val wg = intent.getStringExtra(Constants.NOTIF_WG_PERMISSION_NAME) + if (Constants.NOTIF_WG_PERMISSION_VALUE == wg) { + homeNavRequest = HomeNavRequest.WgMain + return + } + } + + private fun handleRestoreProcess(uri: Uri?) { if (uri == null) { showToastUiCentered( @@ -248,22 +844,7 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { private fun showRestoreDialog(uri: Uri) { if (!isInForeground()) return - - val builder = MaterialAlertDialogBuilder(this, R.style.App_Dialog_NoDim) - builder.setTitle(R.string.brbs_restore_dialog_title) - builder.setMessage(R.string.brbs_restore_dialog_message) - builder.setPositiveButton(getString(R.string.brbs_restore_dialog_positive)) { _, _ -> - startRestore(uri) - observeRestoreWorker() - } - - builder.setNegativeButton(getString(R.string.lbl_cancel)) { _, _ -> - // no-op - } - - builder.setCancelable(true) - val dialog = builder.create() - dialog.show() + homeDialogState = HomeDialog.Restore(uri) } private fun startRestore(fileUri: Uri) { @@ -301,11 +882,6 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { Toast.LENGTH_SHORT ) workManager.pruneWork() - // restart the app so that Room gets fresh SQLite connections - lifecycleScope.launch { - delay(1000.milliseconds) - restartApp() - } } else if ( WorkInfo.State.CANCELLED == workInfo.state || WorkInfo.State.FAILED == workInfo.state @@ -323,30 +899,24 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { } } - private fun restartApp() { - val pm: PackageManager = packageManager - val intent = pm.getLaunchIntentForPackage(packageName) ?: return - val mainIntent = Intent.makeRestartActivityTask(intent.component) - mainIntent.putExtra(INTENT_RESTART_APP, true) - startActivity(mainIntent) - Runtime.getRuntime().exit(0) - } - private fun observeAppState() { VpnController.connectionStatus.observe(this) { if (it == BraveVPNService.State.PAUSED) { - startActivity(Intent().setClass(this, PauseActivity::class.java)) - finish() + // Handled in setContent } } } private fun removeThisMethod() { + // set allowBypass to false for all versions, overriding the user's preference. + // the default was true for Play Store and website versions, and false for F-Droid. + // when allowBypass is true, some OEMs bypass the VPN service, causing connections + // to fail due to the "Block connections without VPN" option. + persistentState.allowBypass = false - val rethinkUid = Utilities.getApplicationInfo(this, this.packageName)?.uid io { - if (rethinkUid != null) FirewallManager.exemptRethinkApp(rethinkUid) - else Logger.e(LOG_TAG_UI, "HomeScreen Rethink UID is null") + appInfoDb.setRethinkToBypassDnsAndFirewall() + appInfoDb.setRethinkToBypassProxy(true) } // change the persistent state for defaultDnsUrl, if its google.com (only for v055d) @@ -361,7 +931,7 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { // if biometric auth is enabled, then set the biometric auth type to 3 (15 minutes) if (persistentState.biometricAuth) { persistentState.biometricAuthType = - MiscSettingsActivity.BioMetricType.FIFTEEN_MIN.action + BioMetricType.FIFTEEN_MIN.action // reset the bio metric auth time, as now the value is changed from System.currentTimeMillis // to SystemClock.elapsedRealtime persistentState.biometricAuthTime = SystemClock.elapsedRealtime() @@ -407,11 +977,6 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { } } - private fun launchOnboardActivity() { - val intent = Intent(this, WelcomeActivity::class.java) - startActivity(intent) - finish() - } private fun updateNewVersion() { if (!isNewVersion()) return @@ -478,18 +1043,6 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { // do not check for debug builds if (BuildConfig.DEBUG) return - // alpha testers get updates via direct distribution, take to the url - if (Utilities.isAlphaBuild()) { - if (isInteractive == AppUpdater.UserPresent.INTERACTIVE) { - Logger.i(LOG_TAG_APP_UPDATE, "update check skipped for alpha build") - openUrl(this, ALPHA_UPDATE_CHECK_URL) - return - } else { - Logger.i(LOG_TAG_APP_UPDATE, "non interactive update check skipped for alpha build") - return - } - } - // Check updates only for play store / website version. Not fDroid. if (!isPlayStoreFlavour() && !isWebsiteFlavour()) { Logger.i(LOG_TAG_APP_UPDATE, "update check not for ${BuildConfig.FLAVOR}") @@ -611,21 +1164,16 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { } private fun showUpdateCompleteSnackbar() { - try { - val container: View = findViewById(R.id.container) - val snack = - Snackbar.make( - container, - getString(R.string.update_complete_snack_message), - Snackbar.LENGTH_INDEFINITE + lifecycleScope.launch { + val result = + snackbarHostState?.showSnackbar( + message = getString(R.string.update_complete_snack_message), + actionLabel = getString(R.string.update_complete_action_snack), + duration = SnackbarDuration.Indefinite ) - snack.setAction(getString(R.string.update_complete_action_snack)) { + if (result == SnackbarResult.ActionPerformed) { appUpdateManager.completeUpdate() } - snack.setActionTextColor(ContextCompat.getColor(this, R.color.primaryLightColorText)) - snack.show() - } catch (e: Exception) { - Logger.e(LOG_TAG_UI, "err showing update complete snackbar: ${e.message}", e) } } @@ -635,78 +1183,7 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { message: String ) { if (!isInForeground()) return - - val builder = MaterialAlertDialogBuilder(this, R.style.App_Dialog_NoDim) - builder.setTitle(title) - - // Determine dialog type based on title to decide if it should be modal - val isUpdateAvailable = title == getString(R.string.download_update_dialog_title) - val isUpToDate = message == getString(R.string.download_update_dialog_message_ok) - val isError = message == getString(R.string.download_update_dialog_failure_message) - val isQuotaExceeded = message == getString(R.string.download_update_dialog_trylater_message) - - // Adjust message for Play Store if needed - if (isUpdateAvailable && source == AppUpdater.InstallSource.STORE) { - // Play Store updates should use native UI, but if we reach here, show appropriate message - builder.setMessage("A new version is available. Please update from Play Store.") - } else { - builder.setMessage(message) - } - - // Make dialog non-dismissible (modal) only when an actual update is available - // User cannot dismiss by tapping outside or pressing back button - // However, user can still choose "Remind me later" button - builder.setCancelable(!isUpdateAvailable) - - when { - isUpdateAvailable -> { - // Update is available - modal dialog with explicit user choice - if (source == AppUpdater.InstallSource.STORE) { - // For Play Store updates, this dialog rarely appears as Google's native UI handles it - // But if it does appear, just show OK to dismiss (native UI should have been shown) - builder.setPositiveButton(getString(R.string.hs_download_positive_default)) { dialogInterface, _ -> - appUpdateManager.completeUpdate() - dialogInterface.dismiss() - } - builder.setNegativeButton(getString(R.string.hs_download_negative_default)) { dialogInterface, _ -> - persistentState.lastAppUpdateCheck = System.currentTimeMillis() - dialogInterface.dismiss() - } - } else { - // For website version, open browser to download - this is the main use case - builder.setPositiveButton(getString(R.string.hs_download_positive_website)) { dialogInterface, _ -> - initiateDownload() - dialogInterface.dismiss() - } - // Negative button allows user to postpone the update - builder.setNegativeButton(getString(R.string.hs_download_negative_default)) { dialogInterface, _ -> - persistentState.lastAppUpdateCheck = System.currentTimeMillis() - dialogInterface.dismiss() - } - } - } - isUpToDate || isError || isQuotaExceeded -> { - // Informational dialogs - dismissible with OK button - builder.setCancelable(true) - builder.setPositiveButton(getString(R.string.hs_download_positive_default)) { dialogInterface, _ -> - dialogInterface.dismiss() - } - } - else -> { - // Fallback for any other case - make it dismissible - builder.setCancelable(true) - builder.setPositiveButton(getString(R.string.hs_download_positive_default)) { dialogInterface, _ -> - dialogInterface.dismiss() - } - } - } - - try { - val dialog = builder.create() - dialog.show() - } catch (e: Exception) { - Logger.e(LOG_TAG_UI, "err showing download dialog: ${e.message}", e) - } + homeDialogState = HomeDialog.Download(source, title, message) } private fun initiateDownload() { @@ -736,145 +1213,1378 @@ class HomeScreenActivity : BaseActivity(R.layout.activity_home_screen) { Logger.v(LOG_TAG_UI, "home screen activity is stopped, app going to background") } - private fun handleOnBackPressed() { - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as? NavHostFragment - val navController = navHostFragment?.navController - val currentId = navController?.currentDestination?.id - val homeId = R.id.homeScreenFragment - - when { - currentId == homeId -> { - finish() - } - currentId == R.id.rethinkPlusDashboardFragment -> { - val btmNavView = findViewById(R.id.nav_view) - btmNavView.selectedItemId = homeId - navController?.navigate( - homeId, - null, - NavOptions.Builder().setPopUpTo(homeId, true).build() - ) - } - else -> { - // Any other non-home top-level destination (statistics, configure, - // about, rethinkPlus), navigate to home and clear the back stack. - val btmNavView = findViewById(R.id.nav_view) - btmNavView.selectedItemId = homeId - navController?.navigate( - homeId, - null, - NavOptions.Builder().setPopUpTo(homeId, true).build() - ) - } - } + + private sealed interface HomeDialog { + data class Restore(val uri: Uri) : HomeDialog + data class Download( + val source: AppUpdater.InstallSource, + val title: String, + val message: String + ) : HomeDialog + + data class AlwaysOnStop(val message: String) : HomeDialog + data object AlwaysOnDisable : HomeDialog + data object PrivateDns : HomeDialog + data class FirstTimeVpn(val intent: Intent) : HomeDialog + data class Stats(val displayText: String, val dump: String) : HomeDialog + data class Sponsor(val usageMessage: String, val amount: String) : HomeDialog + data object Contributors : HomeDialog + data object NoLog : HomeDialog + data object AccessibilityCrash : HomeDialog + data class NewFeatures(val title: String) : HomeDialog + } + + private fun openDetailedStatsUi(type: SummaryStatisticsType) { + val timeCategory = summaryViewModel.uiState.value.timeCategory.value + val category = + SummaryStatisticsViewModel.TimeCategory.fromValue(timeCategory) + ?: SummaryStatisticsViewModel.TimeCategory.ONE_HOUR + homeNavRequest = HomeNavRequest.DetailedStats(type, category) + } + + private fun promptForAppSponsorship() { + val installTime = packageManager.getPackageInfo(packageName, 0).firstInstallTime + val timeDiff = System.currentTimeMillis() - installTime + val days = (timeDiff / (1000L * 60L * 60L * 24L)).toDouble() + val month = days / 30.0 + val amount = month * (0.60 + 0.20) + + val msg = getString( + R.string.sponser_dialog_usage_msg, + days.toInt().toString(), + "%.2f".format(amount) + ) + val formattedAmount = + getString( + R.string.two_argument_no_space, + getString(R.string.symbol_dollar), + "%.2f".format(amount) + ) + homeDialogState = HomeDialog.Sponsor(msg, formattedAmount) + } + + private fun handleMainScreenBtnClickEvent() { + Utilities.delay(TimeUnit.MILLISECONDS.toMillis(500L), lifecycleScope) { } + handleVpnActivation() + } + + private fun handleVpnActivation() { + if (handleAlwaysOnVpn()) return + + if (VpnController.hasTunnel()) { + stopVpnService() + } else { + prepareAndStartVpn() + } + } + + private fun handleAlwaysOnVpn(): Boolean { + if (Utilities.isOtherVpnHasAlwaysOn(this)) { + showAlwaysOnDisableDialog() + return true + } + + if (VpnController.isAlwaysOn(this) && VpnController.hasTunnel()) { + showAlwaysOnStopDialog() + return true + } + + return false + } + + private fun showAlwaysOnStopDialog() { + val message = + if (VpnController.isVpnLockdown()) { + UIUtils.htmlToSpannedText(getString(R.string.always_on_dialog_lockdown_stop_message)) + .toString() + } else { + getString(R.string.always_on_dialog_stop_message) + } + homeDialogState = HomeDialog.AlwaysOnStop(message) + } + + private fun showAlwaysOnDisableDialog() { + homeDialogState = HomeDialog.AlwaysOnDisable + } + + private fun startDnsActivity(screenToLoad: Int) { + if (Utilities.isPrivateDnsActive(this)) { + showPrivateDnsDialog() + return + } + + if (canStartRethinkActivity()) { + io { + val endpoint = appConfig.getRemoteRethinkEndpoint() + val url = endpoint?.url ?: "" + val name = endpoint?.name ?: "" + uiCtx { + homeNavRequest = HomeNavRequest.ConfigureRethinkBasic( + ConfigureRethinkScreenType.DB_LIST, + name, + url + ) } } - ) + return + } + + homeNavRequest = HomeNavRequest.DnsDetail + } + + private fun navigateToDnsDetailIfAllowed() { + if (Utilities.isPrivateDnsActive(this)) { + showPrivateDnsDialog() + return + } + homeNavRequest = HomeNavRequest.DnsDetail + } + + private fun startCustomDnsActivity() { + homeNavRequest = HomeNavRequest.DnsList + } + + private fun startRethinkPlusDnsActivity() { + homeNavRequest = HomeNavRequest.ConfigureRethinkBasic(ConfigureRethinkScreenType.DB_LIST) + } + + private fun startLocalBlocklistConfigureActivity() { + homeNavRequest = HomeNavRequest.ConfigureRethinkBasic(ConfigureRethinkScreenType.LOCAL) + } + + private fun canStartRethinkActivity(): Boolean { + val dns = appConfig.getDnsType() + return dns.isRethinkRemote() && !WireguardManager.oneWireGuardEnabled() + } + + private fun showPrivateDnsDialog() { + homeDialogState = HomeDialog.PrivateDns + } + + private fun startFirewallActivity(screenToLoad: Int) { + homeNavRequest = HomeNavRequest.FirewallSettings + } + + private fun openUniversalFirewallScreen() { + homeNavRequest = HomeNavRequest.UniversalFirewallSettings + } + + private fun openCustomIpScreen() { + homeNavRequest = + HomeNavRequest.CustomRules( + uid = UID_EVERYBODY, + tab = CustomRulesTab.IP, + mode = CustomRulesMode.APP_SPECIFIC + ) + } + + private fun openAppWiseIpScreen() { + homeNavRequest = + HomeNavRequest.CustomRules( + uid = UID_EVERYBODY, + tab = CustomRulesTab.IP, + mode = CustomRulesMode.ALL_RULES + ) + } + + private fun startAppsActivity() { + homeNavRequest = HomeNavRequest.AppList + } + + private fun prepareAndStartVpn() { + if (prepareVpnService()) { + startVpnService() + } + } + + private fun stopVpnService() { + VpnController.stop("home", this) } + private fun startVpnService() { + getNotificationPermissionIfNeeded() + VpnController.start(this, true) + } + + private fun getNotificationPermissionIfNeeded() { + if (!Utilities.isAtleastT()) { + return + } - private fun setupNavigationItemSelectedListener() { - val btmNavView = findViewById(R.id.nav_view) ?: run { - Logger.w(LOG_TAG_UI, "setupNavigationItemSelectedListener: BottomNavigationView not found") + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + ) { return } - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as? NavHostFragment - val navController = navHostFragment?.navController ?: run { - Logger.w(LOG_TAG_UI, "setupNavigationItemSelectedListener: NavController not found") + if (!persistentState.shouldRequestNotificationPermission) { + Logger.w(LOG_TAG_VPN, "User rejected notification permission for the app") return } - val homeId = R.id.homeScreenFragment + notificationPermissionResult.launch(Manifest.permission.POST_NOTIFICATIONS) + } - // Keep the rethinkPlus bottom-nav item highlighted whenever the user is on the - // dashboard (a child destination of rethinkPlus that is not itself a menu item). - navController.addOnDestinationChangedListener { _, destination, _ -> - when (destination.id) { - R.id.rethinkPlusDashboardFragment -> { - // Dashboard is a child of the rethinkPlus flow keep rethinkPlus checked. - btmNavView.menu.findItem(R.id.rethinkPlus)?.isChecked = true - } - R.id.rethinkPlus, - R.id.homeScreenFragment, - R.id.summaryStatisticsFragment, - R.id.configureFragment, - R.id.aboutFragment -> { - // These are direct menu items: BottomNavigationView updates isChecked - // automatically when selectedItemId is set; nothing extra needed here. - } - else -> { /* other destinations: no bottom-nav highlight change */ } + @Throws(ActivityNotFoundException::class) + private fun prepareVpnService(): Boolean { + val prepareVpnIntent: Intent? = + try { + Logger.i(LOG_TAG_VPN, "Preparing VPN service") + VpnService.prepare(this) + } catch (e: NullPointerException) { + Logger.e(LOG_TAG_VPN, "Device does not support system-wide VPN mode.", e) + return false } + if (prepareVpnIntent != null) { + Logger.i(LOG_TAG_VPN, "VPN service is prepared") + showFirstTimeVpnDialog(prepareVpnIntent) + return false } + Logger.i(LOG_TAG_VPN, "VPN service is prepared, starting VPN service") + return true + } - btmNavView.setOnItemSelectedListener { item -> - val currentId = navController.currentDestination?.id + private fun showFirstTimeVpnDialog(prepareVpnIntent: Intent) { + homeDialogState = HomeDialog.FirstTimeVpn(prepareVpnIntent) + } - // Prevent re-navigating if we are already on this destination (or its child). - // For rethinkPlus this also covers rethinkPlusDashboardFragment. - val alreadyThere = when (item.itemId) { - R.id.rethinkPlus -> - currentId == R.id.rethinkPlus || currentId == R.id.rethinkPlusDashboardFragment - else -> currentId == item.itemId - } - if (alreadyThere) return@setOnItemSelectedListener false + private fun registerForActivityResult() { + startForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> { + startVpnService() + } - when (item.itemId) { - R.id.rethinkPlus -> { - // RPN is not available in alpha builds; show a "coming soon" - // toast and stay on the current destination. - if (Utilities.isAlphaBuild()) { + Activity.RESULT_CANCELED -> { showToastUiCentered( this, - getString(R.string.coming_soon_toast), - Toast.LENGTH_SHORT + getString(R.string.hsf_vpn_prepare_failure), + Toast.LENGTH_LONG ) - return@setOnItemSelectedListener false } - // Navigate to rethinkPlus (start destination of the nested nav graph). - // popUpTo homeId with inclusive=false keeps home in the back stack so - // that back from rethinkPlus returns to home, not to a prior tab. - navController.navigate( - R.id.rethinkPlus, - null, - NavOptions.Builder() - .setPopUpTo(homeId, false) - .build() - ) - true - } - homeId -> { - navController.navigate( - homeId, - null, - NavOptions.Builder().setPopUpTo(homeId, true).build() - ) - true + else -> { + stopVpnService() + } } + } - else -> { - navController.navigate( - item.itemId, - null, - NavOptions.Builder().setPopUpTo(homeId, false).build() - ) - true + notificationPermissionResult = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + persistentState.shouldRequestNotificationPermission = it + if (it) { + Logger.i(LOG_TAG_UI, "User accepted notification permission") + } else { + Logger.w(LOG_TAG_UI, "User rejected notification permission") + lifecycleScope.launch { + snackbarHostState?.showSnackbar( + message = getString(R.string.hsf_notification_permission_failure), + duration = SnackbarDuration.Long + ) + } } } + } + + private fun copyTokenToClipboard() { + val text = persistentState.firebaseUserToken + val clipboard = ContextCompat.getSystemService(this, ClipboardManager::class.java) + val clip = ClipData.newPlainText("token", text) + clipboard?.setPrimaryClip(clip) + Toast.makeText(this, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + + private fun initiateFlightRecord() { + io { VpnController.performFlightRecording() } + Toast.makeText(this, "Flight recording started", Toast.LENGTH_SHORT).show() + } + + private fun openEventLogs() { + homeNavRequest = HomeNavRequest.Events + } + + private fun getVersionName(): String { + return Utilities.getPackageMetadata(packageManager, packageName)?.versionName ?: "" + } + + private fun openStatsDialog() { + io { + val stat = VpnController.getNetStat() + val formatedStat = UIUtils.formatNetStat(stat) + val vpnStats = VpnController.vpnStats() + val stats = formatedStat + vpnStats + uiCtx { + val displayText = if (formatedStat == null) "No Stats" else stats + homeDialogState = HomeDialog.Stats(displayText, stats) + } } + } - // Tapping an already-selected tab is a no-op (don't re-navigate or recreate). - btmNavView.setOnItemReselectedListener { /* intentional no-op */ } + private fun copyToClipboard(label: String, text: String): ClipboardManager? { + val clipboard = ContextCompat.getSystemService(this, ClipboardManager::class.java) + clipboard?.setPrimaryClip(ClipData.newPlainText(label, text)) + return clipboard } - private fun io(f: suspend () -> Unit) { - lifecycleScope.launch(Dispatchers.IO) { f() } + private fun openDatabaseDumpDialog() { + homeNavRequest = HomeNavRequest.Database + } + + private fun hasAnyLogsAvailable(): Boolean { + val dir = filesDir + val bugReportDir = java.io.File(dir, BugReportZipper.BUG_REPORT_DIR_NAME) + if (bugReportDir.exists() && bugReportDir.isDirectory) { + val bugReportFiles = bugReportDir.listFiles() + if (bugReportFiles != null && bugReportFiles.any { it.isFile && it.length() > 0 }) { + return true + } + } + + return false + } + + private fun showNoLogDialog() { + homeDialogState = HomeDialog.NoLog + } + + private fun openNotificationSettings() { + val packageName = packageName + try { + val intent = Intent() + if (Utilities.isAtleastO()) { + intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS + intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } else { + intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.data = android.net.Uri.fromParts("package", packageName, null) + } + startActivity(intent) + } catch (e: ActivityNotFoundException) { + showToastUiCentered( + this, + getString(R.string.notification_screen_error), + Toast.LENGTH_SHORT + ) + Logger.w(LOG_TAG_UI, "activity not found ${e.message}", e) + } + } + + private fun showNewFeaturesDialog() { + val v = getVersionName().slice(0..6) + val title = getString(R.string.about_whats_new, v) + homeDialogState = HomeDialog.NewFeatures(title) + } + + private fun showContributors() { + homeDialogState = HomeDialog.Contributors + } + + private fun promptCrashLogAction() { + if (Utilities.isAtleastO()) { + io { + try { + EnhancedBugReport.addLogsToZipFile(this@HomeScreenActivity) + } catch (e: Exception) { + Logger.w(LOG_TAG_UI, "err adding tombstone to zip: ${e.message}", e) + } + } + } + + val dir = filesDir + val zipPath = BugReportZipper.getZipFileName(dir) + val zipFile = java.io.File(zipPath) + + if (!zipFile.exists() || zipFile.length() <= 0) { + showToastUiCentered( + this, + getString(R.string.log_file_not_available), + Toast.LENGTH_SHORT + ) + return + } + + showBugReportSheet = true + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun BugReportFilesSheet(onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + val bugReportFiles = remember { mutableStateListOf() } + var isSending by remember { mutableStateOf(false) } + var progressText by remember { mutableStateOf("") } + var pendingDelete by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + try { + val files = withContext(Dispatchers.IO) { collectAllBugReportFiles() } + bugReportFiles.clear() + bugReportFiles.addAll(files) + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err loading bug report: ${e.message}", e) + showToastUiCentered( + this@HomeScreenActivity, + getString(R.string.bug_report_file_not_found), + Toast.LENGTH_SHORT + ) + onDismiss() + } + } + + val totalSize = bugReportFiles.filter { it.isSelected }.sumOf { it.file.length() } + val hasSelection = bugReportFiles.any { it.isSelected } + val allSelected = bugReportFiles.isNotEmpty() && bugReportFiles.all { it.isSelected } + + RethinkModalBottomSheet( + onDismissRequest = onDismiss, + contentPadding = PaddingValues(Dimensions.spacingNone), + verticalSpacing = Dimensions.spacingNone, + includeBottomSpacer = false + ) { + Column(modifier = Modifier.padding(Dimensions.spacingLg)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Dimensions.spacingMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = allSelected, + onCheckedChange = { checked -> + bugReportFiles.forEach { it.isSelected = checked } + } + ) + Text( + text = + if (allSelected) { + getString(R.string.bug_report_deselect_all) + } else { + getString(R.string.lbl_select_all) + .replaceFirstChar(Char::titlecase) + }, + modifier = Modifier.clickable { + bugReportFiles.forEach { it.isSelected = !allSelected } + } + ) + } + Text( + text = formatFileSize(totalSize), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (bugReportFiles.isEmpty()) { + Text(text = getString(R.string.bug_report_no_files_available)) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + ) { + items(bugReportFiles, key = { it.file.absolutePath }) { item -> + BugReportFileRow( + fileItem = item, + onShare = { openBugReportFile(item.file) }, + onDelete = { pendingDelete = item } + ) + } + } + } + + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (isSending) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(Dimensions.iconSizeSm)) + Spacer(modifier = Modifier.width(Dimensions.spacingSm)) + Text(text = progressText) + } + } + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = { + if (!isSending) { + scope.launch { + sendBugReport( + bugReportFiles = bugReportFiles, + onSending = { sending, text -> + isSending = sending + progressText = text + }, + onDone = { + showBugReportSheet = false + } + ) + } + } + }, + enabled = hasSelection && !isSending + ) { + Text(text = getString(R.string.about_bug_report_dialog_positive_btn)) + } + } + } + } + + pendingDelete?.let { fileItem -> + RethinkConfirmDialog( + onDismissRequest = { pendingDelete = null }, + title = getString(R.string.lbl_delete), + message = getString(R.string.bug_report_delete_confirmation, fileItem.name), + confirmText = getString(R.string.lbl_delete), + dismissText = getString(R.string.lbl_cancel), + isConfirmDestructive = true, + onConfirm = { + pendingDelete = null + scope.launch { + deleteBugReportFile( + fileItem = fileItem, + bugReportFiles = bugReportFiles, + onDismiss = onDismiss + ) + } + }, + onDismiss = { pendingDelete = null } + ) + } + } + + @Composable + private fun BugReportFileRow( + fileItem: BugReportFile, + onShare: () -> Unit, + onDelete: () -> Unit + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.spacingSm) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(Dimensions.cornerRadiusMd) + ) + .padding(Dimensions.spacingMd), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = fileItem.isSelected, + onCheckedChange = { checked -> fileItem.isSelected = checked } + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = fileItem.name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium) + ) + Text( + text = + "${formatFileSize(fileItem.file.length())} - ${formatDate(fileItem.file.lastModified())}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onShare) { + Icon( + painter = painterResource(id = R.drawable.ic_share), + contentDescription = null + ) + } + IconButton(onClick = onDelete) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + } + } + + private fun collectAllBugReportFiles(): List { + val files = mutableListOf() + val dir = filesDir + + val bugReportZip = File(BugReportZipper.getZipFileName(dir)) + if (bugReportZip.exists() && bugReportZip.length() > 0) { + files.add( + BugReportFile( + file = bugReportZip, + name = bugReportZip.name, + type = FileType.ZIP, + isSelected = true + ) + ) + } + + if (Utilities.isAtleastO()) { + val tombstoneZip = EnhancedBugReport.getTombstoneZipFile(this) + if (tombstoneZip != null && tombstoneZip.exists() && tombstoneZip.length() > 0) { + files.add( + BugReportFile( + file = tombstoneZip, + name = tombstoneZip.name, + type = FileType.ZIP, + isSelected = true + ) + ) + } + } + + val bugReportDir = File(dir, BugReportZipper.BUG_REPORT_DIR_NAME) + if (bugReportDir.exists() && bugReportDir.isDirectory) { + bugReportDir.listFiles()?.forEach { file -> + if (file.isFile && file.length() > 0) { + files.add( + BugReportFile( + file = file, + name = file.name, + type = getFileType(file), + isSelected = true + ) + ) + } + } + } + + if (Utilities.isAtleastO()) { + val tombstoneDir = File(dir, EnhancedBugReport.TOMBSTONE_DIR_NAME) + if (tombstoneDir.exists() && tombstoneDir.isDirectory) { + tombstoneDir.listFiles()?.forEach { file -> + if (file.isFile && file.length() > 0) { + files.add( + BugReportFile( + file = file, + name = file.name, + type = FileType.TEXT, + isSelected = true + ) + ) + } + } + } + } + + return files.sortedByDescending { it.file.lastModified() } + } + + private fun getFileType(file: File): FileType { + return when (file.extension.lowercase()) { + "zip" -> FileType.ZIP + "txt", "log" -> FileType.TEXT + else -> FileType.TEXT + } + } + + private suspend fun sendBugReport( + bugReportFiles: List, + onSending: (Boolean, String) -> Unit, + onDone: () -> Unit + ) { + val selectedFiles = bugReportFiles.filter { it.isSelected }.map { it.file } + + if (selectedFiles.isEmpty()) { + showToastUiCentered( + this, + getString(R.string.bug_report_no_files_selected), + Toast.LENGTH_SHORT + ) + return + } + + onSending(true, getString(R.string.bug_report_creating_zip)) + + try { + val attachmentUri = withContext(Dispatchers.IO) { + if (selectedFiles.size == 1) { + getFileUri(selectedFiles[0]) + } else { + createCombinedZip(selectedFiles) + } + } + + if (attachmentUri != null) { + val emailIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.about_mail_to))) + putExtra( + Intent.EXTRA_SUBJECT, + getString(R.string.about_mail_bugreport_subject) + ) + putExtra(Intent.EXTRA_STREAM, attachmentUri) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + startActivity( + Intent.createChooser( + emailIntent, + getString(R.string.about_mail_bugreport_share_title) + ) + ) + onDone() + } else { + showToastUiCentered( + this, + getString(R.string.error_loading_log_file), + Toast.LENGTH_SHORT + ) + } + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err sending bug report: ${e.message}", e) + showToastUiCentered( + this, + getString(R.string.error_loading_log_file), + Toast.LENGTH_SHORT + ) + } finally { + onSending(false, "") + } + } + + private fun createCombinedZip(files: List): Uri? { + val tempDir = cacheDir + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val zipFile = File(tempDir, "rethinkdns_bugreport_$timestamp.zip") + + try { + val addedEntries = mutableSetOf() + + ZipOutputStream(FileOutputStream(zipFile)).use { zos -> + files.forEach { file -> + if (file.extension == "zip") { + ZipFile(file).use { zf -> + val entries = zf.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && !addedEntries.contains(entry.name)) { + addedEntries.add(entry.name) + + val newEntry = ZipEntry(entry.name) + zos.putNextEntry(newEntry) + zf.getInputStream(entry).use { input -> + input.copyTo(zos) + } + zos.closeEntry() + } + } + } + } else { + if (!addedEntries.contains(file.name)) { + addedEntries.add(file.name) + + val entry = ZipEntry(file.name) + zos.putNextEntry(entry) + FileInputStream(file).use { input -> + input.copyTo(zos) + } + zos.closeEntry() + } + } + } + } + + return getFileUri(zipFile) + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err creating combined zip: ${e.message}", e) + zipFile.delete() + return null + } + } + + private fun getFileUri(file: File): Uri? { + return try { + FileProvider.getUriForFile( + this, + BugReportZipper.FILE_PROVIDER_NAME, + file + ) + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err getting file uri: ${e.message}", e) + null + } + } + + private fun formatFileSize(size: Long): String { + return when { + size < BYTES_IN_KB -> "$size B" + size < BYTES_IN_MB -> "${size / BYTES_IN_KB} KB" + else -> String.format(Locale.US, "%.1f MB", size / MB_DIVISOR) + } + } + + private fun formatDate(timestamp: Long): String { + return SimpleDateFormat("MMM d, yyyy HH:mm", Locale.US).format(Date(timestamp)) + } + + private suspend fun deleteBugReportFile( + fileItem: BugReportFile, + bugReportFiles: MutableList, + onDismiss: () -> Unit + ) { + try { + val deleted = withContext(Dispatchers.IO) { fileItem.file.delete() } + + if (deleted) { + bugReportFiles.remove(fileItem) + + showToastUiCentered( + this, + getString(R.string.bug_report_file_deleted, fileItem.name), + Toast.LENGTH_SHORT + ) + + if (bugReportFiles.isEmpty()) { + showToastUiCentered( + this, + getString(R.string.bug_report_no_files_available), + Toast.LENGTH_SHORT + ) + onDismiss() + } + } else { + showToastUiCentered( + this, + getString(R.string.bug_report_delete_failed, fileItem.name), + Toast.LENGTH_SHORT + ) + } + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err deleting file: ${e.message}", e) + showToastUiCentered( + this, + getString(R.string.bug_report_delete_failed, fileItem.name), + Toast.LENGTH_SHORT + ) + } + } + + private fun openBugReportFile(file: File) { + try { + val uri = getFileUri(file) ?: return + + val mimeType = when (file.extension.lowercase()) { + "zip" -> "application/zip" + "txt", "log" -> "text/plain" + else -> "text/plain" + } + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + startActivity(Intent.createChooser(intent, getString(R.string.about_bug_report))) + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err opening file: ${e.message}", e) + showToastUiCentered( + this, + getString(R.string.bug_report_error_opening_file), + Toast.LENGTH_SHORT + ) + } + } + + private fun handleShowAppExitInfo() { + if (WorkScheduler.isWorkRunning(this, WorkScheduler.APP_EXIT_INFO_JOB_TAG)) return + + workScheduler.scheduleOneTimeWorkForAppExitInfo() + + val workManager = WorkManager.getInstance(applicationContext) + workManager.getWorkInfosByTagLiveData(WorkScheduler.APP_EXIT_INFO_ONE_TIME_JOB_TAG).observe( + this + ) { workInfoList -> + val workInfo = workInfoList?.getOrNull(0) ?: return@observe + Logger.i( + Logger.LOG_TAG_SCHEDULER, + "WorkManager state: ${workInfo.state} for ${WorkScheduler.APP_EXIT_INFO_ONE_TIME_JOB_TAG}" + ) + if (WorkInfo.State.SUCCEEDED == workInfo.state) { + onAppExitInfoSuccess() + workManager.pruneWork() + } else if ( + WorkInfo.State.CANCELLED == workInfo.state || + WorkInfo.State.FAILED == workInfo.state + ) { + onAppExitInfoFailure() + workManager.pruneWork() + workManager.cancelAllWorkByTag(WorkScheduler.APP_EXIT_INFO_ONE_TIME_JOB_TAG) + } else { + // no-op + } + } + } + + data class BugReportFile( + val file: File, + val name: String, + val type: FileType, + var isSelected: Boolean + ) + + enum class FileType { + ZIP, + TEXT + } + + companion object { + private const val BYTES_IN_KB = 1024L + private const val BYTES_IN_MB = 1024L * 1024L + private const val MB_DIVISOR = 1024.0 * 1024.0 + const val EXTRA_NAV_TARGET = "extra_nav_target" + const val NAV_TARGET_DOMAIN_CONNECTIONS = "nav_target_domain_connections" + const val NAV_TARGET_APP_INFO = "nav_target_app_info" + const val NAV_TARGET_NETWORK_LOGS = "nav_target_network_logs" + const val NAV_TARGET_WG_MAIN = "nav_target_wg_main" + const val EXTRA_DC_TYPE = "extra_dc_type" + const val EXTRA_DC_FLAG = "extra_dc_flag" + const val EXTRA_DC_DOMAIN = "extra_dc_domain" + const val EXTRA_DC_ASN = "extra_dc_asn" + const val EXTRA_DC_IP = "extra_dc_ip" + const val EXTRA_DC_IS_BLOCKED = "extra_dc_is_blocked" + const val EXTRA_DC_TIME_CATEGORY = "extra_dc_time_category" + const val EXTRA_APP_INFO_UID = "extra_app_info_uid" + } + + @Composable + private fun WhatsNewDialogContent() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimensions.spacingLg) + .verticalScroll(rememberScrollState()) + ) { + HtmlText( + text = getString(R.string.whats_new_version_update), + textAlign = TextAlign.Start + ) + } + } + + @Composable + private fun HomeDialogHost() { + when (val dialog = homeDialogState) { + is HomeDialog.Restore -> { + HomeConfirmDialog( + onDismissRequest = { homeDialogState = null }, + title = getString(R.string.brbs_restore_dialog_title), + message = getString(R.string.brbs_restore_dialog_message), + confirmText = getString(R.string.brbs_restore_dialog_positive), + onConfirm = { + homeDialogState = null + startRestore(dialog.uri) + observeRestoreWorker() + }, + dismissText = getString(R.string.lbl_cancel), + onDismiss = { homeDialogState = null } + ) + } + + is HomeDialog.Download -> { + val isUpdateAvailable = + dialog.title == getString(R.string.download_update_dialog_title) + val resolvedMessage = + if (isUpdateAvailable && dialog.source == AppUpdater.InstallSource.STORE) { + "A new version is available. Please update from Play Store." + } else { + dialog.message + } + val primaryLabel = + if (isUpdateAvailable && dialog.source != AppUpdater.InstallSource.STORE) { + getString(R.string.hs_download_positive_website) + } else { + getString(R.string.hs_download_positive_default) + } + + HomeConfirmDialog( + onDismissRequest = { + if (!isUpdateAvailable) { + homeDialogState = null + } + }, + title = dialog.title, + message = resolvedMessage, + confirmText = primaryLabel, + dismissText = + if (isUpdateAvailable) { + getString(R.string.hs_download_negative_default) + } else { + null + }, + onConfirm = { + if (isUpdateAvailable) { + if (dialog.source == AppUpdater.InstallSource.STORE) { + appUpdateManager.completeUpdate() + } else { + initiateDownload() + } + } + homeDialogState = null + }, + onDismiss = { + if (isUpdateAvailable) { + persistentState.lastAppUpdateCheck = System.currentTimeMillis() + homeDialogState = null + } + } + ) + + } + + is HomeDialog.AlwaysOnStop -> { + HomeAlwaysOnStopDialog( + title = getString(R.string.always_on_dialog_stop_heading), + message = dialog.message, + stopText = getString(R.string.always_on_dialog_positive), + openSettingsText = getString(R.string.always_on_dialog_neutral), + cancelText = getString(R.string.lbl_cancel), + onStop = { + homeDialogState = null + stopVpnService() + }, + onOpenSettings = { + homeDialogState = null + openVpnProfile(this@HomeScreenActivity) + }, + onCancel = { + homeDialogState = null + } + ) + } + + HomeDialog.AlwaysOnDisable -> { + HomeConfirmDialog( + onDismissRequest = {}, + title = getString(R.string.always_on_dialog_heading), + message = getString(R.string.always_on_dialog), + confirmText = getString(R.string.always_on_dialog_positive_btn), + dismissText = getString(R.string.lbl_cancel), + onConfirm = { + homeDialogState = null + openVpnProfile(this@HomeScreenActivity) + }, + onDismiss = { homeDialogState = null } + ) + } + + HomeDialog.PrivateDns -> { + HomeConfirmDialog( + onDismissRequest = {}, + title = getString(R.string.private_dns_dialog_heading), + message = getString(R.string.private_dns_dialog_desc), + confirmText = getString(R.string.private_dns_dialog_positive), + dismissText = getString(R.string.lbl_dismiss), + onConfirm = { + homeDialogState = null + openNetworkSettings( + this@HomeScreenActivity, + Settings.ACTION_WIRELESS_SETTINGS + ) + }, + onDismiss = { homeDialogState = null } + ) + } + + is HomeDialog.FirstTimeVpn -> { + HomeConfirmDialog( + onDismissRequest = {}, + title = getString(R.string.hsf_vpn_dialog_header), + message = getString(R.string.hsf_vpn_dialog_message), + confirmText = getString(R.string.lbl_proceed), + dismissText = getString(R.string.lbl_cancel), + onConfirm = { + homeDialogState = null + try { + startForResult.launch(dialog.intent) + } catch (e: ActivityNotFoundException) { + Logger.e(LOG_TAG_VPN, "Activity not found to start VPN service", e) + showToastUiCentered( + this@HomeScreenActivity, + getString(R.string.hsf_vpn_prepare_failure), + Toast.LENGTH_LONG + ) + } + }, + onDismiss = { homeDialogState = null } + ) + } + + is HomeDialog.Stats -> { + HomeStatsDialog( + onDismissRequest = { homeDialogState = null }, + title = getString(R.string.title_statistics), + displayText = dialog.displayText, + dismissText = getString(R.string.fapps_info_dialog_positive_btn), + copyText = getString(R.string.dns_info_neutral), + onDismiss = { homeDialogState = null }, + onCopy = { + copyToClipboard("stats_dump", dialog.dump) + showToastUiCentered( + this@HomeScreenActivity, + getString(R.string.copied_clipboard), + Toast.LENGTH_SHORT + ) + } + ) + } + + is HomeDialog.Sponsor -> { + /* + * Sponsor dialog intentionally hidden for now. + * + * Dialog( + * onDismissRequest = { homeDialogState = null }, + * properties = DialogProperties(usePlatformDefaultWidth = false) + * ) { + * Surface(color = MaterialTheme.colorScheme.background) { + * Column(modifier = Modifier + * .fillMaxWidth() + * .padding(Dimensions.spacingLg)) { + * Text( + * text = getString(R.string.about_sponsor_link_text), + * style = MaterialTheme.typography.titleLarge + * ) + * Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + * SponsorInfoDialogContent( + * amount = dialog.amount, + * usageMessage = dialog.usageMessage, + * onSponsorClick = { + * openUrl( + * this@HomeScreenActivity, + * RETHINKDNS_SPONSOR_LINK + * ) + * } + * ) + * Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + * Row( + * modifier = Modifier.fillMaxWidth(), + * horizontalArrangement = Arrangement.End + * ) { + * TextButton(onClick = { homeDialogState = null }) { + * Text(text = getString(R.string.lbl_cancel)) + * } + * } + * } + * } + * } + */ + LaunchedEffect(Unit) { + homeDialogState = null + } + } + + HomeDialog.Contributors -> { + Dialog( + onDismissRequest = { homeDialogState = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(color = MaterialTheme.colorScheme.background) { + ContributorsDialogContent(onDismiss = { homeDialogState = null }) + } + } + } + + HomeDialog.NoLog -> { + HomeConfirmDialog( + onDismissRequest = { homeDialogState = null }, + title = getString(R.string.about_bug_no_log_dialog_title), + message = getString(R.string.about_bug_no_log_dialog_message), + confirmText = getString(R.string.about_bug_no_log_dialog_positive_btn), + dismissText = getString(R.string.lbl_cancel), + onConfirm = { + homeDialogState = null + sendEmailIntent(this@HomeScreenActivity) + }, + onDismiss = { homeDialogState = null } + ) + } + + is HomeDialog.NewFeatures -> { + HomeNewFeaturesDialog( + onDismissRequest = { homeDialogState = null }, + title = dialog.title, + dismissText = getString(R.string.about_dialog_positive_button), + contactText = getString(R.string.about_dialog_neutral_button), + onDismiss = { homeDialogState = null }, + onContact = { + homeDialogState = null + sendEmailIntent(this@HomeScreenActivity) + }, + content = { WhatsNewDialogContent() } + ) + } + + HomeDialog.AccessibilityCrash -> { + HomeConfirmDialog( + onDismissRequest = { homeDialogState = null }, + title = getString(R.string.lbl_action_required), + message = getString(R.string.alert_firewall_accessibility_regrant_explanation), + confirmText = getString(R.string.univ_accessibility_crash_dialog_positive), + dismissText = getString(R.string.lbl_cancel), + onConfirm = { + UIUtils.openAndroidAppInfo(this@HomeScreenActivity, packageName) + homeDialogState = null + }, + onDismiss = { homeDialogState = null } + ) + } + + null -> Unit + } + } + + @Composable + private fun ContributorsDialogContent(onDismiss: () -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimensions.spacingXl) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd)) { + Image( + painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel), + contentDescription = getString(R.string.lbl_dismiss) + ) + } + Row( + modifier = Modifier.align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_authors), + contentDescription = null, + modifier = Modifier.size(Dimensions.iconSizeMd) + ) + Spacer(modifier = Modifier.width(Dimensions.spacingSm)) + Text( + text = getString(R.string.contributors_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + + HtmlText( + text = getString(R.string.contributors_list), + textAlign = TextAlign.Center + ) + } + } + + @Suppress("unused") + @Composable + private fun SponsorInfoDialogContent( + amount: String, + usageMessage: String, + onSponsorClick: () -> Unit + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(text = amount, style = MaterialTheme.typography.titleLarge) + Text(text = usageMessage, style = MaterialTheme.typography.bodyMedium) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onSponsorClick) { + Text(text = getString(R.string.about_sponsor_link_text)) + } + } + } + } + + @Composable + private fun HtmlText(text: String, textAlign: TextAlign) { + val textValue = remember(text) { UIUtils.htmlToSpannedText(text).toString() } + Text( + text = textValue, + modifier = Modifier.fillMaxWidth(), + style = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = textAlign + ) + ) + } + + private fun onAppExitInfoFailure() { + showToastUiCentered( + this, + getString(R.string.log_file_not_available), + Toast.LENGTH_SHORT + ) + hideBugReportProgressUi() + } + + private fun onAppExitInfoSuccess() { + promptCrashLogAction() + } + + private fun hideBugReportProgressUi() { + aboutViewModel.setBugReportRunning(false) + } + + private fun io(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + private fun launchFileImport() { + try { + tunnelFileImportResultLauncher.launch("*/*") + } catch (e: ActivityNotFoundException) { + showToastUiCentered( + this, + getString(R.string.blocklist_update_check_failure), + Toast.LENGTH_SHORT + ) + } catch (e: Exception) { + showToastUiCentered( + this, + getString(R.string.blocklist_update_check_failure), + Toast.LENGTH_SHORT + ) + } + } + + private fun launchQrScanner() { + try { + qrImportResultLauncher.launch( + ScanOptions() + .setOrientationLocked(false) + .setBeepEnabled(false) + .setPrompt(resources.getString(R.string.lbl_qr_code)) + ) + } catch (e: ActivityNotFoundException) { + showToastUiCentered( + this, + getString(R.string.blocklist_update_check_failure), + Toast.LENGTH_SHORT + ) + } catch (e: Exception) { + showToastUiCentered( + this, + getString(R.string.blocklist_update_check_failure), + Toast.LENGTH_SHORT + ) + } } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/BubbleActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/BubbleActivity.kt index d0eef2ac9..4ef986b9d 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/BubbleActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/BubbleActivity.kt @@ -15,401 +15,155 @@ */ package com.celzero.bravedns.ui.activity -import Logger import android.content.Intent import android.os.Bundle -import android.view.View import androidx.activity.addCallback +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.lifecycleScope import androidx.paging.Pager import androidx.paging.PagingConfig +import androidx.paging.PagingData import androidx.paging.cachedIn -import androidx.recyclerview.widget.LinearLayoutManager -import by.kirich1409.viewbindingdelegate.viewBinding -import com.celzero.bravedns.R -import com.celzero.bravedns.adapter.BubbleAllowedAppsAdapter -import com.celzero.bravedns.adapter.BubbleBlockedAppsAdapter +import androidx.paging.compose.collectAsLazyPagingItems import com.celzero.bravedns.data.AllowedAppInfo import com.celzero.bravedns.data.BlockedAppInfo import com.celzero.bravedns.database.AppInfoRepository import com.celzero.bravedns.database.ConnectionTrackerDAO import com.celzero.bravedns.database.DnsLogDAO -import com.celzero.bravedns.databinding.ActivityBubbleBinding import com.celzero.bravedns.service.FirewallManager -import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.ui.BaseActivity -import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.ui.compose.bubble.BubbleScreen +import com.celzero.bravedns.ui.compose.theme.RethinkTheme import com.celzero.bravedns.viewmodel.AllowedAppsBubbleViewModel import com.celzero.bravedns.viewmodel.BlockedAppsBubbleViewModel -import kotlinx.coroutines.CancellationException +import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject /** - * BubbleActivity - Content activity for Android Bubble notifications - * - * This activity is launched by Android's Bubble API (Android 10+) when a user - * interacts with a bubble notification. It displays recently blocked apps with - * quick actions to temporarily allow them. - * - * The bubble is created via NotificationCompat.BubbleMetadata in BubbleHelper. - * This activity provides the content that appears when the bubble is expanded. - * - * Based on: https://developer.android.com/develop/ui/views/notifications/bubbles - * - * Key features: - * - Shows list of recently blocked apps - * - Quick action to temporarily allow apps for 15 minutes - * - Material Design 3 UI - * - Works with Android's system bubble framework (not custom overlays) + * BubbleActivity - Content activity for Android Bubble notifications. */ -class BubbleActivity : BaseActivity(R.layout.activity_bubble) { - private val b by viewBinding(ActivityBubbleBinding::bind) - - private val persistentState by inject() +// TODO-refactor: Consider migrating to navigation component +class BubbleActivity : AppCompatActivity() { private val connectionTrackerDAO by inject() private val appInfoRepository by inject() private val dnsLogDAO by inject() - private lateinit var blockedAdapter: BubbleBlockedAppsAdapter - private lateinit var allowedAdapter: BubbleAllowedAppsAdapter - - private var blockedCollectJob: kotlinx.coroutines.Job? = null - private var allowedCollectJob: kotlinx.coroutines.Job? = null - - private var recyclerDecorationsAdded: Boolean = false + private var vpnOn by mutableStateOf(false) + private var refreshKey by mutableIntStateOf(0) companion object { private const val TAG = "BubbleActivity" private const val PAGE_SIZE = 20 - private const val TEMP_ALLOW_DURATION_MINUTES = 15 - private const val MILLIS_PER_MINUTE = 60 - private const val MILLIS_PER_SECOND = 1000 - private const val ITEM_SPACING_DP = 4 } override fun onCreate(savedInstanceState: Bundle?) { - theme.applyStyle(getCurrentTheme(isDarkThemeOn(), persistentState.theme), true) super.onCreate(savedInstanceState) - Logger.d(TAG, "BubbleActivity onCreate, taskId: $taskId") - - // Handle back button press - minimize instead of close - onBackPressedDispatcher.addCallback(this) { - // Move to background, don't finish the activity - moveTaskToBack(true) + Napier.d("$TAG onCreate, taskId: $taskId") + + setContent { + RethinkTheme { + val allowedFlow = remember(vpnOn, refreshKey) { allowedAppsFlow() } + val blockedFlow = remember(vpnOn, refreshKey) { blockedAppsFlow() } + val allowedItems = allowedFlow.collectAsLazyPagingItems() + val blockedItems = blockedFlow.collectAsLazyPagingItems() + + BubbleScreen( + vpnOn = vpnOn, + allowedItems = allowedItems, + blockedItems = blockedItems, + onAllowApp = { app, onRefresh -> allowApp(app, onRefresh) }, + onRemoveAllowed = { app, onRefresh -> removeAllowedApp(app, onRefresh) } + ) + } } + + onBackPressedDispatcher.addCallback(this) { moveTaskToBack(true) } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - Logger.d(TAG, "BubbleActivity onNewIntent - bubble clicked again") - // Don't do anything special - just let onResume handle the refresh + Napier.d("$TAG onNewIntent - bubble clicked again") } override fun onResume() { super.onResume() - - // If VPN is off, don't load anything / don't start collectors. - if (!VpnController.hasTunnel()) { - Logger.i(TAG, "VPN is off; not loading bubble lists") - stopCollectors() - showVpnOffState() - return - } - - showContentState() - setupRecyclerViews() - setupLoadStateListeners() - - // Start collectors once per resume; cancel previous collectors if any. - startAllowedCollector() - startBlockedCollector() - } - - override fun onStop() { - super.onStop() - // Don't stop the service when activity is minimized - // The bubble notification should remain visible - Logger.d(TAG, "BubbleActivity stopped (minimized)") - } - - override fun onDestroy() { - super.onDestroy() - // Don't stop the service when activity is destroyed - // The service manages its own lifecycle based on the toggle setting - Logger.d(TAG, "BubbleActivity destroyed") - } - - - private fun isDarkThemeOn(): Boolean { - return resources.configuration.uiMode and - android.content.res.Configuration.UI_MODE_NIGHT_MASK == - android.content.res.Configuration.UI_MODE_NIGHT_YES - } - - - private fun startAllowedCollector() { - allowedCollectJob?.cancel() - allowedCollectJob = lifecycleScope.launch { - try { - val now = System.currentTimeMillis() - - val allowedAppsPager = Pager( - config = PagingConfig( - pageSize = PAGE_SIZE, - enablePlaceholders = false - ), - pagingSourceFactory = { - AllowedAppsBubbleViewModel(appInfoRepository, now) - } - ).flow.cachedIn(lifecycleScope) - - allowedAppsPager.collect { pagingData -> - if (!isFinishing && !isDestroyed) { - allowedAdapter.submitData(lifecycle, pagingData) - } - } - } catch (_: CancellationException) { - Logger.d(TAG, "Allowed apps loading cancelled (activity destroyed)") - } catch (e: Exception) { - Logger.e(TAG, "err loading allowed apps: ${e.message}", e) - if (!isFinishing && !isDestroyed) { - b.bubbleAllowedAppsLl.visibility = View.GONE - } - } + vpnOn = VpnController.hasTunnel() + refreshKey++ + if (!vpnOn) { + Napier.i("$TAG VPN is off; showing empty state") } } - private fun startBlockedCollector() { - blockedCollectJob?.cancel() - blockedCollectJob = lifecycleScope.launch { - try { - val now = System.currentTimeMillis() - val last15Mins = now - (TEMP_ALLOW_DURATION_MINUTES * MILLIS_PER_MINUTE * MILLIS_PER_SECOND) - - val tempAllowedApps = withContext(Dispatchers.IO) { - appInfoRepository.getAllTempAllowedApps(now) - } - val tempAllowedUids = tempAllowedApps.map { it.uid }.toSet() - - val blockedAppsPager = Pager( - config = PagingConfig( - pageSize = PAGE_SIZE, - enablePlaceholders = false - ), - pagingSourceFactory = { - BlockedAppsBubbleViewModel( - connectionTrackerDAO, - dnsLogDAO, - appInfoRepository, - last15Mins, - tempAllowedUids - ) - } - ).flow.cachedIn(lifecycleScope) - - blockedAppsPager.collect { pagingData -> - if (!isFinishing && !isDestroyed) { - blockedAdapter.submitData(lifecycle, pagingData) - } - } - - } catch (_: CancellationException) { - Logger.d(TAG, "Blocked apps loading cancelled (activity destroyed)") - } catch (e: Exception) { - Logger.e(TAG, "err loading blocked apps: ${e.message}", e) - if (!isFinishing && !isDestroyed) { - b.bubbleProgressCard.visibility = View.GONE - b.bubbleProgressBar.visibility = View.GONE - b.bubbleEmptyState.visibility = View.VISIBLE - b.bubbleRecyclerView.visibility = View.GONE - } - } - } - } - - private fun allowApp(blockedApp: BlockedAppInfo) { - // Optimistic UI update: remove right away from blocked list for fast feedback. - // PagingDataAdapter doesn't support direct removal; we force a refresh after DB update, - // but also hide the row by refreshing immediately. + private fun allowApp(blockedApp: BlockedAppInfo, onRefresh: () -> Unit) { lifecycleScope.launch { try { - Logger.i(TAG, "Temporarily allowing app for 15 minutes: ${blockedApp.appName} (uid: ${blockedApp.uid})") + Napier.i("Temporarily allowing app for 15 minutes: ${blockedApp.appName} (uid: ${blockedApp.uid})") - withContext(Dispatchers.IO) { + withContext(Dispatchers.IO) { FirewallManager.updateTempAllow(blockedApp.uid, true) } - if (!isFinishing && !isDestroyed) { - // Refresh BOTH lists: remove from blocked and show in allowed. - blockedAdapter.refresh() - allowedAdapter.refresh() - } - - Logger.i(TAG, "App temporarily allowed successfully for 15 minutes") + onRefresh() + Napier.i("App temporarily allowed successfully for 15 minutes") } catch (e: Exception) { - Logger.e(TAG, "err allowing app: ${e.message}", e) + Napier.e("err allowing app: ${e.message}") } } } - private fun removeAllowedApp(allowedApp: AllowedAppInfo) { + private fun removeAllowedApp(allowedApp: AllowedAppInfo, onRefresh: () -> Unit) { lifecycleScope.launch { try { - Logger.i(TAG, "Removing temp allow for app: ${allowedApp.appName} (uid: ${allowedApp.uid})") + Napier.i("Removing temp allow for app: ${allowedApp.appName} (uid: ${allowedApp.uid})") withContext(Dispatchers.IO) { - // Clear temp allow status appInfoRepository.clearTempAllowByUid(allowedApp.uid) } - if (!isFinishing && !isDestroyed) { - // Refresh BOTH lists: remove from allowed and allow it to appear again in blocked. - allowedAdapter.refresh() - blockedAdapter.refresh() - } - - Logger.i(TAG, "Temp allow removed successfully") + onRefresh() + Napier.i("Temp allow removed successfully") } catch (e: Exception) { - Logger.e(TAG, "err removing allowed app: ${e.message}", e) + Napier.e("err removing allowed app: ${e.message}") } } } - private fun setupRecyclerViews() { - // Setup blocked apps RecyclerView - blockedAdapter = BubbleBlockedAppsAdapter { blockedApp -> - allowApp(blockedApp) - } - b.bubbleRecyclerView.apply { - layoutManager = LinearLayoutManager(this@BubbleActivity) - adapter = blockedAdapter - if (!recyclerDecorationsAdded) { - addItemDecoration(object : - androidx.recyclerview.widget.RecyclerView.ItemDecoration() { - override fun getItemOffsets( - outRect: android.graphics.Rect, - view: View, - parent: androidx.recyclerview.widget.RecyclerView, - state: androidx.recyclerview.widget.RecyclerView.State - ) { - outRect.bottom = ITEM_SPACING_DP // 4dp - } - }) + private fun allowedAppsFlow(): Flow> { + if (!vpnOn) return flowOf(PagingData.empty()) + val now = System.currentTimeMillis() + return Pager( + config = PagingConfig(pageSize = PAGE_SIZE, enablePlaceholders = false), + pagingSourceFactory = { AllowedAppsBubbleViewModel(appInfoRepository, now) } + ).flow.cachedIn(lifecycleScope) + } + + private fun blockedAppsFlow(): Flow> { + if (!vpnOn) return flowOf(PagingData.empty()) + val now = System.currentTimeMillis() + val last15Mins = now - (15 * 60 * 1000) + return Pager( + config = PagingConfig(pageSize = PAGE_SIZE, enablePlaceholders = false), + pagingSourceFactory = { + BlockedAppsBubbleViewModel( + connectionTrackerDAO, + dnsLogDAO, + appInfoRepository, + last15Mins, + emptySet() + ) } - } - - // Setup allowed apps RecyclerView - allowedAdapter = BubbleAllowedAppsAdapter { allowedApp -> - removeAllowedApp(allowedApp) - } - b.bubbleAllowedRecyclerView.apply { - layoutManager = LinearLayoutManager(this@BubbleActivity) - adapter = allowedAdapter - if (!recyclerDecorationsAdded) { - addItemDecoration(object : - androidx.recyclerview.widget.RecyclerView.ItemDecoration() { - override fun getItemOffsets( - outRect: android.graphics.Rect, - view: View, - parent: androidx.recyclerview.widget.RecyclerView, - state: androidx.recyclerview.widget.RecyclerView.State - ) { - outRect.bottom = ITEM_SPACING_DP // 4dp - } - }) - recyclerDecorationsAdded = true - } - } - } - - private fun setupLoadStateListeners() { - // Set up load state listener for allowed apps - allowedAdapter.addLoadStateListener { loadState -> - if (!isFinishing && !isDestroyed) { - // Check if data is loaded (not loading and no errors) - val isLoaded = loadState.refresh is androidx.paging.LoadState.NotLoading - - if (isLoaded) { - // Show/hide allowed apps card based on item count - val itemCount = allowedAdapter.itemCount - if (itemCount == 0) { - b.bubbleAllowedAppsLl.visibility = View.GONE - } else { - b.bubbleAllowedAppsLl.visibility = View.VISIBLE - b.bubbleAllowedCount.text = itemCount.toString() - } - } - } - } - - // Set up load state listener for blocked apps - blockedAdapter.addLoadStateListener { loadState -> - if (!isFinishing && !isDestroyed) { - val isLoading = loadState.refresh is androidx.paging.LoadState.Loading - val isError = loadState.refresh is androidx.paging.LoadState.Error - val isLoaded = loadState.refresh is androidx.paging.LoadState.NotLoading - - when { - isLoading -> { - // Show loading state - b.bubbleProgressCard.visibility = View.VISIBLE - b.bubbleProgressBar.visibility = View.VISIBLE - b.bubbleEmptyState.visibility = View.GONE - b.bubbleRecyclerView.visibility = View.GONE - } - isError -> { - // Show error/empty state - b.bubbleProgressCard.visibility = View.GONE - b.bubbleProgressBar.visibility = View.GONE - b.bubbleEmptyState.visibility = View.VISIBLE - b.bubbleRecyclerView.visibility = View.GONE - } - isLoaded -> { - // Hide loading, show content or empty state based on item count - b.bubbleProgressCard.visibility = View.GONE - b.bubbleProgressBar.visibility = View.GONE - - val itemCount = blockedAdapter.itemCount - if (itemCount == 0) { - b.bubbleEmptyState.visibility = View.VISIBLE - b.bubbleRecyclerView.visibility = View.GONE - } else { - b.bubbleEmptyState.visibility = View.GONE - b.bubbleRecyclerView.visibility = View.VISIBLE - } - } - } - } - } - } - - private fun showVpnOffState() { - // Avoid loading spinners if VPN isn't running. - runCatching { - b.bubbleProgressCard.visibility = View.GONE - b.bubbleProgressBar.visibility = View.GONE - b.bubbleAllowedAppsLl.visibility = View.GONE - b.bubbleRecyclerView.visibility = View.GONE - b.bubbleEmptyState.visibility = View.VISIBLE - b.bubbleEmptyTitle.setText(R.string.bubble_empty_state_title) - } - } - - private fun showContentState() { - runCatching { - b.bubbleEmptyState.visibility = View.GONE - } - } - - private fun stopCollectors() { - blockedCollectJob?.cancel() - blockedCollectJob = null - allowedCollectJob?.cancel() - allowedCollectJob = null + ).flow.cachedIn(lifecycleScope) } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesDialog.kt new file mode 100644 index 000000000..151603e38 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesDialog.kt @@ -0,0 +1,291 @@ +/* + * Copyright 2024 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 android.graphics.drawable.Drawable +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R +import com.celzero.bravedns.database.CustomDomain +import com.celzero.bravedns.database.WgConfigFilesImmutable +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE +import com.celzero.bravedns.util.Constants.Companion.INVALID_UID +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.ui.compose.theme.Dimensions +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items + +private const val TAG = "AppDomainBtmSht" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppDomainRulesSheet( + uid: Int, + domain: String, + eventLogger: EventLogger, + onDismiss: () -> Unit, + onUpdated: () -> Unit +) { + val context = LocalContext.current + val configAddSuccessToast = stringResource(R.string.config_add_success_toast) + val scope = rememberCoroutineScope() + + var domainRule by remember { mutableStateOf(DomainRulesManager.Status.NONE) } + var customDomain by remember { mutableStateOf(null) } + var appNames by remember { mutableStateOf>(emptyList()) } + var appIcon by remember { mutableStateOf(null) } + var showWgSheet by remember { mutableStateOf(false) } + var wgConfigs by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(uid, domain) { + if (uid == INVALID_UID) { + onDismiss() + return@LaunchedEffect + } + val (names, icon) = withContext(Dispatchers.IO) { fetchRuleSheetAppIdentity(context, uid) } + appNames = names + appIcon = icon + domainRule = withContext(Dispatchers.IO) { DomainRulesManager.status(domain, uid) } + customDomain = + withContext(Dispatchers.IO) { + DomainRulesManager.getObj(uid, domain) ?: DomainRulesManager.makeCustomDomain(uid, domain) + } + } + + RuleSheetModal(onDismissRequest = onDismiss) { + val appName = formatRuleSheetAppName(context, appNames) + RuleSheetLayout(bottomPadding = RuleSheetBottomPaddingWithActions) { + RuleSheetAppHeader(appName = appName, appIcon = appIcon) + + RuleSheetSectionTitle( + text = stringResource(R.string.bsct_block_domain), + ) + + RuleSheetTrustBlockRow( + value = domain, + isTrustSelected = domainRule == DomainRulesManager.Status.TRUST, + isBlockSelected = domainRule == DomainRulesManager.Status.BLOCK, + onTrustClick = { + val target = + if (domainRule == DomainRulesManager.Status.TRUST) { + DomainRulesManager.Status.NONE + } else { + DomainRulesManager.Status.TRUST + } + applyDomainRule( + domain, + uid, + target, + scope, + eventLogger, + onUpdated + ) { domainRule = it } + }, + onBlockClick = { + val target = + if (domainRule == DomainRulesManager.Status.BLOCK) { + DomainRulesManager.Status.NONE + } else { + DomainRulesManager.Status.BLOCK + } + applyDomainRule( + domain, + uid, + target, + scope, + eventLogger, + onUpdated + ) { domainRule = it } + } + ) + + RuleSheetSupportingText( + text = stringResource(R.string.bsac_title_desc), + ) + } + + if (showWgSheet) { + WireguardListSheet( + inputLabel = customDomain?.domain, + selectedProxyId = customDomain?.proxyId.orEmpty(), + wgConfigs = wgConfigs, + onDismiss = { showWgSheet = false }, + onSelected = { conf -> + scope.launch(Dispatchers.IO) { + val current = customDomain + if (current == null) { + Napier.w("$TAG: Custom domain is null") + return@launch + } + val id = + if (conf == null) { + "" + } else { + ID_WG_BASE + conf.id + } + DomainRulesManager.setProxyId(current, id) + current.proxyId = id + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + configAddSuccessToast, + Toast.LENGTH_SHORT + ) + } + } + } + ) + } + } +} + +private fun applyDomainRule( + domain: String, + uid: Int, + status: DomainRulesManager.Status, + scope: CoroutineScope, + eventLogger: EventLogger, + onUpdated: () -> Unit, + onSetStatus: (DomainRulesManager.Status) -> Unit +) { + onSetStatus(status) + val details = "Domain rule applied: $domain, $uid, ${status.name}" + logFirewallRuleChange(eventLogger, "App domain rule", details) + scope.launch(Dispatchers.IO) { + DomainRulesManager.changeStatus( + domain, + uid, + "", + DomainRulesManager.DomainType.DOMAIN, + status + ) + withContext(Dispatchers.Main) { onUpdated() } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WireguardListSheet( + inputLabel: String?, + selectedProxyId: String, + wgConfigs: List, + onDismiss: () -> Unit, + onSelected: (WgConfigFilesImmutable?) -> Unit +) { + val context = LocalContext.current + var currentProxyId by remember(inputLabel, selectedProxyId) { mutableStateOf(selectedProxyId) } + RuleSheetModal(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal, vertical = Dimensions.spacingLg), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + inputLabel?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface + ) + } + + LazyColumn { + items(wgConfigs, key = { it?.id ?: -1 }) { conf -> + val proxyId = conf?.let { ID_WG_BASE + it.id } ?: "" + val isSelected = currentProxyId == proxyId + val name = + conf?.name ?: stringResource(R.string.settings_app_list_default_app) + val idSuffix = conf?.id?.toString()?.padStart(3, '0') + val desc = + if (conf == null) { + stringResource(R.string.settings_app_list_default_app) + } else { + stringResource(R.string.settings_app_list_default_app) + " $idSuffix" + } + + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { + currentProxyId = proxyId + onSelected(conf) + onDismiss() + } + .padding(vertical = Dimensions.spacingSm, horizontal = Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Text( + text = ID_WG_BASE.uppercase(), + style = MaterialTheme.typography.titleMedium + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = name, style = MaterialTheme.typography.bodyLarge) + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + RadioButton(selected = isSelected, onClick = null) + } + } + } + + Spacer(modifier = Modifier.size(Dimensions.spacingSm)) + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesDialog.kt new file mode 100644 index 000000000..c509be0d6 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesDialog.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2024 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 android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.ui.compose.theme.Dimensions +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "AppIpBtmSht" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppIpRulesSheet( + uid: Int, + ipAddress: String, + domains: String, + eventLogger: EventLogger, + onDismiss: () -> Unit, + onUpdated: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var ipRule by remember { mutableStateOf(IpRulesManager.IpRuleStatus.NONE) } + var appNames by remember { mutableStateOf>(emptyList()) } + var appIcon by remember { mutableStateOf(null) } + val domainList = remember(domains) { domains.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + val domainRules = remember { mutableStateMapOf() } + + LaunchedEffect(uid, ipAddress, domains) { + val (names, icon) = withContext(Dispatchers.IO) { fetchRuleSheetAppIdentity(context, uid) } + appNames = names + appIcon = icon + ipRule = withContext(Dispatchers.IO) { + IpRulesManager.getMostSpecificRuleMatch(uid, ipAddress) + } + val statuses = + withContext(Dispatchers.IO) { + domainList.associateWith { DomainRulesManager.getDomainRule(it, uid) } + } + domainRules.clear() + domainRules.putAll(statuses) + } + + RuleSheetModal(onDismissRequest = onDismiss) { + val appName = formatRuleSheetAppName(context, appNames) + RuleSheetLayout(bottomPadding = RuleSheetBottomPaddingWithActions) { + RuleSheetAppHeader(appName = appName, appIcon = appIcon) + + RuleSheetSectionTitle( + text = stringResource(R.string.bsct_block_ip), + ) + + RuleSheetTrustBlockRow( + value = ipAddress, + isTrustSelected = ipRule == IpRulesManager.IpRuleStatus.TRUST, + isBlockSelected = ipRule == IpRulesManager.IpRuleStatus.BLOCK, + onTrustClick = { + val target = + if (ipRule == IpRulesManager.IpRuleStatus.TRUST) { + IpRulesManager.IpRuleStatus.NONE + } else { + IpRulesManager.IpRuleStatus.TRUST + } + applyIpRule( + uid, + ipAddress, + target, + scope, + eventLogger, + onUpdated + ) { ipRule = it } + }, + onBlockClick = { + val target = + if (ipRule == IpRulesManager.IpRuleStatus.BLOCK) { + IpRulesManager.IpRuleStatus.NONE + } else { + IpRulesManager.IpRuleStatus.BLOCK + } + applyIpRule( + uid, + ipAddress, + target, + scope, + eventLogger, + onUpdated + ) { ipRule = it } + } + ) + + if (domainList.isNotEmpty()) { + RuleSheetSectionTitle( + text = stringResource(R.string.bsct_block_domain), + ) + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(horizontal = Dimensions.screenPaddingHorizontal) + ) { + items(domainList, key = { it }) { domain -> + val status = domainRules[domain] ?: DomainRulesManager.Status.NONE + DomainRuleRow( + domain = domain, + status = status, + onUpdate = { newStatus -> + domainRules[domain] = newStatus + applyDomainRule(domain, uid, newStatus, scope) + } + ) + } + } + } + + RuleSheetSupportingText( + text = stringResource(R.string.bsac_title_desc), + ) + } + } +} + +@Composable +private fun DomainRuleRow( + domain: String, + status: DomainRulesManager.Status, + onUpdate: (DomainRulesManager.Status) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = domain, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + TrustBlockToggleStrip( + isTrustSelected = status == DomainRulesManager.Status.TRUST, + isBlockSelected = status == DomainRulesManager.Status.BLOCK, + onTrustClick = { + if (status == DomainRulesManager.Status.TRUST) { + onUpdate(DomainRulesManager.Status.NONE) + } else { + onUpdate(DomainRulesManager.Status.TRUST) + } + }, + onBlockClick = { + if (status == DomainRulesManager.Status.BLOCK) { + onUpdate(DomainRulesManager.Status.NONE) + } else { + onUpdate(DomainRulesManager.Status.BLOCK) + } + }, + iconSize = Dimensions.iconSizeMd, + spacingBefore = Dimensions.spacingSmMd, + spacingBetween = Dimensions.spacingSmMd + ) + } +} + +private fun applyDomainRule( + domain: String, + uid: Int, + status: DomainRulesManager.Status, + scope: CoroutineScope +) { + scope.launch(Dispatchers.IO) { + DomainRulesManager.addDomainRule( + domain.trim(), + status, + DomainRulesManager.DomainType.DOMAIN, + uid + ) + } +} + +private fun applyIpRule( + uid: Int, + ipAddress: String, + status: IpRulesManager.IpRuleStatus, + scope: CoroutineScope, + eventLogger: EventLogger, + onUpdated: () -> Unit, + onSetStatus: (IpRulesManager.IpRuleStatus) -> Unit +) { + onSetStatus(status) + val details = "IP Rule set to ${status.name} for IP: $ipAddress, UID: $uid" + logFirewallRuleChange(eventLogger, "Custom IP", details) + scope.launch(Dispatchers.IO) { + val ipPair = IpRulesManager.getIpNetPort(ipAddress) + val ip = ipPair.first ?: run { + Napier.w("$TAG invalid ip for $ipAddress") + return@launch + } + IpRulesManager.addIpRule(uid, ip, null, status, proxyId = "", proxyCC = "") + withContext(Dispatchers.Main) { onUpdated() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreDialog.kt new file mode 100644 index 000000000..b1f15c94f --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreDialog.kt @@ -0,0 +1,479 @@ +/* + * Copyright 2022 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 android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.Data +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkRequest +import com.celzero.bravedns.R +import com.celzero.bravedns.backup.BackupAgent +import com.celzero.bravedns.backup.BackupHelper +import com.celzero.bravedns.backup.BackupHelper.Companion.BACKUP_FILE_EXTN +import com.celzero.bravedns.backup.BackupHelper.Companion.BACKUP_FILE_NAME +import com.celzero.bravedns.backup.BackupHelper.Companion.BACKUP_FILE_NAME_DATETIME +import com.celzero.bravedns.backup.BackupHelper.Companion.DATA_BUILDER_BACKUP_URI +import com.celzero.bravedns.backup.BackupHelper.Companion.DATA_BUILDER_RESTORE_URI +import com.celzero.bravedns.backup.BackupHelper.Companion.INTENT_RESTART_APP +import com.celzero.bravedns.backup.BackupHelper.Companion.INTENT_TYPE_OCTET +import com.celzero.bravedns.backup.BackupHelper.Companion.INTENT_TYPE_XZIP +import com.celzero.bravedns.backup.RestoreAgent +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.delay +import io.github.aakira.napier.Napier +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackupRestoreSheet( + onDismiss: () -> Unit +) { + val context = LocalContext.current + val activity = context as? FragmentActivity ?: return + val workManager = remember { WorkManager.getInstance(activity.applicationContext) } + var versionText by remember { mutableStateOf("") } + var showBackupDialog by remember { mutableStateOf(false) } + var showRestoreDialog by remember { mutableStateOf(false) } + var showBackupFailureDialog by remember { mutableStateOf(false) } + var showRestoreFailureDialog by remember { mutableStateOf(false) } + + val backupLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + handleBackupResult( + activity, + result, + onFailure = { showBackupFailureDialog = true }, + onBackup = { uri -> startBackupProcess(activity, uri, workManager) } + ) + } + val restoreLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + handleRestoreResult( + activity, + result, + onFailure = { showRestoreFailureDialog = true }, + onRestore = { uri -> startRestoreProcess(activity, uri, workManager) } + ) + } + + LaunchedEffect(Unit) { + versionText = showVersion(activity) + observeBackupWorker(activity, workManager, onFailure = { showBackupFailureDialog = true }) + observeRestoreWorker(activity, workManager, onFailure = { showRestoreFailureDialog = true }) + } + + RuleSheetModal(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal) + .padding(top = Dimensions.spacingXs, bottom = Dimensions.spacing3xl), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + Column(verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs)) { + Text( + text = stringResource(R.string.brbs_title), + style = MaterialTheme.typography.titleLarge + ) + Text( + text = stringResource(R.string.brbs_backup_restore_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + RethinkListGroup { + RethinkListItem( + headline = stringResource(R.string.brbs_backup_title), + supporting = stringResource(R.string.brbs_backup_desc), + leadingIconPainter = painterResource(id = R.drawable.ic_backup), + position = CardPosition.First, + onClick = { showBackupDialog = true } + ) + RethinkListItem( + headline = stringResource(R.string.brbs_restore_title), + supporting = stringResource(R.string.brbs_restore_desc), + leadingIconPainter = painterResource(id = R.drawable.ic_restore), + position = CardPosition.Last, + onClick = { showRestoreDialog = true } + ) + } + + Surface( + shape = RoundedCornerShape(Dimensions.cardCornerRadius), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Text( + text = versionText, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(Dimensions.spacingMd) + ) + } + } + + if (showBackupDialog) { + RethinkConfirmDialog( + onDismissRequest = { showBackupDialog = false }, + title = stringResource(R.string.brbs_backup_dialog_title), + message = stringResource(R.string.brbs_backup_dialog_message), + confirmText = stringResource(R.string.brbs_backup_dialog_positive), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + showBackupDialog = false + backup(activity, backupLauncher) + }, + onDismiss = { showBackupDialog = false } + ) + } + + if (showRestoreDialog) { + RethinkConfirmDialog( + onDismissRequest = { showRestoreDialog = false }, + title = stringResource(R.string.brbs_restore_dialog_title), + message = stringResource(R.string.brbs_restore_dialog_message), + confirmText = stringResource(R.string.brbs_restore_dialog_positive), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + showRestoreDialog = false + restore(activity, restoreLauncher) + }, + onDismiss = { showRestoreDialog = false } + ) + } + + if (showBackupFailureDialog) { + RethinkConfirmDialog( + onDismissRequest = { showBackupFailureDialog = false }, + title = stringResource(R.string.brbs_backup_dialog_failure_title), + message = stringResource(R.string.brbs_backup_dialog_failure_message), + confirmText = stringResource(R.string.brbs_backup_dialog_failure_positive), + dismissText = stringResource(R.string.lbl_dismiss), + onConfirm = { + showBackupFailureDialog = false + backup(activity, backupLauncher) + }, + onDismiss = { showBackupFailureDialog = false } + ) + } + + if (showRestoreFailureDialog) { + RethinkConfirmDialog( + onDismissRequest = { showRestoreFailureDialog = false }, + title = stringResource(R.string.brbs_restore_dialog_failure_title), + message = stringResource(R.string.brbs_restore_dialog_failure_message), + confirmText = stringResource(R.string.brbs_restore_dialog_failure_positive), + dismissText = stringResource(R.string.lbl_dismiss), + onConfirm = { + showRestoreFailureDialog = false + restore(activity, restoreLauncher) + }, + onDismiss = { showRestoreFailureDialog = false } + ) + } + } +} + +private fun showVersion(activity: FragmentActivity): String { + val version = getVersionName(activity) + return activity.getString( + R.string.about_version_install_source, + version, + getDownloadSource(activity) + ) +} + +private fun getVersionName(activity: FragmentActivity): String { + val pInfo: PackageInfo? = + Utilities.getPackageMetadata(activity.packageManager, activity.packageName) + return pInfo?.versionName ?: "" +} + +private fun getDownloadSource(activity: FragmentActivity): String { + if (Utilities.isFdroidFlavour()) return activity.getString(R.string.build__flavor_fdroid) + if (Utilities.isPlayStoreFlavour()) return activity.getString(R.string.build__flavor_play_store) + return activity.getString(R.string.build__flavor_website) +} + +private fun backup( + activity: FragmentActivity, + launcher: androidx.activity.result.ActivityResultLauncher +) { + try { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = INTENT_TYPE_OCTET + val sdf = SimpleDateFormat(BACKUP_FILE_NAME_DATETIME, Locale.ROOT) + val version = getVersionName(activity).replace(' ', '_') + val zipFileName: String = + BACKUP_FILE_NAME + version + sdf.format(Date()) + BACKUP_FILE_EXTN + + intent.putExtra(Intent.EXTRA_TITLE, zipFileName) + + try { + if (intent.resolveActivity(activity.packageManager) != null) { + launcher.launch(intent) + } else { + Napier.e("No activity found to handle CREATE_DOCUMENT intent") + Utilities.showToastUiCentered( + activity, + activity.getString(R.string.brbs_backup_dialog_failure_message), + Toast.LENGTH_LONG + ) + } + } catch (e: android.content.ActivityNotFoundException) { + Napier.e("Activity not found for CREATE_DOCUMENT: ${e.message}") + Utilities.showToastUiCentered( + activity, + activity.getString(R.string.brbs_backup_dialog_failure_message), + Toast.LENGTH_LONG + ) + } + } catch (e: Exception) { + Napier.e("err opening file picker for backup: ${e.message}") + Utilities.showToastUiCentered( + activity, + activity.getString(R.string.brbs_backup_dialog_failure_message), + Toast.LENGTH_LONG + ) + } +} + +private fun restore( + activity: FragmentActivity, + launcher: androidx.activity.result.ActivityResultLauncher +) { + try { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "*/*" + val mimeTypes = arrayOf(INTENT_TYPE_OCTET, INTENT_TYPE_XZIP) + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + + launcher.launch(intent) + } catch (e: Exception) { + Napier.e("err opening file picker: ${e.message}") + Utilities.showToastUiCentered( + activity, + activity.getString(R.string.blocklist_update_check_failure), + Toast.LENGTH_SHORT + ) + } +} + +private fun handleBackupResult( + activity: FragmentActivity, + result: ActivityResult, + onFailure: () -> Unit, + onBackup: (Uri?) -> Unit +) { + when (result.resultCode) { + Activity.RESULT_OK -> { + var backupFileUri: Uri? = null + result.data?.also { uri -> backupFileUri = uri.data } + Napier.i("activity result for backup process with uri: $backupFileUri") + onBackup(backupFileUri) + } + Activity.RESULT_CANCELED -> { + onFailure() + } + else -> { + onFailure() + } + } +} + +private fun handleRestoreResult( + activity: FragmentActivity, + result: ActivityResult, + onFailure: () -> Unit, + onRestore: (Uri?) -> Unit +) { + when (result.resultCode) { + Activity.RESULT_OK -> { + var fileUri: Uri? = null + result.data?.also { uri -> fileUri = uri.data } + Napier.i("activity result for restore process with uri: $fileUri") + onRestore(fileUri) + } + Activity.RESULT_CANCELED -> { + onFailure() + } + else -> { + onFailure() + } + } +} + +private fun startRestoreProcess(activity: FragmentActivity, fileUri: Uri?, workManager: WorkManager) { + if (fileUri == null) { + Napier.w("uri received from activity result is null, cancel restore process") + return + } + + Napier.i("invoke worker to initiate the restore process") + val data = Data.Builder() + data.putString(DATA_BUILDER_RESTORE_URI, fileUri.toString()) + + val importWorker = + OneTimeWorkRequestBuilder() + .setInputData(data.build()) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .addTag(RestoreAgent.TAG) + .build() + workManager.beginWith(importWorker).enqueue() +} + +private fun startBackupProcess(activity: FragmentActivity, backupUri: Uri?, workManager: WorkManager) { + if (backupUri == null) { + Napier.w("uri received from activity result is null, cancel backup process") + return + } + + BackupHelper.stopVpn(activity) + + Napier.i("invoke worker to initiate the backup process") + val data = Data.Builder() + data.putString(DATA_BUILDER_BACKUP_URI, backupUri.toString()) + val downloadWatcher = + OneTimeWorkRequestBuilder() + .setInputData(data.build()) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .addTag(BackupAgent.TAG) + .build() + workManager.beginWith(downloadWatcher).enqueue() +} + +private fun observeBackupWorker( + activity: FragmentActivity, + workManager: WorkManager, + onFailure: () -> Unit +) { + workManager.getWorkInfosByTagLiveData(BackupAgent.TAG).observe(activity) { workInfoList -> + val workInfo = workInfoList?.getOrNull(0) ?: return@observe + Napier.i("WorkManager state: ${workInfo.state} for ${BackupAgent.TAG}") + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> { + Utilities.showToastUiCentered( + activity, + activity.getString(R.string.brbs_backup_complete_toast), + Toast.LENGTH_SHORT + ) + workManager.pruneWork() + } + WorkInfo.State.CANCELLED, WorkInfo.State.FAILED -> { + onFailure() + workManager.pruneWork() + workManager.cancelAllWorkByTag(BackupAgent.TAG) + } + else -> { + // no-op + } + } + } +} + +private fun observeRestoreWorker( + activity: FragmentActivity, + workManager: WorkManager, + onFailure: () -> Unit +) { + workManager.getWorkInfosByTagLiveData(RestoreAgent.TAG).observe(activity) { workInfoList -> + val workInfo = workInfoList?.getOrNull(0) ?: return@observe + Napier.i("WorkManager state: ${workInfo.state} for ${RestoreAgent.TAG}") + if (WorkInfo.State.SUCCEEDED == workInfo.state) { + Utilities.showToastUiCentered( + activity, + activity.getString(R.string.brbs_restore_complete_toast), + Toast.LENGTH_LONG + ) + delay(TimeUnit.MILLISECONDS.toMillis(1000), activity.lifecycleScope) { + restartApp(activity) + } + workManager.pruneWork() + } else if ( + WorkInfo.State.CANCELLED == workInfo.state || + WorkInfo.State.FAILED == workInfo.state + ) { + onFailure() + workManager.pruneWork() + workManager.cancelAllWorkByTag(RestoreAgent.TAG) + } + } +} + +private fun restartApp(context: Context) { + val packageManager: PackageManager = context.packageManager + val intent = packageManager.getLaunchIntentForPackage(context.packageName) + val componentName = intent!!.component + val mainIntent = Intent.makeRestartActivityTask(componentName) + mainIntent.putExtra(INTENT_RESTART_APP, true) + context.startActivity(mainIntent) + Runtime.getRuntime().exit(0) +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BottomSheetShared.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BottomSheetShared.kt new file mode 100644 index 000000000..d20df5c0b --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BottomSheetShared.kt @@ -0,0 +1,672 @@ +/* + * 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.bottomsheet + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.background +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkFilterChip +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkModalBottomSheet +import com.celzero.bravedns.ui.compose.theme.RethinkTwoOptionSegmentedRow +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import com.celzero.bravedns.util.Utilities +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class RuleSheetChipColors( + val neutralText: Color, + val neutralBg: Color, + val negativeText: Color, + val negativeBg: Color, + val positiveText: Color, + val positiveBg: Color +) + +data class RuleSheetChipOption( + val label: String, + val selected: Boolean, + val selectedText: Color, + val selectedContainer: Color, + val onClick: () -> Unit +) + +val RuleSheetBottomPaddingWithActions: Dp = Dimensions.spacing3xl + Dimensions.spacingMd +val RuleSheetBottomPaddingCompact: Dp = Dimensions.spacing2xl + Dimensions.spacingSm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RuleSheetModal( + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + RethinkModalBottomSheet( + onDismissRequest = onDismissRequest, + contentPadding = PaddingValues(0.dp), + verticalSpacing = 0.dp, + includeBottomSpacer = false, + content = content + ) +} + +@Composable +fun RuleSheetLayout( + modifier: Modifier = Modifier, + bottomPadding: Dp, + verticalSpacing: Dp = Dimensions.spacingMd, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(bottom = bottomPadding), + verticalArrangement = Arrangement.spacedBy(verticalSpacing) + ) { + content() + } +} + +@Composable +fun RuleSheetLabeledControlRow( + label: @Composable () -> Unit, + control: (@Composable () -> Unit)? = null, + modifier: Modifier = Modifier, + labelWeight: Float = 1f, + controlWeight: Float = 1f, + horizontalPadding: Dp = Dimensions.spacingMd, + spacing: Dp = Dimensions.spacingSmMd, + controlAlignment: Alignment = Alignment.CenterEnd +) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + Box( + modifier = Modifier.weight(if (control == null) 1f else labelWeight), + contentAlignment = Alignment.CenterStart + ) { + label() + } + if (control != null) { + Box( + modifier = Modifier.weight(controlWeight), + contentAlignment = controlAlignment + ) { + control() + } + } + } +} + +@Composable +fun RuleSheetTextFieldRow( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + keyboardType: KeyboardType = KeyboardType.Text, + label: (@Composable (() -> Unit))? = null, + placeholder: (@Composable (() -> Unit))? = null, + fieldWeight: Float = 1f, + spacing: Dp = Dimensions.spacingSm, + trailingTopPadding: Dp = Dimensions.spacingMd, + trailing: (@Composable (() -> Unit))? = null +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.Top + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(fieldWeight), + singleLine = true, + enabled = enabled, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + label = label, + placeholder = placeholder + ) + if (trailing != null) { + Box( + modifier = Modifier.padding(top = trailingTopPadding), + contentAlignment = Alignment.Center + ) { + trailing() + } + } + } +} + +@Composable +fun RuleSheetDualTextFieldRow( + primaryValue: String, + onPrimaryValueChange: (String) -> Unit, + secondaryValue: String, + onSecondaryValueChange: (String) -> Unit, + primaryLabel: @Composable (() -> Unit), + secondaryLabel: @Composable (() -> Unit), + modifier: Modifier = Modifier, + enabled: Boolean = true, + primaryWeight: Float = 2f, + secondaryWeight: Float = 1f, + spacing: Dp = Dimensions.spacingSm, + primaryKeyboardType: KeyboardType = KeyboardType.Text, + secondaryKeyboardType: KeyboardType = KeyboardType.Number +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + OutlinedTextField( + value = primaryValue, + onValueChange = onPrimaryValueChange, + modifier = Modifier.weight(primaryWeight), + singleLine = true, + label = primaryLabel, + enabled = enabled, + keyboardOptions = KeyboardOptions(keyboardType = primaryKeyboardType) + ) + OutlinedTextField( + value = secondaryValue, + onValueChange = onSecondaryValueChange, + modifier = Modifier.weight(secondaryWeight), + singleLine = true, + label = secondaryLabel, + enabled = enabled, + keyboardOptions = KeyboardOptions(keyboardType = secondaryKeyboardType) + ) + } +} + +@Composable +fun rememberRuleSheetChipColors(): RuleSheetChipColors { + return RuleSheetChipColors( + neutralText = MaterialTheme.colorScheme.onSurfaceVariant, + neutralBg = MaterialTheme.colorScheme.surfaceVariant, + negativeText = MaterialTheme.colorScheme.error, + negativeBg = MaterialTheme.colorScheme.errorContainer, + positiveText = MaterialTheme.colorScheme.tertiary, + positiveBg = MaterialTheme.colorScheme.tertiaryContainer + ) +} + +@Composable +fun RuleSheetAppHeader( + appName: String?, + appIcon: Drawable?, + modifier: Modifier = Modifier, + iconSize: Dp = Dimensions.iconSizeSm, + textStyle: TextStyle = MaterialTheme.typography.bodyMedium, + horizontalPadding: Dp = Dimensions.screenPaddingHorizontal, + onClick: (() -> Unit)? = null +) { + if (appName.isNullOrBlank()) return + + val clickableModifier = + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + + Row( + modifier = modifier + .fillMaxWidth() + .then(clickableModifier) + .padding(horizontal = horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + appIcon?.let { icon -> + val painter = rememberDrawablePainter(icon) + if (painter != null) { + Image( + painter = painter, + contentDescription = null, + modifier = Modifier.size(iconSize) + ) + Spacer(modifier = Modifier.width(Dimensions.spacingSmMd)) + } + } + Text( + text = appName, + style = textStyle, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +fun RuleSheetSummaryPill( + text: String, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f), + textColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, + textStyle: TextStyle = MaterialTheme.typography.labelMedium, + fontWeight: FontWeight = FontWeight.SemiBold, + horizontalPadding: Dp = Dimensions.spacingMd, + verticalPadding: Dp = Dimensions.spacingSm +) { + androidx.compose.material3.Surface( + modifier = modifier, + shape = MaterialTheme.shapes.extraLarge, + color = containerColor + ) { + Text( + text = text, + style = textStyle, + color = textColor, + fontWeight = fontWeight, + modifier = Modifier.padding(horizontal = horizontalPadding, vertical = verticalPadding) + ) + } +} + +@Composable +fun RuleSheetFlagDestinationRow( + flag: String, + destination: String, + modifier: Modifier = Modifier, + destinationStyle: TextStyle = MaterialTheme.typography.titleLarge, + destinationFontFamily: FontFamily = FontFamily.Monospace, + horizontalPadding: Dp = Dimensions.spacingMd +) { + if (destination.isBlank()) return + + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (flag.isNotBlank()) { + Text( + text = flag, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(end = Dimensions.spacingSm) + ) + } + SelectionContainer { + Text( + text = destination, + style = destinationStyle, + fontFamily = destinationFontFamily, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +fun RuleSheetSplitDetailsRow( + modifier: Modifier = Modifier, + horizontalPadding: Dp = Dimensions.spacingXl, + dividerColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + dividerHeight: Dp = 32.dp, + leftContent: @Composable ColumnScope.() -> Unit, + rightContent: @Composable ColumnScope.() -> Unit +) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = horizontalPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.End, + content = leftContent + ) + Spacer(modifier = Modifier.width(Dimensions.spacingSmMd)) + Box(modifier = Modifier.width(1.dp).height(dividerHeight).background(dividerColor)) + Spacer(modifier = Modifier.width(Dimensions.spacingSmMd)) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.Start, + content = rightContent + ) + } +} + +@Composable +fun TrustBlockToggleStrip( + isTrustSelected: Boolean, + isBlockSelected: Boolean, + onTrustClick: () -> Unit, + onBlockClick: () -> Unit, + iconSize: Dp = 28.dp, + spacingBefore: Dp = Dimensions.spacingLg, + spacingBetween: Dp = Dimensions.spacingMd +) { + val trustIcon = if (isTrustSelected) R.drawable.ic_trust_accent else R.drawable.ic_trust + val blockIcon = if (isBlockSelected) R.drawable.ic_block_accent else R.drawable.ic_block + + Spacer(modifier = Modifier.width(spacingBefore)) + Icon( + painter = painterResource(id = trustIcon), + contentDescription = null, + modifier = Modifier.size(iconSize).clickable(onClick = onTrustClick) + ) + Spacer(modifier = Modifier.width(spacingBetween)) + Icon( + painter = painterResource(id = blockIcon), + contentDescription = null, + modifier = Modifier.size(iconSize).clickable(onClick = onBlockClick) + ) +} + +@Composable +fun RuleSheetSectionTitle( + text: String, + modifier: Modifier = Modifier, + horizontalPadding: Dp = Dimensions.screenPaddingHorizontal +) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.fillMaxWidth().padding(horizontal = horizontalPadding) + ) +} + +@Composable +fun RuleSheetSupportingText( + text: String, + modifier: Modifier = Modifier, + horizontalPadding: Dp = Dimensions.screenPaddingHorizontal +) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.fillMaxWidth().padding(horizontal = horizontalPadding) + ) +} + +@Composable +fun RuleSheetDeleteAction( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = Dimensions.screenPaddingHorizontal), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onClick, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text(text = stringResource(R.string.lbl_delete)) + } + } +} + +@Composable +fun RuleSheetSelectionValue( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleMedium +) { + SelectionContainer(modifier = modifier.fillMaxWidth()) { + Text( + text = text, + style = textStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth().padding(horizontal = Dimensions.screenPaddingHorizontal) + ) + } +} + +@Composable +fun RuleSheetTrustBlockRow( + value: String, + isTrustSelected: Boolean, + isBlockSelected: Boolean, + onTrustClick: () -> Unit, + onBlockClick: () -> Unit, + modifier: Modifier = Modifier, + valueTextStyle: TextStyle = MaterialTheme.typography.titleMedium +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ), + verticalAlignment = Alignment.CenterVertically + ) { + SelectionContainer(modifier = Modifier.weight(1f)) { + Text( + text = value, + style = valueTextStyle, + color = MaterialTheme.colorScheme.onSurface + ) + } + TrustBlockToggleStrip( + isTrustSelected = isTrustSelected, + isBlockSelected = isBlockSelected, + onTrustClick = onTrustClick, + onBlockClick = onBlockClick + ) + } +} + +@Composable +fun RuleSheetChipOptionsRow( + options: List, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = Dimensions.screenPaddingHorizontal), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + options.forEach { option -> + Box(modifier = Modifier.weight(1f).widthIn(min = 0.dp)) { + RuleSheetFilterChip( + label = option.label, + selected = option.selected, + selectedText = option.selectedText, + selectedContainer = option.selectedContainer + ) { + option.onClick() + } + } + } + } +} + +@Composable +fun RuleSheetDeleteDialog( + title: String, + message: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = title, + message = message, + confirmText = stringResource(R.string.lbl_delete), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = onConfirm, + onDismiss = onDismiss, + isConfirmDestructive = true + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RuleSheetFilterChip( + label: String, + selected: Boolean, + selectedText: Color, + selectedContainer: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + RethinkFilterChip( + label = label, + selected = selected, + onClick = onClick, + selectedLabelColor = selectedText, + selectedContainerColor = selectedContainer, + modifier = modifier, + minHeight = Dimensions.touchTargetSm + ) +} + +@Composable +fun RuleSheetModeToggle( + autoLabel: String, + manualLabel: String, + isAutoSelected: Boolean, + onAutoClick: () -> Unit, + onManualClick: () -> Unit, + modifier: Modifier = Modifier +) { + RethinkTwoOptionSegmentedRow( + leftLabel = autoLabel, + rightLabel = manualLabel, + leftSelected = isAutoSelected, + onLeftClick = onAutoClick, + onRightClick = onManualClick, + modifier = modifier, + minHeight = Dimensions.touchTargetSm + ) +} + +suspend fun fetchRuleSheetAppIdentity( + context: Context, + uid: Int +): Pair, Drawable?> { + val appNames = FirewallManager.getAppNamesByUid(uid) + val packageName = appNames.firstOrNull()?.let { FirewallManager.getPackageNameByAppName(it) } + val icon = + if (packageName.isNullOrEmpty()) { + null + } else { + Utilities.getIcon(context, packageName) + } + + return appNames to icon +} + +fun formatRuleSheetAppName(context: Context, appNames: List): String? { + return when { + appNames.isEmpty() -> null + appNames.size >= 2 -> + context.getString( + R.string.ctbs_app_other_apps, + appNames[0], + appNames.size.minus(1).toString() + ) + else -> appNames[0] + } +} + +fun formatCustomRuleSheetAppName(context: Context, uid: Int, appNames: List): String { + return when { + uid == UID_EVERYBODY -> + context.getString(R.string.firewall_act_universal_tab) + appNames.isEmpty() -> + context.getString(R.string.network_log_app_name_unknown) + " ($uid)" + appNames.size >= 2 -> + context.getString( + R.string.ctbs_app_other_apps, + appNames[0], + appNames.size.minus(1).toString() + ) + else -> appNames[0] + } +} + +fun logFirewallRuleChange( + eventLogger: EventLogger, + title: String, + details: String, + tag: String? = null +) { + eventLogger.log( + EventType.FW_RULE_MODIFIED, + Severity.LOW, + title, + EventSource.UI, + false, + details + ) + tag?.let { Napier.v("$it $details") } +} + +fun launchRuleMutation( + scope: CoroutineScope, + mutation: suspend () -> T, + onUpdated: (T) -> Unit +) { + scope.launch(Dispatchers.IO) { + val result = mutation() + withContext(Dispatchers.Main) { + onUpdated(result) + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomDomainRulesDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomDomainRulesDialog.kt new file mode 100644 index 000000000..e7e7bdcda --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomDomainRulesDialog.kt @@ -0,0 +1,204 @@ +package com.celzero.bravedns.ui.bottomsheet + + +import android.graphics.drawable.Drawable +import android.text.format.DateUtils +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.database.CustomDomain +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import com.celzero.bravedns.util.Utilities +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "CDRDialog" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomDomainRulesSheet( + customDomain: CustomDomain, + eventLogger: EventLogger, + onDismiss: () -> Unit, + onDeleted: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var appNames by remember { mutableStateOf>(emptyList()) } + var appIcon by remember { mutableStateOf(null) } + var status by remember { mutableStateOf(DomainRulesManager.Status.NONE) } + var showDeleteDialog by remember { mutableStateOf(false) } + + LaunchedEffect(customDomain.uid, customDomain.domain) { + val uid = customDomain.uid + if (uid != UID_EVERYBODY) { + val (names, icon) = withContext(Dispatchers.IO) { fetchRuleSheetAppIdentity(context, uid) } + appNames = names + appIcon = icon + } else { + appNames = emptyList() + appIcon = null + } + + val rules = DomainRulesManager.getDomainRule(customDomain.domain, uid) + status = rules + } + + RuleSheetModal(onDismissRequest = onDismiss) { + val appName = formatCustomRuleSheetAppName(context, customDomain.uid, appNames) + + val now = System.currentTimeMillis() + val time = + DateUtils.getRelativeTimeSpanString( + customDomain.modifiedTs, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + val statusLabel = + when (status) { + DomainRulesManager.Status.TRUST -> stringResource(R.string.ci_trust_txt) + DomainRulesManager.Status.BLOCK -> stringResource(R.string.lbl_blocked) + DomainRulesManager.Status.NONE -> stringResource(R.string.cd_no_rule_txt) + } + val statusText = stringResource(R.string.ci_desc, statusLabel, time) + val deletedToast = stringResource(R.string.cd_toast_deleted) + val chipColors = rememberRuleSheetChipColors() + + RuleSheetLayout(bottomPadding = RuleSheetBottomPaddingCompact) { + RuleSheetDeleteAction(onClick = { showDeleteDialog = true }) + + RuleSheetAppHeader(appName = appName, appIcon = appIcon) + + RuleSheetSectionTitle( + text = stringResource(R.string.lbl_domain), + ) + + RuleSheetSelectionValue(text = customDomain.domain) + + RuleSheetSupportingText( + text = statusText, + ) + + RuleSheetChipOptionsRow( + options = + listOf( + RuleSheetChipOption( + label = stringResource(R.string.ci_no_rule), + selected = status == DomainRulesManager.Status.NONE, + selectedText = chipColors.neutralText, + selectedContainer = chipColors.neutralBg, + onClick = { + updateRule( + customDomain, + DomainRulesManager.Status.NONE, + scope, + eventLogger + ) { newStatus -> + status = newStatus + } + } + ), + RuleSheetChipOption( + label = stringResource(R.string.ci_block), + selected = status == DomainRulesManager.Status.BLOCK, + selectedText = chipColors.negativeText, + selectedContainer = chipColors.negativeBg, + onClick = { + updateRule( + customDomain, + DomainRulesManager.Status.BLOCK, + scope, + eventLogger + ) { newStatus -> + status = newStatus + } + } + ), + RuleSheetChipOption( + label = stringResource(R.string.ci_trust_rule), + selected = status == DomainRulesManager.Status.TRUST, + selectedText = chipColors.positiveText, + selectedContainer = chipColors.positiveBg, + onClick = { + updateRule( + customDomain, + DomainRulesManager.Status.TRUST, + scope, + eventLogger + ) { newStatus -> + status = newStatus + } + } + ) + ) + ) + } + + if (showDeleteDialog) { + RuleSheetDeleteDialog( + title = stringResource(R.string.cd_remove_dialog_title), + message = stringResource(R.string.cd_remove_dialog_message), + onDismiss = { showDeleteDialog = false }, + onConfirm = { + showDeleteDialog = false + scope.launch(Dispatchers.IO) { + DomainRulesManager.deleteDomain(customDomain) + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + deletedToast, + Toast.LENGTH_SHORT + ) + } + } + logEvent( + eventLogger, + "Deleted custom domain rule for ${customDomain.domain}" + ) + onDeleted() + onDismiss() + }, + ) + } + } +} + +private fun updateRule( + customDomain: CustomDomain, + rule: DomainRulesManager.Status, + scope: kotlinx.coroutines.CoroutineScope, + eventLogger: EventLogger, + onUpdated: (DomainRulesManager.Status) -> Unit +) { + launchRuleMutation(scope, mutation = { + when (rule) { + DomainRulesManager.Status.NONE -> DomainRulesManager.noRule(customDomain) + DomainRulesManager.Status.BLOCK -> DomainRulesManager.block(customDomain) + DomainRulesManager.Status.TRUST -> DomainRulesManager.trust(customDomain) + } + val status = DomainRulesManager.Status.getStatus(customDomain.status) + logEvent(eventLogger, "Domain rule for ${customDomain.domain} set to ${status.name}") + status + }, onUpdated = onUpdated) +} + +private fun logEvent(eventLogger: EventLogger, details: String) { + logFirewallRuleChange(eventLogger, "Custom Domain", details, TAG) +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomIpRulesDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomIpRulesDialog.kt new file mode 100644 index 000000000..bab1235d5 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomIpRulesDialog.kt @@ -0,0 +1,235 @@ +package com.celzero.bravedns.ui.bottomsheet + + +import android.graphics.drawable.Drawable +import android.text.format.DateUtils +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.database.CustomIp +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.service.IpRulesManager.IpRuleStatus +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import com.celzero.bravedns.util.Utilities +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "CIRDialog" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomIpRulesSheet( + customIp: CustomIp, + eventLogger: EventLogger, + onDismiss: () -> Unit, + onDeleted: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var appNames by remember { mutableStateOf>(emptyList()) } + var appIcon by remember { mutableStateOf(null) } + var status by remember { mutableStateOf(IpRuleStatus.getStatus(customIp.status)) } + var showDeleteDialog by remember { mutableStateOf(false) } + + LaunchedEffect(customIp.uid, customIp.ipAddress) { + val uid = customIp.uid + if (uid == UID_EVERYBODY) { + appNames = emptyList() + appIcon = null + } else { + val (names, icon) = withContext(Dispatchers.IO) { fetchRuleSheetAppIdentity(context, uid) } + appNames = names + appIcon = icon + } + status = IpRuleStatus.getStatus(customIp.status) + } + + RuleSheetModal(onDismissRequest = onDismiss) { + val appName = formatCustomRuleSheetAppName(context, customIp.uid, appNames) + val now = System.currentTimeMillis() + val uptime = System.currentTimeMillis() - customIp.modifiedDateTime + val time = + DateUtils.getRelativeTimeSpanString( + now - uptime, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + val statusLabel = + when (status) { + IpRuleStatus.TRUST -> stringResource(R.string.ci_trust_txt) + IpRuleStatus.BLOCK -> stringResource(R.string.lbl_blocked) + IpRuleStatus.NONE -> stringResource(R.string.cd_no_rule_txt) + IpRuleStatus.BYPASS_UNIVERSAL -> stringResource(R.string.ci_bypass_universal_txt) + } + val statusText = stringResource(R.string.ci_desc, statusLabel, time) + val deleteToast = stringResource(R.string.univ_ip_delete_individual_toast, customIp.ipAddress) + val chipColors = rememberRuleSheetChipColors() + + RuleSheetLayout(bottomPadding = RuleSheetBottomPaddingCompact) { + RuleSheetDeleteAction(onClick = { showDeleteDialog = true }) + + RuleSheetAppHeader(appName = appName, appIcon = appIcon) + + RuleSheetSelectionValue( + text = customIp.ipAddress, + textStyle = androidx.compose.material3.MaterialTheme.typography.titleLarge + ) + + RuleSheetSupportingText( + text = statusText, + ) + + val thirdOption = + if (customIp.uid == UID_EVERYBODY) { + RuleSheetChipOption( + label = stringResource(R.string.ci_bypass_universal), + selected = status == IpRuleStatus.BYPASS_UNIVERSAL, + selectedText = chipColors.positiveText, + selectedContainer = chipColors.positiveBg, + onClick = { + updateRule(customIp, IpRuleStatus.BYPASS_UNIVERSAL, scope, eventLogger) { + newStatus -> + status = newStatus + } + } + ) + } else { + RuleSheetChipOption( + label = stringResource(R.string.ci_trust_rule), + selected = status == IpRuleStatus.TRUST, + selectedText = chipColors.positiveText, + selectedContainer = chipColors.positiveBg, + onClick = { + updateRule(customIp, IpRuleStatus.TRUST, scope, eventLogger) { newStatus -> + status = newStatus + } + } + ) + } + + RuleSheetChipOptionsRow( + options = + listOf( + RuleSheetChipOption( + label = stringResource(R.string.ci_no_rule), + selected = status == IpRuleStatus.NONE, + selectedText = chipColors.neutralText, + selectedContainer = chipColors.neutralBg, + onClick = { + updateRule(customIp, IpRuleStatus.NONE, scope, eventLogger) { newStatus -> + status = newStatus + } + } + ), + RuleSheetChipOption( + label = stringResource(R.string.ci_block), + selected = status == IpRuleStatus.BLOCK, + selectedText = chipColors.negativeText, + selectedContainer = chipColors.negativeBg, + onClick = { + updateRule(customIp, IpRuleStatus.BLOCK, scope, eventLogger) { newStatus -> + status = newStatus + } + } + ), + thirdOption + ) + ) + } + + if (showDeleteDialog) { + RuleSheetDeleteDialog( + title = stringResource(R.string.univ_firewall_dialog_title), + message = stringResource(R.string.univ_firewall_dialog_message), + onDismiss = { showDeleteDialog = false }, + onConfirm = { + showDeleteDialog = false + scope.launch(Dispatchers.IO) { + IpRulesManager.removeIpRule(customIp.uid, customIp.ipAddress, customIp.port) + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + deleteToast, + Toast.LENGTH_SHORT + ) + } + } + logEvent(eventLogger, "Deleted custom IP rule for ${customIp.ipAddress}") + onDeleted() + onDismiss() + }, + ) + } + } +} + +private fun updateRule( + customIp: CustomIp, + rule: IpRuleStatus, + scope: kotlinx.coroutines.CoroutineScope, + eventLogger: EventLogger, + onUpdated: (IpRuleStatus) -> Unit +) { + launchRuleMutation(scope, mutation = { + val updated = + when (rule) { + IpRuleStatus.NONE -> noRuleIp(customIp, eventLogger) + IpRuleStatus.BLOCK -> blockIp(customIp, eventLogger) + IpRuleStatus.BYPASS_UNIVERSAL -> byPassUniversal(customIp, eventLogger) + IpRuleStatus.TRUST -> byPassAppRule(customIp, eventLogger) + } + Napier.v("$TAG changeIpStatus: ${updated.ipAddress}, status: ${rule.name}") + rule + }, onUpdated = onUpdated) +} + +private suspend fun byPassUniversal(orig: CustomIp, eventLogger: EventLogger): CustomIp { + Napier.i("$TAG set ${orig.ipAddress} to bypass universal") + val copy = orig.deepCopy() + IpRulesManager.updateBypass(copy) + logEvent(eventLogger, "Set IP ${copy.ipAddress} to bypass universal") + return copy +} + +private suspend fun byPassAppRule(orig: CustomIp, eventLogger: EventLogger): CustomIp { + Napier.i("$TAG set ${orig.ipAddress} to bypass app") + val copy = orig.deepCopy() + IpRulesManager.updateTrust(copy) + logEvent(eventLogger, "Set IP ${copy.ipAddress} to trust") + return copy +} + +private suspend fun blockIp(orig: CustomIp, eventLogger: EventLogger): CustomIp { + Napier.i("$TAG block ${orig.ipAddress}") + val copy = orig.deepCopy() + IpRulesManager.updateBlock(copy) + logEvent(eventLogger, "Blocked IP ${copy.ipAddress}") + return copy +} + +private suspend fun noRuleIp(orig: CustomIp, eventLogger: EventLogger): CustomIp { + Napier.i("$TAG no rule for ${orig.ipAddress}") + val copy = orig.deepCopy() + IpRulesManager.updateNoRule(copy) + logEvent(eventLogger, "Set no rule for IP ${copy.ipAddress}") + return copy +} + +private fun logEvent(eventLogger: EventLogger, details: String) { + logFirewallRuleChange(eventLogger, "Custom IP", details) +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/compose/ComposeUtils.kt b/app/src/full/java/com/celzero/bravedns/ui/compose/ComposeUtils.kt new file mode 100644 index 000000000..eb5b7ec02 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/compose/ComposeUtils.kt @@ -0,0 +1,14 @@ +package com.celzero.bravedns.ui.compose + +import android.graphics.drawable.Drawable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.core.graphics.drawable.toBitmap + +@Composable +fun rememberDrawablePainter(drawable: Drawable?): Painter? { + return remember(drawable) { drawable?.toBitmap()?.asImageBitmap()?.let { BitmapPainter(it) } } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoNav.kt b/app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoNav.kt new file mode 100644 index 000000000..ce7c29204 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoNav.kt @@ -0,0 +1,7 @@ +package com.celzero.bravedns.ui.compose.app + +object AppInfoNav { + const val EXTRA_UID = "UID" + const val EXTRA_ACTIVE_CONNS = "ACTIVE_CONNS" + const val EXTRA_ASN = "ASN" +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoScreen.kt b/app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoScreen.kt new file mode 100644 index 000000000..28afb9977 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/compose/app/AppInfoScreen.kt @@ -0,0 +1,1087 @@ +/* + * Copyright 2021 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.compose.app + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MobileOff +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.rounded.WifiOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.asFlow +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.CloseConnsDialog +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.database.AppInfo +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.ProxyManager.ID_NONE +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.bottomsheet.AppDomainRulesSheet +import com.celzero.bravedns.ui.bottomsheet.AppIpRulesSheet +import com.celzero.bravedns.ui.compose.apps.DiagonalWipeIcon +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.CompactEmptyState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkToggleListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.util.Constants.Companion.INVALID_UID +import com.celzero.bravedns.util.Constants.Companion.RETHINK_PACKAGE +import com.celzero.bravedns.util.UIUtils.openAndroidAppInfo +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import com.celzero.bravedns.viewmodel.AppConnectionsViewModel +import com.celzero.bravedns.viewmodel.CustomDomainViewModel +import com.celzero.bravedns.viewmodel.CustomIpViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppInfoScreen( + uid: Int, + eventLogger: EventLogger, + ipRulesViewModel: CustomIpViewModel, + domainRulesViewModel: CustomDomainViewModel, + networkLogsViewModel: AppConnectionsViewModel, + onBackClick: () -> Unit, + onAppWiseIpLogsClick: (Int, Boolean) -> Unit, + onCustomIpRulesClick: (Int) -> Unit, + onCustomDomainRulesClick: (Int) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var appInfo by remember(uid) { mutableStateOf(null) } + var appStatus by remember(uid) { mutableStateOf(FirewallManager.FirewallStatus.NONE) } + var connStatus by remember(uid) { mutableStateOf(FirewallManager.ConnectionStatus.ALLOW) } + var baselineConnStatus by remember(uid) { mutableStateOf(FirewallManager.ConnectionStatus.ALLOW) } + var firewallStatusText by remember(uid) { mutableStateOf("") } + var firewallUpdateVersion by remember(uid) { mutableStateOf(0) } + var isProxyExcluded by remember(uid) { mutableStateOf(false) } + var isTempAllowed by remember(uid) { mutableStateOf(false) } + var proxyDetails by remember(uid) { mutableStateOf("") } + var showNoAppFoundDialog by remember(uid) { mutableStateOf(false) } + + var showDomainRulesSheet by remember { mutableStateOf(false) } + var selectedDomain by remember { mutableStateOf("") } + var showIpRulesSheet by remember { mutableStateOf(false) } + var selectedIp by remember { mutableStateOf("") } + var selectedDomains by remember { mutableStateOf("") } + + var refreshToken by remember(uid) { mutableStateOf(0) } + var closeDialogConn by remember(uid) { mutableStateOf(null) } + + val wireguardAppsProxyMapDesc = stringResource(R.string.wireguard_apps_proxy_map_desc) + val excludeNoPackageErrToast = stringResource(R.string.exclude_no_package_err_toast) + val adaAppStatusBlockMd = stringResource(R.string.ada_app_status_block_md) + val adaAppStatusBlockWifi = stringResource(R.string.ada_app_status_block_wifi) + val adaAppStatusBlock = stringResource(R.string.ada_app_status_block) + val adaAppStatusAllow = stringResource(R.string.ada_app_status_allow) + val adaAppStatusExclude = stringResource(R.string.ada_app_status_exclude) + val adaAppStatusWhitelist = stringResource(R.string.ada_app_status_whitelist) + val adaAppStatusIsolate = stringResource(R.string.ada_app_status_isolate) + val adaAppStatusBypassDnsFirewall = stringResource(R.string.ada_app_status_bypass_dns_firewall) + val adaAppStatusUnknown = stringResource(R.string.ada_app_status_unknown) + val getFirewallStatusText: (FirewallManager.FirewallStatus, FirewallManager.ConnectionStatus) -> String = + { firewallStatus, connectionStatus -> + when (firewallStatus) { + FirewallManager.FirewallStatus.NONE -> { + when (connectionStatus) { + FirewallManager.ConnectionStatus.METERED -> adaAppStatusBlockMd + FirewallManager.ConnectionStatus.UNMETERED -> adaAppStatusBlockWifi + FirewallManager.ConnectionStatus.BOTH -> adaAppStatusBlock + FirewallManager.ConnectionStatus.ALLOW -> adaAppStatusAllow + } + } + FirewallManager.FirewallStatus.EXCLUDE -> adaAppStatusExclude + FirewallManager.FirewallStatus.BYPASS_UNIVERSAL -> adaAppStatusWhitelist + FirewallManager.FirewallStatus.ISOLATE -> adaAppStatusIsolate + FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> adaAppStatusBypassDnsFirewall + FirewallManager.FirewallStatus.UNTRACKED -> adaAppStatusUnknown + } + } + + LaunchedEffect(uid) { + if (uid == INVALID_UID) { + showNoAppFoundDialog = true + return@LaunchedEffect + } + ipRulesViewModel.setUid(uid) + domainRulesViewModel.setUid(uid) + networkLogsViewModel.setUid(uid) + loadAppInfo( + uid = uid, + wireguardAppsProxyMapDesc = wireguardAppsProxyMapDesc, + getFirewallStatusText = getFirewallStatusText, + onLoaded = { + appInfo = it.info + appStatus = it.appStatus + connStatus = it.connStatus + if (it.appStatus == FirewallManager.FirewallStatus.NONE) { + baselineConnStatus = it.connStatus + } + isProxyExcluded = it.isProxyExcluded + isTempAllowed = it.isTempAllowed + proxyDetails = it.proxyDetails + firewallStatusText = it.firewallStatusText + }, + onMissing = { showNoAppFoundDialog = true } + ) + } + + // CloseConnsDialog displayed when user long-presses an active connection + closeDialogConn?.let { conn -> + CloseConnsDialog( + conn = conn, + onConfirm = { + closeDialogConn = null + refreshToken++ + }, + onDismiss = { closeDialogConn = null } + ) + } + + if (showNoAppFoundDialog) { + RethinkConfirmDialog( + onDismissRequest = { showNoAppFoundDialog = false }, + title = stringResource(id = R.string.ada_noapp_dialog_title), + message = stringResource(id = R.string.ada_noapp_dialog_message), + confirmText = stringResource(id = R.string.fapps_info_dialog_positive_btn), + onConfirm = { + showNoAppFoundDialog = false + onBackClick() + } + ) + } + + if (showDomainRulesSheet && selectedDomain.isNotEmpty()) { + AppDomainRulesSheet( + uid = uid, + domain = selectedDomain, + eventLogger = eventLogger, + onDismiss = { showDomainRulesSheet = false }, + onUpdated = { refreshToken++ } + ) + } + if (showIpRulesSheet && selectedIp.isNotEmpty()) { + AppIpRulesSheet( + uid = uid, + ipAddress = selectedIp, + domains = selectedDomains, + eventLogger = eventLogger, + onDismiss = { showIpRulesSheet = false }, + onUpdated = { refreshToken++ } + ) + } + + val isRethink = appInfo?.packageName == RETHINK_PACKAGE + val uptime = VpnController.uptimeMs() + val activeConns = + if (isRethink) { + networkLogsViewModel.getRethinkActiveConnsLimited(uptime) + } else { + networkLogsViewModel.fetchTopActiveConnections(uid, uptime) + } + val activeItems = activeConns.asFlow().collectAsLazyPagingItems() + val domainItems = + if (isRethink) { + networkLogsViewModel.getRethinkDomainLogsLimited().asFlow().collectAsLazyPagingItems() + } else { + networkLogsViewModel.getDomainLogsLimited(uid).asFlow().collectAsLazyPagingItems() + } + val ipItems = + if (isRethink) { + networkLogsViewModel.getRethinkIpLogsLimited().asFlow().collectAsLazyPagingItems() + } else { + networkLogsViewModel.getIpLogsLimited(uid).asFlow().collectAsLazyPagingItems() + } + val activePreview = + remember(activeItems.itemSnapshotList.items, refreshToken) { + activeItems.itemSnapshotList.items.take(8) + } + val domainPreview = + remember(domainItems.itemSnapshotList.items, refreshToken) { + domainItems.itemSnapshotList.items.take(8) + } + val ipPreview = + remember(ipItems.itemSnapshotList.items, refreshToken) { + ipItems.itemSnapshotList.items.take(8) + } + val density = LocalDensity.current + val bottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val info = appInfo + val title = info?.appName?.takeIf { it.isNotBlank() } ?: stringResource(id = R.string.bsct_app_info) + val subtitle = info?.packageName?.takeIf { it.isNotBlank() } + val wifiBlocked = + connStatus == FirewallManager.ConnectionStatus.UNMETERED || + connStatus == FirewallManager.ConnectionStatus.BOTH + val mobileBlocked = + connStatus == FirewallManager.ConnectionStatus.METERED || + connStatus == FirewallManager.ConnectionStatus.BOTH + val isIsolated = appStatus == FirewallManager.FirewallStatus.ISOLATE + val isBypassDnsFirewall = appStatus == FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL + val isBypassUniversal = appStatus == FirewallManager.FirewallStatus.BYPASS_UNIVERSAL + val isExcluded = appStatus == FirewallManager.FirewallStatus.EXCLUDE + var appIcon by remember(uid) { mutableStateOf(null) } + + LaunchedEffect(info?.packageName, info?.appName) { + if (info == null) { + appIcon = null + return@LaunchedEffect + } + appIcon = + withContext(Dispatchers.IO) { + Utilities.getIcon(context, info.packageName, info.appName) + } + } + + fun applyFirewallRule( + firewallStatus: FirewallManager.FirewallStatus, + connectionStatus: FirewallManager.ConnectionStatus + ) { + val requestVersion = firewallUpdateVersion + 1 + firewallUpdateVersion = requestVersion + + // Optimistic update to keep UI deterministic and avoid stale rapid-tap states. + val optimisticText = getFirewallStatusText(firewallStatus, connectionStatus) + firewallStatusText = optimisticText + appStatus = firewallStatus + connStatus = connectionStatus + if (firewallStatus == FirewallManager.FirewallStatus.NONE) { + baselineConnStatus = connectionStatus + } + + updateFirewallStatus( + scope = scope, + context = context, + uid = uid, + appInfo = info, + aStat = firewallStatus, + cStat = connectionStatus, + eventLogger = eventLogger, + excludeNoPackageErrToast = excludeNoPackageErrToast, + getFirewallStatusText = getFirewallStatusText + ) { statusText, updatedAppStatus, updatedConnStatus -> + if (requestVersion != firewallUpdateVersion) return@updateFirewallStatus + firewallStatusText = statusText + appStatus = updatedAppStatus + connStatus = updatedConnStatus + if (updatedAppStatus == FirewallManager.FirewallStatus.NONE) { + baselineConnStatus = updatedConnStatus + } + } + } + + fun toggleExclusiveStatus(target: FirewallManager.FirewallStatus) { + val turningOff = appStatus == target + if (!turningOff && appStatus == FirewallManager.FirewallStatus.NONE) { + baselineConnStatus = connStatus + } + val nextStatus = + if (turningOff) { + FirewallManager.FirewallStatus.NONE + } else { + target + } + val nextConnStatus = + if (nextStatus == FirewallManager.FirewallStatus.NONE) { + baselineConnStatus + } else { + FirewallManager.ConnectionStatus.ALLOW + } + applyFirewallRule(nextStatus, nextConnStatus) + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + RethinkLargeTopBar( + title = title, + subtitle = subtitle, + onBackClick = onBackClick, + scrollBehavior = scrollBehavior, + titleLeading = { + val iconPainter = + rememberDrawablePainter(appIcon ?: Utilities.getDefaultIcon(context)) + iconPainter?.let { painter -> + Image( + painter = painter, + contentDescription = null, + modifier = + Modifier + .size(Dimensions.iconSizeXl) + .clip(RoundedCornerShape(Dimensions.cornerRadiusMd)) + ) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = + PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.screenPaddingHorizontal + bottomInset + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + if (info == null) { + item { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + CompactEmptyState(message = stringResource(id = R.string.ada_noapp_dialog_message)) + RethinkActionListItem( + title = stringResource(id = R.string.ada_noapp_dialog_positive), + iconRes = R.drawable.ic_arrow_back_24, + position = CardPosition.Single, + onClick = onBackClick + ) + } + } + } + return@LazyColumn + } + + item { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius3xl), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(id = R.string.lbl_status), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppInfoStatusBadge( + label = firewallStatusText, + active = true + ) + if (isTempAllowed) { + AppInfoStatusBadge( + label = stringResource(id = R.string.temp_allow_label), + active = true + ) + } + } + if (proxyDetails.isNotBlank()) { + Text( + text = proxyDetails, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + item { SectionHeader(title = stringResource(id = R.string.lbl_firewall)) } + item { + AppFirewallPairRow( + leftTitle = stringResource(id = R.string.ada_app_unmetered), + leftDescription = stringResource(id = R.string.firewall_status_block_unmetered), + leftEnabled = wifiBlocked, + leftAllowedIcon = Icons.Rounded.Wifi, + leftBlockedIcon = Icons.Rounded.WifiOff, + onLeftClick = { + val newConnStatus = + when (connStatus) { + FirewallManager.ConnectionStatus.UNMETERED -> FirewallManager.ConnectionStatus.ALLOW + FirewallManager.ConnectionStatus.BOTH -> FirewallManager.ConnectionStatus.METERED + FirewallManager.ConnectionStatus.METERED -> FirewallManager.ConnectionStatus.BOTH + FirewallManager.ConnectionStatus.ALLOW -> FirewallManager.ConnectionStatus.UNMETERED + } + applyFirewallRule(FirewallManager.FirewallStatus.NONE, newConnStatus) + }, + rightTitle = stringResource(id = R.string.lbl_mobile_data), + rightDescription = stringResource(id = R.string.firewall_status_block_metered), + rightEnabled = mobileBlocked, + rightAllowedIcon = Icons.Rounded.PhoneAndroid, + rightBlockedIcon = Icons.Rounded.MobileOff, + onRightClick = { + val newConnStatus = + when (connStatus) { + FirewallManager.ConnectionStatus.METERED -> FirewallManager.ConnectionStatus.ALLOW + FirewallManager.ConnectionStatus.UNMETERED -> FirewallManager.ConnectionStatus.BOTH + FirewallManager.ConnectionStatus.BOTH -> FirewallManager.ConnectionStatus.UNMETERED + FirewallManager.ConnectionStatus.ALLOW -> FirewallManager.ConnectionStatus.METERED + } + applyFirewallRule(FirewallManager.FirewallStatus.NONE, newConnStatus) + } + ) + } + item { + RethinkListGroup { + RethinkActionListItem( + title = stringResource(id = R.string.ada_app_isolate), + description = stringResource(id = R.string.firewall_status_isolate), + iconRes = R.drawable.ic_firewall_lockdown_off, + accentColor = MaterialTheme.colorScheme.error, + position = cardPositionFor(0, 3), + trailing = { + AppInfoStatusBadge( + label = + stringResource( + id = if (isIsolated) R.string.lbbs_enabled else R.string.lbl_disabled + ), + active = isIsolated + ) + }, + onClick = { + toggleExclusiveStatus(FirewallManager.FirewallStatus.ISOLATE) + } + ) + RethinkActionListItem( + title = stringResource(id = R.string.ada_app_bypass_dns_firewall), + description = stringResource(id = R.string.firewall_status_bypass_dns_firewall), + iconRes = R.drawable.ic_bypass_dns_firewall_off, + accentColor = MaterialTheme.colorScheme.tertiary, + position = cardPositionFor(1, 3), + trailing = { + AppInfoStatusBadge( + label = + stringResource( + id = if (isBypassDnsFirewall) R.string.lbbs_enabled else R.string.lbl_disabled + ), + active = isBypassDnsFirewall + ) + }, + onClick = { + toggleExclusiveStatus(FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL) + } + ) + RethinkActionListItem( + title = stringResource(id = R.string.ada_app_bypass_univ), + description = stringResource(id = R.string.firewall_status_whitelisted), + iconRes = R.drawable.ic_firewall_bypass_off, + accentColor = MaterialTheme.colorScheme.tertiary, + position = cardPositionFor(2, 3), + trailing = { + AppInfoStatusBadge( + label = + stringResource( + id = if (isBypassUniversal) R.string.lbbs_enabled else R.string.lbl_disabled + ), + active = isBypassUniversal + ) + }, + onClick = { + toggleExclusiveStatus(FirewallManager.FirewallStatus.BYPASS_UNIVERSAL) + } + ) + RethinkActionListItem( + title = stringResource(id = R.string.ada_app_exclude), + description = stringResource(id = R.string.firewall_status_excluded), + iconRes = R.drawable.ic_firewall_exclude_off, + accentColor = MaterialTheme.colorScheme.secondary, + position = cardPositionFor(3, 3), + trailing = { + AppInfoStatusBadge( + label = + stringResource( + id = if (isExcluded) R.string.lbbs_enabled else R.string.lbl_disabled + ), + active = isExcluded + ) + }, + onClick = { + toggleExclusiveStatus(FirewallManager.FirewallStatus.EXCLUDE) + } + ) + } + } + + item { SectionHeader(title = stringResource(id = R.string.lbl_advanced)) } + item { + RethinkListGroup { + RethinkToggleListItem( + title = stringResource(id = R.string.exclude_apps_from_proxy), + description = stringResource(id = R.string.settings_exclude_proxy_apps_desc), + checked = isProxyExcluded, + onCheckedChange = { enabled -> + isProxyExcluded = enabled + scope.launch(Dispatchers.IO) { + FirewallManager.updateIsProxyExcluded(uid, enabled) + } + }, + iconRes = R.drawable.ic_proxy, + accentColor = MaterialTheme.colorScheme.secondary, + position = cardPositionFor(0, 1) + ) + RethinkToggleListItem( + title = stringResource(id = R.string.temp_allow_label), + description = stringResource(id = R.string.temp_allow_desc), + checked = isTempAllowed, + onCheckedChange = { enabled -> + isTempAllowed = enabled + scope.launch(Dispatchers.IO) { + FirewallManager.updateTempAllow(uid, enabled) + } + }, + iconRes = R.drawable.ic_timeout, + accentColor = MaterialTheme.colorScheme.tertiary, + position = cardPositionFor(1, 1) + ) + } + } + + item { SectionHeader(title = stringResource(id = R.string.lbl_rules)) } + item { + RethinkListGroup { + RethinkActionListItem( + title = stringResource(id = R.string.about_settings_app_info), + iconRes = R.drawable.ic_app_info, + position = cardPositionFor(0, 2), + trailing = { + Icon( + painter = painterResource(id = R.drawable.ic_right_arrow_small), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { openAndroidAppInfo(context, info.packageName) } + ) + RethinkActionListItem( + title = stringResource(id = R.string.lbl_ip_rules), + iconRes = R.drawable.ic_ip_info, + position = cardPositionFor(1, 2), + trailing = { + Icon( + painter = painterResource(id = R.drawable.ic_right_arrow_small), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { onCustomIpRulesClick(uid) } + ) + RethinkActionListItem( + title = stringResource(id = R.string.lbl_domain_rules), + iconRes = R.drawable.ic_dns_rules_as_firewall, + position = cardPositionFor(2, 2), + trailing = { + Icon( + painter = painterResource(id = R.drawable.ic_right_arrow_small), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { onCustomDomainRulesClick(uid) } + ) + } + } + + item { + LogSectionCard( + title = stringResource(id = R.string.top_active_conns), + badgeCount = activeItems.itemCount, + onClick = { onAppWiseIpLogsClick(uid, false) } + ) { + if (activeItems.loadState.refresh is LoadState.Loading && activeItems.itemCount == 0) { + CompactEmptyState(message = stringResource(id = R.string.lbl_loading)) + } else if (activeItems.itemCount == 0) { + CompactEmptyState(message = stringResource(id = R.string.fapps_empty_subtitle)) + } else { + AppInfoLogPreviewList( + items = activePreview, + title = { beautifyCommaSeparated(it.ipAddress) }, + subtitle = { beautifyCommaSeparated(it.appOrDnsName) }, + onClick = { closeDialogConn = it } + ) + } + } + } + + item { + LogSectionCard( + title = stringResource(id = R.string.ssv_most_contacted_domain_heading), + badgeCount = domainItems.itemCount, + onClick = { onAppWiseIpLogsClick(uid, false) } + ) { + if (domainItems.loadState.refresh is LoadState.Loading && domainItems.itemCount == 0) { + CompactEmptyState(message = stringResource(id = R.string.lbl_loading)) + } else if (domainItems.itemCount == 0) { + CompactEmptyState(message = stringResource(id = R.string.fapps_empty_subtitle)) + } else { + AppInfoLogPreviewList( + items = domainPreview, + title = { + val domain = beautifyCommaSeparated(it.appOrDnsName) + if (domain.isNotBlank()) domain else beautifyCommaSeparated(it.ipAddress) + }, + subtitle = { + val ip = beautifyCommaSeparated(it.ipAddress) + ip.takeIf { value -> value.isNotBlank() && value != beautifyCommaSeparated(it.appOrDnsName) } + }, + onClick = { + selectedDomain = it.appOrDnsName.orEmpty() + showDomainRulesSheet = true + } + ) + } + } + } + + item { + LogSectionCard( + title = stringResource(id = R.string.ssv_most_contacted_ips_heading), + badgeCount = ipItems.itemCount, + onClick = { onAppWiseIpLogsClick(uid, false) } + ) { + if (ipItems.loadState.refresh is LoadState.Loading && ipItems.itemCount == 0) { + CompactEmptyState(message = stringResource(id = R.string.lbl_loading)) + } else if (ipItems.itemCount == 0) { + CompactEmptyState(message = stringResource(id = R.string.fapps_empty_subtitle)) + } else { + AppInfoLogPreviewList( + items = ipPreview, + title = { beautifyCommaSeparated(it.ipAddress) }, + subtitle = { beautifyCommaSeparated(it.appOrDnsName) }, + onClick = { + selectedIp = it.ipAddress + selectedDomains = it.appOrDnsName.orEmpty() + showIpRulesSheet = true + } + ) + } + } + } + + item { Spacer(modifier = Modifier.height(Dimensions.spacingSm)) } + } + } +} + +@Composable +private fun AppInfoStatusBadge( + label: String, + active: Boolean +) { + Surface( + shape = RoundedCornerShape(100.dp), + color = + if (active) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = + if (active) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun AppFirewallPairRow( + leftTitle: String, + leftDescription: String, + leftEnabled: Boolean, + leftAllowedIcon: androidx.compose.ui.graphics.vector.ImageVector, + leftBlockedIcon: androidx.compose.ui.graphics.vector.ImageVector, + onLeftClick: () -> Unit, + rightTitle: String, + rightDescription: String, + rightEnabled: Boolean, + rightAllowedIcon: androidx.compose.ui.graphics.vector.ImageVector, + rightBlockedIcon: androidx.compose.ui.graphics.vector.ImageVector, + onRightClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + AppFirewallTile( + modifier = Modifier.weight(1f), + title = leftTitle, + description = leftDescription, + enabled = leftEnabled, + allowedIcon = leftAllowedIcon, + blockedIcon = leftBlockedIcon, + shape = RoundedCornerShape(topStart = 22.dp, topEnd = 8.dp, bottomStart = 22.dp, bottomEnd = 8.dp), + onClick = onLeftClick + ) + AppFirewallTile( + modifier = Modifier.weight(1f), + title = rightTitle, + description = rightDescription, + enabled = rightEnabled, + allowedIcon = rightAllowedIcon, + blockedIcon = rightBlockedIcon, + shape = RoundedCornerShape(topStart = 8.dp, topEnd = 22.dp, bottomStart = 8.dp, bottomEnd = 22.dp), + onClick = onRightClick + ) + } +} + +@Composable +private fun AppFirewallTile( + modifier: Modifier = Modifier, + title: String, + description: String, + enabled: Boolean, + allowedIcon: androidx.compose.ui.graphics.vector.ImageVector, + blockedIcon: androidx.compose.ui.graphics.vector.ImageVector, + shape: RoundedCornerShape, + onClick: () -> Unit +) { + val blockedTint = MaterialTheme.colorScheme.error + val allowedTint = MaterialTheme.colorScheme.onSurfaceVariant + Surface( + modifier = modifier, + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + onClick = onClick + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = + Modifier + .size(28.dp), + contentAlignment = Alignment.Center + ) { + DiagonalWipeIcon( + blocked = enabled, + allowedIcon = allowedIcon, + blockedIcon = blockedIcon, + allowedTint = allowedTint, + blockedTint = blockedTint, + contentDescription = title, + modifier = Modifier.size(22.dp) + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + AppInfoStatusBadge( + label = stringResource(id = if (enabled) R.string.lbbs_enabled else R.string.lbl_disabled), + active = enabled + ) + } + } +} + +@Composable +private fun LogSectionCard( + title: String, + badgeCount: Int = 0, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius3xl), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (badgeCount > 0) { + AppInfoStatusBadge( + label = badgeCount.toString(), + active = true + ) + } + } + Icon( + painter = painterResource(id = R.drawable.ic_right_arrow_small), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 6.dp) + ) { + content() + } + } + } +} + +@Composable +private fun AppInfoLogPreviewList( + items: List, + title: (AppConnection) -> String, + subtitle: (AppConnection) -> String?, + onClick: (AppConnection) -> Unit +) { + RethinkListGroup { + items.forEachIndexed { index, conn -> + AppInfoLogPreviewRow( + title = title(conn), + subtitle = subtitle(conn), + count = conn.count, + flag = conn.flag, + position = cardPositionFor(index, items.lastIndex), + onClick = { onClick(conn) } + ) + } + } +} + +@Composable +private fun AppInfoLogPreviewRow( + title: String, + subtitle: String?, + count: Int, + flag: String, + position: CardPosition, + onClick: () -> Unit +) { + RethinkListItem( + headline = title.ifBlank { "-" }, + supporting = subtitle?.takeIf { it.isNotBlank() }, + position = position, + leadingContent = { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(34.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = flag.takeIf { it.isNotBlank() } ?: "\u2022", + style = MaterialTheme.typography.titleSmall + ) + } + } + }, + trailing = { + AppInfoStatusBadge( + label = count.toString(), + active = false + ) + }, + onClick = onClick + ) +} + +private fun beautifyCommaSeparated(value: String?): String { + if (value.isNullOrBlank()) return "" + return value + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString(", ") +} + +private data class AppInfoLoad( + val info: AppInfo, + val appStatus: FirewallManager.FirewallStatus, + val connStatus: FirewallManager.ConnectionStatus, + val isProxyExcluded: Boolean, + val isTempAllowed: Boolean, + val proxyDetails: String, + val firewallStatusText: String +) + +private suspend fun loadAppInfo( + uid: Int, + wireguardAppsProxyMapDesc: String, + getFirewallStatusText: (FirewallManager.FirewallStatus, FirewallManager.ConnectionStatus) -> String, + onLoaded: (AppInfoLoad) -> Unit, + onMissing: () -> Unit +) { + val info = withContext(Dispatchers.IO) { FirewallManager.getAppInfoByUid(uid) } + if (info == null || uid == INVALID_UID || info.tombstoneTs > 0) { + onMissing() + return + } + val status = FirewallManager.appStatus(info.uid) + val conn = FirewallManager.connectionStatus(info.uid) + val proxy = + ProxyManager.getProxyIdForApp(uid).takeIf { it.isNotEmpty() && it != ID_NONE } + ?.let { wireguardAppsProxyMapDesc.format(it) } + .orEmpty() + val firewallStatusText = getFirewallStatusText(status, conn) + onLoaded( + AppInfoLoad( + info = info, + appStatus = status, + connStatus = conn, + isProxyExcluded = info.isProxyExcluded, + isTempAllowed = FirewallManager.isTempAllowed(info.uid), + proxyDetails = proxy, + firewallStatusText = firewallStatusText + ) + ) +} + +private fun updateFirewallStatus( + scope: CoroutineScope, + context: Context, + uid: Int, + appInfo: AppInfo?, + aStat: FirewallManager.FirewallStatus, + cStat: FirewallManager.ConnectionStatus, + eventLogger: EventLogger, + excludeNoPackageErrToast: String, + getFirewallStatusText: (FirewallManager.FirewallStatus, FirewallManager.ConnectionStatus) -> String, + onUpdated: (String, FirewallManager.FirewallStatus, FirewallManager.ConnectionStatus) -> Unit +) { + val info = appInfo ?: return + if (aStat == FirewallManager.FirewallStatus.EXCLUDE && FirewallManager.isUnknownPackage(uid)) { + showToastUiCentered(context, excludeNoPackageErrToast, Toast.LENGTH_LONG) + return + } + scope.launch(Dispatchers.IO) { + FirewallManager.updateFirewallStatus(info.uid, aStat, cStat) + val statusText = getFirewallStatusText(aStat, cStat) + withContext(Dispatchers.Main) { + onUpdated(statusText, aStat, cStat) + } + eventLogger.log( + type = EventType.FW_RULE_MODIFIED, + severity = Severity.LOW, + message = "Firewall status changed", + source = EventSource.MANAGER, + userAction = true, + details = "Firewall status changed for ${info.appName} (${info.uid}), new status: $aStat, conn status: $cStat" + ) + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/compose/dns/DnsDetailScreen.kt b/app/src/full/java/com/celzero/bravedns/ui/compose/dns/DnsDetailScreen.kt new file mode 100644 index 000000000..fd4cea8b1 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/compose/dns/DnsDetailScreen.kt @@ -0,0 +1,1165 @@ +/* + * 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.compose.dns + + +import Logger +import Logger.LOG_TAG_DNS +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.asFlow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.celzero.bravedns.R +import com.celzero.bravedns.customdownloader.LocalBlocklistCoordinator +import com.celzero.bravedns.data.AppConfig.Companion.DOH_INDEX +import com.celzero.bravedns.data.AppConfig.Companion.DOT_INDEX +import com.celzero.bravedns.download.AppDownloadManager +import com.celzero.bravedns.download.DownloadConstants +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.bottomsheet.RuleSheetModal +import com.celzero.bravedns.ui.bottomsheet.RuleSheetSummaryPill +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkMultiActionDialog +import com.celzero.bravedns.ui.compose.theme.RethinkTwoOptionSegmentedRow +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS +import com.celzero.bravedns.util.Constants.Companion.LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME +import com.celzero.bravedns.util.Constants.Companion.RETHINK_SEARCH_URL +import com.celzero.bravedns.util.ResourceRecordTypes +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.blocklistCanonicalPath +import com.celzero.bravedns.util.Utilities.convertLongToTime +import com.celzero.bravedns.util.Utilities.deleteRecursive +import com.celzero.bravedns.util.Utilities.tos +import com.celzero.firestack.backend.Backend +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * DNS Detail Screen - A composable screen that shows DNS settings and configuration. + * This is the Compose equivalent of DnsDetailActivity. + * + * @param viewModel The DnsSettingsViewModel for managing DNS settings state + * @param persistentState The PersistentState for accessing app preferences + * @param appDownloadManager The AppDownloadManager for handling blocklist downloads + * @param onCustomDnsClick Callback when custom DNS is clicked (navigates to DNS list) + * @param onRethinkPlusDnsClick Callback when Rethink Plus DNS is clicked + * @param onLocalBlocklistConfigureClick Callback when local blocklist configure is clicked + * @param onBackClick Optional callback for back navigation + */ +@Composable +fun DnsDetailScreen( + viewModel: DnsSettingsViewModel, + persistentState: PersistentState, + appDownloadManager: AppDownloadManager, + initialFocusKey: String? = null, + onCustomDnsClick: () -> Unit, + onRethinkPlusDnsClick: () -> Unit, + onLocalBlocklistConfigureClick: () -> Unit, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Dialog/Sheet state + var showRecordTypesSheet by remember { mutableStateOf(false) } + var showSystemDnsDialog by remember { mutableStateOf(false) } + var systemDnsDialogText by remember { mutableStateOf("") } + var showSmartDnsDialog by remember { mutableStateOf(false) } + var smartDnsDialogText by remember { mutableStateOf("") } + var showLocalBlocklistsSheet by remember { mutableStateOf(false) } + + // Local blocklist state + var showDownloadDialog by remember { mutableStateOf(false) } + var downloadDialogIsRedownload by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + var showLockdownDialog by remember { mutableStateOf(false) } + + var headingText by remember { mutableStateOf("") } + var versionText by remember { mutableStateOf("") } + var canConfigure by remember { mutableStateOf(false) } + var canCopy by remember { mutableStateOf(false) } + var canSearch by remember { mutableStateOf(false) } + var showCheckDownload by remember { mutableStateOf(true) } + var showDownload by remember { mutableStateOf(false) } + var showRedownload by remember { mutableStateOf(false) } + var isChecking by remember { mutableStateOf(false) } + var isDownloading by remember { mutableStateOf(false) } + var isRedownloading by remember { mutableStateOf(false) } + val localBlocklistInUseText = stringResource( + R.string.settings_local_blocklist_in_use, + persistentState.numberOfLocalBlocklists.toString(), + ) + val localBlocklistHeadingText = stringResource(R.string.lbbs_heading) + val localBlocklistVersionText = + if (persistentState.localBlocklistTimestamp == INIT_TIME_MS) { + "" + } else { + stringResource( + R.string.settings_local_blocklist_version, + convertLongToTime( + persistentState.localBlocklistTimestamp, + Constants.TIME_FORMAT_2, + ), + ) + } + val blocklistUpdateFailureText = stringResource(R.string.blocklist_update_check_failure) + val blocklistUpdateNotRequiredText = stringResource(R.string.blocklist_update_check_not_required) + val blocklistNotAvailableToastText = stringResource(R.string.blocklist_not_available_toast) + val configAddSuccessToastText = stringResource(R.string.config_add_success_toast) + val ssvToastStartRethinkText = stringResource(R.string.ssv_toast_start_rethink) + val smartDnsDescriptionText = stringResource(R.string.smart_dns_desc) + val symbolStarText = stringResource(R.string.symbol_star) + val copyClipboardLabelText = stringResource(R.string.copy_clipboard_label) + val infoDialogUrlCopyToastText = stringResource(R.string.info_dialog_url_copy_toast_msg) + val infoDialogRethinkToastText = stringResource(R.string.info_dialog_rethink_toast_msg) + // Helper functions for local blocklist UI state + fun showCheckUpdateUi() { + showCheckDownload = true + showDownload = false + showRedownload = false + isChecking = false + isDownloading = false + isRedownloading = false + } + + fun showUpdateUi() { + showCheckDownload = false + showDownload = true + showRedownload = false + isChecking = false + isDownloading = false + isRedownloading = false + } + + fun showRedownloadUi() { + showCheckDownload = false + showDownload = false + showRedownload = true + isChecking = false + isDownloading = false + isRedownloading = false + } + + fun enableBlocklistUi() { + headingText = localBlocklistInUseText + canConfigure = true + canCopy = true + canSearch = true + } + + fun disableBlocklistUi() { + headingText = localBlocklistHeadingText + canConfigure = false + canCopy = false + canSearch = false + } + + fun updateLocalBlocklistUi() { + if (Utilities.isPlayStoreFlavour()) { + return + } + + if (persistentState.blocklistEnabled) { + enableBlocklistUi() + return + } + + disableBlocklistUi() + } + + fun initLocalBlocklistVersion() { + if (persistentState.localBlocklistTimestamp == INIT_TIME_MS) { + showCheckUpdateUi() + versionText = "" + return + } + + versionText = localBlocklistVersionText + + if (persistentState.newestRemoteBlocklistTimestamp == INIT_TIME_MS) { + showCheckUpdateUi() + return + } + + if (persistentState.newestLocalBlocklistTimestamp > persistentState.localBlocklistTimestamp) { + showUpdateUi() + return + } + + showCheckUpdateUi() + } + + fun handleDownloadStatus(status: AppDownloadManager.DownloadManagerStatus) { + when (status) { + AppDownloadManager.DownloadManagerStatus.IN_PROGRESS -> { + isChecking = true + } + AppDownloadManager.DownloadManagerStatus.STARTED -> { + isChecking = true + } + AppDownloadManager.DownloadManagerStatus.NOT_STARTED -> { + // no-op + } + AppDownloadManager.DownloadManagerStatus.SUCCESS -> { + showUpdateUi() + isChecking = false + isDownloading = false + isRedownloading = false + appDownloadManager.downloadRequired.postValue( + AppDownloadManager.DownloadManagerStatus.NOT_STARTED + ) + } + AppDownloadManager.DownloadManagerStatus.FAILURE -> { + isChecking = false + isDownloading = false + isRedownloading = false + Utilities.showToastUiCentered( + context, + blocklistUpdateFailureText, + Toast.LENGTH_SHORT + ) + appDownloadManager.downloadRequired.postValue( + AppDownloadManager.DownloadManagerStatus.NOT_STARTED + ) + } + AppDownloadManager.DownloadManagerStatus.NOT_REQUIRED -> { + showRedownloadUi() + isChecking = false + Utilities.showToastUiCentered( + context, + blocklistUpdateNotRequiredText, + Toast.LENGTH_SHORT + ) + appDownloadManager.downloadRequired.postValue( + AppDownloadManager.DownloadManagerStatus.NOT_STARTED + ) + } + AppDownloadManager.DownloadManagerStatus.NOT_AVAILABLE -> { + Utilities.showToastUiCentered( + context, + blocklistNotAvailableToastText, + Toast.LENGTH_SHORT + ) + } + } + } + + fun dismissLocalBlocklistsSheet() { + showLocalBlocklistsSheet = false + viewModel.updateUiState() + } + + fun proceedWithDownload(isRedownload: Boolean) { + scope.launch(Dispatchers.Main) { + var status = AppDownloadManager.DownloadManagerStatus.NOT_STARTED + isDownloading = !isRedownload + isRedownloading = isRedownload + val currentTs = persistentState.localBlocklistTimestamp + withContext(Dispatchers.IO) { + status = appDownloadManager.downloadLocalBlocklist(currentTs, isRedownload) + } + handleDownloadStatus(status) + } + } + + fun downloadLocalBlocklist(isRedownload: Boolean) { + if (VpnController.isVpnLockdown() && !persistentState.useCustomDownloadManager) { + showLockdownDialog = true + return + } + proceedWithDownload(isRedownload) + } + + fun deleteLocalBlocklist() { + scope.launch(Dispatchers.Main) { + withContext(Dispatchers.IO) { + val path = blocklistCanonicalPath(context, LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME) + val dir = File(path) + deleteRecursive(dir) + persistentState.localBlocklistTimestamp = INIT_TIME_MS + persistentState.localBlocklistStamp = "" + persistentState.newestLocalBlocklistTimestamp = INIT_TIME_MS + } + + updateLocalBlocklistUi() + showCheckUpdateUi() + Utilities.showToastUiCentered( + context, + configAddSuccessToastText, + Toast.LENGTH_SHORT + ) + } + } + + fun isBlocklistUpdateAvailable() { + scope.launch(Dispatchers.IO) { + appDownloadManager.isDownloadRequired( + com.celzero.bravedns.service.RethinkBlocklistManager.DownloadType.LOCAL + ) + } + } + + fun isLocalBlocklistStampAvailable(): Boolean { + return persistentState.localBlocklistStamp.isNotEmpty() + } + + fun setBraveDnsLocal() { + persistentState.blocklistEnabled = true + } + + fun removeBraveDnsLocal() { + persistentState.blocklistEnabled = false + } + + fun enableBlocklist() { + if (persistentState.blocklistEnabled) { + removeBraveDnsLocal() + updateLocalBlocklistUi() + return + } + + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + ssvToastStartRethinkText, + Toast.LENGTH_SHORT + ) + return + } + + scope.launch(Dispatchers.Main) { + val blocklistsExist = withContext(Dispatchers.Default) { + Utilities.hasLocalBlocklists( + context, + persistentState.localBlocklistTimestamp + ) + } + + if (blocklistsExist) { + setBraveDnsLocal() + if (isLocalBlocklistStampAvailable()) { + updateLocalBlocklistUi() + } else { + dismissLocalBlocklistsSheet() + onLocalBlocklistConfigureClick() + } + } else { + dismissLocalBlocklistsSheet() + onLocalBlocklistConfigureClick() + } + } + } + + fun invokeLocalBlocklistActivity() { + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + ssvToastStartRethinkText, + Toast.LENGTH_SHORT + ) + return + } + + dismissLocalBlocklistsSheet() + onLocalBlocklistConfigureClick() + } + + fun openLocalBlocklist() { + updateLocalBlocklistUi() + initLocalBlocklistVersion() + showLocalBlocklistsSheet = true + } + + fun showSystemDnsDialog(dns: String) { + systemDnsDialogText = dns + showSystemDnsDialog = true + } + + fun showSmartDnsInfoDialog() { + scope.launch(Dispatchers.IO) { + val ids = VpnController.getPlusResolvers() + val dnsList: MutableList = mutableListOf() + ids.forEach { + val index = it.substringAfter(Backend.Plus).getOrNull(0) + if (index == null) { + Logger.w(LOG_TAG_DNS, "smart(plus) dns resolver id is empty: $it") + return@forEach + } + if (index != DOH_INDEX && index != DOT_INDEX) { + Logger.w(LOG_TAG_DNS, "smart(plus) dns resolver id is not doh or dot: $it") + return@forEach + } + val transport = VpnController.getPlusTransportById(it) + val address = transport?.addr?.tos() ?: "" + if (address.isNotEmpty()) dnsList.add(address) + } + + Logger.i(LOG_TAG_DNS, "smart(plus) dns list size: ${dnsList.size}") + withContext(Dispatchers.Main) { + val stringBuilder = StringBuilder() + val desc = smartDnsDescriptionText + stringBuilder.append(desc).append("\n\n") + dnsList.forEach { + val txt = "$symbolStarText $it" + stringBuilder.append(txt).append("\n") + } + smartDnsDialogText = stringBuilder.toString() + showSmartDnsDialog = true + } + } + } + + // Initialize local blocklist state + LaunchedEffect(Unit) { + updateLocalBlocklistUi() + initLocalBlocklistVersion() + } + + val workManager = WorkManager.getInstance(context) + val downloadRequiredStatus by appDownloadManager.downloadRequired + .asFlow() + .collectAsStateWithLifecycle(initialValue = AppDownloadManager.DownloadManagerStatus.NOT_STARTED) + val customDownloadWorkInfos by workManager + .getWorkInfosByTagLiveData(LocalBlocklistCoordinator.CUSTOM_DOWNLOAD) + .asFlow() + .collectAsStateWithLifecycle(initialValue = emptyList()) + val downloadTagWorkInfos by workManager + .getWorkInfosByTagLiveData(DownloadConstants.DOWNLOAD_TAG) + .asFlow() + .collectAsStateWithLifecycle(initialValue = emptyList()) + val fileTagWorkInfos by workManager + .getWorkInfosByTagLiveData(DownloadConstants.FILE_TAG) + .asFlow() + .collectAsStateWithLifecycle(initialValue = emptyList()) + + LaunchedEffect(downloadRequiredStatus) { + Napier.i("Check for blocklist update, status: $downloadRequiredStatus") + if (downloadRequiredStatus != AppDownloadManager.DownloadManagerStatus.NOT_STARTED) { + handleDownloadStatus(downloadRequiredStatus) + } + } + + LaunchedEffect(customDownloadWorkInfos) { + val workInfo = customDownloadWorkInfos.getOrNull(0) ?: return@LaunchedEffect + Napier.i("WorkManager state: ${workInfo.state} for ${LocalBlocklistCoordinator.CUSTOM_DOWNLOAD}") + if (workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING) { + isDownloading = true + } else if (workInfo.state == WorkInfo.State.SUCCEEDED) { + isDownloading = false + showUpdateUi() + workManager.pruneWork() + } else if (workInfo.state == WorkInfo.State.CANCELLED || workInfo.state == WorkInfo.State.FAILED) { + isDownloading = false + Utilities.showToastUiCentered( + context, + blocklistUpdateFailureText, + Toast.LENGTH_SHORT + ) + workManager.pruneWork() + workManager.cancelAllWorkByTag(LocalBlocklistCoordinator.CUSTOM_DOWNLOAD) + } + } + + LaunchedEffect(downloadTagWorkInfos) { + val workInfo = downloadTagWorkInfos.getOrNull(0) ?: return@LaunchedEffect + Napier.i("WorkManager state: ${workInfo.state} for ${DownloadConstants.DOWNLOAD_TAG}") + if (workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING) { + isDownloading = true + } else if (workInfo.state == WorkInfo.State.CANCELLED || workInfo.state == WorkInfo.State.FAILED) { + isDownloading = false + Utilities.showToastUiCentered( + context, + blocklistUpdateFailureText, + Toast.LENGTH_SHORT + ) + workManager.pruneWork() + workManager.cancelAllWorkByTag(DownloadConstants.DOWNLOAD_TAG) + workManager.cancelAllWorkByTag(DownloadConstants.FILE_TAG) + } + } + + LaunchedEffect(fileTagWorkInfos) { + val workInfo = fileTagWorkInfos.getOrNull(0) ?: return@LaunchedEffect + if (workInfo.state == WorkInfo.State.SUCCEEDED) { + isDownloading = false + showUpdateUi() + workManager.pruneWork() + } else if (workInfo.state == WorkInfo.State.CANCELLED || workInfo.state == WorkInfo.State.FAILED) { + isDownloading = false + Utilities.showToastUiCentered( + context, + blocklistUpdateFailureText, + Toast.LENGTH_SHORT + ) + workManager.pruneWork() + workManager.cancelAllWorkByTag(DownloadConstants.FILE_TAG) + } + } + + // Observe lifecycle for onResume + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.updateUiState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // Main content + DnsSettingsScreen( + uiState = uiState, + initialFocusKey = initialFocusKey, + onRefreshClick = { viewModel.refreshDns() }, + onSystemDnsClick = { viewModel.enableSystemDns() }, + onSystemDnsInfoClick = { + scope.launch(Dispatchers.IO) { + val sysDns = VpnController.getSystemDns() + withContext(Dispatchers.Main) { + showSystemDnsDialog(sysDns) + } + } + }, + onCustomDnsClick = onCustomDnsClick, + onRethinkPlusDnsClick = onRethinkPlusDnsClick, + onSmartDnsClick = { viewModel.enableSmartDns() }, + onSmartDnsInfoClick = { showSmartDnsInfoDialog() }, + onLocalBlocklistClick = { openLocalBlocklist() }, + onCustomDownloaderChange = { viewModel.setUseCustomDownloadManager(it) }, + onPeriodicUpdateChange = { viewModel.setPeriodicallyCheckBlocklistUpdate(it) }, + onDnsAlgChange = { viewModel.setDnsAlgEnabled(it) }, + onSplitDnsChange = { viewModel.setSplitDns(it) }, + onBypassDnsBlockChange = { viewModel.setBypassBlockInDns(it) }, + onAllowedRecordTypesClick = { showRecordTypesSheet = true }, + onFavIconChange = { viewModel.setFavIconEnabled(it) }, + onDnsCacheChange = { viewModel.setEnableDnsCache(it) }, + onProxyDnsChange = { viewModel.setProxyDns(it) }, + onUndelegatedDomainsChange = { viewModel.setUseSystemDnsForUndelegatedDomains(it) }, + onFallbackChange = { viewModel.setUseFallbackDnsToBypass(it) }, + onPreventLeaksChange = { viewModel.setPreventDnsLeaksEnabled(it) } + ) + + // DNS Record Types Sheet + if (showRecordTypesSheet) { + DnsRecordTypesSheet( + persistentState = persistentState, + onDismiss = { showRecordTypesSheet = false } + ) + } + + // System DNS Dialog + if (showSystemDnsDialog) { + RethinkMultiActionDialog( + onDismissRequest = { showSystemDnsDialog = false }, + title = stringResource(R.string.network_dns), + message = systemDnsDialogText, + primaryText = stringResource(R.string.ada_noapp_dialog_positive), + onPrimary = { showSystemDnsDialog = false }, + secondaryText = stringResource(R.string.dns_info_neutral), + onSecondary = { + UIUtils.clipboardCopy( + context, + systemDnsDialogText, + copyClipboardLabelText + ) + Utilities.showToastUiCentered( + context, + infoDialogUrlCopyToastText, + Toast.LENGTH_SHORT + ) + showSystemDnsDialog = false + } + ) + } + + // Smart DNS Dialog + if (showSmartDnsDialog) { + RethinkMultiActionDialog( + onDismissRequest = { showSmartDnsDialog = false }, + title = stringResource(R.string.smart_dns), + message = smartDnsDialogText, + primaryText = stringResource(R.string.ada_noapp_dialog_positive), + onPrimary = { showSmartDnsDialog = false }, + secondaryText = stringResource(R.string.dns_info_neutral), + onSecondary = { + UIUtils.clipboardCopy( + context, + smartDnsDialogText, + copyClipboardLabelText + ) + Utilities.showToastUiCentered( + context, + infoDialogUrlCopyToastText, + Toast.LENGTH_SHORT + ) + showSmartDnsDialog = false + } + ) + } + + // Local Blocklists Sheet + if (showLocalBlocklistsSheet) { + LocalBlocklistsSheet( + headingText = headingText, + versionText = versionText, + canConfigure = canConfigure, + canCopy = canCopy, + canSearch = canSearch, + showCheckDownload = showCheckDownload, + showDownload = showDownload, + showRedownload = showRedownload, + isChecking = isChecking, + isDownloading = isDownloading, + isRedownloading = isRedownloading, + isBlocklistEnabled = persistentState.blocklistEnabled, + onDismiss = { dismissLocalBlocklistsSheet() }, + onEnableBlocklist = { enableBlocklist() }, + onConfigure = { invokeLocalBlocklistActivity() }, + onCopy = { + val url = Constants.RETHINK_BASE_URL_MAX + persistentState.localBlocklistStamp + UIUtils.clipboardCopy( + context, + url, + copyClipboardLabelText + ) + Utilities.showToastUiCentered( + context, + infoDialogRethinkToastText, + Toast.LENGTH_SHORT + ) + }, + onSearch = { + dismissLocalBlocklistsSheet() + val url = RETHINK_SEARCH_URL + Uri.encode(persistentState.localBlocklistStamp) + UIUtils.openUrl(context, url) + }, + onCheckUpdate = { + isChecking = true + isBlocklistUpdateAvailable() + }, + onDownload = { + downloadDialogIsRedownload = false + showDownloadDialog = true + }, + onRedownload = { + downloadDialogIsRedownload = true + showDownloadDialog = true + }, + onDelete = { showDeleteDialog = true } + ) + } + + // Download Dialog + if (showDownloadDialog) { + val title = if (downloadDialogIsRedownload) { + stringResource(R.string.local_blocklist_redownload) + } else { + stringResource(R.string.local_blocklist_download) + } + val message = if (downloadDialogIsRedownload) { + stringResource( + R.string.local_blocklist_redownload_desc, + convertLongToTime( + persistentState.localBlocklistTimestamp, + Constants.TIME_FORMAT_2 + ) + ) + } else { + stringResource(R.string.local_blocklist_download_desc) + } + RethinkConfirmDialog( + onDismissRequest = { showDownloadDialog = false }, + title = title, + message = message, + confirmText = stringResource(R.string.settings_local_blocklist_dialog_positive), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + showDownloadDialog = false + downloadLocalBlocklist(downloadDialogIsRedownload) + }, + onDismiss = { showDownloadDialog = false } + ) + } + + // Delete Dialog + if (showDeleteDialog) { + RethinkConfirmDialog( + onDismissRequest = { showDeleteDialog = false }, + title = stringResource(R.string.lbl_delete), + message = stringResource(R.string.local_blocklist_delete_desc), + confirmText = stringResource(R.string.lbl_delete), + dismissText = stringResource(R.string.lbl_cancel), + isConfirmDestructive = true, + onConfirm = { + showDeleteDialog = false + deleteLocalBlocklist() + }, + onDismiss = { showDeleteDialog = false } + ) + } + + // Lockdown Dialog + if (showLockdownDialog) { + RethinkConfirmDialog( + onDismissRequest = { showLockdownDialog = false }, + title = stringResource(R.string.lockdown_download_enable_inapp), + message = stringResource(R.string.lockdown_download_message), + confirmText = stringResource(R.string.lockdown_download_enable_inapp), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + showLockdownDialog = false + persistentState.useCustomDownloadManager = true + downloadLocalBlocklist(downloadDialogIsRedownload) + }, + onDismiss = { + showLockdownDialog = false + proceedWithDownload(downloadDialogIsRedownload) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DnsRecordTypesSheet( + persistentState: PersistentState, + onDismiss: () -> Unit +) { + var isAutoMode by remember { mutableStateOf(persistentState.dnsRecordTypesAutoMode) } + val selected = remember { + mutableStateListOf().apply { + addAll(getInitialRecordSelection(persistentState)) + } + } + + val allTypes = remember { + ResourceRecordTypes.entries.filter { it != ResourceRecordTypes.UNKNOWN } + } + + val sortedTypes by remember { + derivedStateOf { allTypes.sortedBy { it.name } } + } + val selectedCount = if (isAutoMode) allTypes.size else selected.size + + RuleSheetModal(onDismissRequest = onDismiss) { + RethinkBottomSheetCard( + modifier = Modifier.padding(horizontal = Dimensions.screenPaddingHorizontal), + contentPadding = PaddingValues(Dimensions.cardPadding) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd), + verticalAlignment = Alignment.Top + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(Dimensions.iconContainerMd) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = R.drawable.ic_allow_dns_records), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(Dimensions.iconSizeSm) + ) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + Text( + text = stringResource(R.string.cd_allowed_dns_record_types_heading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.cd_allowed_dns_record_types_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm)) { + RuleSheetSummaryPill( + text = if (isAutoMode) { + stringResource(R.string.settings_ip_text_ipv46) + } else { + stringResource(R.string.lbl_manual) + } + ) + RuleSheetSummaryPill( + text = "${stringResource(R.string.rt_filter_parent_selected)} $selectedCount/${allTypes.size}" + ) + } + + RethinkTwoOptionSegmentedRow( + leftLabel = stringResource(R.string.settings_ip_text_ipv46), + rightLabel = stringResource(R.string.lbl_manual), + leftSelected = isAutoMode, + onLeftClick = { + if (!isAutoMode) { + isAutoMode = true + persistentState.dnsRecordTypesAutoMode = true + } + }, + onRightClick = { + if (isAutoMode) { + isAutoMode = false + persistentState.dnsRecordTypesAutoMode = false + } + } + ) + } + + SectionHeader(title = stringResource(R.string.lbl_allowed)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + contentPadding = PaddingValues(bottom = Dimensions.spacing2xl), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + itemsIndexed( + items = sortedTypes, + key = { _, type -> type.value } + ) { index, type -> + val position = when { + sortedTypes.size == 1 -> CardPosition.Single + index == 0 -> CardPosition.First + index == sortedTypes.lastIndex -> CardPosition.Last + else -> CardPosition.Middle + } + RecordTypeRow( + type = type, + isAutoMode = isAutoMode, + isSelected = selected.contains(type.name), + position = position, + onToggle = { + if (isAutoMode) return@RecordTypeRow + if (selected.contains(type.name)) { + selected.remove(type.name) + } else { + selected.add(type.name) + } + persistentState.setAllowedDnsRecordTypes(selected.toSet()) + } + ) + } + } + } +} + +@Composable +private fun RecordTypeRow( + type: ResourceRecordTypes, + isAutoMode: Boolean, + isSelected: Boolean, + position: CardPosition, + onToggle: () -> Unit +) { + val containerColor = if (isSelected && !isAutoMode) { + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.48f) + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + + RethinkListItem( + headline = type.name, + supporting = type.desc, + position = position, + defaultContainerColor = containerColor, + enabled = !isAutoMode, + onClick = onToggle, + trailing = { + Box( + modifier = Modifier.width(Dimensions.touchTargetMin), + contentAlignment = Alignment.CenterEnd + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { _ -> + if (!isAutoMode) { + onToggle() + } + }, + enabled = !isAutoMode + ) + } + } + ) +} + +private fun getInitialRecordSelection(persistentState: PersistentState): List { + if (!persistentState.dnsRecordTypesAutoMode) { + return persistentState.getAllowedDnsRecordTypes().toList() + } + val storedSelection = persistentState.allowedDnsRecordTypesString + if (storedSelection.isNotEmpty()) { + return storedSelection.split(",").filter { it.isNotEmpty() } + } + return listOf( + ResourceRecordTypes.A.name, + ResourceRecordTypes.AAAA.name, + ResourceRecordTypes.CNAME.name, + ResourceRecordTypes.HTTPS.name, + ResourceRecordTypes.SVCB.name + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocalBlocklistsSheet( + headingText: String, + versionText: String, + canConfigure: Boolean, + canCopy: Boolean, + canSearch: Boolean, + showCheckDownload: Boolean, + showDownload: Boolean, + showRedownload: Boolean, + isChecking: Boolean, + isDownloading: Boolean, + isRedownloading: Boolean, + isBlocklistEnabled: Boolean, + onDismiss: () -> Unit, + onEnableBlocklist: () -> Unit, + onConfigure: () -> Unit, + onCopy: () -> Unit, + onSearch: () -> Unit, + onCheckUpdate: () -> Unit, + onDownload: () -> Unit, + onRedownload: () -> Unit, + onDelete: () -> Unit +) { + RuleSheetModal(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + RethinkBottomSheetCard(contentPadding = PaddingValues(Dimensions.cardPadding)) { + Text( + text = headingText, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (versionText.isNotEmpty()) { + Text( + text = versionText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + SectionHeader(title = stringResource(R.string.lbbs_state_header)) + RethinkListGroup { + RethinkActionListItem( + title = if (isBlocklistEnabled) { + stringResource(R.string.lbbs_toggle_off) + } else { + stringResource(R.string.lbbs_toggle_on) + }, + description = stringResource(R.string.lbbs_toggle_desc), + iconRes = R.drawable.ic_local_blocklist, + position = CardPosition.Single, + onClick = onEnableBlocklist + ) + } + + SectionHeader(title = stringResource(R.string.lbbs_actions_header)) + RethinkListGroup { + RethinkActionListItem( + title = stringResource(R.string.lbbs_configure), + iconRes = R.drawable.ic_settings, + position = CardPosition.First, + enabled = canConfigure, + onClick = onConfigure + ) + RethinkActionListItem( + title = stringResource(R.string.lbbs_copy), + iconRes = R.drawable.ic_copy, + position = CardPosition.Middle, + enabled = canCopy, + onClick = onCopy + ) + RethinkActionListItem( + title = stringResource(R.string.lbbs_search), + iconRes = R.drawable.ic_search, + position = CardPosition.Last, + enabled = canSearch, + onClick = onSearch + ) + } + + SectionHeader(title = stringResource(R.string.lbbs_maintenance_header)) + RethinkListGroup { + var maintenanceIndex = 0 + val maintenanceCount = + (if (showCheckDownload) 1 else 0) + + (if (showDownload) 1 else 0) + + (if (showRedownload) 1 else 0) + + 1 + + fun pos(): CardPosition { + return when { + maintenanceCount == 1 -> CardPosition.Single + maintenanceIndex == 0 -> CardPosition.First + maintenanceIndex == maintenanceCount - 1 -> CardPosition.Last + else -> CardPosition.Middle + } + } + + if (showCheckDownload) { + RethinkActionListItem( + title = stringResource(R.string.lbbs_update_check), + iconRes = R.drawable.ic_blocklist_update_check, + position = pos(), + enabled = !isChecking, + trailing = if (isChecking) { + { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } + } else { + null + }, + onClick = onCheckUpdate + ) + maintenanceIndex += 1 + } + if (showDownload) { + RethinkActionListItem( + title = stringResource(R.string.local_blocklist_download), + iconRes = R.drawable.ic_update, + position = pos(), + enabled = !isDownloading, + trailing = if (isDownloading) { + { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } + } else { + null + }, + onClick = onDownload + ) + maintenanceIndex += 1 + } + if (showRedownload) { + RethinkActionListItem( + title = stringResource(R.string.local_blocklist_redownload), + iconRes = R.drawable.ic_update, + position = pos(), + enabled = !isRedownloading, + trailing = if (isRedownloading) { + { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } + } else { + null + }, + onClick = onRedownload + ) + maintenanceIndex += 1 + } + RethinkActionListItem( + title = stringResource(R.string.lbl_delete), + iconRes = R.drawable.ic_delete, + position = pos(), + onClick = onDelete + ) + } + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/compose/logs/DomainConnectionsScreen.kt b/app/src/full/java/com/celzero/bravedns/ui/compose/logs/DomainConnectionsScreen.kt new file mode 100644 index 000000000..d772cc742 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/compose/logs/DomainConnectionsScreen.kt @@ -0,0 +1,250 @@ +/* + * Copyright 2024 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.compose.logs + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.asFlow +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.ConnectionRow +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar +import com.celzero.bravedns.util.UIUtils.getCountryNameFromFlag +import com.celzero.bravedns.viewmodel.DomainConnectionsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DomainConnectionsScreen( + viewModel: DomainConnectionsViewModel, + type: DomainConnectionsInputType, + flag: String, + domain: String, + asn: String, + ip: String, + isBlocked: Boolean, + timeCategory: DomainConnectionsViewModel.TimeCategory, + onBackClick: () -> Unit +) { + val titleText = + when (type) { + DomainConnectionsInputType.DOMAIN -> domain + DomainConnectionsInputType.FLAG -> + stringResource(R.string.two_argument_space, flag, getCountryNameFromFlag(flag)) + DomainConnectionsInputType.ASN -> asn + DomainConnectionsInputType.IP -> ip + } + val subtitleText = subtitleFor(timeCategory) + + LaunchedEffect(type, flag, domain, asn, ip, isBlocked) { + when (type) { + DomainConnectionsInputType.DOMAIN -> { + viewModel.setDomain(domain, isBlocked) + } + DomainConnectionsInputType.FLAG -> { + viewModel.setFlag(flag) + } + DomainConnectionsInputType.ASN -> { + viewModel.setAsn(asn, isBlocked) + } + DomainConnectionsInputType.IP -> { + viewModel.setIp(ip, isBlocked) + } + } + } + + LaunchedEffect(timeCategory) { + viewModel.timeCategoryChanged(timeCategory) + } + + Scaffold( + topBar = { + RethinkTopBar( + title = stringResource(id = R.string.app_name_small_case), + onBackClick = onBackClick + ) + } + ) { paddingValues -> + Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + Header(titleText, subtitleText) + Box(modifier = Modifier.fillMaxSize()) { + ConnectionsList(viewModel, type) + if (shouldShowEmpty(viewModel, type)) { + EmptyState() + } + } + } + } +} + +@Composable +private fun Header(title: String, subtitle: String) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(id = R.string.app_name_small_case), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.alpha(0.5f) + ) + Spacer(modifier = Modifier.size(6.dp)) + Column { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun ConnectionsList( + viewModel: DomainConnectionsViewModel, + type: DomainConnectionsInputType +) { + val liveData = + when (type) { + DomainConnectionsInputType.DOMAIN -> viewModel.domainConnectionList + DomainConnectionsInputType.FLAG -> viewModel.flagConnectionList + DomainConnectionsInputType.ASN -> viewModel.asnConnectionList + DomainConnectionsInputType.IP -> viewModel.ipConnectionList + } + val items = liveData.asFlow().collectAsLazyPagingItems() + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(count = items.itemCount) { index -> + val item = items[index] ?: return@items + ConnectionRow(item) + } + } +} + +@Composable +private fun EmptyState() { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.blocklist_update_check_failure), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + Image( + painter = painterResource(id = R.drawable.illustrations_no_record), + contentDescription = null, + modifier = Modifier.size(220.dp) + ) + } +} + +@Composable +private fun subtitleFor(timeCategory: DomainConnectionsViewModel.TimeCategory): String { + return when (timeCategory) { + DomainConnectionsViewModel.TimeCategory.ONE_HOUR -> { + stringResource( + id = R.string.three_argument, + stringResource(id = R.string.lbl_last), + stringResource(id = R.string.numeric_one), + stringResource(id = R.string.lbl_hour) + ) + } + DomainConnectionsViewModel.TimeCategory.TWENTY_FOUR_HOUR -> { + stringResource( + id = R.string.three_argument, + stringResource(id = R.string.lbl_last), + stringResource(id = R.string.numeric_twenty_four), + stringResource(id = R.string.lbl_hour) + ) + } + DomainConnectionsViewModel.TimeCategory.SEVEN_DAYS -> { + stringResource( + id = R.string.three_argument, + stringResource(id = R.string.lbl_last), + stringResource(id = R.string.numeric_seven), + stringResource(id = R.string.lbl_day) + ) + } + } +} + +@Composable +private fun shouldShowEmpty( + viewModel: DomainConnectionsViewModel, + type: DomainConnectionsInputType +): Boolean { + val liveData = + when (type) { + DomainConnectionsInputType.DOMAIN -> viewModel.domainConnectionList + DomainConnectionsInputType.FLAG -> viewModel.flagConnectionList + DomainConnectionsInputType.ASN -> viewModel.asnConnectionList + DomainConnectionsInputType.IP -> viewModel.ipConnectionList + } + val items = liveData.asFlow().collectAsLazyPagingItems() + return items.itemCount == 0 && items.loadState.append.endOfPaginationReached +} + +enum class DomainConnectionsInputType(val type: Int) { + DOMAIN(0), + FLAG(1), + ASN(2), + IP(3); + + companion object { + fun fromValue(value: Int): DomainConnectionsInputType { + return entries.firstOrNull { it.type == value } ?: DOMAIN + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/compose/rpn/RpnWinProxyDetailsScreen.kt b/app/src/full/java/com/celzero/bravedns/ui/compose/rpn/RpnWinProxyDetailsScreen.kt new file mode 100644 index 000000000..c58d385ca --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/compose/rpn/RpnWinProxyDetailsScreen.kt @@ -0,0 +1,274 @@ +/* + * 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.compose.rpn + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar +import com.celzero.bravedns.util.Utilities +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RpnWinProxyDetailsScreen( + countryCode: String, + onBackClick: () -> Unit +) { + val context = LocalContext.current + val title = stringResource(R.string.rpn_proxy_details_title) + val noProxyTitle = stringResource(R.string.rpn_no_proxy_found_title) + val noProxyDesc = stringResource(R.string.rpn_no_proxy_found_desc) + val selectAppsLabel = stringResource(R.string.rpn_select_apps_for_proxy) + val appsInfoToast = stringResource(R.string.rpn_proxy_apps_info_toast) + var appsCount by remember { mutableStateOf("-") } + var domainsCount by remember { mutableStateOf("-") } + var ipsCount by remember { mutableStateOf("-") } + var proxyError by remember { mutableStateOf("") } + var proxyName by remember { mutableStateOf("") } + var proxyWho by remember { mutableStateOf("") } + var proxyLatencyMs by remember { mutableStateOf(null) } + var proxyLastConnectedMs by remember { mutableStateOf(null) } + var isProxyActive by remember { mutableStateOf(false) } + var showNoProxyFoundDialog by remember { mutableStateOf(false) } + + LaunchedEffect(countryCode) { + if (countryCode.isEmpty()) { + Napier.w(tag = TAG, message = "empty country code, showing dialog") + showNoProxyFoundDialog = true + return@LaunchedEffect + } + + val loaded = + withContext(Dispatchers.IO) { + val appsByCountry = ProxyManager.getAppsCountForProxy(countryCode) + val appsByWin = ProxyManager.getAppsCountForProxy(ProxyManager.ID_RPN_WIN) + val apps = if (appsByCountry > 0) appsByCountry else appsByWin + val ipCount = IpRulesManager.getRulesCountByCC(countryCode) + val domainCount = DomainRulesManager.getRulesCountByCC(countryCode) + val details = RpnProxyManager.getWinProxyDetails(countryCode) + Napier.i(tag = TAG, message = "apps: $apps, ips: $ipCount, domains: $domainCount for country code: $countryCode, has details: ${details != null}") + Triple(apps to domainCount to ipCount, details, details == null) + } + + appsCount = loaded.first.first.first.toString() + domainsCount = loaded.first.first.second.toString() + ipsCount = loaded.first.second.toString() + proxyName = loaded.second?.name.orEmpty() + proxyWho = loaded.second?.who.orEmpty() + proxyLatencyMs = loaded.second?.latencyMs + proxyLastConnectedMs = loaded.second?.lastConnectedMs + isProxyActive = loaded.second?.isActive == true + showNoProxyFoundDialog = loaded.third + } + + Scaffold( + topBar = { + RethinkTopBar( + title = title, + onBackClick = onBackClick + ) + } + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(paddingValues) + ) { + if (showNoProxyFoundDialog) { + RethinkConfirmDialog( + onDismissRequest = {}, + title = noProxyTitle, + message = noProxyDesc, + confirmText = stringResource(R.string.ada_noapp_dialog_positive), + onConfirm = onBackClick + ) + } + StatsRow(appsCount, domainsCount, ipsCount) + Spacer(modifier = Modifier.height(12.dp)) + DetailsSection( + countryCode = countryCode, + proxyError = proxyError, + proxyName = proxyName, + proxyWho = proxyWho, + proxyLatencyMs = proxyLatencyMs, + proxyLastConnectedMs = proxyLastConnectedMs, + isProxyActive = isProxyActive + ) + Spacer(modifier = Modifier.height(16.dp)) + ActionButton(onClick = { + Utilities.showToastUiCentered( + context, + appsInfoToast, + Toast.LENGTH_LONG + ) + }, label = selectAppsLabel) + } + } +} + +@Composable +private fun StatsRow(appsCount: String, domainsCount: String, ipsCount: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatCard(label = stringResource(R.string.rpn_proxy_apps), value = appsCount, modifier = Modifier.weight(1f)) + StatCard(label = stringResource(R.string.rpn_proxy_domains), value = domainsCount, modifier = Modifier.weight(1f)) + StatCard(label = stringResource(R.string.rpn_proxy_ips), value = ipsCount, modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = CardDefaults.outlinedCardBorder() +) { + Column( + modifier = Modifier.padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(text = label, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun DetailsSection( + countryCode: String, + proxyError: String, + proxyName: String, + proxyWho: String, + proxyLatencyMs: Int?, + proxyLastConnectedMs: Long?, + isProxyActive: Boolean +) { + val fallback = stringResource(R.string.symbol_hyphen) + val latencyText = proxyLatencyMs?.let { stringResource(R.string.dns_query_latency, it.toString()) } ?: fallback + val lastConnectedText = + if (proxyLastConnectedMs == null || proxyLastConnectedMs <= 0L) { + fallback + } else { + val elapsedMs = (System.currentTimeMillis() - proxyLastConnectedMs).coerceAtLeast(0L) + val minutes = elapsedMs / 60000L + when { + minutes < 1L -> stringResource(R.string.bubble_time_just_now) + minutes < 60L -> stringResource(R.string.bubble_time_minutes_ago, minutes.toInt()) + else -> stringResource(R.string.bubble_time_hours_ago, (minutes / 60L).toInt()) + } + } + val statusText = if (isProxyActive) stringResource(R.string.rpn_proxy_connected) else stringResource(R.string.lbl_disabled) + val statusColor = if (isProxyActive) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = proxyName.ifBlank { stringResource(R.string.rpn_proxy_name) }, + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(12.dp)) + DetailRow(label = stringResource(R.string.rpn_proxy_who), value = proxyWho.ifBlank { fallback }) + if (proxyError.isNotEmpty()) { + DetailRow(label = stringResource(R.string.rpn_proxy_error), value = proxyError, valueColor = MaterialTheme.colorScheme.error) + } + Spacer(modifier = Modifier.height(12.dp)) + DetailRow(label = stringResource(R.string.rpn_proxy_country), value = countryCode.uppercase()) + DetailRow(label = stringResource(R.string.rpn_proxy_latency), value = latencyText) + DetailRow(label = stringResource(R.string.rpn_proxy_last_connected), value = lastConnectedText) + Spacer(modifier = Modifier.height(12.dp)) + DetailRow(label = stringResource(R.string.rpn_proxy_status), value = statusText, valueColor = statusColor) + } +} + +@Composable +private fun DetailRow(label: String, value: String, valueColor: Color = MaterialTheme.colorScheme.onSurface) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Text(text = value, style = MaterialTheme.typography.bodyMedium, color = valueColor) + } +} + +@Composable +private fun ActionButton(onClick: () -> Unit, label: String) { + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 12.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_loop_back_app), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(text = label) + } +} + +private const val TAG = "RpnWinProxyDetails" diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/CustomLanIpDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/CustomLanIpDialog.kt index b34223a8b..0ae2eedab 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/dialog/CustomLanIpDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/CustomLanIpDialog.kt @@ -15,285 +15,203 @@ */ package com.celzero.bravedns.ui.dialog -import Logger -import Logger.LOG_TAG_UI -import android.app.Activity -import android.content.res.ColorStateList -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.Window -import android.widget.EditText + import android.widget.Toast -import androidx.appcompat.app.AppCompatDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import com.celzero.bravedns.R -import com.celzero.bravedns.databinding.DialogCustomLanIpBinding import com.celzero.bravedns.service.PersistentState -import com.celzero.bravedns.util.UIUtils.fetchColor -import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors -import com.google.android.material.button.MaterialButton +import com.celzero.bravedns.ui.bottomsheet.RuleSheetDualTextFieldRow +import com.celzero.bravedns.ui.bottomsheet.RuleSheetModeToggle +import com.celzero.bravedns.ui.bottomsheet.RuleSheetModal +import com.celzero.bravedns.ui.bottomsheet.RuleSheetSectionTitle +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetActionRow +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard +import com.celzero.bravedns.ui.compose.theme.RethinkSecondaryActionStyle import inet.ipaddr.IPAddressString - -class CustomLanIpDialog( - activity: Activity, - private val persistentState: PersistentState, - themeId: Int -) : AppCompatDialog(activity, themeId) { - - companion object { - private const val GATEWAY_4_PREFIX = 24 - private const val GATEWAY_6_PREFIX = 120 - private const val ROUTER_4_PREFIX = 32 - private const val ROUTER_6_PREFIX = 128 - private const val DNS_4_PREFIX = 32 - private const val DNS_6_PREFIX = 128 - - private const val GATEWAY_4_IP = "10.111.222.1" - private const val GATEWAY_6_IP = "fd66:f83a:c650::1" - private const val ROUTER_4_IP = "10.111.222.2" - private const val ROUTER_6_IP = "fd66:f83a:c650::2" - private const val DNS_4_IP = "10.111.222.3" - private const val DNS_6_IP = "fd66:f83a:c650::3" - } - private lateinit var binding: DialogCustomLanIpBinding - - // Track initial state to detect changes - private var initialMode: Boolean = false - private var currentMode: Boolean = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - requestWindowFeature(Window.FEATURE_NO_TITLE) - binding = DialogCustomLanIpBinding.inflate(LayoutInflater.from(context)) - setContentView(binding.root) - setCancelable(true) - - // reset customIpChanged flag when dialog is created - persistentState.customModeOrIpChanged = false - - setupInitialState() - setupClickListeners() - } - - private fun setupInitialState() { - initialMode = persistentState.customLanIpMode - currentMode = initialMode - - if (currentMode) { - selectManualModeUi() - } else { - selectAutoModeUi() - } - } - - private fun loadDefaultAutoValues() { - // Load default gateway values - binding.gatewayIpv4.setText(GATEWAY_4_IP) - binding.gatewayIpv4Prefix.setText(GATEWAY_4_PREFIX.toString()) - binding.gatewayIpv6.setText(GATEWAY_6_IP) - binding.gatewayIpv6Prefix.setText(GATEWAY_6_PREFIX.toString()) - - // Load default router values - binding.routerIpv4.setText(ROUTER_4_IP) - binding.routerIpv4Prefix.setText(ROUTER_4_PREFIX.toString()) - binding.routerIpv6.setText(ROUTER_6_IP) - binding.routerIpv6Prefix.setText(ROUTER_6_PREFIX.toString()) - - // Load default DNS values - binding.dnsIpv4.setText(DNS_4_IP) - binding.dnsIpv4Prefix.setText(DNS_4_PREFIX.toString()) - binding.dnsIpv6.setText(DNS_6_IP) - binding.dnsIpv6Prefix.setText(DNS_6_PREFIX.toString()) - } - - private fun setupClickListeners() { - binding.autoToggleBtn.setOnClickListener { - currentMode = false - selectAutoModeUi() - } - binding.manualToggleBtn.setOnClickListener { - currentMode = true - selectManualModeUi() - } - - binding.resetButton.setOnClickListener { - if (!currentMode) { - Toast.makeText(context, R.string.custom_lan_ip_saved_auto, Toast.LENGTH_SHORT).show() - } else { - resetManualFields() - } - } - - binding.saveButton.setOnClickListener { - if (!currentMode) { - saveAutoMode() - } else { - saveManualMode() - } - } - } - - private fun selectAutoModeUi() { - selectToggleBtnUi(binding.autoToggleBtn) - unselectToggleBtnUi(binding.manualToggleBtn) - - binding.modeDesc.text = context.getString(R.string.custom_lan_ip_auto_desc) - - setManualFieldsEnabled(false) - binding.resetButton.isEnabled = false - - // Load default AUTO values - loadDefaultAutoValues() - } - - private fun selectManualModeUi() { - selectToggleBtnUi(binding.manualToggleBtn) - unselectToggleBtnUi(binding.autoToggleBtn) - - binding.modeDesc.text = context.getString(R.string.custom_lan_ip_manual_desc) - - setManualFieldsEnabled(true) - binding.resetButton.isEnabled = true - - // Load saved manual values if they exist, otherwise load defaults - loadManualValues() +import io.github.aakira.napier.Napier + +private const val GATEWAY_4_PREFIX = 24 +private const val GATEWAY_6_PREFIX = 120 +private const val ROUTER_4_PREFIX = 32 +private const val ROUTER_6_PREFIX = 128 +private const val DNS_4_PREFIX = 32 +private const val DNS_6_PREFIX = 128 + +private const val GATEWAY_4_IP = "10.111.222.1" +private const val GATEWAY_6_IP = "fd66:f83a:c650::1" +private const val ROUTER_4_IP = "10.111.222.2" +private const val ROUTER_6_IP = "fd66:f83a:c650::2" +private const val DNS_4_IP = "10.111.222.3" +private const val DNS_6_IP = "fd66:f83a:c650::3" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomLanIpSheet( + persistentState: PersistentState, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var initialMode by remember { mutableStateOf(false) } + var currentMode by remember { mutableStateOf(false) } + + var gatewayIpv4 by remember { mutableStateOf("") } + var gatewayIpv4Prefix by remember { mutableStateOf("") } + var gatewayIpv6 by remember { mutableStateOf("") } + var gatewayIpv6Prefix by remember { mutableStateOf("") } + + var routerIpv4 by remember { mutableStateOf("") } + var routerIpv4Prefix by remember { mutableStateOf("") } + var routerIpv6 by remember { mutableStateOf("") } + var routerIpv6Prefix by remember { mutableStateOf("") } + + var dnsIpv4 by remember { mutableStateOf("") } + var dnsIpv4Prefix by remember { mutableStateOf("") } + var dnsIpv6 by remember { mutableStateOf("") } + var dnsIpv6Prefix by remember { mutableStateOf("") } + + var errorMessage by remember { mutableStateOf("") } + + fun loadDefaultAutoValues() { + gatewayIpv4 = GATEWAY_4_IP + gatewayIpv4Prefix = GATEWAY_4_PREFIX.toString() + gatewayIpv6 = GATEWAY_6_IP + gatewayIpv6Prefix = GATEWAY_6_PREFIX.toString() + + routerIpv4 = ROUTER_4_IP + routerIpv4Prefix = ROUTER_4_PREFIX.toString() + routerIpv6 = ROUTER_6_IP + routerIpv6Prefix = ROUTER_6_PREFIX.toString() + + dnsIpv4 = DNS_4_IP + dnsIpv4Prefix = DNS_4_PREFIX.toString() + dnsIpv6 = DNS_6_IP + dnsIpv6Prefix = DNS_6_PREFIX.toString() } - private fun loadManualValues() { - // If saved values exist in persistent state, load them - // Otherwise, load default values + fun loadManualValues() { if (persistentState.customLanGatewayIpv4.isNotBlank()) { - loadIpAndPrefixIntoFields(persistentState.customLanGatewayIpv4, binding.gatewayIpv4, binding.gatewayIpv4Prefix) + loadIpAndPrefixIntoFields(persistentState.customLanGatewayIpv4) { ip, prefix -> + gatewayIpv4 = ip + gatewayIpv4Prefix = prefix + } } else { - binding.gatewayIpv4.setText(GATEWAY_4_IP) - binding.gatewayIpv4Prefix.setText(GATEWAY_4_PREFIX.toString()) + gatewayIpv4 = GATEWAY_4_IP + gatewayIpv4Prefix = GATEWAY_4_PREFIX.toString() } if (persistentState.customLanGatewayIpv6.isNotBlank()) { - loadIpAndPrefixIntoFields(persistentState.customLanGatewayIpv6, binding.gatewayIpv6, binding.gatewayIpv6Prefix) + loadIpAndPrefixIntoFields(persistentState.customLanGatewayIpv6) { ip, prefix -> + gatewayIpv6 = ip + gatewayIpv6Prefix = prefix + } } else { - binding.gatewayIpv6.setText(GATEWAY_6_IP) - binding.gatewayIpv6Prefix.setText(GATEWAY_6_PREFIX.toString()) + gatewayIpv6 = GATEWAY_6_IP + gatewayIpv6Prefix = GATEWAY_6_PREFIX.toString() } if (persistentState.customLanRouterIpv4.isNotBlank()) { - loadIpAndPrefixIntoFields(persistentState.customLanRouterIpv4, binding.routerIpv4, binding.routerIpv4Prefix) + loadIpAndPrefixIntoFields(persistentState.customLanRouterIpv4) { ip, prefix -> + routerIpv4 = ip + routerIpv4Prefix = prefix + } } else { - binding.routerIpv4.setText(ROUTER_4_IP) - binding.routerIpv4Prefix.setText(ROUTER_4_PREFIX.toString()) + routerIpv4 = ROUTER_4_IP + routerIpv4Prefix = ROUTER_4_PREFIX.toString() } if (persistentState.customLanRouterIpv6.isNotBlank()) { - loadIpAndPrefixIntoFields(persistentState.customLanRouterIpv6, binding.routerIpv6, binding.routerIpv6Prefix) + loadIpAndPrefixIntoFields(persistentState.customLanRouterIpv6) { ip, prefix -> + routerIpv6 = ip + routerIpv6Prefix = prefix + } } else { - binding.routerIpv6.setText(ROUTER_6_IP) - binding.routerIpv6Prefix.setText(ROUTER_6_PREFIX.toString()) + routerIpv6 = ROUTER_6_IP + routerIpv6Prefix = ROUTER_6_PREFIX.toString() } if (persistentState.customLanDnsIpv4.isNotBlank()) { - loadIpAndPrefixIntoFields(persistentState.customLanDnsIpv4, binding.dnsIpv4, binding.dnsIpv4Prefix) + loadIpAndPrefixIntoFields(persistentState.customLanDnsIpv4) { ip, prefix -> + dnsIpv4 = ip + dnsIpv4Prefix = prefix + } } else { - binding.dnsIpv4.setText(DNS_4_IP) - binding.dnsIpv4Prefix.setText(DNS_4_PREFIX.toString()) + dnsIpv4 = DNS_4_IP + dnsIpv4Prefix = DNS_4_PREFIX.toString() } if (persistentState.customLanDnsIpv6.isNotBlank()) { - loadIpAndPrefixIntoFields(persistentState.customLanDnsIpv6, binding.dnsIpv6, binding.dnsIpv6Prefix) + loadIpAndPrefixIntoFields(persistentState.customLanDnsIpv6) { ip, prefix -> + dnsIpv6 = ip + dnsIpv6Prefix = prefix + } } else { - binding.dnsIpv6.setText(DNS_6_IP) - binding.dnsIpv6Prefix.setText(DNS_6_PREFIX.toString()) + dnsIpv6 = DNS_6_IP + dnsIpv6Prefix = DNS_6_PREFIX.toString() } } - private fun selectToggleBtnUi(mb: MaterialButton) { - mb.backgroundTintList = ColorStateList.valueOf(fetchToggleBtnColors(context, R.color.accentGood)) - mb.setTextColor(fetchColor(context, R.attr.homeScreenHeaderTextColor)) - } - - private fun unselectToggleBtnUi(mb: MaterialButton) { - mb.setTextColor(fetchColor(context, R.attr.primaryTextColor)) - mb.backgroundTintList = ColorStateList.valueOf(fetchToggleBtnColors(context, R.color.defaultToggleBtnBg)) + fun hideError() { + errorMessage = "" } - private fun setManualFieldsEnabled(enabled: Boolean) { - // Gateway - binding.gatewayIpv4.isEnabled = enabled - binding.gatewayIpv4Prefix.isEnabled = enabled - binding.gatewayIpv6.isEnabled = enabled - binding.gatewayIpv6Prefix.isEnabled = enabled - // Router - binding.routerIpv4.isEnabled = enabled - binding.routerIpv4Prefix.isEnabled = enabled - binding.routerIpv6.isEnabled = enabled - binding.routerIpv6Prefix.isEnabled = enabled - // DNS - binding.dnsIpv4.isEnabled = enabled - binding.dnsIpv4Prefix.isEnabled = enabled - binding.dnsIpv6.isEnabled = enabled - binding.dnsIpv6Prefix.isEnabled = enabled - } - - private fun resetManualFields() { - // Reset to default values instead of clearing + fun resetManualFields() { loadDefaultAutoValues() - - // Clear any error message hideError() - Toast.makeText(context, R.string.custom_lan_ip_saved_manual, Toast.LENGTH_SHORT).show() } - private fun showError(message: String) { - binding.errorText.text = message - binding.errorText.visibility = View.VISIBLE - } - - private fun hideError() { - binding.errorText.visibility = View.GONE - binding.errorText.text = "" - } - - private fun saveAutoMode() { + fun saveAutoMode() { try { - // Check if mode changed val modeChanged = initialMode != currentMode - - // AUTO mode: mark mode as auto and clear any saved custom values persistentState.customLanIpMode = false - - // Set the customIpChanged flag if mode changed OR we had custom values and now cleared them if (modeChanged) { persistentState.customModeOrIpChanged = true - Logger.i(LOG_TAG_UI, "Custom LAN IPs cleared (switched to AUTO)") + Napier.i("Custom LAN IPs cleared (switched to AUTO)") } - hideError() Toast.makeText(context, R.string.custom_lan_ip_saved_auto, Toast.LENGTH_SHORT).show() - dismiss() + onDismiss() } catch (e: Exception) { - Logger.e(LOG_TAG_UI, "err saving custom lan ip (auto): ${e.message}", e) - showError(context.getString(R.string.custom_lan_ip_save_error)) + Napier.e("err saving custom lan ip (auto): ${e.message}") + errorMessage = context.getString(R.string.custom_lan_ip_save_error) } } - private fun saveManualMode() { + fun saveManualMode() { try { - val gatewayV4 = binding.gatewayIpv4.text?.toString()?.trim().orEmpty() - val gatewayV4Prefix = binding.gatewayIpv4Prefix.text?.toString()?.trim().orEmpty() - val gatewayV6 = binding.gatewayIpv6.text?.toString()?.trim().orEmpty() - val gatewayV6Prefix = binding.gatewayIpv6Prefix.text?.toString()?.trim().orEmpty() - - val routerV4 = binding.routerIpv4.text?.toString()?.trim().orEmpty() - val routerV4Prefix = binding.routerIpv4Prefix.text?.toString()?.trim().orEmpty() - val routerV6 = binding.routerIpv6.text?.toString()?.trim().orEmpty() - val routerV6Prefix = binding.routerIpv6Prefix.text?.toString()?.trim().orEmpty() - - val dnsV4 = binding.dnsIpv4.text?.toString()?.trim().orEmpty() - val dnsV4Prefix = binding.dnsIpv4Prefix.text?.toString()?.trim().orEmpty() - val dnsV6 = binding.dnsIpv6.text?.toString()?.trim().orEmpty() - val dnsV6Prefix = binding.dnsIpv6Prefix.text?.toString()?.trim().orEmpty() - - // Validate IP + prefix pairs; only allow private/ULA ranges + val gatewayV4 = gatewayIpv4.trim() + val gatewayV4Prefix = gatewayIpv4Prefix.trim() + val gatewayV6 = gatewayIpv6.trim() + val gatewayV6Prefix = gatewayIpv6Prefix.trim() + + val routerV4 = routerIpv4.trim() + val routerV4Prefix = routerIpv4Prefix.trim() + val routerV6 = routerIpv6.trim() + val routerV6Prefix = routerIpv6Prefix.trim() + + val dnsV4 = dnsIpv4.trim() + val dnsV4Prefix = dnsIpv4Prefix.trim() + val dnsV6 = dnsIpv6.trim() + val dnsV6Prefix = dnsIpv6Prefix.trim() + if (!validateIpv4WithPrefix(gatewayV4, gatewayV4Prefix) || !validateIpv6WithPrefix(gatewayV6, gatewayV6Prefix) || !validateIpv4WithPrefix(routerV4, routerV4Prefix) || @@ -301,11 +219,10 @@ class CustomLanIpDialog( !validateIpv4WithPrefix(dnsV4, dnsV4Prefix) || !validateIpv6WithPrefix(dnsV6, dnsV6Prefix) ) { - showError(context.getString(R.string.custom_lan_ip_validation_error)) + errorMessage = context.getString(R.string.custom_lan_ip_validation_error) return } - // Combine new values val newGatewayV4 = combineIpAndPrefix(gatewayV4, gatewayV4Prefix) val newGatewayV6 = combineIpAndPrefix(gatewayV6, gatewayV6Prefix) val newRouterV4 = combineIpAndPrefix(routerV4, routerV4Prefix) @@ -313,179 +230,316 @@ class CustomLanIpDialog( val newDnsV4 = combineIpAndPrefix(dnsV4, dnsV4Prefix) val newDnsV6 = combineIpAndPrefix(dnsV6, dnsV6Prefix) - // Check if any IP values have changed - val ipValuesChanged = newGatewayV4 != persistentState.customLanGatewayIpv4 || + val ipValuesChanged = + newGatewayV4 != persistentState.customLanGatewayIpv4 || newGatewayV6 != persistentState.customLanGatewayIpv6 || newRouterV4 != persistentState.customLanRouterIpv4 || newRouterV6 != persistentState.customLanRouterIpv6 || newDnsV4 != persistentState.customLanDnsIpv4 || newDnsV6 != persistentState.customLanDnsIpv6 - // Check if mode changed val modeChanged = initialMode != currentMode persistentState.customLanIpMode = true - - // Store combined ip/prefix strings; empty pair becomes "" persistentState.customLanGatewayIpv4 = newGatewayV4 persistentState.customLanGatewayIpv6 = newGatewayV6 - persistentState.customLanRouterIpv4 = newRouterV4 persistentState.customLanRouterIpv6 = newRouterV6 - persistentState.customLanDnsIpv4 = newDnsV4 persistentState.customLanDnsIpv6 = newDnsV6 - // Set the customIpChanged flag if mode changed OR IP values changed if (modeChanged || ipValuesChanged) { persistentState.customModeOrIpChanged = true - Logger.i(LOG_TAG_UI, "Custom LAN IPs changed - mode changed: $modeChanged, IP values changed: $ipValuesChanged") + Napier.i( + "Custom LAN IPs changed - mode changed: $modeChanged, IP values changed: $ipValuesChanged" + ) } hideError() Toast.makeText(context, R.string.custom_lan_ip_saved_manual, Toast.LENGTH_SHORT).show() - dismiss() + onDismiss() } catch (e: Exception) { - Logger.e(LOG_TAG_UI, "err saving custom lan ip (manual): ${e.message}", e) - showError(context.getString(R.string.custom_lan_ip_save_error)) + Napier.e("err saving custom lan ip (manual): ${e.message}") + errorMessage = context.getString(R.string.custom_lan_ip_save_error) } } - private fun loadIpAndPrefixIntoFields(value: String, ipField: EditText, prefixField: EditText) { - if (value.isBlank()) { - ipField.setText("") - prefixField.setText("") - return + LaunchedEffect(Unit) { + persistentState.customModeOrIpChanged = false + initialMode = persistentState.customLanIpMode + currentMode = initialMode + if (currentMode) { + loadManualValues() + } else { + loadDefaultAutoValues() } - val parts = value.split("/") - val ip = parts.getOrNull(0).orEmpty() - val prefix = parts.getOrNull(1).orEmpty() - ipField.setText(ip) - prefixField.setText(prefix) - } - - private fun combineIpAndPrefix(ip: String, prefix: String): String { - if (ip.isBlank() && prefix.isBlank()) return "" - // By this point, validation has already ensured both are non-empty and well-formed - return "$ip/$prefix" } - private fun validateIpv4WithPrefix(ip: String, prefixText: String): Boolean { - // Both must be empty or both must be filled - if (ip.isEmpty() && prefixText.isEmpty()) return true - if (ip.isEmpty() || prefixText.isEmpty()) { - Logger.w(LOG_TAG_UI, "IPv4 validation failed: both IP and prefix must be provided together") - return false - } - - return try { - // Validate IP address - val addr = IPAddressString(ip).address - if (addr == null) { - Logger.w(LOG_TAG_UI, "IPv4 validation failed: invalid IP address format: $ip") - return false - } - if (!addr.isIPv4) { - Logger.w(LOG_TAG_UI, "IPv4 validation failed: not an IPv4 address: $ip") - return false + RuleSheetModal(onDismissRequest = onDismiss) { + val manualEnabled = currentMode + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingLg + ) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + RethinkBottomSheetCard { + RuleSheetModeToggle( + autoLabel = context.getString(R.string.settings_ip_text_ipv46), + manualLabel = context.getString(R.string.lbl_manual), + isAutoSelected = !currentMode, + onAutoClick = { + currentMode = false + loadDefaultAutoValues() + hideError() + }, + onManualClick = { + currentMode = true + loadManualValues() + hideError() + } + ) + Text( + text = + if (currentMode) { + context.getString(R.string.custom_lan_ip_manual_desc) + } else { + context.getString(R.string.custom_lan_ip_auto_desc) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - // Only allow RFC1918 private IPv4 ranges (unique local for IPv4) - val host = addr.toNormalizedString() // e.g. "10.0.0.1" - if (!isRfc1918Ipv4(host)) { - Logger.w(LOG_TAG_UI, "IPv4 validation failed: not a private/unique local address (must be 10.x.x.x, 172.16-31.x.x, or 192.168.x.x): $host") - return false + RethinkBottomSheetCard { + RuleSheetSectionTitle( + text = context.getString(R.string.custom_lan_ip_gateway), + horizontalPadding = 0.dp + ) + RuleSheetDualTextFieldRow( + primaryValue = gatewayIpv4, + onPrimaryValueChange = { gatewayIpv4 = it }, + secondaryValue = gatewayIpv4Prefix, + onSecondaryValueChange = { gatewayIpv4Prefix = it }, + primaryLabel = { Text(text = context.getString(R.string.settings_ip_text_ipv4)) }, + secondaryLabel = { Text(text = context.getString(R.string.lbl_prefix)) }, + enabled = manualEnabled + ) + RuleSheetDualTextFieldRow( + primaryValue = gatewayIpv6, + onPrimaryValueChange = { gatewayIpv6 = it }, + secondaryValue = gatewayIpv6Prefix, + onSecondaryValueChange = { gatewayIpv6Prefix = it }, + primaryLabel = { Text(text = context.getString(R.string.settings_ip_text_ipv6)) }, + secondaryLabel = { Text(text = context.getString(R.string.lbl_prefix)) }, + enabled = manualEnabled + ) + + RuleSheetSectionTitle( + text = context.getString(R.string.custom_lan_ip_router), + horizontalPadding = 0.dp + ) + RuleSheetDualTextFieldRow( + primaryValue = routerIpv4, + onPrimaryValueChange = { routerIpv4 = it }, + secondaryValue = routerIpv4Prefix, + onSecondaryValueChange = { routerIpv4Prefix = it }, + primaryLabel = { Text(text = context.getString(R.string.settings_ip_text_ipv4)) }, + secondaryLabel = { Text(text = context.getString(R.string.lbl_prefix)) }, + enabled = manualEnabled + ) + RuleSheetDualTextFieldRow( + primaryValue = routerIpv6, + onPrimaryValueChange = { routerIpv6 = it }, + secondaryValue = routerIpv6Prefix, + onSecondaryValueChange = { routerIpv6Prefix = it }, + primaryLabel = { Text(text = context.getString(R.string.settings_ip_text_ipv6)) }, + secondaryLabel = { Text(text = context.getString(R.string.lbl_prefix)) }, + enabled = manualEnabled + ) + + RuleSheetSectionTitle( + text = context.getString(R.string.dns_mode_info_title), + horizontalPadding = 0.dp + ) + RuleSheetDualTextFieldRow( + primaryValue = dnsIpv4, + onPrimaryValueChange = { dnsIpv4 = it }, + secondaryValue = dnsIpv4Prefix, + onSecondaryValueChange = { dnsIpv4Prefix = it }, + primaryLabel = { Text(text = context.getString(R.string.settings_ip_text_ipv4)) }, + secondaryLabel = { Text(text = context.getString(R.string.lbl_prefix)) }, + enabled = manualEnabled + ) + RuleSheetDualTextFieldRow( + primaryValue = dnsIpv6, + onPrimaryValueChange = { dnsIpv6 = it }, + secondaryValue = dnsIpv6Prefix, + onSecondaryValueChange = { dnsIpv6Prefix = it }, + primaryLabel = { Text(text = context.getString(R.string.settings_ip_text_ipv6)) }, + secondaryLabel = { Text(text = context.getString(R.string.lbl_prefix)) }, + enabled = manualEnabled + ) } - // Validate prefix length - val prefix = prefixText.toIntOrNull() - if (prefix == null) { - Logger.w(LOG_TAG_UI, "IPv4 validation failed: invalid prefix length: $prefixText") - return false - } - if (prefix !in 0..32) { - Logger.w(LOG_TAG_UI, "IPv4 validation failed: prefix length must be 0-32, got: $prefix") - return false + RethinkBottomSheetActionRow( + primaryText = context.getString(R.string.lbl_save), + onPrimaryClick = { + if (!currentMode) { + saveAutoMode() + } else { + saveManualMode() + } + }, + secondaryText = context.getString(R.string.lbl_reset), + onSecondaryClick = { + if (!currentMode) { + Toast.makeText(context, R.string.custom_lan_ip_saved_auto, Toast.LENGTH_SHORT).show() + } else { + resetManualFields() + } + }, + secondaryEnabled = currentMode, + secondaryStyle = RethinkSecondaryActionStyle.TEXT + ) + + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = Dimensions.spacingXs) + ) } - - true - } catch (e: Exception) { - Logger.e(LOG_TAG_UI, "IPv4 validation error for $ip/$prefixText: ${e.message}", e) - false } } +} - private fun validateIpv6WithPrefix(ip: String, prefixText: String): Boolean { - // Both must be empty or both must be filled - if (ip.isEmpty() && prefixText.isEmpty()) return true - if (ip.isEmpty() || prefixText.isEmpty()) { - Logger.w(LOG_TAG_UI, "IPv6 validation failed: both IP and prefix must be provided together") - return false - } +private fun loadIpAndPrefixIntoFields( + value: String, + onLoaded: (String, String) -> Unit +) { + if (value.isBlank()) { + onLoaded("", "") + return + } + val parts = value.split("/") + val ip = parts.getOrNull(0).orEmpty() + val prefix = parts.getOrNull(1).orEmpty() + onLoaded(ip, prefix) +} - return try { - // Validate IP address - val addr = IPAddressString(ip).address - if (addr == null) { - Logger.w(LOG_TAG_UI, "IPv6 validation failed: invalid IP address format: $ip") - return false - } - if (!addr.isIPv6) { - Logger.w(LOG_TAG_UI, "IPv6 validation failed: not an IPv6 address: $ip") - return false - } +private fun combineIpAndPrefix(ip: String, prefix: String): String { + if (ip.isBlank() && prefix.isBlank()) return "" + return "$ip/$prefix" +} - // Only allow Unique Local IPv6 (fc00::/7) - val host = addr.toNormalizedString() // e.g. "fd00:abcd::1" - if (!isUlaIpv6(host)) { - Logger.w(LOG_TAG_UI, "IPv6 validation failed: not a unique local address (must start with fc or fd): $host") - return false - } +private fun validateIpv4WithPrefix(ip: String, prefixText: String): Boolean { + if (ip.isEmpty() && prefixText.isEmpty()) return true + if (ip.isEmpty() || prefixText.isEmpty()) { + Napier.w("IPv4 validation failed: both IP and prefix must be provided together") + return false + } - // Validate prefix length - val prefix = prefixText.toIntOrNull() - if (prefix == null) { - Logger.w(LOG_TAG_UI, "IPv6 validation failed: invalid prefix length: $prefixText") - return false - } - if (prefix !in 0..128) { - Logger.w(LOG_TAG_UI, "IPv6 validation failed: prefix length must be 0-128, got: $prefix") - return false - } + return try { + val addr = IPAddressString(ip).address + if (addr == null) { + Napier.w("IPv4 validation failed: invalid IP address format: $ip") + return false + } + if (!addr.isIPv4) { + Napier.w("IPv4 validation failed: not an IPv4 address: $ip") + return false + } - true - } catch (e: Exception) { - Logger.e(LOG_TAG_UI, "IPv6 validation error for $ip/$prefixText: ${e.message}", e) - false + val host = addr.toNormalizedString() + if (!isRfc1918Ipv4(host)) { + Napier.w( + "IPv4 validation failed: not a private/unique local address (must be 10.x.x.x, 172.16-31.x.x, or 192.168.x.x): $host" + ) + return false + } + + val prefix = prefixText.toIntOrNull() + if (prefix == null) { + Napier.w("IPv4 validation failed: invalid prefix length: $prefixText") + return false + } + if (prefix !in 0..32) { + Napier.w("IPv4 validation failed: prefix out of range: $prefixText") + return false } + true + } catch (e: Exception) { + Napier.w("IPv4 validation failed: ${e.message}") + false } +} - // RFC1918 private IPv4 ranges using simple string prefix checks. - // This assumes normalized dotted decimal addresses like "10.0.0.1". - private fun isRfc1918Ipv4(host: String): Boolean { - // 10.0.0.0/8 - if (host.startsWith("10.")) return true - - // 172.16.0.0/12: 172.16.0.0 – 172.31.255.255 - if (host.startsWith("172.")) { - val parts = host.split(".") - if (parts.size == 4) { - val second = parts[1].toIntOrNull() ?: return false - if (second in 16..31) return true - } +private fun validateIpv6WithPrefix(ip: String, prefixText: String): Boolean { + if (ip.isEmpty() && prefixText.isEmpty()) return true + if (ip.isEmpty() || prefixText.isEmpty()) { + Napier.w("IPv6 validation failed: both IP and prefix must be provided together") + return false + } + + return try { + val addr = IPAddressString(ip).address + if (addr == null) { + Napier.w("IPv6 validation failed: invalid IP address format: $ip") + return false + } + if (!addr.isIPv6) { + Napier.w("IPv6 validation failed: not an IPv6 address: $ip") + return false } - // 192.168.0.0/16 - if (host.startsWith("192.168.")) return true + val host = addr.toNormalizedString() + if (!isUlaIpv6(host)) { + Napier.w( + "IPv6 validation failed: not a unique local address (must start with fc or fd): $host" + ) + return false + } - return false + val prefix = prefixText.toIntOrNull() + if (prefix == null) { + Napier.w("IPv6 validation failed: invalid prefix length: $prefixText") + return false + } + if (prefix !in 0..128) { + Napier.w("IPv6 validation failed: prefix out of range: $prefixText") + return false + } + true + } catch (e: Exception) { + Napier.w("IPv6 validation failed: ${e.message}") + false } +} + +private fun isRfc1918Ipv4(host: String): Boolean { + if (host.startsWith("10.")) return true - // Unique Local IPv6 (fc00::/7) string check. - // Normalized IPv6 will start with "fc" or "fd" for ULA. - private fun isUlaIpv6(host: String): Boolean { - val lower = host.lowercase() - // fc00::/7 = addresses starting with fc or fd - return lower.startsWith("fc") || lower.startsWith("fd") + if (host.startsWith("172.")) { + val parts = host.split(".") + if (parts.size == 4) { + val second = parts[1].toIntOrNull() ?: return false + if (second in 16..31) return true + } } + + if (host.startsWith("192.168.")) return true + + return false +} + +private fun isUlaIpv6(host: String): Boolean { + val lower = host.lowercase() + return lower.startsWith("fc") || lower.startsWith("fd") } diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/DnsCryptRelaysDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/DnsCryptRelaysDialog.kt index 2c130b678..6ad36066a 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/dialog/DnsCryptRelaysDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/DnsCryptRelaysDialog.kt @@ -15,45 +15,76 @@ 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.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.celzero.bravedns.databinding.DialogDnscryptRelaysBinding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.RelayRow +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.database.DnsCryptRelayEndpoint +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar -class DnsCryptRelaysDialog( - private var activity: Activity, - internal var adapter: RecyclerView.Adapter<*>, - themeID: Int -) : Dialog(activity, themeID) { - - private lateinit var b: DialogDnscryptRelaysBinding - - private var mLayoutManager: RecyclerView.LayoutManager? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - requestWindowFeature(Window.FEATURE_NO_TITLE) - b = DialogDnscryptRelaysBinding.inflate(layoutInflater) - setContentView(b.root) - initView() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DnsCryptRelaysDialog( + appConfig: AppConfig, + relays: LiveData>, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(color = MaterialTheme.colorScheme.background) { + DnsCryptRelaysContent(appConfig = appConfig, relays = relays, onDismiss = onDismiss) + } } +} - private fun initView() { - window?.setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.MATCH_PARENT - ) - - mLayoutManager = LinearLayoutManager(activity) - - b.recyclerViewDialog.layoutManager = mLayoutManager - b.recyclerViewDialog.adapter = adapter - - b.customDialogOkButton.setOnClickListener { this.dismiss() } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DnsCryptRelaysContent( + appConfig: AppConfig, + relays: LiveData>, + onDismiss: () -> Unit +) { + val items = relays.asFlow().collectAsLazyPagingItems() + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + RethinkTopBar( + title = stringResource(R.string.cd_dnscrypt_relay_heading), + onBackClick = onDismiss + ) + } + ) { padding -> + Column( + modifier = Modifier.padding(padding) + ) { + LazyColumn( + modifier = Modifier.padding(horizontal = Dimensions.screenPaddingHorizontal), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + items(items.itemCount) { index -> + val item = items[index] ?: return@items + RelayRow(item, appConfig) + } + } + } } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/NetworkReachabilityDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/NetworkReachabilityDialog.kt index 330e57a98..453b33538 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/dialog/NetworkReachabilityDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/NetworkReachabilityDialog.kt @@ -15,364 +15,263 @@ */ package com.celzero.bravedns.ui.dialog + import Logger import Logger.LOG_TAG_UI -import android.app.Activity -import android.content.res.ColorStateList -import android.graphics.drawable.Drawable import android.net.NetworkCapabilities -import android.os.Bundle -import android.view.View -import android.view.Window import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import com.celzero.bravedns.R -import com.celzero.bravedns.databinding.DialogInputIpsBinding import com.celzero.bravedns.service.ConnectionMonitor import com.celzero.bravedns.service.ConnectionMonitor.Companion.SCHEME_HTTP import com.celzero.bravedns.service.ConnectionMonitor.Companion.SCHEME_HTTPS import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.bottomsheet.RuleSheetLabeledControlRow +import com.celzero.bravedns.ui.bottomsheet.RuleSheetModeToggle +import com.celzero.bravedns.ui.bottomsheet.RuleSheetModal +import com.celzero.bravedns.ui.bottomsheet.RuleSheetTextFieldRow +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetActionRow +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard +import com.celzero.bravedns.ui.compose.theme.RethinkSecondaryActionStyle import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.UIUtils -import com.google.android.material.button.MaterialButton import inet.ipaddr.IPAddress.IPVersion import inet.ipaddr.IPAddressString +import java.net.MalformedURLException +import java.net.URL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.net.MalformedURLException -import java.net.URL - -class NetworkReachabilityDialog(activity: Activity, - private val persistentState: PersistentState, - themeId: Int -) : androidx.appcompat.app.AppCompatDialog(activity, themeId) { - - private lateinit var binding: DialogInputIpsBinding - - private var useAuto: Boolean = false - - companion object { - private const val URL4 = "IPv4" - private const val URL6 = "IPv6" - private const val URL_SEGMENT4 = "#ipv4" - private const val URL_SEGMENT6 = "#ipv6" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - requestWindowFeature(Window.FEATURE_NO_TITLE) - binding = DialogInputIpsBinding.inflate(layoutInflater) - setContentView(binding.root) - setCancelable(true) - initViews() - setupListeners() - } - - private fun initViews() { - binding.saveButton.text = context.getString(R.string.lbl_save).uppercase() - binding.testButton.text = context.getString(R.string.lbl_test).uppercase() - - useAuto = persistentState.performAutoNetworkConnectivityChecks - if (useAuto) { - updateAutoModeUi() - } else { - updateManualModeUi() - } - setAllStatusIconsVisibility(View.GONE) - setAllProgressBarsVisibility(View.GONE) - setProtocolsUi() - } - - private fun setProtocolsUi() { - val protocols = VpnController.protocols() - if (protocols.contains(URL4)) { - binding.protocolV4.setImageResource(R.drawable.ic_tick) - } else { - binding.protocolV4.setImageResource(R.drawable.ic_cross_accent) - } - if (protocols.contains(URL6)) { - binding.protocolV6.setImageResource(R.drawable.ic_tick) - } else { - binding.protocolV6.setImageResource(R.drawable.ic_cross_accent) +private const val URL4 = "IPv4" +private const val URL6 = "IPv6" +private const val URL_SEGMENT4 = "#ipv4" +private const val URL_SEGMENT6 = "#ipv6" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkReachabilitySheet( + persistentState: PersistentState, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var useAuto by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var buttonsEnabled by remember { mutableStateOf(true) } + + var ipv4Address1 by remember { mutableStateOf("") } + var ipv4Address2 by remember { mutableStateOf("") } + var ipv6Address1 by remember { mutableStateOf("") } + var ipv6Address2 by remember { mutableStateOf("") } + var urlV4Address1 by remember { mutableStateOf("") } + var urlV4Address2 by remember { mutableStateOf("") } + var urlV6Address1 by remember { mutableStateOf("") } + var urlV6Address2 by remember { mutableStateOf("") } + + var statusIpv41 by remember { mutableStateOf(null) } + var statusIpv42 by remember { mutableStateOf(null) } + var statusUrlV41 by remember { mutableStateOf(null) } + var statusUrlV42 by remember { mutableStateOf(null) } + var statusIpv61 by remember { mutableStateOf(null) } + var statusIpv62 by remember { mutableStateOf(null) } + var statusUrlV61 by remember { mutableStateOf(null) } + var statusUrlV62 by remember { mutableStateOf(null) } + + var progressIpv41 by remember { mutableStateOf(false) } + var progressIpv42 by remember { mutableStateOf(false) } + var progressUrlV41 by remember { mutableStateOf(false) } + var progressUrlV42 by remember { mutableStateOf(false) } + var progressIpv61 by remember { mutableStateOf(false) } + var progressIpv62 by remember { mutableStateOf(false) } + var progressUrlV61 by remember { mutableStateOf(false) } + var progressUrlV62 by remember { mutableStateOf(false) } + + fun setAllStatusIconsVisibility(visible: Boolean) { + if (!visible) { + statusIpv41 = null + statusIpv42 = null + statusUrlV41 = null + statusUrlV42 = null + statusIpv61 = null + statusIpv62 = null + statusUrlV61 = null + statusUrlV62 = null } } - private fun setupListeners() { - binding.autoToggleBtn.setOnClickListener { - useAuto = true - updateAutoModeUi() - selectToggleBtnUi(binding.autoToggleBtn) - unselectToggleBtnUi(binding.manualToggleBtn) - } - binding.manualToggleBtn.setOnClickListener { - useAuto = false - updateManualModeUi() - selectToggleBtnUi(binding.manualToggleBtn) - unselectToggleBtnUi(binding.autoToggleBtn) - } - binding.resetChip.setOnClickListener { resetToDefaults() } - binding.testButton.setOnClickListener { testConnections() } - binding.saveButton.setOnClickListener { saveIps() } + fun setAllProgressBarsVisibility(visible: Boolean) { + progressIpv41 = visible + progressIpv42 = visible + progressUrlV41 = visible + progressUrlV42 = visible + progressIpv61 = visible + progressIpv62 = visible + progressUrlV61 = visible + progressUrlV62 = visible } - private fun updateAutoModeUi() { - binding.resetChip.visibility = View.GONE - binding.saveButton.visibility = View.GONE - - selectToggleBtnUi(binding.autoToggleBtn) - unselectToggleBtnUi(binding.manualToggleBtn) - + fun updateAutoModeUi() { val autoTxt = context.getString(R.string.lbl_auto) - val v4 = listOf( - ConnectionMonitor.SCHEME_IP + ConnectionMonitor.PROTOCOL_V4 + " " + autoTxt, - ConnectionMonitor.SCHEME_HTTPS + ConnectionMonitor.PROTOCOL_V4 + " " + autoTxt - ) - val v6 = listOf( - ConnectionMonitor.SCHEME_IP + ConnectionMonitor.PROTOCOL_V6 + " " + autoTxt, - ConnectionMonitor.SCHEME_HTTPS + ConnectionMonitor.PROTOCOL_V6 + " " + autoTxt - ) - binding.ipv4Address1.apply { isEnabled = false; setText(v4[0]) } - binding.ipv4Address2.apply { isEnabled = false; setText(v4[1]) } - binding.ipv6Address1.apply { isEnabled = false; setText(v6[0]) } - binding.ipv6Address2.apply { isEnabled = false; setText(v6[1]) } - - binding.urlV4Layout1.visibility = View.GONE - binding.urlV6Layout1.visibility = View.GONE - binding.urlV4Layout2.visibility = View.GONE - binding.urlV6Layout2.visibility = View.GONE - - setAllStatusIconsVisibility(View.GONE) - setAllProgressBarsVisibility(View.GONE) - binding.errorMessage.visibility = View.GONE + ipv4Address1 = ConnectionMonitor.SCHEME_IP + ConnectionMonitor.PROTOCOL_V4 + " " + autoTxt + ipv4Address2 = ConnectionMonitor.SCHEME_HTTPS + ConnectionMonitor.PROTOCOL_V4 + " " + autoTxt + ipv6Address1 = ConnectionMonitor.SCHEME_IP + ConnectionMonitor.PROTOCOL_V6 + " " + autoTxt + ipv6Address2 = ConnectionMonitor.SCHEME_HTTPS + ConnectionMonitor.PROTOCOL_V6 + " " + autoTxt + errorMessage = "" + setAllStatusIconsVisibility(false) + setAllProgressBarsVisibility(false) } - private fun updateManualModeUi() { - binding.resetChip.visibility = View.VISIBLE - binding.saveButton.visibility = View.VISIBLE - selectToggleBtnUi(binding.manualToggleBtn) - unselectToggleBtnUi(binding.autoToggleBtn) - + fun updateManualModeUi() { val itemsIp4 = persistentState.pingv4Ips.split(",").toTypedArray() val itemsIp6 = persistentState.pingv6Ips.split(",").toTypedArray() val itemsUrl4 = persistentState.pingv4Url.split(",").toTypedArray() val itemsUrl6 = persistentState.pingv6Url.split(",").toTypedArray() + ipv4Address1 = itemsIp4.getOrNull(0) ?: "" + ipv4Address2 = itemsIp4.getOrNull(1) ?: "" + urlV4Address1 = itemsUrl4.getOrNull(0)?.split(URL_SEGMENT4)?.firstOrNull() ?: Constants.urlV4probes[0] + urlV4Address2 = itemsUrl4.getOrNull(1)?.split(URL_SEGMENT4)?.firstOrNull() ?: Constants.urlV4probes[0] + ipv6Address1 = itemsIp6.getOrNull(0) ?: "" + ipv6Address2 = itemsIp6.getOrNull(1) ?: "" + urlV6Address1 = itemsUrl6.getOrNull(0)?.split(URL_SEGMENT6)?.firstOrNull() ?: Constants.urlV6probes[0] + urlV6Address2 = itemsUrl6.getOrNull(1)?.split(URL_SEGMENT6)?.firstOrNull() ?: Constants.urlV6probes[1] + errorMessage = "" + setAllStatusIconsVisibility(false) + setAllProgressBarsVisibility(false) + } - binding.urlV4Layout1.visibility = View.VISIBLE - binding.urlV6Layout1.visibility = View.VISIBLE - binding.urlV4Layout2.visibility = View.VISIBLE - binding.urlV6Layout2.visibility = View.VISIBLE - - binding.ipv4Address1.apply { isEnabled = true; setText(itemsIp4.getOrNull(0) ?: "") } - binding.ipv4Address2.apply { isEnabled = true; setText(itemsIp4.getOrNull(1) ?: "") } - binding.urlV4Address1.apply { isEnabled = true; setText(itemsUrl4.getOrNull(0)?.split(URL_SEGMENT4)?.firstOrNull() ?: Constants.urlV4probes[0]) } - binding.urlV4Address2.apply { isEnabled = true; setText(itemsUrl4.getOrNull(1)?.split(URL_SEGMENT4)?.firstOrNull() ?: Constants.urlV4probes[0]) } - binding.ipv6Address1.apply { isEnabled = true; setText(itemsIp6.getOrNull(0) ?: "") } - binding.ipv6Address2.apply { isEnabled = true; setText(itemsIp6.getOrNull(1) ?: "") } - binding.urlV6Address1.apply { isEnabled = true; setText(itemsUrl6.getOrNull(0)?.split(URL_SEGMENT6)?.firstOrNull() ?: Constants.urlV6probes[0]) } - binding.urlV6Address2.apply { isEnabled = true; setText(itemsUrl6.getOrNull(1)?.split(URL_SEGMENT6)?.firstOrNull() ?: Constants.urlV6probes[1]) } + fun resetToDefaults() { + ipv4Address1 = Constants.ip4probes[0] + ipv4Address2 = Constants.ip4probes[1] + urlV4Address1 = Constants.urlV4probes[0].split(URL_SEGMENT4).firstOrNull() ?: Constants.urlV4probes[0] + urlV4Address2 = Constants.urlV4probes[1].split(URL_SEGMENT4).firstOrNull() ?: Constants.urlV4probes[1] + ipv6Address1 = Constants.ip6probes[0] + ipv6Address2 = Constants.ip6probes[1] + urlV6Address1 = Constants.urlV6probes[0].split(URL_SEGMENT6).firstOrNull() ?: Constants.urlV6probes[0] + urlV6Address2 = Constants.urlV6probes[1].split(URL_SEGMENT6).firstOrNull() ?: Constants.urlV6probes[1] + errorMessage = "" + setAllStatusIconsVisibility(false) + setAllProgressBarsVisibility(false) + } - setAllStatusIconsVisibility(View.GONE) - setAllProgressBarsVisibility(View.GONE) - binding.errorMessage.visibility = View.GONE + fun updateButtonsEnabled(enabled: Boolean) { + buttonsEnabled = enabled } - private fun resetToDefaults() { - binding.ipv4Address1.setText(Constants.ip4probes[0]) - binding.ipv4Address2.setText(Constants.ip4probes[1]) - binding.urlV4Address1.setText(Constants.urlV4probes[0].split(URL_SEGMENT4).firstOrNull() ?: Constants.urlV4probes[0]) - binding.urlV4Address2.setText(Constants.urlV4probes[1].split(URL_SEGMENT4).firstOrNull() ?: Constants.urlV4probes[1]) - - binding.ipv6Address1.setText(Constants.ip6probes[0]) - binding.ipv6Address2.setText(Constants.ip6probes[1]) - binding.urlV6Address1.setText(Constants.urlV6probes[0].split(URL_SEGMENT6).firstOrNull() ?: Constants.urlV6probes[0]) - binding.urlV6Address2.setText(Constants.urlV6probes[1].split(URL_SEGMENT6).firstOrNull() ?: Constants.urlV6probes[1]) - binding.errorMessage.visibility = View.GONE - setAllStatusIconsVisibility(View.GONE) - setAllProgressBarsVisibility(View.GONE) + fun updateStatusIcons(results: Map) { + statusIpv41 = results["ipv4_1"] + statusIpv42 = results["ipv4_2"] + statusUrlV41 = results["url4_1"] + statusUrlV42 = results["url4_2"] + statusIpv61 = results["ipv6_1"] + statusIpv62 = results["ipv6_2"] + statusUrlV61 = results["url6_1"] + statusUrlV62 = results["url6_2"] + setAllStatusIconsVisibility(true) } - private fun testConnections() { - setButtonsEnabled(false) - setAllProgressBarsVisibility(View.VISIBLE) - setAllStatusIconsVisibility(View.GONE) - binding.errorMessage.visibility = View.GONE + fun testConnections() { + updateButtonsEnabled(false) + setAllProgressBarsVisibility(true) + setAllStatusIconsVisibility(false) + errorMessage = "" - io { + scope.launch(Dispatchers.IO) { try { val results = mutableMapOf() val v41 = - if (useAuto) ConnectionMonitor.SCHEME_IP + ":" + ConnectionMonitor.PROTOCOL_V4 else binding.ipv4Address1.text.toString() + if (useAuto) ConnectionMonitor.SCHEME_IP + ":" + ConnectionMonitor.PROTOCOL_V4 else ipv4Address1 val v42 = - if (useAuto) ConnectionMonitor.SCHEME_HTTPS + ":" + ConnectionMonitor.PROTOCOL_V4 else binding.ipv4Address2.text.toString() + if (useAuto) ConnectionMonitor.SCHEME_HTTPS + ":" + ConnectionMonitor.PROTOCOL_V4 else ipv4Address2 val v61 = - if (useAuto) ConnectionMonitor.SCHEME_IP + ":" + ConnectionMonitor.PROTOCOL_V6 else binding.ipv6Address1.text.toString() + if (useAuto) ConnectionMonitor.SCHEME_IP + ":" + ConnectionMonitor.PROTOCOL_V6 else ipv6Address1 val v62 = - if (useAuto) ConnectionMonitor.SCHEME_HTTPS + ":" + ConnectionMonitor.PROTOCOL_V6 else binding.ipv6Address2.text.toString() + if (useAuto) ConnectionMonitor.SCHEME_HTTPS + ":" + ConnectionMonitor.PROTOCOL_V6 else ipv6Address2 - results["ipv4_1"] = probeIpOrUrl(v41) - results["ipv4_2"] = probeIpOrUrl(v42) + results["ipv4_1"] = probeIpOrUrl(v41, useAuto) + results["ipv4_2"] = probeIpOrUrl(v42, useAuto) if (!useAuto) { - results["url4_1"] = probeIpOrUrl(binding.urlV4Address1.text.toString() + URL_SEGMENT4) - results["url4_2"] = probeIpOrUrl(binding.urlV4Address2.text.toString() + URL_SEGMENT4) + results["url4_1"] = probeIpOrUrl(urlV4Address1 + URL_SEGMENT4, useAuto) + results["url4_2"] = probeIpOrUrl(urlV4Address2 + URL_SEGMENT4, useAuto) } - results["ipv6_1"] = probeIpOrUrl(v61) - results["ipv6_2"] = probeIpOrUrl(v62) + results["ipv6_1"] = probeIpOrUrl(v61, useAuto) + results["ipv6_2"] = probeIpOrUrl(v62, useAuto) if (!useAuto) { - results["url6_1"] = probeIpOrUrl(binding.urlV6Address1.text.toString() + URL_SEGMENT6) - results["url6_2"] = probeIpOrUrl(binding.urlV6Address2.text.toString() + URL_SEGMENT6) + results["url6_1"] = probeIpOrUrl(urlV6Address1 + URL_SEGMENT6, useAuto) + results["url6_2"] = probeIpOrUrl(urlV6Address2 + URL_SEGMENT6, useAuto) } - uiCtx { - setAllProgressBarsVisibility(View.GONE) + withContext(Dispatchers.Main) { + setAllProgressBarsVisibility(false) updateStatusIcons(results) - setButtonsEnabled(true) + updateButtonsEnabled(true) } } catch (e: Exception) { - Logger.e(LOG_TAG_UI , "NwReachability; testConnections error: ${e.message}", e) - uiCtx { - binding.errorMessage.text = - context.getString(R.string.blocklist_update_check_failure) - binding.errorMessage.visibility = View.VISIBLE - setAllProgressBarsVisibility(View.GONE) - setButtonsEnabled(true) + Logger.e(LOG_TAG_UI, "NwReachability; testConnections error: ${e.message}", e) + withContext(Dispatchers.Main) { + errorMessage = context.getString(R.string.blocklist_update_check_failure) + setAllProgressBarsVisibility(false) + updateButtonsEnabled(true) } } } } - private suspend fun probeIpOrUrl(ipOrUrl: String): ConnectionMonitor.ProbeResult? { - return try { - VpnController.probeIpOrUrl(ipOrUrl, useAuto) - } catch (e: Exception) { - Logger.d(LOG_TAG_UI, "NwReachability; probeIpOrUrl err: ${e.message}") - null - } - } - - private fun updateStatusIcons(results: Map) { - binding.statusIpv41.setImageDrawable(getDrawableForProbeResult(results["ipv4_1"])) - binding.statusIpv42.setImageDrawable(getDrawableForProbeResult(results["ipv4_2"])) - binding.statusUrlV41.setImageDrawable(getDrawableForProbeResult(results["url4_1"])) - binding.statusUrlV42.setImageDrawable(getDrawableForProbeResult(results["url4_2"])) - binding.statusIpv61.setImageDrawable(getDrawableForProbeResult(results["ipv6_1"])) - binding.statusIpv62.setImageDrawable(getDrawableForProbeResult(results["ipv6_2"])) - binding.statusUrlV61.setImageDrawable(getDrawableForProbeResult(results["url6_1"])) - binding.statusUrlV62.setImageDrawable(getDrawableForProbeResult(results["url6_2"])) - setAllStatusIconsVisibility(View.VISIBLE) - } - - private fun getDrawableForProbeResult(probeResult: ConnectionMonitor.ProbeResult?): Drawable? { - val failureDrawable = ContextCompat.getDrawable(context, R.drawable.ic_cross_accent) - if (probeResult == null || !probeResult.ok) return failureDrawable - - val cap = probeResult.capabilities - val resId = when { - cap?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> R.drawable.ic_firewall_wifi_on - cap?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> R.drawable.ic_firewall_data_on - else -> R.drawable.ic_tick - } - val drawable = ContextCompat.getDrawable(context, resId) ?: failureDrawable - drawable?.setTint(UIUtils.fetchColor(context, R.attr.accentGood)) - return drawable - } - - private fun setAllStatusIconsVisibility(visibility: Int) { - binding.statusIpv41.visibility = visibility - binding.statusIpv42.visibility = visibility - binding.statusUrlV41.visibility = visibility - binding.statusUrlV42.visibility = visibility - binding.statusIpv61.visibility = visibility - binding.statusIpv62.visibility = visibility - binding.statusUrlV61.visibility = visibility - binding.statusUrlV62.visibility = visibility - } - - private fun setAllProgressBarsVisibility(visibility: Int) { - binding.progressIpv41.visibility = visibility - binding.progressIpv42.visibility = visibility - binding.progressUrlV41.visibility = visibility - binding.progressUrlV42.visibility = visibility - binding.progressIpv61.visibility = visibility - binding.progressIpv62.visibility = visibility - binding.progressUrlV61.visibility = visibility - binding.progressUrlV62.visibility = visibility - } - - private fun setButtonsEnabled(enabled: Boolean) { - binding.testButton.isEnabled = enabled - binding.saveButton.isEnabled = enabled - } - - private fun saveIps() { - val defaultDrawable = ContextCompat.getDrawable(context, R.drawable.edittext_default) - val errorDrawable = ContextCompat.getDrawable(context, R.drawable.edittext_error) + fun saveIps() { if (!useAuto) { - val valid41 = isValidIp(binding.ipv4Address1.text.toString(), IPVersion.IPV4) - val valid42 = isValidIp(binding.ipv4Address2.text.toString(), IPVersion.IPV4) - val validUrl41 = isValidUrl(binding.urlV4Address1.text.toString()) - val validUrl42 = isValidUrl(binding.urlV4Address2.text.toString()) - val valid61 = isValidIp(binding.ipv6Address1.text.toString(), IPVersion.IPV6) - val valid62 = isValidIp(binding.ipv6Address2.text.toString(), IPVersion.IPV6) - val validUrl61 = isValidUrl(binding.urlV6Address1.text.toString()) - val validUrl62 = isValidUrl(binding.urlV6Address2.text.toString()) - - binding.ipv4Address1.background = if (valid41) defaultDrawable else errorDrawable - binding.ipv4Address2.background = if (valid42) defaultDrawable else errorDrawable - binding.urlV4Address1.background = if (validUrl41) defaultDrawable else errorDrawable - binding.urlV4Address2.background = if (validUrl42) defaultDrawable else errorDrawable - binding.ipv6Address1.background = if (valid61) defaultDrawable else errorDrawable - binding.ipv6Address2.background = if (valid62) defaultDrawable else errorDrawable - binding.urlV6Address1.background = if (validUrl61) defaultDrawable else errorDrawable - binding.urlV6Address2.background = if (validUrl62) defaultDrawable else errorDrawable - - if (!valid41 || !valid42 || !validUrl41 || !validUrl42 || !valid61 || !valid62 || !validUrl61 || !validUrl62) { - binding.errorMessage.text = context.getString(R.string.cd_dns_proxy_error_text_1) - binding.errorMessage.visibility = View.VISIBLE + val valid41 = isValidIp(ipv4Address1, IPVersion.IPV4) + val valid42 = isValidIp(ipv4Address2, IPVersion.IPV4) + val validUrl41 = isValidUrl(urlV4Address1) + val validUrl42 = isValidUrl(urlV4Address2) + val valid61 = isValidIp(ipv6Address1, IPVersion.IPV6) + val valid62 = isValidIp(ipv6Address2, IPVersion.IPV6) + val validUrl61 = isValidUrl(urlV6Address1) + val validUrl62 = isValidUrl(urlV6Address2) + + if (!valid41 || !valid42 || !validUrl41 || !validUrl42 || !valid61 || !valid62 || !validUrl61 || !validUrl62) { + errorMessage = context.getString(R.string.cd_dns_proxy_error_text_1) return } } - val ip4 = listOf( - binding.ipv4Address1.text.toString(), - binding.ipv4Address2.text.toString() - ) - val ip6 = listOf( - binding.ipv6Address1.text.toString(), - binding.ipv6Address2.text.toString() - ) - val url4Txt1 = if (binding.urlV4Address1.text.toString().contains(URL_SEGMENT4)) { - binding.urlV4Address1.text.toString() - } else { - binding.urlV4Address1.text.toString() + URL_SEGMENT4 - } - val url4Txt2 = if (binding.urlV4Address2.text.toString().contains(URL_SEGMENT4)) { - binding.urlV4Address2.text.toString() - } else { - binding.urlV4Address2.text.toString() + URL_SEGMENT4 - } - val url6Txt1 = if (binding.urlV6Address1.text.toString().contains(URL_SEGMENT6)) { - binding.urlV6Address1.text.toString() - } else { - binding.urlV6Address1.text.toString() + URL_SEGMENT6 - } - val url6Txt2 = if (binding.urlV6Address2.text.toString().contains(URL_SEGMENT6)) { - binding.urlV6Address2.text.toString() - } else { - binding.urlV6Address2.text.toString() + URL_SEGMENT6 - } + val ip4 = listOf(ipv4Address1, ipv4Address2) + val ip6 = listOf(ipv6Address1, ipv6Address2) + val url4Txt1 = if (urlV4Address1.contains(URL_SEGMENT4)) urlV4Address1 else urlV4Address1 + URL_SEGMENT4 + val url4Txt2 = if (urlV4Address2.contains(URL_SEGMENT4)) urlV4Address2 else urlV4Address2 + URL_SEGMENT4 + val url6Txt1 = if (urlV6Address1.contains(URL_SEGMENT6)) urlV6Address1 else urlV6Address1 + URL_SEGMENT6 + val url6Txt2 = if (urlV6Address2.contains(URL_SEGMENT6)) urlV6Address2 else urlV6Address2 + URL_SEGMENT6 val url4Txt = listOf(url4Txt1, url4Txt2) val url6Txt = listOf(url6Txt1, url6Txt2) val isSame = persistentState.pingv4Ips == ip4.joinToString(",") && - persistentState.pingv6Ips == ip6.joinToString(",") && - persistentState.pingv4Url == url4Txt.joinToString(",") && - persistentState.pingv6Url == url6Txt.joinToString(",") + persistentState.pingv6Ips == ip6.joinToString(",") && + persistentState.pingv4Url == url4Txt.joinToString(",") && + persistentState.pingv6Url == url6Txt.joinToString(",") if (isSame) { - dismiss() + onDismiss() return } persistentState.pingv4Ips = ip4.joinToString(",") @@ -384,52 +283,268 @@ class NetworkReachabilityDialog(activity: Activity, context.getString(R.string.config_add_success_toast), Toast.LENGTH_LONG ).show() - io { + scope.launch(Dispatchers.IO) { VpnController.notifyConnectionMonitor() } - dismiss() + onDismiss() } - private fun io(fn: suspend () -> Unit) { - lifecycleScope.launch(Dispatchers.IO) { fn() } + LaunchedEffect(Unit) { + useAuto = persistentState.performAutoNetworkConnectivityChecks + if (useAuto) { + updateAutoModeUi() + } else { + updateManualModeUi() + } + setAllStatusIconsVisibility(false) + setAllProgressBarsVisibility(false) + errorMessage = "" } - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } + RuleSheetModal(onDismissRequest = onDismiss) { + val protocols = VpnController.protocols() + Column( + modifier = + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingLg + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + RethinkBottomSheetCard { + RuleSheetModeToggle( + autoLabel = context.getString(R.string.settings_ip_text_ipv46), + manualLabel = context.getString(R.string.lbl_manual), + isAutoSelected = useAuto, + onAutoClick = { + useAuto = true + updateAutoModeUi() + }, + onManualClick = { + useAuto = false + updateManualModeUi() + } + ) + Text( + text = context.getString(R.string.bypasses_network_restrictions), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - private fun isValidIp(ipString: String, type: IPVersion): Boolean { - return try { - val addr = IPAddressString(ipString).toAddress() - when { - type.isIPv4 -> addr.isIPv4 - type.isIPv6 -> addr.isIPv6 - else -> false + RethinkBottomSheetCard { + ProtocolHeaderRow( + title = context.getString(R.string.settings_ip_text_ipv4), + isSupported = protocols.contains(URL4) + ) { + if (!useAuto) { + TextButton(onClick = { resetToDefaults() }) { + Text(text = context.getString(R.string.brbs_restore_title)) + } + } + } + AddressRow( + value = ipv4Address1, + onValueChange = { ipv4Address1 = it }, + enabled = !useAuto, + progress = progressIpv41, + result = statusIpv41 + ) + AddressRow( + value = ipv4Address2, + onValueChange = { ipv4Address2 = it }, + enabled = !useAuto, + progress = progressIpv42, + result = statusIpv42 + ) + if (!useAuto) { + AddressRow( + value = urlV4Address1, + onValueChange = { urlV4Address1 = it }, + enabled = true, + progress = progressUrlV41, + result = statusUrlV41 + ) + AddressRow( + value = urlV4Address2, + onValueChange = { urlV4Address2 = it }, + enabled = true, + progress = progressUrlV42, + result = statusUrlV42 + ) + } + } + + RethinkBottomSheetCard { + ProtocolHeaderRow( + title = context.getString(R.string.settings_ip_text_ipv6), + isSupported = protocols.contains(URL6) + ) + AddressRow( + value = ipv6Address1, + onValueChange = { ipv6Address1 = it }, + enabled = !useAuto, + progress = progressIpv61, + result = statusIpv61 + ) + AddressRow( + value = ipv6Address2, + onValueChange = { ipv6Address2 = it }, + enabled = !useAuto, + progress = progressIpv62, + result = statusIpv62 + ) + if (!useAuto) { + AddressRow( + value = urlV6Address1, + onValueChange = { urlV6Address1 = it }, + enabled = true, + progress = progressUrlV61, + result = statusUrlV61 + ) + AddressRow( + value = urlV6Address2, + onValueChange = { urlV6Address2 = it }, + enabled = true, + progress = progressUrlV62, + result = statusUrlV62 + ) + } + } + + RethinkBottomSheetActionRow( + primaryText = context.getString(R.string.lbl_save), + onPrimaryClick = { saveIps() }, + primaryEnabled = buttonsEnabled, + secondaryText = context.getString(R.string.lbl_test), + onSecondaryClick = { testConnections() }, + secondaryEnabled = buttonsEnabled, + secondaryStyle = RethinkSecondaryActionStyle.TEXT + ) + + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = Dimensions.spacingXs) + ) } - } catch (_: Exception) { - false } } +} + +@Composable +private fun ProtocolHeaderRow( + title: String, + isSupported: Boolean, + trailing: @Composable (() -> Unit)? = null +) { + RuleSheetLabeledControlRow( + label = { + Row(horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm)) { + StatusIcon(isOk = isSupported) + Text(text = title) + } + }, + control = trailing, + horizontalPadding = Dimensions.spacingNone, + controlWeight = 0.7f + ) +} + +@Composable +private fun StatusIcon(isOk: Boolean) { + val icon = + if (isOk) { + R.drawable.ic_tick + } else { + R.drawable.ic_cross_accent + } + androidx.compose.material3.Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = if (isOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) +} - private fun isValidUrl(url: String): Boolean { - return try { - val parsed = URL(url) - (parsed.protocol == SCHEME_HTTPS || parsed.protocol == SCHEME_HTTP) && - parsed.host.isNotEmpty() && - parsed.query == null && - parsed.ref == null - } catch (e: MalformedURLException) { - false +@Composable +private fun AddressRow( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + progress: Boolean, + result: ConnectionMonitor.ProbeResult? +) { + val trailingContent: (@Composable (() -> Unit))? = + when { + progress -> { + { CircularProgressIndicator() } + } + result != null -> { + { + val resId = getDrawableForProbeResult(result) + androidx.compose.material3.Icon( + painter = painterResource(id = resId), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + else -> null } + + RuleSheetTextFieldRow( + value = value, + onValueChange = onValueChange, + enabled = enabled, + trailing = trailingContent + ) +} + +private fun getDrawableForProbeResult(probeResult: ConnectionMonitor.ProbeResult?): Int { + if (probeResult == null || !probeResult.ok) return R.drawable.ic_cross_accent + + val cap = probeResult.capabilities + return when { + cap?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> R.drawable.ic_firewall_wifi_on + cap?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> R.drawable.ic_firewall_data_on + else -> R.drawable.ic_tick } +} + +private suspend fun probeIpOrUrl(ipOrUrl: String, useAuto: Boolean): ConnectionMonitor.ProbeResult? { + return try { + VpnController.probeIpOrUrl(ipOrUrl, useAuto) + } catch (e: Exception) { + Logger.d(LOG_TAG_UI, "NwReachability; probeIpOrUrl err: ${e.message}") + null + } +} - private fun selectToggleBtnUi(mb: MaterialButton) { - mb.backgroundTintList = ColorStateList.valueOf(UIUtils.fetchToggleBtnColors(context, R.color.accentGood)) - mb.setTextColor(UIUtils.fetchColor(context, R.attr.homeScreenHeaderTextColor)) +private fun isValidIp(ipString: String, type: IPVersion): Boolean { + return try { + val addr = IPAddressString(ipString).toAddress() + when { + type.isIPv4 -> addr.isIPv4 + type.isIPv6 -> addr.isIPv6 + else -> false + } + } catch (_: Exception) { + false } +} - private fun unselectToggleBtnUi(mb: MaterialButton) { - mb.setTextColor(UIUtils.fetchColor(context, R.attr.primaryTextColor)) - mb.backgroundTintList = ColorStateList.valueOf(UIUtils.fetchToggleBtnColors(context, R.color.defaultToggleBtnBg)) +private fun isValidUrl(url: String): Boolean { + return try { + val parsed = URL(url) + (parsed.protocol == SCHEME_HTTPS || parsed.protocol == SCHEME_HTTP) && + parsed.host.isNotEmpty() && + parsed.query == null && + parsed.ref == null + } catch (e: MalformedURLException) { + false } } 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 429c089e6..ecd4504e3 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 @@ -1,141 +1,129 @@ package com.celzero.bravedns.ui.dialog -import android.graphics.Color -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.graphics.drawable.toDrawable -import androidx.fragment.app.DialogFragment -import by.kirich1409.viewbindingdelegate.viewBinding -import com.celzero.bravedns.R -import com.celzero.bravedns.databinding.DialogSubscriptionAnimBinding -import nl.dionsegijn.konfetti.core.Angle -import nl.dionsegijn.konfetti.core.Party -import nl.dionsegijn.konfetti.core.Position -import nl.dionsegijn.konfetti.core.Rotation -import nl.dionsegijn.konfetti.core.emitter.Emitter -import nl.dionsegijn.konfetti.core.models.Shape -import nl.dionsegijn.konfetti.core.models.Size -import java.util.concurrent.TimeUnit - -class SubscriptionAnimDialog : DialogFragment() { - private val b by viewBinding(DialogSubscriptionAnimBinding::bind) - - companion object { - // Dialog display duration - private const val DIALOG_DISPLAY_DURATION_MS = 2000L - - // Konfetti animation constants - private const val PARTY_SPEED_DEFAULT = 30f - private const val PARTY_MAX_SPEED_DEFAULT = 50f - private const val PARTY_DAMPING = 0.9f - private const val PARTY_SPREAD_DEFAULT = 45 - private const val PARTY_TIME_TO_LIVE_MS = 3000L - private const val PARTY_EMITTER_DURATION_MS = 100L - private const val PARTY_EMITTER_MAX_DEFAULT = 30 - - // Speed variations for party copies - private const val PARTY_SPEED_VARIANT_1 = 55f - private const val PARTY_MAX_SPEED_VARIANT_1 = 65f - private const val PARTY_SPREAD_VARIANT = 10 - private const val PARTY_EMITTER_MAX_VARIANT = 10 - - private const val PARTY_SPEED_VARIANT_2 = 65f - private const val PARTY_MAX_SPEED_VARIANT_2 = 80f - - // Position constants - private const val POSITION_X_CENTER = 0.5 - private const val POSITION_Y_BOTTOM = 1.0 - - private const val ARG_TITLE = "arg_title" - private const val ARG_MESSAGE = "arg_message" - - /** - * Creates a [SubscriptionAnimDialog] with optional [title] and [message] overlaid on the - * konfetti animation. Pass null to leave the text fields empty (default/normal flow). - */ - fun newInstance(title: String? = null, message: String? = null): SubscriptionAnimDialog { - return SubscriptionAnimDialog().apply { - if (title != null || message != null) { - arguments = Bundle().apply { - title?.let { putString(ARG_TITLE, it) } - message?.let { putString(ARG_MESSAGE, it) } - } - } +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random +import kotlinx.coroutines.delay + +@Composable +fun SubscriptionAnimDialog(onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box(modifier = Modifier.fillMaxSize()) { + ConfettiOverlay() + LaunchedEffect(Unit) { + delay(DIALOG_DISPLAY_DURATION_MS) + onDismiss() } } } +} - - 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? - ): View? { - return inflater.inflate(R.layout.dialog_subscription_anim, container, false) - } - - override fun onStart() { - super.onStart() - dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) - dialog?.setCancelable(true) - - // Apply optional title/message passed via arguments - arguments?.getString(ARG_TITLE)?.let { b.tvTitle.text = it } - arguments?.getString(ARG_MESSAGE)?.let { b.tvMessage.text = it } - - b.konfettiView.start(festive()) - // 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 { - val party = Party( - speed = PARTY_SPEED_DEFAULT, - maxSpeed = PARTY_MAX_SPEED_DEFAULT, - damping = PARTY_DAMPING, - angle = Angle.TOP, - spread = PARTY_SPREAD_DEFAULT, - size = listOf(Size.SMALL, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE), - shapes = listOf(Shape.Square, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle), - timeToLive = PARTY_TIME_TO_LIVE_MS, - rotation = Rotation(), - colors = listOf(0xf0efe4, 0xe6e5de, 0xf4306d, 0xfbfbf7, 0xd8d6c2, 0xf0efe4, 0xe6e5de, 0xf4306d, 0xfbfbf7, 0xd8d6c2), - emitter = Emitter(duration = PARTY_EMITTER_DURATION_MS, TimeUnit.MILLISECONDS).max(PARTY_EMITTER_MAX_DEFAULT), - position = Position.Relative(POSITION_X_CENTER, POSITION_Y_BOTTOM) - ) - - return listOf( - party, - party.copy( - speed = PARTY_SPEED_VARIANT_1, - maxSpeed = PARTY_MAX_SPEED_VARIANT_1, - spread = PARTY_SPREAD_VARIANT, - emitter = Emitter(duration = PARTY_EMITTER_DURATION_MS, TimeUnit.MILLISECONDS).max(PARTY_EMITTER_MAX_VARIANT), - ), - party.copy( - speed = PARTY_SPEED_VARIANT_2, - maxSpeed = PARTY_MAX_SPEED_VARIANT_2, - spread = PARTY_SPREAD_VARIANT, - emitter = Emitter(duration = PARTY_EMITTER_DURATION_MS, TimeUnit.MILLISECONDS).max(PARTY_EMITTER_MAX_VARIANT), +private const val DIALOG_DISPLAY_DURATION_MS = 2000L +private const val CONFETTI_COUNT = 90 +private const val CONFETTI_DURATION_MS = 1600 +private const val CONFETTI_SPAWN_Y = 1.05f +private const val CONFETTI_GRAVITY = 0.55f + +@Composable +private fun ConfettiOverlay() { + val palette = + remember { + listOf( + Color(0xfff0efe4), + Color(0xffe6e5de), + Color(0xfff4306d), + Color(0xfffbfbf7), + Color(0xffd8d6c2) ) + } + val particles = + remember { + val random = Random(42) + List(CONFETTI_COUNT) { + ConfettiParticle( + angle = random.nextFloat() * 80f + 50f, + speed = random.nextFloat() * 220f + 420f, + size = random.nextFloat() * 8f + 6f, + color = palette[random.nextInt(palette.size)], + spin = random.nextFloat() * 360f, + shape = if (random.nextBoolean()) Shape.Circle else Shape.Square, + drift = random.nextFloat() * 0.4f + 0.1f + ) + } + } + val progress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = + tween( + durationMillis = CONFETTI_DURATION_MS, + easing = LinearEasing + ) ) } + Canvas(modifier = Modifier.fillMaxSize()) { + val time = progress.value + val width = size.width + val height = size.height + particles.forEachIndexed { index, p -> + val theta = Math.toRadians(p.angle.toDouble()) + val vx = cos(theta).toFloat() * p.speed + val vy = -sin(theta).toFloat() * p.speed + val t = time + (index % 10) * 0.01f + val x = width * 0.5f + vx * t + (t * t) * (p.drift * width * 0.02f) + val y = height * CONFETTI_SPAWN_Y + vy * t + (t * t) * (CONFETTI_GRAVITY * height * 0.2f) + val rotation = p.spin * t * 1.2f + rotate(rotation, pivot = Offset(x, y)) { + when (p.shape) { + Shape.Circle -> + drawCircle( + color = p.color, + radius = p.size, + center = Offset(x, y) + ) + Shape.Square -> + drawRect( + color = p.color, + topLeft = Offset(x - p.size, y - p.size), + size = Size(p.size * 2f, p.size * 2f) + ) + } + } + } + } +} +private data class ConfettiParticle( + val angle: Float, + val speed: Float, + val size: Float, + val color: Color, + val spin: Float, + val shape: Shape, + val drift: Float +) + +private enum class Shape { + Circle, + Square } diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt index e6dc89a80..6f174ae97 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt @@ -15,186 +15,196 @@ */ package com.celzero.bravedns.ui.dialog -import Logger -import android.app.Activity -import android.app.Dialog -import android.os.Bundle -import android.view.View -import android.view.Window -import android.view.WindowManager import android.widget.Toast -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import com.celzero.bravedns.R -import com.celzero.bravedns.databinding.DialogWgAddPeerBinding import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.ui.bottomsheet.RuleSheetTextFieldRow +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetActionRow +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard +import com.celzero.bravedns.ui.compose.theme.RethinkSecondaryActionStyle import com.celzero.bravedns.util.UIUtils.getDurationInHumanReadableFormat import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.tos import com.celzero.bravedns.wireguard.Peer import com.celzero.bravedns.wireguard.util.ErrorMessages +import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class WgAddPeerDialog( - private val activity: Activity, - themeID: Int, - private var configId: Int, - private val wgPeer: Peer? -) : Dialog(activity, themeID) { - - private lateinit var b: DialogWgAddPeerBinding - - private var isEditing = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - requestWindowFeature(Window.FEATURE_NO_TITLE) - b = DialogWgAddPeerBinding.inflate(layoutInflater) - setContentView(b.root) - initView() - setupClickListener() - } - - private fun initView() { - window?.setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.MATCH_PARENT - ) - setupAutoExpand(b.peerAllowedIps) - - if (wgPeer != null) { - isEditing = true - b.peerPublicKey.setText(wgPeer.getPublicKey().base64()) - if (wgPeer.getPreSharedKey().isPresent) { - b.peerPresharedKey.setText(wgPeer.getPreSharedKey().get().base64()) +@Composable +fun WgAddPeerDialog( + configId: Int, + wgPeer: Peer?, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val isEditing = wgPeer != null + + WgDialog(onDismissRequest = onDismiss) { + WgDialogColumn( + scrollable = true, + verticalSpacing = Dimensions.spacingMd + ) { + var publicKey by remember { mutableStateOf(wgPeer?.getPublicKey()?.base64()?.tos().orEmpty()) } + var presharedKey by remember { + mutableStateOf( + if (wgPeer?.getPreSharedKey()?.isPresent == true) { + wgPeer.getPreSharedKey().get().base64().tos().orEmpty() + } else { + "" + } + ) } - b.peerAllowedIps.setText(wgPeer.getAllowedIps().joinToString { it.toString() }) - if (wgPeer.getEndpoint().isPresent) { - b.peerEndpoint.setText(wgPeer.getEndpoint().get().toString()) + var allowedIps by remember { + mutableStateOf(wgPeer?.getAllowedIps()?.joinToString { it.toString() }.orEmpty()) } - if (wgPeer.persistentKeepalive.isPresent) { - val kas = wgPeer.persistentKeepalive.get() - b.keepAliveHint.visibility = View.VISIBLE - b.peerPersistentKeepAlive.setText(kas.toString()) - b.keepAliveHint.text = getDurationInHumanReadableFormat(activity, kas) - } else { - b.keepAliveHint.visibility = View.GONE + var endpoint by remember { + mutableStateOf( + if (wgPeer?.getEndpoint()?.isPresent == true) { + wgPeer.getEndpoint().get().toString() + } else { + "" + } + ) } - } - // re-measure after setting text - b.root.post { - triggerExpandNow() - } - } - - private fun triggerExpandNow() { - listOf(b.peerAllowedIps).forEach { adjustMaxLines(it) } - } - - private fun setupAutoExpand(et: com.google.android.material.textfield.TextInputEditText) { - et.setHorizontallyScrolling(false) - et.maxLines = 4 // initial cap - et.doOnTextChanged { _, _, _, _ -> adjustMaxLines(et) } - } - - private fun adjustMaxLines(et: com.google.android.material.textfield.TextInputEditText) { - // post to ensure lineCount updated after layout - et.post { - val lines = et.lineCount - val threshold = 4 - val hardCap = 12 - if (lines > threshold) { - et.maxLines = minOf(lines, hardCap) + var keepAlive by remember { + mutableStateOf( + if (wgPeer?.persistentKeepalive?.isPresent == true) { + wgPeer.persistentKeepalive.get().toString() + } else { + "" + } + ) } - } - } - - private fun setupClickListener() { - b.customDialogDismissButton.setOnClickListener { this.dismiss() } - - b.peerPersistentKeepAlive.doOnTextChanged { text, _, _, _ -> - if (text.toString().isNotEmpty()) { - try { - val kas = text.toString().toInt() - b.keepAliveHint.visibility = View.VISIBLE - b.keepAliveHint.text = getDurationInHumanReadableFormat(activity, kas) - } catch (_: NumberFormatException) { - b.keepAliveHint.visibility = View.GONE - } - } else { - b.keepAliveHint.visibility = View.GONE + var keepAliveHint by remember { + mutableStateOf( + if (wgPeer?.persistentKeepalive?.isPresent == true) { + getDurationInHumanReadableFormat(context, wgPeer.persistentKeepalive.get()) + } else { + "" + } + ) } - } - - b.customDialogOkButton.setOnClickListener { - b.customDialogOkButton.isEnabled = false - val peerPublicKey = b.peerPublicKey.text.toString() - val presharedKey = b.peerPresharedKey.text.toString() - val peerEndpoint = b.peerEndpoint.text.toString() - val peerPersistentKeepAlive = b.peerPersistentKeepAlive.text.toString() - val allowedIps = b.peerAllowedIps.text.toString() - - try { - val builder = Peer.Builder() - if (allowedIps.isNotEmpty()) builder.parseAllowedIPs(allowedIps) - if (peerEndpoint.isNotEmpty()) builder.parseEndpoint(peerEndpoint) - if (peerPersistentKeepAlive.isNotEmpty()) - builder.parsePersistentKeepalive(peerPersistentKeepAlive) - if (presharedKey.isNotEmpty()) builder.parsePreSharedKey(presharedKey) - if (peerPublicKey.isNotEmpty()) builder.parsePublicKey(peerPublicKey) - val newPeer = builder.build() - - ui { - showSaving() - ioCtx { - if (wgPeer != null && isEditing) - WireguardManager.deletePeer(configId, wgPeer) - WireguardManager.addPeer(configId, newPeer) - } - Utilities.showToastUiCentered( - activity, - context.getString(R.string.config_add_success_toast), - Toast.LENGTH_SHORT + RethinkBottomSheetCard { + Text(text = stringResource(R.string.add_peer), style = MaterialTheme.typography.titleLarge) + RuleSheetTextFieldRow( + value = publicKey, + onValueChange = { publicKey = it }, + label = { Text(text = stringResource(R.string.lbl_public_key)) }, + keyboardType = KeyboardType.Password + ) + RuleSheetTextFieldRow( + value = presharedKey, + onValueChange = { presharedKey = it }, + label = { Text(text = stringResource(R.string.lbl_preshared_key)) }, + keyboardType = KeyboardType.Password + ) + RuleSheetTextFieldRow( + value = keepAlive, + onValueChange = { value -> + keepAlive = value + keepAliveHint = + value.toIntOrNull()?.let { getDurationInHumanReadableFormat(context, it) } + .orEmpty() + }, + label = { Text(text = stringResource(R.string.lbl_persistent_keepalive)) }, + keyboardType = KeyboardType.Number + ) + if (keepAliveHint.isNotBlank()) { + Text( + text = keepAliveHint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - this.dismiss() } - } catch (e: Throwable) { - resetSaveButton() - val ex = Logger.throwableToException(e) - Logger.e(Logger.LOG_TAG_PROXY, "Error while adding peer", ex) - Utilities.showToastUiCentered( - activity, - ErrorMessages[activity, e], - Toast.LENGTH_SHORT + RuleSheetTextFieldRow( + value = endpoint, + onValueChange = { endpoint = it }, + label = { Text(text = stringResource(R.string.parse_error_inet_endpoint)) }, + keyboardType = KeyboardType.Password + ) + RuleSheetTextFieldRow( + value = allowedIps, + onValueChange = { allowedIps = it }, + label = { Text(text = stringResource(R.string.lbl_allowed_ips)) }, + keyboardType = KeyboardType.Text ) - return@setOnClickListener } + RethinkBottomSheetActionRow( + primaryText = stringResource(R.string.lbl_save), + onPrimaryClick = { + scope.launch { + savePeer( + context = context, + configId = configId, + wgPeer = wgPeer, + isEditing = isEditing, + publicKey = publicKey, + presharedKey = presharedKey, + allowedIps = allowedIps, + endpoint = endpoint, + keepAlive = keepAlive, + onSuccess = onDismiss, + onError = { message -> + Utilities.showToastUiCentered(context, message, Toast.LENGTH_SHORT) + } + ) + } + }, + secondaryText = stringResource(R.string.lbl_dismiss), + onSecondaryClick = onDismiss, + secondaryStyle = RethinkSecondaryActionStyle.TEXT + ) } } +} - /** Switch the Save button into a loading state: spinner visible, text dimmed. */ - private fun showSaving() { - b.customDialogOkButton.text = activity.getString(R.string.lbl_saving) - b.customDialogOkButton.isEnabled = false - b.customDialogOkProgress.visibility = View.VISIBLE - } - - /** Restore the Save button to its normal, interactive state. */ - private fun resetSaveButton() { - b.customDialogOkButton.text = activity.getString(R.string.lbl_save) - b.customDialogOkButton.isEnabled = true - b.customDialogOkProgress.visibility = View.GONE - } - - private fun ui(f: suspend () -> Unit) { - (activity as LifecycleOwner).lifecycleScope.launch(Dispatchers.Main) { f() } - } - - private suspend fun ioCtx(f: suspend () -> Unit) { - withContext(Dispatchers.IO) { f() } +private suspend fun savePeer( + context: android.content.Context, + configId: Int, + wgPeer: Peer?, + isEditing: Boolean, + publicKey: String, + presharedKey: String, + allowedIps: String, + endpoint: String, + keepAlive: String, + onSuccess: () -> Unit, + onError: (String) -> Unit +) { + try { + val builder = Peer.Builder() + if (allowedIps.isNotEmpty()) builder.parseAllowedIPs(allowedIps) + if (endpoint.isNotEmpty()) builder.parseEndpoint(endpoint) + if (keepAlive.isNotEmpty()) builder.parsePersistentKeepalive(keepAlive) + if (presharedKey.isNotEmpty()) builder.parsePreSharedKey(presharedKey) + if (publicKey.isNotEmpty()) builder.parsePublicKey(publicKey) + val newPeer = builder.build() + + withContext(Dispatchers.IO) { + if (wgPeer != null && isEditing) { + WireguardManager.deletePeer(configId, wgPeer) + } + WireguardManager.addPeer(configId, newPeer) + } + onSuccess() + } catch (e: Throwable) { + Napier.e("Error while adding peer", e) + onError(ErrorMessages[context, e]) } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgDialogShared.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgDialogShared.kt new file mode 100644 index 000000000..ea251fd59 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgDialogShared.kt @@ -0,0 +1,126 @@ +/* + * 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.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.RadioButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog + +@Composable +internal fun WgDialog( + onDismissRequest: () -> Unit, + useSurface: Boolean = false, + content: @Composable () -> Unit +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + if (useSurface) { + Surface(color = MaterialTheme.colorScheme.background) { + content() + } + } else { + content() + } + } +} + +@Composable +internal fun WgDialogColumn( + modifier: Modifier = Modifier, + verticalSpacing: Dp = Dimensions.spacingMd, + scrollable: Boolean = false, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingLg + ) + .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier), + verticalArrangement = Arrangement.spacedBy(verticalSpacing), + content = content + ) +} + +@Composable +internal fun WgConfirmDialog( + title: String, + message: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + confirmText: String = stringResource(R.string.lbl_include), + isConfirmDestructive: Boolean = false +) { + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = title, + message = message, + confirmText = confirmText, + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = onConfirm, + onDismiss = onDismiss, + isConfirmDestructive = isConfirmDestructive + ) +} + +@Composable +internal fun WgOptionRow( + text: String, + selected: Boolean, + enabled: Boolean, + onSelected: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .wrapContentWidth() + .clickable(enabled = enabled, onClick = onSelected), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selected, + onClick = null, + enabled = enabled + ) + Text(text = text) + } +} 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 973a057a3..4520e25ab 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,52 +15,71 @@ */ package com.celzero.bravedns.ui.dialog -import android.app.Activity -import com.celzero.bravedns.adapter.HopItem +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.HopRow import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetActionRow +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard import com.celzero.bravedns.wireguard.Config +import io.github.aakira.napier.Napier -/** - * Dialog for WireGuard configuration hopping - * Now extends GenericHopDialog to reuse common hop logic - */ -class WgHopDialog( - activity: Activity, - themeID: Int, +@Composable +fun WgHopDialog( srcId: Int, - configs: List, + hopables: 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 + onDismiss: () -> Unit ) { - companion object { - /** - * 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 + val context = LocalContext.current + WgDialog(onDismissRequest = onDismiss) { + val selectedHopId = remember(selectedId) { mutableStateOf(selectedId) } + WgDialogColumn( + modifier = Modifier.fillMaxSize(), + verticalSpacing = Dimensions.spacingSmMd + ) { + Text( + text = stringResource(R.string.hop_add_remove_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + RethinkBottomSheetCard(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + items(hopables) { config -> + val mapping = WireguardManager.getConfigFilesById(config.getId()) ?: return@items + HopRow( + context = context, + srcId = srcId, + config = config, + isActive = mapping.isActive, + selectedId = selectedHopId.value, + onSelectedIdChange = { selectedHopId.value = it } + ) + } + } + } + RethinkBottomSheetActionRow( + primaryText = stringResource(R.string.ada_noapp_dialog_positive), + onPrimaryClick = { + Napier.d("Dismiss hop dialog") + onDismiss() + } ) } } 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 118aa9dc2..fb84dfb4f 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 @@ -15,331 +15,640 @@ */ package com.celzero.bravedns.ui.dialog -import Logger -import Logger.LOG_TAG_PROXY -import android.app.Activity -import android.app.Dialog -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.os.Bundle -import android.view.Window -import android.view.WindowManager -import android.view.animation.Animation -import android.view.animation.RotateAnimation -import android.widget.CompoundButton +import android.graphics.Color as AndroidColor +import android.os.Build import android.widget.Toast -import androidx.appcompat.widget.SearchView -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat import com.celzero.bravedns.R -import com.celzero.bravedns.adapter.WgIncludeAppsAdapter +import com.celzero.bravedns.adapter.IncludeAppRow import com.celzero.bravedns.database.RefreshDatabase -import com.celzero.bravedns.databinding.DialogWgAppsBinding +import com.celzero.bravedns.database.ProxyApplicationMapping +import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.ui.compose.firewall.IndexedFastScroller +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.CompactEmptyState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkSearchField +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel -import com.google.android.material.chip.Chip -import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject - -class WgIncludeAppsDialog( - private var activity: Activity, - internal var adapter: WgIncludeAppsAdapter, - var viewModel: ProxyAppsMappingViewModel, - themeID: Int, - private val proxyId: String, - private val proxyName: String -) : Dialog(activity, themeID), SearchView.OnQueryTextListener, KoinComponent { - - private lateinit var b: DialogWgAppsBinding - - private lateinit var animation: Animation - private val refreshDatabase by inject() - private var filterType: TopLevelFilter = TopLevelFilter.ALL_APPS - private var searchText = "" - - 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 REFRESH_TIMEOUT: Long = 4000 +import java.util.Locale + +@Composable +fun WgIncludeAppsDialog( + viewModel: ProxyAppsMappingViewModel, + proxyId: String, + proxyName: String, + onDismiss: () -> Unit +) { + WgDialog(onDismissRequest = onDismiss) { + WgIncludeAppsDialogScreen( + viewModel = viewModel, + proxyId = proxyId, + proxyName = proxyName, + onDismiss = onDismiss + ) } +} - enum class TopLevelFilter(val id: Int) { - ALL_APPS(0), - SELECTED_APPS(1), - UNSELECTED_APPS(2); +@Composable +fun WgIncludeAppsScreen( + viewModel: ProxyAppsMappingViewModel, + proxyId: String, + proxyName: String, + onDismiss: () -> Unit +) { + WgIncludeAppsDialogScreen( + viewModel = viewModel, + proxyId = proxyId, + proxyName = proxyName, + onDismiss = onDismiss, + inDialog = false + ) +} - fun getLabelId(): Int { - return when (this) { - ALL_APPS -> R.string.lbl_all - SELECTED_APPS -> R.string.rt_filter_parent_selected - UNSELECTED_APPS -> R.string.lbl_unselected - } - } - } +private const val REFRESH_TIMEOUT: Long = 4000 +private val FAST_SCROLLER_LIST_END_PADDING = 32.dp +private val DONE_FAB_CLEARANCE = 112.dp - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - requestWindowFeature(Window.FEATURE_NO_TITLE) - b = DialogWgAppsBinding.inflate(layoutInflater) - setContentView(b.root) - setCancelable(false) - addAnimation() - remakeFirewallChipsUi() - observeApps() - initializeValues() - initializeClickListeners() - } +enum class TopLevelFilter(val id: Int) { + ALL_APPS(0), + SELECTED_APPS(1), + UNSELECTED_APPS(2); - 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 - ) - animation.repeatCount = ANIMATION_REPEAT_COUNT - animation.duration = ANIMATION_DURATION + fun getLabelId(): Int { + return when (this) { + ALL_APPS -> R.string.lbl_all + SELECTED_APPS -> R.string.rt_filter_parent_selected + UNSELECTED_APPS -> R.string.lbl_unselected + } } +} - private fun initializeValues() { - window?.setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.MATCH_PARENT - ) - - val layoutManager = LinearLayoutManager(activity) - b.wgIncludeAppRecyclerViewDialog.layoutManager = layoutManager - b.wgIncludeAppRecyclerViewDialog.adapter = adapter +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun WgIncludeAppsDialogScreen( + viewModel: ProxyAppsMappingViewModel, + proxyId: String, + proxyName: String, + onDismiss: () -> Unit, + inDialog: Boolean = true +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val refreshDatabase = remember { RefreshDatabaseProvider.get() } + var query by remember { mutableStateOf("") } + var selectedFilter by remember { mutableStateOf(TopLevelFilter.ALL_APPS) } + val apps by viewModel.apps.collectAsState(initial = emptyList()) + val allApps by viewModel.allApps.collectAsState(initial = emptyList()) + val listState = rememberLazyListState() + var isDialogVisible by remember { mutableStateOf(true) } + var isRefreshing by remember { mutableStateOf(false) } + var showOverflowMenu by remember { mutableStateOf(false) } + var excludedUids by remember { mutableStateOf>(emptySet()) } + val density = LocalDensity.current + val navBarBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + val showFastScroller = apps.size >= 8 + val fastScrollerKeys = remember(apps) { buildFastScrollerIndexKeys(apps) } + + if (inDialog) { + TransparentDialogSystemBars() + } else { + BackHandler(onBack = onDismiss) } - private fun observeApps() { - // 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, safeCount.toString()) + fun updateInterfaceDetails(mapping: com.celzero.bravedns.database.ProxyApplicationMapping, include: Boolean) { + scope.launch(Dispatchers.IO) { + if (FirewallManager.isAppExcludedFromProxy(mapping.uid)) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.exclude_apps_from_proxy_failure_toast), + Toast.LENGTH_LONG + ) + } + return@launch + } + if (include) { + ProxyManager.updateProxyIdForPackage(mapping.uid, mapping.packageName, proxyId, proxyName) + } else { + ProxyManager.setNoProxyForPackage(mapping.uid, mapping.packageName) + } } } - private fun remakeFirewallChipsUi() { - b.wgIncludeAppDialogChipGroup.removeAllViews() - - val all = - makeFirewallChip( - TopLevelFilter.ALL_APPS.id, - activity.getString(TopLevelFilter.ALL_APPS.getLabelId()), - true - ) - - val selected = - makeFirewallChip( - TopLevelFilter.SELECTED_APPS.id, - activity.getString(TopLevelFilter.SELECTED_APPS.getLabelId()), - false - ) - - val unselected = - makeFirewallChip( - TopLevelFilter.UNSELECTED_APPS.id, - activity.getString(TopLevelFilter.UNSELECTED_APPS.getLabelId()), - false - ) - - b.wgIncludeAppDialogChipGroup.addView(all) - b.wgIncludeAppDialogChipGroup.addView(selected) - b.wgIncludeAppDialogChipGroup.addView(unselected) + fun selectAllApps() { + val appSnapshot = allApps.distinctBy { it.uid to it.packageName } + val excludedSnapshot = excludedUids + scope.launch(Dispatchers.IO) { + // Apply selection in one DB/cache update so the UI reflects quickly. + ProxyManager.setProxyIdForAllApps(proxyId, proxyName) + + // Keep excluded apps out of proxy routing. + if (excludedSnapshot.isNotEmpty()) { + appSnapshot + .asSequence() + .filter { excludedSnapshot.contains(it.uid) } + .forEach { mapping -> + ProxyManager.setNoProxyForPackage(mapping.uid, mapping.packageName) + } + } else { + appSnapshot.forEach { mapping -> + if (FirewallManager.isAppExcludedFromProxy(mapping.uid)) { + ProxyManager.setNoProxyForPackage(mapping.uid, mapping.packageName) + } + } + } + } } - private fun makeFirewallChip(id: Int, label: String, checked: Boolean): Chip { - val chip = this.layoutInflater.inflate(R.layout.item_chip_filter, b.root, false) as Chip - chip.tag = id - chip.text = label - chip.isChecked = checked - - chip.setOnCheckedChangeListener { button: CompoundButton, isSelected: Boolean -> - if (isSelected) { - applyFilter(button.tag) - colorUpChipIcon(chip) - } else { - // no-op - // no action needed for checkState: false + fun unselectAllApps() { + val appSnapshot = allApps.distinctBy { it.uid to it.packageName } + scope.launch(Dispatchers.IO) { + ProxyManager.removeProxyId(proxyId) + // Sweep per app to clear any stale/legacy Orbot mappings missed by id-only bulk update. + appSnapshot.forEach { mapping -> + val isMappedToCurrentProxy = + mapping.proxyId.equals(proxyId, ignoreCase = true) || + mapping.proxyName.equals(proxyName, ignoreCase = true) + if (isMappedToCurrentProxy) { + ProxyManager.setNoProxyForPackage(mapping.uid, mapping.packageName) + } } } + } - return chip + DisposableEffect(Unit) { + onDispose { isDialogVisible = false } } - private fun colorUpChipIcon(chip: Chip) { - val colorFilter = - PorterDuffColorFilter( - ContextCompat.getColor(activity, R.color.primaryText), - PorterDuff.Mode.SRC_IN - ) - chip.checkedIcon?.colorFilter = colorFilter - chip.chipIcon?.colorFilter = colorFilter + LaunchedEffect(query, selectedFilter) { + viewModel.setFilter(query, selectedFilter, proxyId) } - private fun applyFilter(tag: Any) { - when (tag as Int) { - TopLevelFilter.ALL_APPS.id -> { - filterType = TopLevelFilter.ALL_APPS - viewModel.setFilter(searchText, filterType, proxyId) - } - TopLevelFilter.SELECTED_APPS.id -> { - filterType = TopLevelFilter.SELECTED_APPS - viewModel.setFilter(searchText, filterType, proxyId) + LaunchedEffect(allApps) { + val snapshot = allApps + excludedUids = + withContext(Dispatchers.IO) { + val excluded = mutableSetOf() + snapshot.forEach { mapping -> + if (FirewallManager.isAppExcludedFromProxy(mapping.uid)) { + excluded.add(mapping.uid) + } + } + excluded } - TopLevelFilter.UNSELECTED_APPS.id -> { - filterType = TopLevelFilter.UNSELECTED_APPS - viewModel.setFilter(searchText, filterType, proxyId) + } + + fun refreshApps() { + if (isRefreshing) return + isRefreshing = true + scope.launch(Dispatchers.IO) { + refreshDatabase.refresh(RefreshDatabase.ACTION_REFRESH_INTERACTIVE) + } + scope.launch { + delay(REFRESH_TIMEOUT) + if (isDialogVisible) { + isRefreshing = false + Utilities.showToastUiCentered( + context, + context.getString(R.string.refresh_complete), + Toast.LENGTH_SHORT + ) } } } - private fun initializeClickListeners() { - b.wgIncludeAppDialogOkButton.setOnClickListener { - clearSearch() - dismiss() + val allAppsSelected = + allApps.isNotEmpty() && + allApps.all { mapping -> + excludedUids.contains(mapping.uid) || + mapping.proxyId.equals(proxyId, ignoreCase = true) || + mapping.proxyName.equals(proxyName, ignoreCase = true) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = if (inDialog) Color.Transparent else MaterialTheme.colorScheme.background, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onDismiss, + icon = { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null + ) + }, + text = { Text(text = stringResource(R.string.lbl_done)) }, + modifier = Modifier.padding(bottom = navBarBottomInset + Dimensions.spacingSm) + ) + }, + topBar = { + RethinkTopBar( + title = proxyName, + onBackClick = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface, + actions = { + ProxyAppsTopBarFilterGroup( + selectedFilter = selectedFilter, + onFilterChange = { selectedFilter = it } + ) + IconButton(onClick = { showOverflowMenu = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.cd_more) + ) + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false } + ) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null + ) + }, + text = { + Text( + text = + if (isRefreshing) { + stringResource(R.string.lbl_loading) + } else { + stringResource(R.string.cd_refresh) + } + ) + }, + enabled = !isRefreshing, + onClick = { + showOverflowMenu = false + refreshApps() + } + ) + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = + if (allAppsSelected) { + Icons.Filled.Clear + } else { + Icons.Filled.Check + }, + contentDescription = null + ) + }, + text = { + Text( + text = + if (allAppsSelected) { + stringResource(R.string.lbl_unselect_all) + } else { + stringResource(R.string.lbl_select_all) + } + ) + }, + onClick = { + showOverflowMenu = false + if (allAppsSelected) { + unselectAllApps() + } else { + selectAllApps() + } + } + ) + } + } + ) } + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + contentPadding = + PaddingValues( + end = if (showFastScroller) FAST_SCROLLER_LIST_END_PADDING else 0.dp, + bottom = DONE_FAB_CLEARANCE + navBarBottomInset + ), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + item { + ProxyAppsControlDeck( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = Dimensions.spacingSm), + query = query, + onQueryChange = { query = it } + ) + } - b.wgIncludeAppDialogSearchView.setOnQueryTextListener(this) + if (apps.isEmpty()) { + item { + CompactEmptyState( + message = stringResource(R.string.fapps_empty_subtitle) + ) + } + } + for (index in apps.indices) { + val item = apps[index] + val currentInitial = appInitial(item.appName, item.packageName) + val previousInitial = + if (index > 0) { + appInitial(apps[index - 1].appName, apps[index - 1].packageName) + } else { + null + } + val nextInitial = + if (index < apps.size - 1) { + appInitial(apps[index + 1].appName, apps[index + 1].packageName) + } else { + null + } + val isFirstInGroup = previousInitial == null || currentInitial != previousInitial + val isLastInGroup = nextInitial == null || currentInitial != nextInitial + + if (isFirstInGroup) { + stickyHeader(key = "proxy_header_$currentInitial") { + ProxyAppsLetterHeader(letter = currentInitial) + } + } + + item(key = "proxy_app_${item.uid}_${item.packageName}") { + IncludeAppRow( + mapping = item, + proxyId = proxyId, + position = + when { + isFirstInGroup && isLastInGroup -> CardPosition.Single + isFirstInGroup -> CardPosition.First + isLastInGroup -> CardPosition.Last + else -> CardPosition.Middle + }, + onInterfaceUpdate = { mapping, include -> + updateInterfaceDetails(mapping, include) + } + ) + } + } + } - b.wgIncludeAppDialogSearchView.setOnCloseListener { - clearSearch() - false + if (showFastScroller) { + IndexedFastScroller( + items = fastScrollerKeys, + listState = listState, + getIndexKey = { it }, + scrollItemOffset = 2, + minItemCount = 8, + modifier = + Modifier + .align(Alignment.CenterEnd) + .padding(top = Dimensions.spacingSm, bottom = Dimensions.spacingSm + navBarBottomInset) + .padding(end = 2.dp) + ) + } } + } +} - b.wgIncludeAppSelectAllCheckbox.setOnClickListener { - showDialog(b.wgIncludeAppSelectAllCheckbox.isChecked) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ProxyAppsTopBarFilterGroup( + selectedFilter: TopLevelFilter, + onFilterChange: (TopLevelFilter) -> Unit +) { + val options = listOf(TopLevelFilter.ALL_APPS, TopLevelFilter.SELECTED_APPS) + val selectedOption = + if (selectedFilter == TopLevelFilter.UNSELECTED_APPS) { + TopLevelFilter.ALL_APPS + } else { + selectedFilter } - b.wgRemainingAppsBtn.setOnClickListener { showConfirmationDialog() } - - b.wgIncludeAppSelectAllCheckbox.setOnCheckedChangeListener(null) - - b.wgRefreshList.setOnClickListener { - b.wgRefreshList.isEnabled = false - b.wgRefreshList.animation = animation - b.wgRefreshList.startAnimation(animation) - refreshDatabase() - val l = activity as LifecycleOwner - Utilities.delay(REFRESH_TIMEOUT, l.lifecycleScope) { - if (this.isShowing) { - b.wgRefreshList.isEnabled = true - b.wgRefreshList.clearAnimation() - Utilities.showToastUiCentered( - context, - context.getString(R.string.refresh_complete), - Toast.LENGTH_SHORT - ) - } + Row(horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween)) { + options.forEachIndexed { index, option -> + val isSelected = option == selectedOption + ToggleButton( + checked = isSelected, + onCheckedChange = { checked -> + if (checked && !isSelected) onFilterChange(option) + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = + ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = + Modifier + .heightIn(min = 34.dp) + .semantics { role = Role.RadioButton } + ) { + Text( + text = stringResource(option.getLabelId()), + maxLines = 1, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium + ) } } } +} - private fun refreshDatabase() { - io { refreshDatabase.refresh(RefreshDatabase.ACTION_REFRESH_INTERACTIVE) } - } +private fun buildFastScrollerIndexKeys(loadedItems: List): List { + val indexKeys = mutableListOf() + var previousInitial: String? = null - private fun refreshPagingAdapter() { - viewModel.setFilter(searchText, filterType, proxyId) - adapter.refresh() + loadedItems.forEach { item -> + val initial = appInitial(item.appName, item.packageName) + if (initial != previousInitial) { + indexKeys.add(initial) + previousInitial = initial + } + indexKeys.add(item.appName.ifBlank { item.packageName }) } - private fun clearSearch() { - viewModel.setFilter("", TopLevelFilter.ALL_APPS, proxyId) - } + return indexKeys +} - private fun showDialog(toAdd: Boolean) { - val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim) - if (toAdd) { - builder.setTitle(context.getString(R.string.include_all_app_wg_dialog_title)) - builder.setMessage(context.getString(R.string.include_all_app_wg_dialog_desc)) - } else { - builder.setTitle(context.getString(R.string.exclude_all_app_wg_dialog_title)) - builder.setMessage(context.getString(R.string.exclude_all_app_wg_dialog_desc)) - } - builder.setCancelable(true) - builder.setPositiveButton( - if (toAdd) context.getString(R.string.lbl_include) - else context.getString(R.string.exclude) - ) { _, _ -> - io { - if (toAdd) { - Logger.i(LOG_TAG_PROXY, "Adding all apps to proxy $proxyId, $proxyName") - ProxyManager.setProxyIdForAllApps(proxyId, proxyName) +@Composable +private fun TransparentDialogSystemBars() { + val view = LocalView.current + DisposableEffect(view) { + val window = (view.parent as? DialogWindowProvider)?.window + if (window != null) { + val originalNavBarColor = window.navigationBarColor + val originalNavBarDividerColor = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.navigationBarDividerColor } else { - Logger.i(LOG_TAG_PROXY, "Removing all apps from proxy $proxyId, $proxyName") - ProxyManager.setNoProxyForAllAppsForProxy(proxyId) + null } - // re-apply current filter to force Paging source reload and UI refresh - withContext(Dispatchers.Main) { - // Update checkbox state to match the action taken - b.wgIncludeAppSelectAllCheckbox.isChecked = toAdd - refreshPagingAdapter() + val originalContrastEnforced = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced + } else { + null } + WindowCompat.setDecorFitsSystemWindows(window, false) + window.navigationBarColor = AndroidColor.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.navigationBarDividerColor = AndroidColor.TRANSPARENT } - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - // Revert checkbox state on cancel - b.wgIncludeAppSelectAllCheckbox.isChecked = !toAdd - } - - builder.create().show() - } - - private fun showConfirmationDialog() { - val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim) - builder.setTitle(context.getString(R.string.remaining_apps_dialog_title)) - builder.setMessage(context.getString(R.string.remaining_apps_dialog_desc)) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_include)) { _, _ -> - 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) { - refreshPagingAdapter() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + onDispose { + WindowCompat.setDecorFitsSystemWindows(window, true) + window.navigationBarColor = originalNavBarColor + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && originalNavBarDividerColor != null) { + window.navigationBarDividerColor = originalNavBarDividerColor + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && originalContrastEnforced != null) { + window.isNavigationBarContrastEnforced = originalContrastEnforced } } + } else { + onDispose {} } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - // no-op - } - - builder.create().show() } +} - override fun onQueryTextSubmit(query: String): Boolean { - searchText = query - viewModel.setFilter(query, filterType, proxyId) - return true +@Composable +private fun ProxyAppsControlDeck( + modifier: Modifier = Modifier, + query: String, + onQueryChange: (String) -> Unit +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + RethinkSearchField( + modifier = Modifier.fillMaxWidth(), + query = query, + onQueryChange = onQueryChange, + placeholder = stringResource(R.string.search_proxy_add_apps), + onClearQuery = { onQueryChange("") }, + clearQueryContentDescription = stringResource(R.string.cd_clear_search), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + iconSize = 18.dp, + trailingIconSize = 16.dp, + trailingIconButtonSize = 32.dp + ) } +} - override fun onQueryTextChange(query: String): Boolean { - searchText = query - viewModel.setFilter(query, filterType, proxyId) - return true +private object RefreshDatabaseProvider : KoinComponent { + val refreshDatabase: RefreshDatabase by inject() + + fun get(): RefreshDatabase = refreshDatabase +} + +@Composable +private fun ProxyAppsLetterHeader(letter: String) { + androidx.compose.foundation.layout.Box( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(start = 20.dp, top = 20.dp, bottom = 4.dp) + ) { + Text( + text = letter, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) } +} - private fun io(f: suspend () -> Unit) { - (activity as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } +private fun appInitial(appName: String, packageName: String): String { + val source = appName.ifBlank { packageName }.trim() + if (source.isEmpty()) return "#" + val first = source.first() + return if (first.isLetter()) { + first.uppercaseChar().toString() + } else { + source.first().toString().uppercase(Locale.getDefault()) } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgSsidDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgSsidDialog.kt index d6c502174..22476fd62 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgSsidDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgSsidDialog.kt @@ -15,278 +15,320 @@ */ package com.celzero.bravedns.ui.dialog -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.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 androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.celzero.bravedns.R -import com.celzero.bravedns.adapter.SsidAdapter import com.celzero.bravedns.data.SsidItem -import com.celzero.bravedns.databinding.DialogWgSsidBinding -import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.ui.bottomsheet.RuleSheetTextFieldRow +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetActionRow +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard +import com.celzero.bravedns.ui.compose.theme.RethinkSecondaryActionStyle import com.celzero.bravedns.util.Utilities -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class WgSsidDialog( - private val activity: Activity, - private val themeId: Int, - private val currentSsids: String, - private val onSave: (String) -> Unit -) : Dialog(activity, themeId) { - - private lateinit var b: DialogWgSsidBinding - private lateinit var ssidAdapter: SsidAdapter - private val ssidItems = mutableListOf() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - requestWindowFeature(Window.FEATURE_NO_TITLE) - - b = DialogWgSsidBinding.inflate(layoutInflater) - setContentView(b.root) - setCancelable(false) - setupDialog() - setupRecyclerView() - loadCurrentSsids() - setupClickListeners() - } - - private fun setupDialog() { - window?.setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.WRAP_CONTENT +@Composable +fun WgSsidDialog( + currentSsids: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit +) { + WgDialog(onDismissRequest = onDismiss) { + SsidDialogContent( + currentSsids = currentSsids, + onSave = onSave, + onDismiss = onDismiss ) - - window?.setGravity(Gravity.CENTER) - ViewCompat.setOnApplyWindowInsetsListener(b.root) { view, insets -> - val sysInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding(0, sysInsets.top, 0, sysInsets.bottom) - insets - } - - b.descriptionTextView.text = getDescTxt() - b.ssidTextInputLayout.hint = context.getString(R.string.wg_ssid_input_hint, context.getString(R.string.lbl_ssids)) - 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)) - disableOrEnableRadioButtons(false) - - // listeners to update description text when radio buttons change - b.ssidConditionRadioGroup.setOnCheckedChangeListener { _, _ -> - updateDescriptionText() - } - - b.ssidMatchTypeRadioGroup.setOnCheckedChangeListener { _, _ -> - updateDescriptionText() - } - } - - 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.wg_ssid_dialog_description, firstArg, 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 +@Composable +private fun SsidDialogContent( + currentSsids: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val ssidItems = remember { + mutableStateListOf().apply { + addAll(SsidItem.parseStorageList(currentSsids)) } } - - private fun loadCurrentSsids() { - val parsedSsids = SsidItem.parseStorageList(currentSsids) - ssidItems.clear() - ssidItems.addAll(parsedSsids) - ssidAdapter.notifyDataSetChanged() + var ssidInput by remember { mutableStateOf("") } + var isEqual by remember { mutableStateOf(true) } + var isExact by remember { mutableStateOf(false) } + var deleteTarget by remember { mutableStateOf(null) } + + val canEdit = ssidInput.isNotBlank() + 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 + val description = context.getString(R.string.wg_ssid_dialog_description, firstArg, secArg, thirdArg) + + if (deleteTarget != null) { + val item = deleteTarget ?: return + WgConfirmDialog( + title = stringResource(R.string.lbl_delete), + message = + stringResource( + R.string.two_argument_space, + stringResource(R.string.lbl_delete), + item.name + ), + confirmText = stringResource(R.string.lbl_delete), + isConfirmDestructive = true, + onDismiss = { deleteTarget = null }, + onConfirm = { + ssidItems.remove(item) + deleteTarget = null + } + ) } - private fun setupClickListeners() { - b.addSsidBtn.setOnClickListener { - addSsid() - } - - b.ssidEditText.setOnEditorActionListener { view, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - // Hide keyboard first - val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - - // Post the addSsid call to ensure it happens after keyboard is hidden - // This prevents focus search issues - view.post { - view.clearFocus() - addSsid() + WgDialogColumn(verticalSpacing = Dimensions.spacingMd) { + Text(text = stringResource(R.string.wg_setting_ssid_title), style = MaterialTheme.typography.titleLarge) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + + RethinkBottomSheetCard { + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + items(ssidItems, key = { it.name + it.type.id }) { item -> + SsidRow(ssidItem = item, onDeleteClick = { deleteTarget = item }) } - - // Return true to indicate we handled the action - true - } else { - false } } - b.ssidEditText.addTextChangedListener { text -> - val isNotEmpty = !text.isNullOrBlank() - - // Enable or disable add button based on text - b.addSsidBtn.isEnabled = isNotEmpty - b.addSsidBtn.isClickable = isNotEmpty - - // Enable or disable radio buttons based on text - // User should only be able to change settings when there's an SSID to apply them to - disableOrEnableRadioButtons(isNotEmpty) - - // Change button background color based on state - val context = b.addSsidBtn.context - val enabledColor = UIUtils.fetchColor(context, R.attr.accentGood) - val disabledColor = UIUtils.fetchColor(context, R.attr.primaryLightColorText) + RethinkBottomSheetCard { + WgOptionGroup( + title = stringResource(R.string.lbl_action), + enabled = canEdit, + options = listOf( + WgChoiceOption( + text = stringResource(R.string.lbl_connect), + selected = isEqual, + onSelected = { isEqual = true } + ), + WgChoiceOption( + text = pauseTxt, + selected = !isEqual, + onSelected = { isEqual = false } + ) + ) + ) - b.addSsidBtn.setTextColor(if (isNotEmpty) enabledColor else disabledColor) - } + WgOptionGroup( + title = stringResource(R.string.lbl_criteria), + enabled = canEdit, + options = listOf( + WgChoiceOption( + text = stringResource(R.string.wg_ssid_type_exact), + selected = isExact, + onSelected = { isExact = true } + ), + WgChoiceOption( + text = stringResource(R.string.wg_ssid_type_wildcard), + selected = !isExact, + onSelected = { isExact = false } + ) + ) + ) - b.cancelBtn.setOnClickListener { - dismiss() - } + Column(verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm)) { + Text( + text = stringResource(R.string.lbl_ssid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + RuleSheetTextFieldRow( + value = ssidInput, + onValueChange = { ssidInput = it }, + placeholder = { Text(text = stringResource(R.string.lbl_ssid)) }, + ) + } - b.saveBtn.setOnClickListener { - saveSsids() + Row(horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm)) { + Button( + onClick = { + addSsid( + context = context, + ssidInput = ssidInput, + isEqual = isEqual, + isExact = isExact, + items = ssidItems, + onReset = { + ssidInput = "" + isEqual = true + isExact = false + } + ) + }, + enabled = canEdit + ) { + Text( + text = stringResource(R.string.lbl_add), + color = if (canEdit) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } - } - private fun disableOrEnableRadioButtons(enable: Boolean) { - b.radioEqual.isEnabled = enable - b.radioEqual.isClickable = enable - b.radioNotEqual.isEnabled = enable - b.radioNotEqual.isClickable = enable - b.radioExact.isEnabled = enable - b.radioExact.isClickable = enable - b.radioWildcard.isEnabled = enable - b.radioWildcard.isClickable = enable + RethinkBottomSheetActionRow( + primaryText = stringResource(R.string.fapps_info_dialog_positive_btn), + onPrimaryClick = { + val finalSsids = SsidItem.toStorageList(ssidItems.toList()) + onSave(finalSsids) + onDismiss() + }, + secondaryText = stringResource(R.string.lbl_cancel), + onSecondaryClick = onDismiss, + secondaryStyle = RethinkSecondaryActionStyle.TEXT + ) } +} - private fun addSsid() { - val ssidName = b.ssidEditText.text?.toString()?.trim() - - if (ssidName.isNullOrBlank()) { - Utilities.showToastUiCentered( - activity, - activity.getString(R.string.wg_ssid_invalid_error, activity.getString(R.string.lbl_ssids)), - Toast.LENGTH_SHORT - ) - return +private data class WgChoiceOption( + val text: String, + val selected: Boolean, + val onSelected: () -> Unit +) + +@Composable +private fun WgOptionGroup( + title: String, + enabled: Boolean, + options: List +) { + Column(verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm)) { + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd)) { + options.forEach { option -> + WgOptionRow( + text = option.text, + selected = option.selected, + enabled = enabled, + onSelected = option.onSelected + ) + } } + } +} - // Validate SSID name - if (!isValidSsidName(ssidName)) { - Utilities.showToastUiCentered( - activity, - activity.getString(R.string.config_add_success_toast), - Toast.LENGTH_SHORT +@Composable +private fun SsidRow(ssidItem: SsidItem, onDeleteClick: () -> Unit) { + val context = LocalContext.current + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = ssidItem.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = ssidItem.type.getDisplayName(context), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + IconButton(onClick = onDeleteClick) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = stringResource(R.string.lbl_delete) ) - return - } - - // Determine the selected type based on both radio groups - val isEqual = b.radioEqual.isChecked - val isExact = b.radioExact.isChecked - - val selectedType = 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 newSsidItem = SsidItem(ssidName, selectedType) - - // Check if same name and type already exists - val existingWithSameType = ssidItems.find { - it.name.equals(ssidName, ignoreCase = true) && it.type == selectedType - } - - if (existingWithSameType != null) { - // Same name and type already exists, just clear input - b.ssidEditText.text?.clear() - resetToDefaultSelection() - return - } - - // Check if same name exists with different type - val existingWithDifferentType = ssidItems.find { - it.name.equals(ssidName, ignoreCase = true) && it.type != selectedType - } - - if (existingWithDifferentType != null) { - // Remove the existing one and add the new one (update) - ssidAdapter.removeSsidItem(existingWithDifferentType) } + } +} - ssidAdapter.addSsidItem(newSsidItem) - b.ssidEditText.text?.clear() +private fun isValidSsidName(ssidName: String): Boolean { + return ssidName.length <= 32 && ssidName.isNotBlank() +} - // Reset to default selection - resetToDefaultSelection() +private fun addSsid( + context: android.content.Context, + ssidInput: String, + isEqual: Boolean, + isExact: Boolean, + items: MutableList, + onReset: () -> Unit +) { + val ssidName = ssidInput.trim() + if (ssidName.isBlank()) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.wg_ssid_invalid_error, context.getString(R.string.lbl_ssids)), + Toast.LENGTH_SHORT + ) + return } - private fun resetToDefaultSelection() { - b.radioEqual.isChecked = true - b.radioWildcard.isChecked = true + if (!isValidSsidName(ssidName)) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.config_add_success_toast), + Toast.LENGTH_SHORT + ) + return } - private fun isValidSsidName(ssidName: String): Boolean { - // Basic validation - reasonable length - return ssidName.length <= 32 && - ssidName.isNotBlank() + val selectedType = when { + isEqual && isExact -> SsidItem.SsidType.EQUAL_EXACT + isEqual && !isExact -> SsidItem.SsidType.EQUAL_WILDCARD + !isEqual && isExact -> SsidItem.SsidType.NOTEQUAL_EXACT + else -> SsidItem.SsidType.NOTEQUAL_WILDCARD } - private fun showDeleteConfirmation(ssidItem: SsidItem) { - val builder = MaterialAlertDialogBuilder(activity, R.style.App_Dialog_NoDim) - builder.setTitle(activity.getString(R.string.lbl_delete)) - builder.setMessage( - activity.getString(R.string.two_argument_space, activity.getString(R.string.lbl_delete), ssidItem.name) - ) - builder.setCancelable(true) - builder.setPositiveButton(activity.getString(R.string.lbl_delete)) { _, _ -> - ssidAdapter.removeSsidItem(ssidItem) - } - builder.setNegativeButton(activity.getString(R.string.lbl_cancel)) { _, _ -> - // no-op - } - builder.create().show() + val existingWithSameType = + items.find { it.name.equals(ssidName, ignoreCase = true) && it.type == selectedType } + if (existingWithSameType != null) { + onReset() + return } - private fun saveSsids() { - val finalSsids = SsidItem.toStorageList(ssidAdapter.getSsidItems()) - onSave(finalSsids) - dismiss() + val existingWithDifferentType = + items.find { it.name.equals(ssidName, ignoreCase = true) && it.type != selectedType } + if (existingWithDifferentType != null) { + items.remove(existingWithDifferentType) } + + items.add(SsidItem(ssidName, selectedType)) + onReset() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistFilterHost.kt b/app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistFilterHost.kt new file mode 100644 index 000000000..7dc108bfc --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistFilterHost.kt @@ -0,0 +1,22 @@ +/* + * 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.rethink + +import androidx.lifecycle.MutableLiveData + +interface RethinkBlocklistFilterHost { + fun filterObserver(): MutableLiveData +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistState.kt b/app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistState.kt new file mode 100644 index 000000000..673e08ddf --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/rethink/RethinkBlocklistState.kt @@ -0,0 +1,58 @@ +/* + * 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.rethink + +import androidx.lifecycle.MutableLiveData + +object RethinkBlocklistState { + val selectedFileTags: MutableLiveData> = MutableLiveData() + + fun updateFileTagList(fileTags: Set) { + selectedFileTags.postValue(fileTags.toMutableSet()) + } + + fun getSelectedFileTags(): Set { + return selectedFileTags.value ?: emptySet() + } + + enum class BlocklistSelectionFilter(val id: Int) { + ALL(0), + SELECTED(1) + } + + class Filters { + var query: String = "%%" + var filterSelected: BlocklistSelectionFilter = BlocklistSelectionFilter.ALL + var subGroups: MutableSet = mutableSetOf() + } + + enum class BlocklistView(val tag: String) { + PACKS("1"), + ADVANCED("2"); + + fun isSimple() = this == PACKS + + companion object { + fun getTag(tag: String): BlocklistView { + return if (tag == PACKS.tag) { + PACKS + } else { + ADVANCED + } + } + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt b/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt index 9cb885c79..77d271482 100644 --- a/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt +++ b/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt @@ -22,45 +22,28 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.res.TypedArray -import android.graphics.Color -import android.graphics.Typeface +import android.graphics.Paint import android.net.Uri import android.os.Build import android.provider.Settings import android.text.Html -import android.text.SpannableString import android.text.Spanned import android.text.format.DateUtils -import android.text.style.StyleSpan -import android.util.TypedValue -import android.view.View -import android.view.ViewGroup -import android.view.ViewOutlineProvider -import android.widget.FrameLayout -import android.widget.TextView import android.widget.Toast import androidx.appcompat.widget.AppCompatTextView -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.core.text.HtmlCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import com.celzero.bravedns.R -import com.celzero.bravedns.database.AppInfoRepository.Companion.NO_PACKAGE_PREFIX import com.celzero.bravedns.database.DnsLog import com.celzero.bravedns.glide.FavIconDownloader import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.DnsLogTracker -import com.celzero.bravedns.service.PersistentState import com.celzero.firestack.backend.Backend -import com.celzero.firestack.backend.GoMetrics import com.celzero.firestack.backend.NetStat -import com.google.android.material.bottomnavigation.BottomNavigationView +import java.util.Locale import com.google.android.material.radiobutton.MaterialRadioButton -import com.google.android.material.snackbar.Snackbar import java.util.Calendar import java.util.Date import java.util.regex.Matcher @@ -68,6 +51,25 @@ import java.util.regex.Pattern object UIUtils { + fun formatBytes(bytes: Long): String { + if (bytes <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + var value = bytes.toDouble() + var unitIndex = 0 + + while (value >= 1024 && unitIndex < units.size - 1) { + value /= 1024 + unitIndex++ + } + + return if (value == value.toLong().toDouble()) { + "${value.toLong()} ${units[unitIndex]}" + } else { + String.format(Locale.US, "%.1f %s", value, units[unitIndex]) + } + } + + fun getDnsStatusStringRes(status: Long?): Int { if (status == null) return R.string.failed_using_default @@ -264,15 +266,6 @@ object UIUtils { } fun openAndroidAppInfo(context: Context, packageName: String?) { - if (packageName?.startsWith(NO_PACKAGE_PREFIX) == true) { - Utilities.showToastUiCentered( - context, - context.getString(R.string.ctbs_app_info_not_available_toast), - Toast.LENGTH_SHORT - ) - return - } - try { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.fromParts("package", packageName, null) @@ -288,78 +281,6 @@ object UIUtils { } } - fun fetchColor(context: Context, attr: Int): Int { - val typedValue = TypedValue() - val a: TypedArray = context.obtainStyledAttributes(typedValue.data, intArrayOf(attr)) - val color = a.getColor(0, 0) - a.recycle() - return color - } - - fun fetchToggleBtnColors(context: Context, attr: Int): Int { - val attributeFetch = - when (attr) { - R.color.firewallNoRuleToggleBtnTxt -> { - R.attr.firewallNoRuleToggleBtnTxt - } - R.color.firewallNoRuleToggleBtnBg -> { - R.attr.firewallNoRuleToggleBtnBg - } - R.color.firewallBlockToggleBtnTxt -> { - R.attr.firewallBlockToggleBtnTxt - } - R.color.firewallBlockToggleBtnBg -> { - R.attr.firewallBlockToggleBtnBg - } - R.color.firewallWhiteListToggleBtnTxt -> { - R.attr.firewallWhiteListToggleBtnTxt - } - R.color.firewallWhiteListToggleBtnBg -> { - R.attr.firewallWhiteListToggleBtnBg - } - R.color.firewallExcludeToggleBtnBg -> { - R.attr.firewallExcludeToggleBtnBg - } - R.color.firewallExcludeToggleBtnTxt -> { - R.attr.firewallExcludeToggleBtnTxt - } - R.color.defaultToggleBtnBg -> { - R.attr.defaultToggleBtnBg - } - R.color.defaultToggleBtnTxt -> { - R.attr.defaultToggleBtnTxt - } - R.color.accentGood -> { - R.attr.accentGood - } - R.color.accentBad -> { - R.attr.accentBad - } - R.color.chipBgNeutral -> { - R.attr.chipBgColorNeutral - } - R.color.chipBgNegative -> { - R.attr.chipBgColorNegative - } - R.color.chipBgPositive -> { - R.attr.chipBgColorPositive - } - R.color.chipTextNeutral -> { - R.attr.chipTextNeutral - } - R.color.chipTextNegative -> { - R.attr.chipTextNegative - } - R.color.chipTextPositive -> { - R.attr.chipTextPositive - } - else -> { - R.attr.chipBgColorPositive - } - } - return fetchColor(context, attributeFetch) - } - suspend fun fetchFavIcon(context: Context, dnsLog: DnsLog) { if (dnsLog.groundedQuery()) return @@ -657,10 +578,9 @@ object UIUtils { fun getAccentColor(appTheme: Int): Int { return when (appTheme) { - Themes.SYSTEM_DEFAULT.id -> R.color.accentGoodBlack + Themes.LIGHT.id, Themes.LIGHT_PLUS.id -> R.color.accentGoodLight Themes.DARK.id -> R.color.accentGood - Themes.LIGHT.id -> R.color.accentGoodLight - Themes.TRUE_BLACK.id -> R.color.accentGoodBlack + Themes.DARK_PLUS.id, Themes.SYSTEM_DEFAULT.id -> R.color.accentGoodBlack else -> R.color.accentGoodBlack } } @@ -708,11 +628,10 @@ object UIUtils { val nic = stat.nic()?.toString() val rdnsInfo = stat.rdnsinfo()?.toString() val nicInfo = stat.nicinfo()?.toString() - + val go = stat.go()?.toString() val tun = stat.tun()?.toString() - - var stats = nic + nicInfo + tun + fwd + ip + icmp + tcp + udp + rdnsInfo + var stats = nic + nicInfo + tun + fwd + ip + icmp + tcp + udp + rdnsInfo + go stats = stats.replace("{", "\n") stats = stats.replace("}", "\n\n") stats = stats.replace(",", "\n") @@ -720,20 +639,8 @@ object UIUtils { return stats } - fun formatNetMetrics(stat: GoMetrics?): String? { - if (stat == null) return null - - val go = stat.go()?.toString() - val c = stat.c - val m = stat.m - val l = stat.l - - var stats = go + l + c + m - stats = stats.replace("{", "\n") - stats = stats.replace("}", "\n\n") - stats = stats.replace(",", "\n") - - return stats + fun AppCompatTextView.underline() { + paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG } fun AppCompatTextView.setBadgeDotVisible(context: Context, visible: Boolean) { @@ -762,293 +669,3 @@ object UIUtils { } } } - -/** - * Centralized, themed, and throttled Snackbar helper for the Rethink app. - * - * ### Usage - * ```kotlin - * SnackbarHelper.show( - * view = b.root, // any view in the hierarchy - * message = getString(R.string.server_selection_proxy_unavailable), - * actionLabel = getString(R.string.server_selection_error_retry), - * action = { retryLoadingServers() } - * ) - * ``` - */ -object SnackbarHelper { - - /** Minimum ms between two identical messages. Prevents rapid-fire error floods. */ - private const val THROTTLE_MS = 30_000L - - /** Last shown message and the timestamp it was shown. */ - private var lastMessage: String = "" - private var lastShownAt: Long = 0L - - /** Reference to the currently visible Snackbar so we can dismiss it before showing a new one. */ - private var current: Snackbar? = null - - /** - * Show a themed, deduped Snackbar. - * - * @param view Any view inside the fragment/activity hierarchy. The helper walks up to - * find the best anchor ([CoordinatorLayout] or the decor view) and also - * looks for a [BottomNavigationView] to position above it automatically. - * @param message The message to display. - * @param duration [Snackbar.LENGTH_LONG] by default. Pass [Snackbar.LENGTH_INDEFINITE] - * for actionable errors the user must explicitly dismiss. - * @param actionLabel Optional label for the action button. - * @param action Optional callback when the action button is tapped. - * @param forceShow If `true`, bypass the throttle and always show (use sparingly). - */ - fun show( - view: View, - message: String, - duration: Int = Snackbar.LENGTH_LONG, - actionLabel: String? = null, - action: (() -> Unit)? = null, - forceShow: Boolean = false - ) { - val now = System.currentTimeMillis() - - // Throttle: drop identical repeated messages within THROTTLE_MS. - if (!forceShow && - message == lastMessage && - (now - lastShownAt) < THROTTLE_MS - ) { - Logger.d(LOG_TAG_UI, "SnackbarHelper: suppressed duplicate '${message.take(40)}'") - return - } - - // Dismiss any currently visible Snackbar before showing a new one. - current?.dismiss() - current = null - - val snackbar = Snackbar.make(bestAnchorFor(view), message, duration) - - // Anchor above BottomNavigationView so the snackbar is never hidden behind it. - val navView = findBottomNavView(view) - navView?.let { - snackbar.anchorView = it - } - - // Style the container view. - val snackView = snackbar.view - styleContainer(snackView, view.context, navView != null) - - // Style the message text. - val msgTv = snackView.findViewById( - com.google.android.material.R.id.snackbar_text - ) - msgTv?.let { - it.setTextColor(resolveThemeColor(view.context, R.attr.primaryTextColor)) - it.textSize = 14f - it.maxLines = 3 - } - - // Action button. - if (actionLabel != null && action != null) { - snackbar.setAction(actionLabel) { - current = null - action() - } - snackbar.setActionTextColor( - resolveThemeColor(view.context, R.attr.accentGood) - ) - val actionTv = snackView.findViewById( - com.google.android.material.R.id.snackbar_action - ) - actionTv?.let { - it.textSize = 14f - it.isAllCaps = false - } - } - - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - if (current === transientBottomBar) current = null - } - }) - - ViewCompat.setOnApplyWindowInsetsListener(snackView) { v, insets -> - val navBarHeight = insets - .getInsets(WindowInsetsCompat.Type.navigationBars()) - .bottom - - (v.layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp -> - lp.bottomMargin += navBarHeight + 80 - v.layoutParams = lp - } - - insets - } - - current = snackbar - lastMessage = message - lastShownAt = now - snackbar.show() - Logger.d(LOG_TAG_UI, "SnackbarHelper: showing '${message.take(60)}'") - } - - /** Dismiss the currently visible Snackbar, if any. */ - fun dismiss() { - current?.dismiss() - current = null - } - - /** - * Show the stability-program enrollment Snackbar with a **Disable** action. - * - * Automatically anchors above the BottomNavigationView when one is present in the - * view hierarchy (e.g. fragments hosted by HomeScreenActivity). - * - * @param view Any view inside the activity/fragment hierarchy. - * @param persistentState The PersistentState instance used to disable the program. - */ - fun showStabilityProgram(view: View, persistentState: PersistentState) { - val context = view.context - val message = context.getString(R.string.stability_program_snackbar_msg) - val actionLabel = context.getString(R.string.stability_program_snackbar_disable) - show( - view = view, - message = message, - duration = Snackbar.LENGTH_LONG, - actionLabel = actionLabel, - action = { - persistentState.firebaseErrorReportingEnabled = false - FirebaseErrorReporting.setEnabled(false) - Logger.i(LOG_TAG_UI, "Stability program disabled by user via snackbar") - }, - forceShow = true - ) - } - - /** - * Capitalize the first letter of each word and lowercase the rest. - * E.g. "HELLO WORLD" → "Hello World", "hELLO wORLD" → "Hello World" - */ - fun String.capitalizeWords(): String { - return split(" ") - .joinToString(" ") { word -> - word.lowercase().replaceFirstChar { it.uppercase() } - } - } - - /** - * Apply a bold typeface to the string. - */ - fun String.italic(): SpannableString { - return SpannableString(this).apply { - setSpan( - StyleSpan(Typeface.ITALIC), - 0, - length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - } - - /** - * Walk the view hierarchy upward to find the nearest [CoordinatorLayout] ancestor. - * Falls back to the root decor view so the Snackbar is never clipped by - * `fitsSystemWindows` insets on the fragment root. - */ - private fun bestAnchorFor(view: View): View { - var v: View? = view - while (v != null) { - if (v is CoordinatorLayout) return v - v = v.parent as? View - } - return view.rootView ?: view - } - - /** - * Walk the full view tree (from the root downward) to find a [BottomNavigationView]. - * Returns `null` if none is found (e.g., in standalone activities without a nav bar). - */ - private fun findBottomNavView(view: View): BottomNavigationView? { - // Walk up to the root first, then search the entire tree from there. - val root = view.rootView ?: return null - - // 1. Try finding by ID directly (most reliable if ID is known) - val navView = root.findViewById(R.id.nav_view) - if (navView != null && navView.isShown) { - return navView - } - - // 2. Fallback to recursive search - return (root as? ViewGroup)?.let { searchForBottomNav(it) } - } - - private fun searchForBottomNav(group: ViewGroup): BottomNavigationView? { - for (i in 0 until group.childCount) { - val child = group.getChildAt(i) - if (child is BottomNavigationView && child.isShown) return child - if (child is ViewGroup) { - val found = searchForBottomNav(child) - if (found != null) return found - } - } - return null - } - - /** - * Apply the themed background, elevation, and margins to the Snackbar container. - * - */ - private fun styleContainer(snackView: View, context: Context, isAnchored: Boolean) { - // First child is always the SnackbarContentLayout. - val contentView: View = (snackView as? ViewGroup)?.getChildAt(0) ?: snackView - - // Outer container → transparent so the nav-bar insets area is see-through. - snackView.background = null - - // Content view → themed card background + shadow. - contentView.background = ContextCompat.getDrawable(context, R.drawable.snackbar_background) - // Elevation on the content view so the shadow follows the rounded-rect outline. - contentView.elevation = context.resources.getDimension(R.dimen.snackbar_elevation) - // GradientDrawable provides the rounded-rect outline via BACKGROUND provider. - contentView.outlineProvider = ViewOutlineProvider.BACKGROUND - - // Outer layout margins: float the bar away from screen edges and off the nav bar. - val hMargin = context.resources.getDimensionPixelSize(R.dimen.snackbar_horizontal_margin) - var bMargin = context.resources.getDimensionPixelSize(R.dimen.snackbar_bottom_margin) - - // if the snackbar is anchored, we need to add more margin to it so it is shown above the - // navigation menu to some extent. - if (isAnchored) { - bMargin *= 2 - } - - val lp = snackView.layoutParams - when (lp) { - is CoordinatorLayout.LayoutParams -> { - lp.setMargins(hMargin, lp.topMargin, hMargin, bMargin) - snackView.layoutParams = lp - } - is FrameLayout.LayoutParams -> { - lp.setMargins(hMargin, lp.topMargin, hMargin, bMargin) - snackView.layoutParams = lp - } - } - } - - /** - * Resolve a theme attribute to a concrete color int. - * Falls back to white (#FFFFFF) if the attribute is not found so text is - * always visible even if the theme is misconfigured. - */ - private fun resolveThemeColor(context: Context, attrRes: Int): Int { - val tv = TypedValue() - return if (context.theme.resolveAttribute(attrRes, tv, true)) { - if (tv.resourceId != 0) { - ContextCompat.getColor(context, tv.resourceId) - } else { - tv.data - } - } else { - Color.WHITE - } - } -} - diff --git a/app/src/full/java/com/celzero/bravedns/util/WindowExtensions.kt b/app/src/full/java/com/celzero/bravedns/util/WindowExtensions.kt index f52484dbf..fc722a89a 100644 --- a/app/src/full/java/com/celzero/bravedns/util/WindowExtensions.kt +++ b/app/src/full/java/com/celzero/bravedns/util/WindowExtensions.kt @@ -21,24 +21,16 @@ import android.app.Dialog import android.graphics.Color import android.graphics.drawable.Drawable import android.os.Build -import android.util.TypedValue import android.view.View -import android.view.Window import android.view.WindowManager import androidx.annotation.ColorInt import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.content.res.AppCompatResources import androidx.core.graphics.drawable.toDrawable -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment import com.celzero.bravedns.R import com.celzero.bravedns.util.Utilities.isAtleastR import com.celzero.bravedns.util.Utilities.isAtleastS -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import java.util.WeakHashMap import java.util.function.Consumer /** Utility extension functions to configure Activity/Dialog/BottomSheet window appearance generically. */ @@ -58,16 +50,13 @@ fun AppCompatActivity.handleFrostEffectIfNeeded(themeId: Int) { if (isAtleastS()) { window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND) setupWindowBlurListener(windowBackgroundDrawable) - // Apply the current system blur state immediately so the first draw is - // already correct (the attach-listener fires later and handles transitions). val enabled = windowManager.isCrossWindowBlurEnabled Logger.v(LOG_TAG_UI, "Blur enabled by system? $enabled") - updateWindowForBlurs(windowBackgroundDrawable, enabled) } else { Logger.v(LOG_TAG_UI, "Blurs not supported, below Android S") - updateWindowForBlurs(windowBackgroundDrawable, blursEnabled = false) + updateWindowForBlurs(windowBackgroundDrawable, blursEnabled = false /* blursEnabled */) } - // FLAG_DIM_BEHIND is managed inside updateWindowForBlurs, not set unconditionally here. + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) } @RequiresApi(Build.VERSION_CODES.S) @@ -90,56 +79,26 @@ private fun AppCompatActivity.setupWindowBlurListener(windowBackgroundDrawable: ) } -// Blur radius in dp for density-independent blur strength across devices. -// Converted to px at point of use. 150dp is the platform maximum; 120dp -// gives a strong frosted blur while leaving some headroom. -private const val BACKGROUND_BLUR_RADIUS_DP = 120f -private const val BLUR_BEHIND_RADIUS_DP = 120f -// Stronger dim to reduce background visibility while still letting the blur -// show through. 0.7f was too aggressive; 0.45f strikes a balance between -// obscuring background content and retaining the glass aesthetic. -private const val DIM_AMOUNT_WITH_BLUR = 0.45f -// Frost theme is only selectable on S+, so the no-blur path is a safeguard only. -// No dim is applied; the nearly-opaque window background acts as the backdrop. -private const val DIM_AMOUNT_NO_BLUR = 0.0f -// ~59 % opacity of the dark surface colour — strong frosted tint that -// significantly reduces background visibility without fully hiding the blur. -private const val WINDOW_BACKGROUND_ALPHA_WITH_BLUR = 40 -// Nearly-opaque fallback when blur is unavailable (pre-S safety net). -private const val WINDOW_BACKGROUND_ALPHA_NO_BLUR = 230 +private const val BACKGROUND_BLUR_RADIUS = 80 +private const val BLUR_BEHIND_RADIUS = 80 +private const val DIM_AMOUNT_WITH_BLUR = 0.7f +private const val DIM_AMOUNT_NO_BLUR = 1f +private const val WINDOW_BACKGROUND_ALPHA_WITH_BLUR = 55 +private const val WINDOW_BACKGROUND_ALPHA_NO_BLUR = 255 private fun AppCompatActivity.updateWindowForBlurs( windowBackgroundDrawable: Drawable?, blursEnabled: Boolean, ) { - // Adjust the frosted-glass tint overlay: low opacity when the blur is doing its job, - // nearly-opaque as a solid fallback when blur is unavailable. windowBackgroundDrawable?.alpha = if (blursEnabled) WINDOW_BACKGROUND_ALPHA_WITH_BLUR else WINDOW_BACKGROUND_ALPHA_NO_BLUR - // Manage FLAG_DIM_BEHIND together with the dim amount so they are always in sync. - // A subtle compositor dim complements the frosted overlay; no dim is needed in the - // fallback path because the opaque window background handles separation. - if (blursEnabled) { - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) - window.setDimAmount(DIM_AMOUNT_WITH_BLUR) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) - window.setDimAmount(DIM_AMOUNT_NO_BLUR) - } - + window.setDimAmount(if (blursEnabled) DIM_AMOUNT_WITH_BLUR else DIM_AMOUNT_NO_BLUR) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Convert dp blur radii to px for density-independent blur strength. - val dm = resources.displayMetrics - val bgBlurPx = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, BACKGROUND_BLUR_RADIUS_DP, dm - ).toInt() - val behindBlurPx = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, BLUR_BEHIND_RADIUS_DP, dm - ).toInt() - window.setBackgroundBlurRadius(bgBlurPx) - window.attributes.blurBehindRadius = behindBlurPx + // Set the window background blur and blur behind radii + window.setBackgroundBlurRadius(BACKGROUND_BLUR_RADIUS) + window.attributes.blurBehindRadius = BLUR_BEHIND_RADIUS window.attributes = window.attributes } } @@ -155,47 +114,15 @@ fun Dialog.useTransparentNoDimBackground( window?.setBackgroundDrawable(color.toDrawable()) } -fun AppCompatDialog.useTransparentNoDimBackground( - @ColorInt color: Int = Color.TRANSPARENT -) { - (this as Dialog?)?.useTransparentNoDimBackground(color) -} - -fun BottomSheetDialog.useTransparentNoDimBackground( - @ColorInt color: Int = Color.TRANSPARENT -) { - (this as Dialog?)?.useTransparentNoDimBackground(color) -} - -/** Allow calling the helper directly on a DialogFragment/BottomSheetDialogFragment. */ -fun DialogFragment?.useTransparentNoDimBackground( - @ColorInt color: Int = Color.TRANSPARENT -) { - this?.dialog?.useTransparentNoDimBackground(color) -} - -fun BottomSheetDialogFragment?.useTransparentNoDimBackground( - @ColorInt color: Int = Color.TRANSPARENT -) { - this?.dialog?.useTransparentNoDimBackground(color) -} - -// Keyed by Window (one per Activity instance) so concurrent activities never -// stomp each other's saved state. WeakHashMap prevents leaks when activities finish. -private val frostStateByWindow = WeakHashMap() +private var frostWasEnabled = false fun AppCompatActivity.disableFrostTemporarily() { - val blurWasEnabled = - window.attributes.flags and WindowManager.LayoutParams.FLAG_BLUR_BEHIND != 0 - // Persist per-window so that a second activity's call never overwrites this one's state. - frostStateByWindow[window] = blurWasEnabled + frostWasEnabled = window.attributes.flags and WindowManager.LayoutParams.FLAG_BLUR_BEHIND != 0 - if (blurWasEnabled) { + if (frostWasEnabled) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { window.setBackgroundBlurRadius(0) window.attributes.blurBehindRadius = 0 - // Commit the attribute change to WindowManager (was missing in the original). - window.attributes = window.attributes } window.clearFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND) window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) @@ -205,17 +132,7 @@ fun AppCompatActivity.disableFrostTemporarily() { } fun AppCompatActivity.restoreFrost(themeId: Int) { - if (frostStateByWindow[window] != true) return - frostStateByWindow.remove(window) - handleFrostEffectIfNeeded(themeId) -} + if (!frostWasEnabled) return -fun Fragment.disableFrostTemporarily() { - val activity = activity as? AppCompatActivity ?: return - activity.disableFrostTemporarily() -} - -fun Fragment.restoreFrost(themeId: Int) { - val activity = activity as? AppCompatActivity ?: return - activity.restoreFrost(themeId) + handleFrostEffectIfNeeded(themeId) } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt index a5a985dca..d7221aa43 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt @@ -1,282 +1,243 @@ package com.celzero.bravedns.viewmodel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.cachedIn -import androidx.paging.liveData import com.celzero.bravedns.database.AppInfo import com.celzero.bravedns.database.AppInfoDAO import com.celzero.bravedns.service.FirewallManager -import com.celzero.bravedns.ui.activity.AppListActivity -import com.celzero.bravedns.util.Constants -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import com.celzero.bravedns.ui.compose.firewall.Filters +import com.celzero.bravedns.ui.compose.firewall.FirewallFilter +import com.celzero.bravedns.ui.compose.firewall.TopLevelFilter +import java.util.Locale +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +@OptIn(FlowPreview::class) class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { - private val filter: MutableLiveData = MutableLiveData() - private val category: MutableSet = mutableSetOf() - private var topLevelFilter = AppListActivity.TopLevelFilter.ALL - private var firewallFilter = AppListActivity.FirewallFilter.ALL - private var search: String = "" + private val defaultFilters = Filters(topLevelFilter = TopLevelFilter.INSTALLED) + private val baseFilters = MutableStateFlow(defaultFilters.copy(searchString = "")) + private val searchInput = MutableStateFlow(defaultFilters.searchString) + private val bulkUpdateMutex = Mutex() - init { - filter.value = "" - } - - val appInfo = filter.switchMap { input: String -> getAppInfo(input) } + private val effectiveFilters: StateFlow = + combine( + baseFilters, + searchInput + .debounce(300) + .distinctUntilChanged() + ) { base, debouncedSearch -> + base.copy(searchString = debouncedSearch.trim()) + } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + defaultFilters + ) - private fun setFilterWithDebounce(searchString: String) { - viewModelScope.launch { - debounceFilter(searchString) + val appInfo: StateFlow> = + combine( + appInfoDAO.getAllAppDetailsFlow(), + effectiveFilters + ) { apps, filters -> + filterAndSortApps(apps, filters) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + fun setFilter(filters: Filters) { + baseFilters.value = filters.copy(searchString = "") + searchInput.value = filters.searchString } - private var debounceJob: Job? = null - private fun debounceFilter(searchString: String) { - debounceJob?.cancel() - debounceJob = viewModelScope.launch { - delay(300) // 300ms debounce delay - filter.value = searchString + private fun filterAndSortApps( + apps: List, + filters: Filters + ): List { + return apps + .asSequence() + .filter { app -> matchesTopLevelFilter(app, filters.topLevelFilter) } + .filter { app -> matchesCategoryFilter(app, filters.categoryFilters) } + .filter { app -> matchesFirewallFilter(app, filters.firewallFilter) } + .filter { app -> matchesSearch(app, filters.searchString) } + .sortedWith( + compareBy( + { app -> app.appName.ifBlank { app.packageName }.lowercase(Locale.getDefault()) }, + { app -> app.packageName.lowercase(Locale.getDefault()) }, + { app -> app.uid } + ) + ) + .toList() + } + + private fun matchesTopLevelFilter( + app: AppInfo, + filter: TopLevelFilter + ): Boolean { + return when (filter) { + TopLevelFilter.ALL -> true + TopLevelFilter.INSTALLED -> !app.isSystemApp + TopLevelFilter.SYSTEM -> app.isSystemApp } } - fun setFilter(filters: AppListActivity.Filters) { - this.category.clear() - this.category.addAll(filters.categoryFilters) + private fun matchesCategoryFilter( + app: AppInfo, + categories: Set + ): Boolean { + if (categories.isEmpty()) return true + return categories.contains(app.appCategory) + } - this.firewallFilter = filters.firewallFilter - this.topLevelFilter = filters.topLevelFilter + private fun matchesFirewallFilter( + app: AppInfo, + filter: FirewallFilter + ): Boolean { + val firewallMatches = + filter.getFilter().contains(app.firewallStatus) && + filter.getConnectionStatusFilter().contains(app.connectionStatus) + val bypassProxyMatches = filter == FirewallFilter.BYPASS && app.isProxyExcluded + return firewallMatches || bypassProxyMatches + } - this.search = filters.searchString - setFilterWithDebounce(filters.searchString) + private fun matchesSearch( + app: AppInfo, + search: String + ): Boolean { + if (search.isBlank()) return true + val query = search.trim() + return app.appName.contains(query, ignoreCase = true) } - private fun getAppInfo(searchString: String): LiveData> { - return when (topLevelFilter) { - // get the app info based on the filter - AppListActivity.TopLevelFilter.ALL -> { - allApps(searchString) - } - AppListActivity.TopLevelFilter.INSTALLED -> { - installedApps(searchString) - } - AppListActivity.TopLevelFilter.SYSTEM -> { - systemApps(searchString) - } + // apply the firewall rules to the filtered apps + suspend fun updateUnmeteredStatus(blocked: Boolean) { + bulkUpdateMutex.withLock { + val appList = appInfo.value + appList + .distinctBy { it.uid } + .forEach { + val connStatus = FirewallManager.connectionStatus(it.uid) + val appStatus = getAppStateForWifi(blocked, connStatus) + FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) + } } } - private fun getBypassProxyFilter(): Set { - val filter = firewallFilter.getFilter() - val bypassFilter = setOf(2, 7) - if (filter == bypassFilter) { - return setOf(1) + suspend fun updateMeteredStatus(blocked: Boolean) { + bulkUpdateMutex.withLock { + val appList = appInfo.value + appList + .distinctBy { it.uid } + .forEach { + val connStatus = FirewallManager.connectionStatus(it.uid) + val appStatus = getAppStateForMobileData(blocked, connStatus) + FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) + } } - return setOf() // empty set (as query uses or condition) } - private fun allApps(searchString: String): LiveData> { - val includeProxyBypass = getBypassProxyFilter() - return if (category.isEmpty()) { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - appInfoDAO.getAppInfos( - "%$searchString%", - firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter(), - includeProxyBypass + suspend fun updateBypassStatus(bypass: Boolean) { + bulkUpdateMutex.withLock { + val appList = appInfo.value + val appStatus = + if (bypass) { + AppState( + FirewallManager.FirewallStatus.BYPASS_UNIVERSAL, + FirewallManager.ConnectionStatus.ALLOW ) - } - .liveData - .cachedIn(viewModelScope) - } else { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - appInfoDAO.getAppInfos( - "%$searchString%", - category, - firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter(), - includeProxyBypass + } else { + AppState( + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.ALLOW ) } - .liveData - .cachedIn(viewModelScope) + appList + .distinctBy { it.uid } + .forEach { + FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) + } } } - private fun installedApps(search: String): LiveData> { - val includeProxyBypass = getBypassProxyFilter() - return if (category.isEmpty()) { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - appInfoDAO.getInstalledApps( - "%$search%", - firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter(), - includeProxyBypass + suspend fun updateBypassDnsFirewall(bypass: Boolean) { + bulkUpdateMutex.withLock { + val appList = appInfo.value + val appStatus = + if (bypass) { + AppState( + FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL, + FirewallManager.ConnectionStatus.ALLOW ) - } - .liveData - .cachedIn(viewModelScope) - } else { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - appInfoDAO.getInstalledApps( - "%$search%", - category, - firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter(), - includeProxyBypass + } else { + AppState( + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.ALLOW ) } - .liveData - .cachedIn(viewModelScope) + appList + .distinctBy { it.uid } + .forEach { + FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) + } } } - private fun systemApps(search: String): LiveData> { - val includeProxyBypass = getBypassProxyFilter() - return if (category.isEmpty()) { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - appInfoDAO.getSystemApps( - "%$search%", - firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter(), - includeProxyBypass + suspend fun updateExcludeStatus(exclude: Boolean) { + bulkUpdateMutex.withLock { + val appList = appInfo.value + val appStatus = + if (exclude) { + AppState( + FirewallManager.FirewallStatus.EXCLUDE, + FirewallManager.ConnectionStatus.ALLOW ) - } - .liveData - .cachedIn(viewModelScope) - } else { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - appInfoDAO.getSystemApps( - "%$search%", - category, - firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter(), - includeProxyBypass + } else { + AppState( + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.ALLOW ) } - .liveData - .cachedIn(viewModelScope) + appList + .distinctBy { it.uid } + .forEach { + FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) + } } } - // apply the firewall rules to the filtered apps - suspend fun updateUnmeteredStatus(blocked: Boolean) { - val appList = getFilteredApps() - appList - .distinctBy { it.uid } - .forEach { - val connStatus = FirewallManager.connectionStatus(it.uid) - val appStatus = getAppStateForWifi(blocked, connStatus) - FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) - } - } - - suspend fun updateMeteredStatus(blocked: Boolean) { - val appList = getFilteredApps() - appList - .distinctBy { it.uid } - .forEach { - val connStatus = FirewallManager.connectionStatus(it.uid) - val appStatus = getAppStateForMobileData(blocked, connStatus) - FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) - } - } - - suspend fun updateBypassStatus(bypass: Boolean) { - val appList = getFilteredApps() - // update the bypass status for the filtered apps - // if the app is already in the bypass list, remove it - // else add it to the bypass list - val appStatus = - if (bypass) { - AppState( - FirewallManager.FirewallStatus.BYPASS_UNIVERSAL, - FirewallManager.ConnectionStatus.ALLOW - ) - } else { - AppState( - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW - ) - } - appList - .distinctBy { it.uid } - .forEach { FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) } - } - - suspend fun updateBypassDnsFirewall(bypass: Boolean) { - val appList = getFilteredApps() - // update the bypass status for the filtered apps - // if the app is already in the bypass list, remove it - // else add it to the bypass list - val appStatus = - if (bypass) { - AppState( - FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL, - FirewallManager.ConnectionStatus.ALLOW - ) - } else { - AppState( - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW - ) - } - appList - .distinctBy { it.uid } - .forEach { FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) } - } - - suspend fun updateExcludeStatus(exclude: Boolean) { - val appList = getFilteredApps() - // update the exclude status for the filtered apps - // if the app is already in the exclude list, remove it - // else add it to the exclude list - val appStatus = - if (exclude) { - AppState( - FirewallManager.FirewallStatus.EXCLUDE, - FirewallManager.ConnectionStatus.ALLOW - ) - } else { - AppState( - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW - ) - } - appList - .distinctBy { it.uid } - .forEach { FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) } - } - suspend fun updateLockdownStatus(lockdown: Boolean) { - val appList = getFilteredApps() - // update the lockdown status for the filtered apps - // if the app is already in the lockdown list, remove it - // else add it to the lockdown list - val appStatus = - if (lockdown) { - AppState( - FirewallManager.FirewallStatus.ISOLATE, - FirewallManager.ConnectionStatus.ALLOW - ) - } else { - AppState( - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW - ) - } + bulkUpdateMutex.withLock { + val appList = appInfo.value + val appStatus = + if (lockdown) { + AppState( + FirewallManager.FirewallStatus.ISOLATE, + FirewallManager.ConnectionStatus.ALLOW + ) + } else { + AppState( + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.ALLOW + ) + } - appList - .distinctBy { it.uid } - .forEach { FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) } + appList + .distinctBy { it.uid } + .forEach { + FirewallManager.updateFirewallStatus(it.uid, appStatus.fid, appStatus.cid) + } + } } private fun getAppStateForWifi( @@ -406,34 +367,7 @@ class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { } } - private fun getFilteredApps(): List { - val appType = - when (topLevelFilter) { - AppListActivity.TopLevelFilter.ALL -> { - setOf(0, 1) - } - AppListActivity.TopLevelFilter.INSTALLED -> { - setOf(0) - } - AppListActivity.TopLevelFilter.SYSTEM -> { - setOf(1) - } - } - return if (category.isEmpty()) { - appInfoDAO.getFilteredApps( - "%$search%", - firewallFilter.getFilter(), - appType, - firewallFilter.getConnectionStatusFilter() - ) - } else { - appInfoDAO.getFilteredApps( - "%$search%", - category, - firewallFilter.getFilter(), - appType, - firewallFilter.getConnectionStatusFilter() - ) - } + suspend fun getAppCount(): Int { + return appInfoDAO.getAppCount() } } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt index 9e0b4bd37..a92568cbd 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt @@ -27,7 +27,6 @@ import androidx.paging.cachedIn import androidx.paging.liveData import com.celzero.bravedns.database.ConnectionTracker import com.celzero.bravedns.database.ConnectionTrackerDAO -import com.celzero.bravedns.ui.fragment.ConnectionTrackerFragment import com.celzero.bravedns.util.Constants.Companion.LIVEDATA_PAGE_SIZE import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -36,6 +35,10 @@ import kotlinx.coroutines.launch class ConnectionTrackerViewModel(private val connectionTrackerDAO: ConnectionTrackerDAO) : ViewModel() { + companion object { + const val PROTOCOL_FILTER_PREFIX = "P:" + } + private val _filterString = MutableLiveData() private val filterString: LiveData = _filterString private val filterRules: MutableSet = mutableSetOf() @@ -90,7 +93,7 @@ class ConnectionTrackerViewModel(private val connectionTrackerDAO: ConnectionTra private fun fetchNetworkLogs(input: String): LiveData> { // spl case: treat input with P:UDP, P:TCP, P:ICMP as protocol filter - val protocolPrefix = ConnectionTrackerFragment.PROTOCOL_FILTER_PREFIX.lowercase() + val protocolPrefix = PROTOCOL_FILTER_PREFIX.lowercase() val s = input.trim().lowercase() if (s.startsWith(protocolPrefix)) { val protocol = s.substringAfter(protocolPrefix) @@ -161,11 +164,20 @@ class ConnectionTrackerViewModel(private val connectionTrackerDAO: ConnectionTra } private fun getAllNetworkLogs(input: String): LiveData> { - return Pager(pagingConfig) { - if (input.isBlank()) connectionTrackerDAO.getConnectionTrackerByName() - else connectionTrackerDAO.getConnectionTrackerByName("%$input%") - } - .liveData - .cachedIn(viewModelScope) + return if (filterRules.isNotEmpty()) { + Pager(pagingConfig) { + if (input.isBlank()) connectionTrackerDAO.getConnectionsFiltered(filterRules) + else connectionTrackerDAO.getConnectionsFiltered("%$input%", filterRules) + } + .liveData + .cachedIn(viewModelScope) + } else { + Pager(pagingConfig) { + if (input.isBlank()) connectionTrackerDAO.getConnectionTrackerByName() + else connectionTrackerDAO.getConnectionTrackerByName("%$input%") + } + .liveData + .cachedIn(viewModelScope) + } } } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt index a18519797..620a356a9 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt @@ -15,35 +15,36 @@ */ package com.celzero.bravedns.viewmodel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig +import androidx.paging.PagingData import androidx.paging.cachedIn -import androidx.paging.liveData +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.data.SummaryStatisticsType import com.celzero.bravedns.database.ConnectionTrackerDAO import com.celzero.bravedns.database.StatsSummaryDao import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.ui.fragment.SummaryStatisticsFragment import com.celzero.bravedns.util.Constants +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* class DetailedStatisticsViewModel( private val connectionTrackerDAO: ConnectionTrackerDAO, private val statsDao: StatsSummaryDao ) : ViewModel() { - private val allActiveConns: MutableLiveData = MutableLiveData() - private val allowedNetworkActivity: MutableLiveData = MutableLiveData() - private val blockedNetworkActivity: MutableLiveData = MutableLiveData() - private val allowedAsn: MutableLiveData = MutableLiveData() - private val blockedAsn: MutableLiveData = MutableLiveData() - private val allowedDomains: MutableLiveData = MutableLiveData() - private val blockedDomains: MutableLiveData = MutableLiveData() - private val allowedIps: MutableLiveData = MutableLiveData() - private val blockedIps: MutableLiveData = MutableLiveData() - private val allowedCountries: MutableLiveData = MutableLiveData() - private val startTime: MutableLiveData = MutableLiveData() + private val _allActiveConns: MutableStateFlow = MutableStateFlow(0L) + private val _allowedNetworkActivity: MutableStateFlow = MutableStateFlow("") + private val _blockedNetworkActivity: MutableStateFlow = MutableStateFlow("") + private val _allowedAsn: MutableStateFlow = MutableStateFlow("") + private val _blockedAsn: MutableStateFlow = MutableStateFlow("") + private val _allowedDomains: MutableStateFlow = MutableStateFlow("") + private val _blockedDomains: MutableStateFlow = MutableStateFlow("") + private val _allowedIps: MutableStateFlow = MutableStateFlow("") + private val _blockedIps: MutableStateFlow = MutableStateFlow("") + private val _allowedCountries: MutableStateFlow = MutableStateFlow("") + private val _startTime: MutableStateFlow = MutableStateFlow(0L) companion object { private const val ONE_HOUR_MILLIS = 1 * 60 * 60 * 1000L @@ -51,37 +52,37 @@ class DetailedStatisticsViewModel( private const val ONE_WEEK_MILLIS = 7 * ONE_DAY_MILLIS } - fun setData(type: SummaryStatisticsFragment.SummaryStatisticsType) { + fun setData(type: SummaryStatisticsType) { when (type) { - SummaryStatisticsFragment.SummaryStatisticsType.TOP_ACTIVE_CONNS -> { - allActiveConns.value = VpnController.uptimeMs() + SummaryStatisticsType.TOP_ACTIVE_CONNS -> { + _allActiveConns.value = VpnController.uptimeMs() } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONNECTED_APPS -> { - allowedNetworkActivity.value = "" + SummaryStatisticsType.MOST_CONNECTED_APPS -> { + _allowedNetworkActivity.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_APPS -> { - blockedNetworkActivity.value = "" + SummaryStatisticsType.MOST_BLOCKED_APPS -> { + _blockedNetworkActivity.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONNECTED_ASN -> { - allowedAsn.value = "" + SummaryStatisticsType.MOST_CONNECTED_ASN -> { + _allowedAsn.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_ASN -> { - blockedAsn.value = "" + SummaryStatisticsType.MOST_BLOCKED_ASN -> { + _blockedAsn.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> { - allowedDomains.value = "" + SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> { + _allowedDomains.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> { - blockedDomains.value = "" + SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> { + _blockedDomains.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONTACTED_IPS -> { - allowedIps.value = "" + SummaryStatisticsType.MOST_CONTACTED_IPS -> { + _allowedIps.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_IPS -> { - blockedIps.value = "" + SummaryStatisticsType.MOST_BLOCKED_IPS -> { + _blockedIps.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> { - allowedCountries.value = "" + SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> { + _allowedCountries.value = "" } } } @@ -89,108 +90,114 @@ class DetailedStatisticsViewModel( fun timeCategoryChanged(timeCategory: SummaryStatisticsViewModel.TimeCategory) { when (timeCategory) { SummaryStatisticsViewModel.TimeCategory.ONE_HOUR -> { - startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS + _startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS } SummaryStatisticsViewModel.TimeCategory.TWENTY_FOUR_HOUR -> { - startTime.value = System.currentTimeMillis() - ONE_DAY_MILLIS + _startTime.value = System.currentTimeMillis() - ONE_DAY_MILLIS } SummaryStatisticsViewModel.TimeCategory.SEVEN_DAYS -> { - startTime.value = System.currentTimeMillis() - ONE_WEEK_MILLIS + _startTime.value = System.currentTimeMillis() - ONE_WEEK_MILLIS } } } - val getAllActiveConns = - allActiveConns.switchMap { - val to = System.currentTimeMillis() - it + @OptIn(ExperimentalCoroutinesApi::class) + val getAllActiveConns: Flow> = + _allActiveConns.flatMapLatest { uptime -> + val to = System.currentTimeMillis() - uptime Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - statsDao.getAllActiveConns(to) - } - .liveData - .cachedIn(viewModelScope) + statsDao.getAllActiveConns(to) + }.flow.cachedIn(viewModelScope) } - val getAllAllowedAppNetworkActivity = - allowedNetworkActivity.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllAllowedAppNetworkActivity: Flow> = + combine(_allowedNetworkActivity, _startTime) { _, start -> + start + }.flatMapLatest { start -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getAllAllowedApps(to) - } - .liveData - .cachedIn(viewModelScope) + statsDao.getAllAllowedApps(start) + }.flow.cachedIn(viewModelScope) } - val getAllAllowedAsn = - allowedAsn.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllAllowedAsn: Flow> = + combine(_allowedAsn, _startTime) { _, start -> + start + }.flatMapLatest { start -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getAllConnectedASN(to) - } - .liveData - .cachedIn(viewModelScope) + statsDao.getAllConnectedASN(start) + }.flow.cachedIn(viewModelScope) } - val getAllBlockedAsn = - blockedAsn.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllBlockedAsn: Flow> = + combine(_blockedAsn, _startTime) { _, start -> + start + }.flatMapLatest { start -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getAllBlockedASN(to) - } - .liveData - .cachedIn(viewModelScope) + statsDao.getAllBlockedASN(start) + }.flow.cachedIn(viewModelScope) } - val getAllBlockedAppNetworkActivity = - blockedNetworkActivity.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllBlockedAppNetworkActivity: Flow> = + combine(_blockedNetworkActivity, _startTime) { _, start -> + start + }.flatMapLatest { start -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getAllBlockedApps(to) - } - .liveData - .cachedIn(viewModelScope) + statsDao.getAllBlockedApps(start) + }.flow.cachedIn(viewModelScope) } - val getAllBlockedDomains = blockedDomains.switchMap { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getAllBlockedDomains(to) - }.liveData.cachedIn(viewModelScope) - } + @OptIn(ExperimentalCoroutinesApi::class) + val getAllBlockedDomains: Flow> = + combine(_blockedDomains, _startTime) { _, start -> + start + }.flatMapLatest { start -> + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + statsDao.getAllBlockedDomains(start) + }.flow.cachedIn(viewModelScope) + } - val getAllContactedDomains = allowedDomains.switchMap { - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getAllContactedDomains(to) - }.liveData.cachedIn(viewModelScope) - } + @OptIn(ExperimentalCoroutinesApi::class) + val getAllContactedDomains: Flow> = + combine(_allowedDomains, _startTime) { _, start -> + start + }.flatMapLatest { start -> + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + statsDao.getAllContactedDomains(start) + }.flow.cachedIn(viewModelScope) + } - val getAllContactedIps = - allowedIps.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllContactedIps: Flow> = + combine(_allowedIps, _startTime) { _, start -> + start + }.flatMapLatest { start -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getAllContactedIps(to) - } - .liveData - .cachedIn(viewModelScope) + connectionTrackerDAO.getAllContactedIps(start) + }.flow.cachedIn(viewModelScope) } - val getAllBlockedIps = - blockedIps.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllBlockedIps: Flow> = + combine(_blockedIps, _startTime) { _, start -> + start + }.flatMapLatest { start -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getAllBlockedIps(to) - } - .liveData - .cachedIn(viewModelScope) + connectionTrackerDAO.getAllBlockedIps(start) + }.flow.cachedIn(viewModelScope) } - val getAllContactedCountries = - allowedCountries.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllContactedCountries: Flow> = + combine(_allowedCountries, _startTime) { _, start -> + start + }.flatMapLatest { start -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getAllContactedCountries(to) - } - .liveData - .cachedIn(viewModelScope) + statsDao.getAllContactedCountries(start) + }.flow.cachedIn(viewModelScope) } } + diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/DnsLogViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/DnsLogViewModel.kt index 51c09fe04..19aeea8a9 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/DnsLogViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/DnsLogViewModel.kt @@ -27,19 +27,16 @@ import androidx.paging.cachedIn import androidx.paging.liveData import com.celzero.bravedns.database.DnsLog import com.celzero.bravedns.database.DnsLogDAO -import com.celzero.bravedns.ui.fragment.DnsLogFragment import com.celzero.bravedns.util.Constants.Companion.LIVEDATA_PAGE_SIZE import com.celzero.bravedns.util.ResourceRecordTypes.Companion.getHandledTypes class DnsLogViewModel(private val dnsLogDAO: DnsLogDAO) : ViewModel() { - private val filteredList: MutableLiveData = MutableLiveData() - private var filterType = DnsLogFragment.DnsLogFilter.ALL + private var filteredList: MutableLiveData = MutableLiveData() + private var filterType = DnsLogFilter.ALL private val pagingConfig: PagingConfig private var isWireGuardLogs = false - private var isRpnLogs = false private var wgDnsId: String = "" - private var rpnDnsId: String = "" init { filteredList.value = "" @@ -60,23 +57,21 @@ class DnsLogViewModel(private val dnsLogDAO: DnsLogDAO) : ViewModel() { if (isWireGuardLogs) { // WireGuard DNS logs are not handled currently return getWgDnsLogs(wgDnsId) - } else if (isRpnLogs) { - return getRpnDnsLogs(rpnDnsId) } return when (filterType) { - DnsLogFragment.DnsLogFilter.ALL -> { + DnsLogFilter.ALL -> { getAllDnsLogs(filter) } - DnsLogFragment.DnsLogFilter.ALLOWED -> { + DnsLogFilter.ALLOWED -> { getAllowedDnsLogs(filter) } - DnsLogFragment.DnsLogFilter.BLOCKED -> { + DnsLogFilter.BLOCKED -> { getBlockedDnsLogs(filter) } - DnsLogFragment.DnsLogFilter.MAYBE_BLOCKED -> { + DnsLogFilter.MAYBE_BLOCKED -> { getMaybeBlockedDnsLogs(filter) } - DnsLogFragment.DnsLogFilter.UNKNOWN_RECORDS -> { + DnsLogFilter.UNKNOWN_RECORDS -> { getUnknownRecordDnsLogs(filter) } } @@ -102,14 +97,6 @@ class DnsLogViewModel(private val dnsLogDAO: DnsLogDAO) : ViewModel() { .cachedIn(viewModelScope) } - private fun getRpnDnsLogs(rpnId: String): LiveData> { - return Pager(pagingConfig) { - dnsLogDAO.getDnsLogsForRpn(rpnId) - } - .liveData - .cachedIn(viewModelScope) - } - private fun getAllowedDnsLogs(filter: String): LiveData> { return Pager(pagingConfig) { if (filter.isEmpty()) { @@ -159,7 +146,7 @@ class DnsLogViewModel(private val dnsLogDAO: DnsLogDAO) : ViewModel() { .cachedIn(viewModelScope) } - fun setFilter(searchString: String, type: DnsLogFragment.DnsLogFilter) { + fun setFilter(searchString: String, type: DnsLogFilter) { filterType = type if (searchString.isNotBlank()) filteredList.value = searchString @@ -172,9 +159,11 @@ class DnsLogViewModel(private val dnsLogDAO: DnsLogDAO) : ViewModel() { filteredList.value = filteredList.value } - fun setIsRpnLogs(isRLogs: Boolean, rpnId: String) { - isRpnLogs = isRLogs - rpnDnsId = "%$rpnId%" - filteredList.value = filteredList.value + enum class DnsLogFilter(val id: Int) { + ALL(0), + ALLOWED(1), + BLOCKED(2), + MAYBE_BLOCKED(3), + UNKNOWN_RECORDS(4) } } 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 755588baa..6c510b59e 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/ProxyAppsMappingViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/ProxyAppsMappingViewModel.kt @@ -16,60 +16,103 @@ package com.celzero.bravedns.viewmodel import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -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 +import com.celzero.bravedns.database.ProxyApplicationMapping +import com.celzero.bravedns.ui.dialog.TopLevelFilter +import java.util.Locale +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +@OptIn(FlowPreview::class) class ProxyAppsMappingViewModel(private val mappingDAO: ProxyApplicationMappingDAO) : ViewModel() { - private val filteredList: MutableLiveData = MutableLiveData() - private var filterType: WgIncludeAppsDialog.TopLevelFilter = - WgIncludeAppsDialog.TopLevelFilter.ALL_APPS - private var proxyId: String = "" + private data class ProxyAppsFilterState( + val searchQuery: String, + val filterType: TopLevelFilter, + val proxyId: String + ) - init { - filterType = WgIncludeAppsDialog.TopLevelFilter.ALL_APPS - proxyId = "" - filteredList.postValue("%%") - } + private val filterState = + MutableStateFlow( + ProxyAppsFilterState( + searchQuery = "", + filterType = TopLevelFilter.ALL_APPS, + proxyId = "" + ) + ) - var apps = - filteredList.switchMap { searchTxt -> - Pager(PagingConfig(LIVEDATA_PAGE_SIZE)) { - when (filterType) { - WgIncludeAppsDialog.TopLevelFilter.ALL_APPS -> - mappingDAO.getAllAppsMapping(searchTxt, proxyId) - WgIncludeAppsDialog.TopLevelFilter.SELECTED_APPS -> - mappingDAO.getSelectedAppsMapping(searchTxt, proxyId) - WgIncludeAppsDialog.TopLevelFilter.UNSELECTED_APPS -> - mappingDAO.getUnSelectedAppsMapping(searchTxt, proxyId) - } - } - .liveData - .cachedIn(viewModelScope) - } + val apps = + combine( + mappingDAO.getWgAppMappingFlow(), + filterState + .debounce(200) + .distinctUntilChanged() + ) { apps, state -> + filterAndSortApps(apps, state) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) - // 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) - } + // Unfiltered list for operations that must apply globally (for example, "Select all"). + val allApps: StateFlow> = + mappingDAO + .getWgAppMappingFlow() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) - fun setFilter(filter: String, type: WgIncludeAppsDialog.TopLevelFilter, pid: String) { - filterType = type - this.proxyId = pid - filteredList.postValue("%$filter%") + fun setFilter(filter: String, type: TopLevelFilter, pid: String) { + filterState.value = + ProxyAppsFilterState( + searchQuery = filter.trim(), + filterType = type, + proxyId = pid + ) } fun getAppCountById(configId: String): LiveData { return mappingDAO.getAppCountByIdLiveData(configId) } + + private fun filterAndSortApps( + apps: List, + state: ProxyAppsFilterState + ): List { + val query = state.searchQuery.lowercase(Locale.getDefault()) + val hasQuery = query.isNotBlank() + + return apps + .asSequence() + .filter { app -> + when (state.filterType) { + TopLevelFilter.ALL_APPS -> true + TopLevelFilter.SELECTED_APPS -> app.proxyId == state.proxyId + TopLevelFilter.UNSELECTED_APPS -> app.proxyId != state.proxyId + } + } + .filter { app -> + if (!hasQuery) return@filter true + app.appName.contains(query, ignoreCase = true) + } + .sortedWith( + compareBy( + { it.appName.ifBlank { it.packageName }.lowercase(Locale.getDefault()) }, + { it.packageName.lowercase(Locale.getDefault()) }, + { it.uid } + ) + ) + .toList() + } } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkLocalFileTagViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkLocalFileTagViewModel.kt index 5ec350540..5d0d8383a 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkLocalFileTagViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkLocalFileTagViewModel.kt @@ -25,14 +25,14 @@ import androidx.paging.cachedIn import androidx.paging.liveData import com.celzero.bravedns.data.FileTag import com.celzero.bravedns.database.RethinkLocalFileTagDao -import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment +import com.celzero.bravedns.ui.rethink.RethinkBlocklistState import com.celzero.bravedns.util.Constants.Companion.LIVEDATA_PAGE_SIZE class RethinkLocalFileTagViewModel(private val rethinkLocalDao: RethinkLocalFileTagDao) : ViewModel() { - private val list: MutableLiveData = MutableLiveData() - private var blocklistFilter: RethinkBlocklistFragment.Filters? = null + private var list: MutableLiveData = MutableLiveData() + private var blocklistFilter: RethinkBlocklistState.Filters? = null init { list.value = "" @@ -74,7 +74,7 @@ class RethinkLocalFileTagViewModel(private val rethinkLocalDao: RethinkLocalFile private fun getSelectedFilter(): MutableSet { if ( blocklistFilter?.filterSelected == - RethinkBlocklistFragment.BlocklistSelectionFilter.SELECTED + RethinkBlocklistState.BlocklistSelectionFilter.SELECTED ) { return mutableSetOf(1) } @@ -89,7 +89,7 @@ class RethinkLocalFileTagViewModel(private val rethinkLocalDao: RethinkLocalFile list.value = searchText } - fun setFilter(filter: RethinkBlocklistFragment.Filters) { + fun setFilter(filter: RethinkBlocklistState.Filters) { this.blocklistFilter = filter list.value = filter.query } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkRemoteFileTagViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkRemoteFileTagViewModel.kt index 36be65d62..519a9ee33 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkRemoteFileTagViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/RethinkRemoteFileTagViewModel.kt @@ -25,14 +25,14 @@ import androidx.paging.cachedIn import androidx.paging.liveData import com.celzero.bravedns.data.FileTag import com.celzero.bravedns.database.RethinkRemoteFileTagDao -import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment +import com.celzero.bravedns.ui.rethink.RethinkBlocklistState import com.celzero.bravedns.util.Constants.Companion.LIVEDATA_PAGE_SIZE class RethinkRemoteFileTagViewModel(private val rethinkRemoteDao: RethinkRemoteFileTagDao) : ViewModel() { - private val list: MutableLiveData = MutableLiveData() - private var blocklistFilter: RethinkBlocklistFragment.Filters? = null + private var list: MutableLiveData = MutableLiveData() + private var blocklistFilter: RethinkBlocklistState.Filters? = null init { list.value = "" @@ -77,7 +77,7 @@ class RethinkRemoteFileTagViewModel(private val rethinkRemoteDao: RethinkRemoteF private fun getSelectedFilter(): MutableSet { if ( blocklistFilter?.filterSelected == - RethinkBlocklistFragment.BlocklistSelectionFilter.SELECTED + RethinkBlocklistState.BlocklistSelectionFilter.SELECTED ) { return mutableSetOf(1) } @@ -92,7 +92,7 @@ class RethinkRemoteFileTagViewModel(private val rethinkRemoteDao: RethinkRemoteF list.value = searchText } - fun setFilter(filter: RethinkBlocklistFragment.Filters) { + fun setFilter(filter: RethinkBlocklistState.Filters) { this.blocklistFilter = filter list.value = filter.query } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt index 3f6f3e931..cbc21db7e 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt @@ -15,34 +15,40 @@ */ package com.celzero.bravedns.viewmodel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig +import androidx.paging.PagingData import androidx.paging.cachedIn -import androidx.paging.liveData +import com.celzero.bravedns.data.AppConnection import com.celzero.bravedns.data.DataUsageSummary import com.celzero.bravedns.database.ConnectionTracker import com.celzero.bravedns.database.ConnectionTrackerDAO import com.celzero.bravedns.database.StatsSummaryDao import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Constants +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.flow.flatMapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class SummaryStatisticsViewModel( private val connectionTrackerDAO: ConnectionTrackerDAO, private val statsDao: StatsSummaryDao ) : ViewModel() { - private val topActiveConns: MutableLiveData = MutableLiveData() - private val networkActivity: MutableLiveData = MutableLiveData() - private val asn: MutableLiveData = MutableLiveData() - private val countryActivities: MutableLiveData = MutableLiveData() - private val domains: MutableLiveData = MutableLiveData() - private val ips: MutableLiveData = MutableLiveData() - private var timeCategory: TimeCategory = TimeCategory.ONE_HOUR - private val startTime: MutableLiveData = MutableLiveData() - private var loadMoreClicked: Boolean = false + private val _uiState = MutableStateFlow(SummaryStatisticsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val startTime = MutableStateFlow(System.currentTimeMillis() - ONE_HOUR_MILLIS) + private val topActiveConnsTick = MutableStateFlow(VpnController.uptimeMs()) + private val refreshTick = MutableStateFlow(0L) companion object { private const val ONE_HOUR_MILLIS = 1 * 60 * 60 * 1000L @@ -60,148 +66,140 @@ class SummaryStatisticsViewModel( } } - init { - // set from and to time to current and 1 hr before - startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS - topActiveConns.value = VpnController.uptimeMs() - networkActivity.value = "" - asn.value = "" - } + data class SummaryStatisticsUiState( + val timeCategory: TimeCategory = TimeCategory.ONE_HOUR, + val dataUsage: DataUsageSummary = DataUsageSummary(0, 0, 0, 0) + ) - fun getTimeCategory(): TimeCategory { - return timeCategory - } - fun setLoadMoreClicked(b: Boolean) { - loadMoreClicked = b - // initialise the live data to trigger the switchMap - domains.value = "" - countryActivities.value = "" - ips.value = "" + init { + updateDataUsage() } fun timeCategoryChanged(tc: TimeCategory) { - timeCategory = tc - when (tc) { - TimeCategory.ONE_HOUR -> { - startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS - } - TimeCategory.TWENTY_FOUR_HOUR -> { - startTime.value = System.currentTimeMillis() - ONE_DAY_MILLIS - } - TimeCategory.SEVEN_DAYS -> { - startTime.value = System.currentTimeMillis() - ONE_WEEK_MILLIS - } + val st = when (tc) { + TimeCategory.ONE_HOUR -> System.currentTimeMillis() - ONE_HOUR_MILLIS + TimeCategory.TWENTY_FOUR_HOUR -> System.currentTimeMillis() - ONE_DAY_MILLIS + TimeCategory.SEVEN_DAYS -> System.currentTimeMillis() - ONE_WEEK_MILLIS } - networkActivity.value = "" - asn.value = "" - if (loadMoreClicked) { - countryActivities.value = "" - ips.value = "" - domains.value = "" + startTime.value = st + _uiState.update { it.copy(timeCategory = tc) } + refreshTick.update { it + 1 } + updateDataUsage() + } + + private fun updateDataUsage() { + viewModelScope.launch { + val to = startTime.value + val usage = withContext(Dispatchers.IO) { + connectionTrackerDAO.getTotalUsages(to, ConnectionTracker.ConnType.METERED.value) + } + _uiState.update { it.copy(dataUsage = usage) } } } - val getTopActiveConns = - topActiveConns.switchMap { + @OptIn(ExperimentalCoroutinesApi::class) + val getTopActiveConns: Flow> = + topActiveConnsTick.flatMapLatest { it -> val to = System.currentTimeMillis() - it Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { statsDao.getTopActiveConns(to) } - .liveData + .flow .cachedIn(viewModelScope) } - val getAllowedAppNetworkActivity = - networkActivity.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getAllowedAppNetworkActivity: Flow> = + refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - // use dnsQuery as appName - val to = startTime.value ?: 0L - statsDao.getMostAllowedApps(to) - } - .liveData + statsDao.getMostAllowedApps(startTime.value) + } + .flow .cachedIn(viewModelScope) } - val getBlockedAppNetworkActivity = - networkActivity.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getBlockedAppNetworkActivity: Flow> = + refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - // use dnsQuery as appName - val to = startTime.value ?: 0L - statsDao.getMostBlockedApps(to) - } - .liveData + statsDao.getMostBlockedApps(startTime.value) + } + .flow .cachedIn(viewModelScope) } - val getMostConnectedASN = - asn.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getMostConnectedASN: Flow> = + refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getMostConnectedASN(to) - } - .liveData + statsDao.getMostConnectedASN(startTime.value) + } + .flow .cachedIn(viewModelScope) } - val getMostBlockedASN = - asn.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getMostBlockedASN: Flow> = + refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getMostBlockedASN(to) - } - .liveData + statsDao.getMostBlockedASN(startTime.value) + } + .flow .cachedIn(viewModelScope) } - val mbd = domains.switchMap { + @OptIn(ExperimentalCoroutinesApi::class) + val mbd: Flow> = refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getMostBlockedDomains(to) + statsDao.getMostBlockedDomains(startTime.value) } - .liveData + .flow .cachedIn(viewModelScope) } - val mcd = domains.switchMap { + @OptIn(ExperimentalCoroutinesApi::class) + val mcd: Flow> = refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getMostContactedDomains(to) + statsDao.getMostContactedDomains(startTime.value) } - .liveData + .flow .cachedIn(viewModelScope) } - val getMostContactedIps = ips.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getMostContactedIps: Flow> = refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getMostContactedIps(to) + connectionTrackerDAO.getMostContactedIps(startTime.value) } - .liveData + .flow .cachedIn(viewModelScope) } - val getMostBlockedIps = ips.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getMostBlockedIps: Flow> = refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getMostBlockedIps(to) + connectionTrackerDAO.getMostBlockedIps(startTime.value) } - .liveData + .flow .cachedIn(viewModelScope) } - val getMostContactedCountries = - countryActivities.switchMap { _ -> + @OptIn(ExperimentalCoroutinesApi::class) + val getMostContactedCountries: Flow> = + refreshTick.flatMapLatest { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - statsDao.getMostContactedCountries(to) - } - .liveData + statsDao.getMostContactedCountries(startTime.value) + } + .flow .cachedIn(viewModelScope) } - suspend fun totalUsage(): DataUsageSummary { - val to = startTime.value ?: 0L - return connectionTrackerDAO.getTotalUsages(to, ConnectionTracker.ConnType.METERED.value) + suspend fun getTopAppsForCountry(flag: String, limit: Int = 5): List { + if (flag.isBlank()) return emptyList() + val to = startTime.value + return withContext(Dispatchers.IO) { + statsDao.getFlagDetailsLimited(flag = flag, to = to, limit = limit) + } } } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt index 63247d3a9..447a13c39 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt @@ -15,11 +15,18 @@ */ package com.celzero.bravedns.viewmodel +import com.celzero.bravedns.ui.compose.home.HomeScreenViewModel +import com.celzero.bravedns.ui.compose.dns.DnsSettingsViewModel +import com.celzero.bravedns.ui.compose.about.AboutViewModel +import org.koin.android.ext.koin.androidApplication import org.koin.core.module.dsl.viewModel import org.koin.dsl.module object ViewModelModule { - private val modelModules = module { + private val viewModelModule = module { + viewModel { HomeScreenViewModel(get(), get()) } + viewModel { DnsSettingsViewModel(get(), get(), get()) } + viewModel { AboutViewModel(get(), get(), get()) } viewModel { ConnectionTrackerViewModel(get()) } viewModel { DnsCryptEndpointViewModel(get()) } viewModel { DnsCryptRelayEndpointViewModel(get()) } @@ -40,20 +47,16 @@ object ViewModelModule { viewModel { RemoteBlocklistPacksMapViewModel(get()) } viewModel { ProxyAppsMappingViewModel(get()) } viewModel { WgConfigViewModel(get()) } + viewModel { CheckoutViewModel(androidApplication(), get()) } viewModel { DoTEndpointViewModel(get()) } viewModel { ODoHEndpointViewModel(get()) } - viewModel { BlockFreeDnsViewModel(get(), get(), get(), get(), get(), get()) } viewModel { RethinkLogViewModel(get()) } viewModel { AlertsViewModel(get(), get()) } viewModel { ConsoleLogViewModel(get()) } viewModel { DomainConnectionsViewModel(get()) } viewModel { WgNwActivityViewModel(get()) } viewModel { EventsViewModel(get()) } - viewModel { PurchaseHistoryViewModel(get()) } - viewModel { ManagePurchaseViewModel() } - viewModel { ServerOrderHistoryViewModel(get()) } - viewModel { ServerSelectionViewModel() } } - val modules = listOf(modelModules) + val modules = listOf(viewModelModule) } diff --git a/app/src/main/assets/database/rethink_v22.db-shm b/app/src/main/assets/database/rethink_v22.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3 + ProxyType.HTTP_SOCKS5 + + proxyType.isProxyTypeSocks5() && currentProxyType.isProxyTypeHttp() -> + ProxyType.HTTP_SOCKS5 + + proxyType.isProxyTypeHttp() -> ProxyType.HTTP + proxyType.isProxyTypeSocks5() -> ProxyType.SOCKS5 + else -> proxyType } - setProxy(ProxyType.SOCKS5, provider) - return - } + setProxy(nextProxyType, provider) } fun removeAllProxies() { @@ -1077,7 +1083,7 @@ internal constructor( } fun removeProxy(removeType: ProxyType, removeProvider: ProxyProvider) { - val currentProxyType = ProxyType.of(getProxyType()) + val currentProxyType = proxySelection().type if (currentProxyType.isProxyTypeHttpSocks5()) { if (removeType.isProxyTypeHttp()) { @@ -1116,67 +1122,59 @@ internal constructor( // Settings.ProxyModeSOCKS5 // Settings.ProxyModeHTTPS fun getTunProxyMode(): TunProxyMode { - val type = persistentState.proxyType - val provider = persistentState.proxyProvider - Logger.d(LOG_TAG_VPN, "selected proxy type: $type, with provider as $provider") - - if (ProxyProvider.WIREGUARD.name == provider) { - return TunProxyMode.WIREGUARD - } + val selection = proxySelection() + Logger.d( + LOG_TAG_VPN, + "selected proxy type: ${selection.type.name}, with provider as ${selection.provider.name}" + ) - if (ProxyProvider.ORBOT.name == provider) { - return TunProxyMode.ORBOT + when (selection.provider) { + ProxyProvider.WIREGUARD -> return TunProxyMode.WIREGUARD + ProxyProvider.ORBOT -> return TunProxyMode.ORBOT + else -> Unit } - when (type) { - ProxyType.HTTP.name -> { - return TunProxyMode.HTTPS - } - ProxyType.SOCKS5.name -> { - return TunProxyMode.SOCKS5 - } - ProxyType.HTTP_SOCKS5.name -> { + when (selection.type) { + ProxyType.HTTP -> return TunProxyMode.HTTPS + ProxyType.SOCKS5 -> return TunProxyMode.SOCKS5 + ProxyType.HTTP_SOCKS5 -> { // FIXME: tunnel does not support both http and socks5 at once. return TunProxyMode.SOCKS5 } + else -> Unit } return TunProxyMode.NONE } fun isCustomHttpProxyEnabled(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - // return false if the proxy provider is not custom - if (!proxyProvider.isProxyProviderCustom()) return false - - val proxyType = ProxyType.of(persistentState.proxyType) - return proxyType.isProxyTypeHttp() || proxyType.isProxyTypeHttpSocks5() + val selection = proxySelection() + return selection.provider.isProxyProviderCustom() && hasHttpProxyTypeEnabled() } fun isCustomSocks5Enabled(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - // return false if the proxy provider is not custom - if (!proxyProvider.isProxyProviderCustom()) return false - - val proxyType = ProxyType.of(persistentState.proxyType) - return proxyType.isProxyTypeSocks5() || proxyType.isProxyTypeHttpSocks5() + val selection = proxySelection() + return selection.provider.isProxyProviderCustom() && hasSocks5ProxyTypeEnabled() } fun isOrbotProxyEnabled(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - return proxyProvider.isProxyProviderOrbot() + return proxySelection().provider.isProxyProviderOrbot() } fun isWgEnabled(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - return proxyProvider.isProxyProviderWireguard() + return proxySelection().provider.isProxyProviderWireguard() } fun isProxyEnabled(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - if (proxyProvider.isProxyProviderNone()) return false + val selection = proxySelection() + return !selection.provider.isProxyProviderNone() && selection.type.isAnyProxyEnabled() + } + + fun hasHttpProxyTypeEnabled(): Boolean { + return proxySelection().type.isProxyTypeHasHttp() + } - val proxyType = ProxyType.of(persistentState.proxyType) - return proxyType.isAnyProxyEnabled() + fun hasSocks5ProxyTypeEnabled(): Boolean { + return proxySelection().type.isSocks5Enabled() } fun canEnableProxy(): Boolean { @@ -1184,33 +1182,23 @@ internal constructor( } fun canEnableSocks5Proxy(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - return !getBraveMode().isDnsMode() && - (proxyProvider.isProxyProviderNone() || proxyProvider.isProxyProviderCustom()) + return canEnableProxyForProviders(ProxyProvider.NONE, ProxyProvider.CUSTOM) } fun canEnableWireguardProxy(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - return !getBraveMode().isDnsMode() && - (proxyProvider.isProxyProviderNone() || proxyProvider.isProxyProviderWireguard()) + return canEnableProxyForProviders(ProxyProvider.NONE, ProxyProvider.WIREGUARD) } fun canEnableHttpProxy(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - return !getBraveMode().isDnsMode() && - (proxyProvider.isProxyProviderNone() || proxyProvider.isProxyProviderCustom()) + return canEnableProxyForProviders(ProxyProvider.NONE, ProxyProvider.CUSTOM) } fun canEnableTcpProxy(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - return !getBraveMode().isDnsMode() && - (proxyProvider.isProxyProviderNone() || proxyProvider.isProxyProviderTcp()) + return canEnableProxyForProviders(ProxyProvider.NONE, ProxyProvider.TCP) } fun canEnableOrbotProxy(): Boolean { - val proxyProvider = ProxyProvider.getProxyProvider(persistentState.proxyProvider) - return canEnableProxy() && - (proxyProvider.isProxyProviderNone() || proxyProvider.isProxyProviderOrbot()) + return canEnableProxyForProviders(ProxyProvider.NONE, ProxyProvider.ORBOT) } suspend fun getConnectedSocks5Proxy(): ProxyEndpoint? { @@ -1246,8 +1234,8 @@ internal constructor( val sb = StringBuilder() sb.append(" Brave mode: ${getBraveMode()}\n") sb.append(" DNS type: ${getDnsType()}\n") - sb.append(" Proxy type: ${ProxyType.of(getProxyType()).name}\n") - sb.append(" Proxy provider: ${getProxyProvider()}\n") + sb.append(" Proxy type: ${proxySelection().type.name}\n") + sb.append(" Proxy provider: ${proxySelection().provider.name}\n") sb.append(" Pcap mode: ${getPcapFilePath()}\n") sb.append(" Connected DNS: ${persistentState.connectedDnsName}\n") sb.append(" Prevent DNS leaks: ${persistentState.preventDnsLeaks}\n") diff --git a/app/src/main/java/com/celzero/bravedns/data/SummaryStatisticsType.kt b/app/src/main/java/com/celzero/bravedns/data/SummaryStatisticsType.kt new file mode 100644 index 000000000..f8fd53759 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/data/SummaryStatisticsType.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2022 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.data + +enum class SummaryStatisticsType(val tid: Int) { + MOST_CONNECTED_APPS(0), + MOST_BLOCKED_APPS(1), + MOST_CONNECTED_ASN(2), + MOST_BLOCKED_ASN(3), + MOST_CONTACTED_DOMAINS(4), + MOST_CONTACTED_COUNTRIES(5), + MOST_BLOCKED_DOMAINS(6), + MOST_CONTACTED_IPS(7), + MOST_BLOCKED_IPS(8), + TOP_ACTIVE_CONNS(9); + + companion object { + fun getType(t: Int): SummaryStatisticsType { + return entries.find { it.tid == t } ?: MOST_CONNECTED_APPS + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/database/AppInfoDAO.kt b/app/src/main/java/com/celzero/bravedns/database/AppInfoDAO.kt index 48ac09cc0..1c3fb4a1d 100644 --- a/app/src/main/java/com/celzero/bravedns/database/AppInfoDAO.kt +++ b/app/src/main/java/com/celzero/bravedns/database/AppInfoDAO.kt @@ -24,6 +24,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.celzero.bravedns.data.DataUsage +import kotlinx.coroutines.flow.Flow @Dao interface AppInfoDAO { @@ -64,6 +65,11 @@ interface AppInfoDAO { fun tombstoneApp(oldUid: Int, newUid: Int, tombstoneTs: Long, modifiedTs: Long) @Query("select * from AppInfo order by appCategory, uid") fun getAllAppDetails(): List + @Query("select * from AppInfo order by lower(appName), uid, packageName") + fun getAllAppDetailsFlow(): Flow> + + @Query("select count(*) from AppInfo") + suspend fun getAppCount(): Int @Query( "select * from AppInfo where isSystemApp = 1 and (appName like :search or uid like :search or packageName like :search) and (firewallStatus in (:firewall) or isProxyExcluded in (:isProxyExcluded)) and connectionStatus in (:connectionStatus) order by lower(appName)" @@ -129,24 +135,26 @@ interface AppInfoDAO { ): PagingSource @Query( - "select * from AppInfo where (appName like :search or uid like :search or packageName like :search) and appCategory in (:cat) and isSystemApp in (:appType) and firewallStatus in (:firewall) and connectionStatus in (:connectionStatus) order by lower(appName)" + "select * from AppInfo where (appName like :search or uid like :search or packageName like :search) and appCategory in (:cat) and isSystemApp in (:appType) and (firewallStatus in (:firewall) or isProxyExcluded in (:isProxyExcluded)) and connectionStatus in (:connectionStatus) order by lower(appName)" ) fun getFilteredApps( search: String, cat: Set, firewall: Set, appType: Set, - connectionStatus: Set + connectionStatus: Set, + isProxyExcluded: Set ): List @Query( - "select * from AppInfo where (appName like :search or uid like :search or packageName like :search) and isSystemApp in (:appType) and firewallStatus in (:firewall) and connectionStatus in (:connectionStatus) order by lower(appName)" + "select * from AppInfo where (appName like :search or uid like :search or packageName like :search) and isSystemApp in (:appType) and (firewallStatus in (:firewall) or isProxyExcluded in (:isProxyExcluded)) and connectionStatus in (:connectionStatus) order by lower(appName)" ) fun getFilteredApps( search: String, firewall: Set, appType: Set, - connectionStatus: Set + connectionStatus: Set, + isProxyExcluded: Set ): List @Query( @@ -177,7 +185,7 @@ interface AppInfoDAO { @Query("update AppInfo set isProxyExcluded = :bypass where packageName = 'com.celzero.bravedns'") fun setRethinkToBypassProxy(bypass: Boolean) - @Query("update AppInfo set firewallStatus = 7 and connectionStatus = 3 where packageName = 'com.celzero.bravedns'") + @Query("update AppInfo set firewallStatus = 7, connectionStatus = 3 where packageName = 'com.celzero.bravedns'") fun setRethinkToBypassDnsAndFirewall() @Query("select * from AppInfo where tempAllowEnabled = 1 and tempAllowExpiryTime > 0") diff --git a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt index e8a8df8b9..eb14c7d6d 100644 --- a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt +++ b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt @@ -84,6 +84,17 @@ interface ConnectionTrackerDAO { ) fun getConnectionTrackerByName(query: String): PagingSource + @Query("select * from ConnectionTracker where blockedByRule in (:filter) order by id desc LIMIT $MAX_LOGS") + fun getConnectionsFiltered(filter: Set): PagingSource + + @Query( + "select * from ConnectionTracker where (appName like :query or ipAddress like :query or dnsQuery like :query or flag like :query or proxyDetails like :query or connId like :query) and blockedByRule in (:filter) order by id desc LIMIT $MAX_LOGS" + ) + fun getConnectionsFiltered( + query: String, + filter: Set + ): PagingSource + @Query("select * from ConnectionTracker where isBlocked = 1 order by id desc LIMIT $MAX_LOGS") fun getBlockedConnections(): PagingSource @@ -187,6 +198,36 @@ interface ConnectionTrackerDAO { @Query("select count(id) from ConnectionTracker") fun logsCount(): LiveData + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM ConnectionTracker WHERE TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getAllLoggedAppsWithCount(): List + + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM ConnectionTracker WHERE blockedByRule in (:rules) AND TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getAllLoggedAppsWithCountFiltered(rules: Set): List + + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM ConnectionTracker WHERE isBlocked = 0 AND TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getAllowedLoggedAppsWithCount(): List + + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM ConnectionTracker WHERE isBlocked = 0 AND blockedByRule in (:rules) AND TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getAllowedLoggedAppsWithCountFiltered(rules: Set): List + + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM ConnectionTracker WHERE isBlocked = 1 AND TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getBlockedLoggedAppsWithCount(): List + + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM ConnectionTracker WHERE isBlocked = 1 AND blockedByRule in (:rules) AND TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getBlockedLoggedAppsWithCountFiltered(rules: Set): List + @Query( "select timeStamp from ConnectionTracker where id = (select min(id) from ConnectionTracker)" ) diff --git a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt index 31ad0bebc..af6d67903 100644 --- a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt +++ b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt @@ -77,6 +77,30 @@ class ConnectionTrackerRepository(private val connectionTrackerDAO: ConnectionTr return connectionTrackerDAO.logsCount() } + suspend fun getAllLoggedAppsWithCount(rules: Set = emptySet()): List { + return if (rules.isEmpty()) { + connectionTrackerDAO.getAllLoggedAppsWithCount() + } else { + connectionTrackerDAO.getAllLoggedAppsWithCountFiltered(rules) + } + } + + suspend fun getAllowedLoggedAppsWithCount(rules: Set = emptySet()): List { + return if (rules.isEmpty()) { + connectionTrackerDAO.getAllowedLoggedAppsWithCount() + } else { + connectionTrackerDAO.getAllowedLoggedAppsWithCountFiltered(rules) + } + } + + suspend fun getBlockedLoggedAppsWithCount(rules: Set = emptySet()): List { + return if (rules.isEmpty()) { + connectionTrackerDAO.getBlockedLoggedAppsWithCount() + } else { + connectionTrackerDAO.getBlockedLoggedAppsWithCountFiltered(rules) + } + } + fun getLeastLoggedTime(): Long { return connectionTrackerDAO.getLeastLoggedTime() } diff --git a/app/src/main/java/com/celzero/bravedns/database/ConsoleLogDatabase.kt b/app/src/main/java/com/celzero/bravedns/database/ConsoleLogDatabase.kt index 0b69bf6fd..54054fcf2 100644 --- a/app/src/main/java/com/celzero/bravedns/database/ConsoleLogDatabase.kt +++ b/app/src/main/java/com/celzero/bravedns/database/ConsoleLogDatabase.kt @@ -33,9 +33,9 @@ abstract class ConsoleLogDatabase : RoomDatabase() { } private val MIGRATION_1_2 = object : androidx.room.migration.Migration(1, 2) { - override fun migrate(database: androidx.sqlite.db.SupportSQLiteDatabase) { + override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) { // set default log level to 3 (INFO) - database.execSQL("ALTER TABLE ConsoleLog ADD COLUMN level INTEGER DEFAULT 3") + db.execSQL("ALTER TABLE ConsoleLog ADD COLUMN level INTEGER DEFAULT 3") } } } diff --git a/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt b/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt index 4516dc166..ad485973c 100644 --- a/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt +++ b/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt @@ -94,6 +94,21 @@ interface DnsLogDAO { @Query("select count(id) from DNSLogs") fun logsCount(): LiveData + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM DNSLogs WHERE TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getAllLoggedAppsWithCount(): List + + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM DNSLogs WHERE isBlocked = 0 AND blockLists = '' AND TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getAllowedLoggedAppsWithCount(): List + + @Query( + "SELECT appName AS appName, packageName AS packageName, COUNT(id) AS count FROM DNSLogs WHERE isBlocked = 1 AND TRIM(appName) != '' GROUP BY appName, packageName ORDER BY count DESC" + ) + suspend fun getBlockedLoggedAppsWithCount(): List + @Query("select time from DNSLogs where id = (select min(id) from DNSLogs)") fun getLeastLoggedTime(): Long diff --git a/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt b/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt index 72c604bff..ba20984db 100644 --- a/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt +++ b/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt @@ -39,6 +39,18 @@ class DnsLogRepository(private val dnsLogDAO: DnsLogDAO) { return dnsLogDAO.logsCount() } + suspend fun getAllLoggedAppsWithCount(): List { + return dnsLogDAO.getAllLoggedAppsWithCount() + } + + suspend fun getAllowedLoggedAppsWithCount(): List { + return dnsLogDAO.getAllowedLoggedAppsWithCount() + } + + suspend fun getBlockedLoggedAppsWithCount(): List { + return dnsLogDAO.getBlockedLoggedAppsWithCount() + } + fun getLeastLoggedTime(): Long { return dnsLogDAO.getLeastLoggedTime() } diff --git a/app/src/main/java/com/celzero/bravedns/database/LogAppCount.kt b/app/src/main/java/com/celzero/bravedns/database/LogAppCount.kt new file mode 100644 index 000000000..dbfb0088d --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/database/LogAppCount.kt @@ -0,0 +1,23 @@ +/* + * 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.database + +data class LogAppCount( + val appName: String, + val packageName: String, + val count: Int +) + 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 d5c9065e0..b6996a392 100644 --- a/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt +++ b/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt @@ -47,8 +47,7 @@ import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.service.WireguardManager -import com.celzero.bravedns.ui.NotificationHandlerActivity -import com.celzero.bravedns.ui.activity.AppLockActivity +import com.celzero.bravedns.ui.HomeScreenActivity import com.celzero.bravedns.util.AndroidUidConfig import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Constants.Companion.INVALID_UID @@ -686,7 +685,7 @@ internal constructor( ctx.getSystemService(VpnService.NOTIFICATION_SERVICE) as NotificationManager Logger.d(LOG_TAG_VPN, "Number of new apps: $appSize, show notification") - val intent = Intent(ctx, NotificationHandlerActivity::class.java) + val intent = Intent(ctx, HomeScreenActivity::class.java) intent.putExtra( Constants.NOTIF_INTENT_EXTRA_NEW_APP_NAME, Constants.NOTIF_INTENT_EXTRA_NEW_APP_VALUE @@ -698,7 +697,7 @@ internal constructor( var builder: NotificationCompat.Builder if (isAtleastO()) { val name: CharSequence = ctx.getString(R.string.notif_channel_firewall_alerts) - val description = ctx.resources.getString(R.string.notif_channel_desc_firewall_alerts) + val description = ctx.getString(R.string.notif_channel_desc_firewall_alerts) val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(NOTIF_CHANNEL_ID_FIREWALL_ALERTS, name, importance) channel.description = description @@ -708,9 +707,9 @@ internal constructor( builder = NotificationCompat.Builder(ctx, NOTIF_CHANNEL_ID_FIREWALL_ALERTS) } - val contentTitle: String = ctx.resources.getString(R.string.new_app_bulk_notification_title) + val contentTitle: String = ctx.getString(R.string.new_app_bulk_notification_title) val contentText: String = - ctx.resources.getString(R.string.new_app_bulk_notification_content, appSize.toString()) + ctx.getString(R.string.new_app_bulk_notification_content, appSize.toString()) builder .setSmallIcon(R.drawable.ic_notification_icon) @@ -766,7 +765,7 @@ internal constructor( ctx.getSystemService(VpnService.NOTIFICATION_SERVICE) as NotificationManager Logger.d(LOG_TAG_VPN, "New app installed: $appName, show notification") - val intent = Intent(ctx, NotificationHandlerActivity::class.java) + val intent = Intent(ctx, HomeScreenActivity::class.java) intent.putExtra( Constants.NOTIF_INTENT_EXTRA_NEW_APP_NAME, Constants.NOTIF_INTENT_EXTRA_NEW_APP_VALUE @@ -784,7 +783,7 @@ internal constructor( val builder: NotificationCompat.Builder if (isAtleastO()) { val name: CharSequence = ctx.getString(R.string.notif_channel_firewall_alerts) - val description = ctx.resources.getString(R.string.notif_channel_desc_firewall_alerts) + val description = ctx.getString(R.string.notif_channel_desc_firewall_alerts) val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(NOTIF_CHANNEL_ID_FIREWALL_ALERTS, name, importance) channel.description = description @@ -794,9 +793,9 @@ internal constructor( builder = NotificationCompat.Builder(ctx, NOTIF_CHANNEL_ID_FIREWALL_ALERTS) } - val contentTitle: String = ctx.resources.getString(R.string.lbl_action_required) + val contentTitle: String = ctx.getString(R.string.lbl_action_required) val contentText: String = - ctx.resources.getString(R.string.new_app_notification_content, appName) + ctx.getString(R.string.new_app_notification_content, appName) builder .setSmallIcon(R.drawable.ic_notification_icon) @@ -825,13 +824,13 @@ internal constructor( val notificationAction: NotificationCompat.Action = NotificationCompat.Action( 0, - ctx.resources.getString(R.string.allow).uppercase(), + ctx.getString(R.string.allow).uppercase(), openIntent1 ) val notificationAction2: NotificationCompat.Action = NotificationCompat.Action( 0, - ctx.resources.getString(R.string.new_app_notification_action_deny).uppercase(), + ctx.getString(R.string.new_app_notification_action_deny).uppercase(), openIntent2 ) builder.addAction(notificationAction) @@ -850,7 +849,7 @@ internal constructor( return } - val intent = Intent(ctx, AppLockActivity::class.java) + val intent = Intent(ctx, HomeScreenActivity::class.java) val nm = ctx.getSystemService(VpnService.NOTIFICATION_SERVICE) as NotificationManager val pendingIntent = getActivityPendingIntent( @@ -861,7 +860,7 @@ internal constructor( ) if (isAtleastO()) { val name: CharSequence = ctx.getString(R.string.notif_channel_firewall_alerts) - val description = ctx.resources.getString(R.string.notif_channel_desc_firewall_alerts) + val description = ctx.getString(R.string.notif_channel_desc_firewall_alerts) val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(NOTIF_CHANNEL_ID_FIREWALL_ALERTS, name, importance) channel.description = description @@ -870,8 +869,8 @@ internal constructor( var builder: NotificationCompat.Builder = NotificationCompat.Builder(ctx, NOTIF_CHANNEL_ID_FIREWALL_ALERTS) - val contentTitle = ctx.resources.getString(R.string.rules_load_failure_heading) - val contentText = ctx.resources.getString(R.string.rules_load_failure_desc) + val contentTitle = ctx.getString(R.string.rules_load_failure_heading) + val contentText = ctx.getString(R.string.rules_load_failure_desc) builder .setSmallIcon(R.drawable.ic_notification_icon) .setContentTitle(contentTitle) @@ -884,7 +883,7 @@ internal constructor( val notificationAction: NotificationCompat.Action = NotificationCompat.Action( 0, - ctx.resources.getString(R.string.rules_load_failure_reload), + ctx.getString(R.string.rules_load_failure_reload), openIntent ) builder.addAction(notificationAction) diff --git a/app/src/main/java/com/celzero/bravedns/database/RethinkDnsEndpointRepository.kt b/app/src/main/java/com/celzero/bravedns/database/RethinkDnsEndpointRepository.kt index d677227da..2a9fe2210 100644 --- a/app/src/main/java/com/celzero/bravedns/database/RethinkDnsEndpointRepository.kt +++ b/app/src/main/java/com/celzero/bravedns/database/RethinkDnsEndpointRepository.kt @@ -17,70 +17,78 @@ package com.celzero.bravedns.database import androidx.room.Transaction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class RethinkDnsEndpointRepository(private val rethinkDnsEndpointDao: RethinkDnsEndpointDao) { @Transaction suspend fun update(rethinkDnsEndpoint: RethinkDnsEndpoint) { - rethinkDnsEndpointDao.removeConnectionStatus() - rethinkDnsEndpointDao.update(rethinkDnsEndpoint) + withContext(Dispatchers.IO) { + rethinkDnsEndpointDao.removeConnectionStatus() + rethinkDnsEndpointDao.update(rethinkDnsEndpoint) + } } suspend fun insertWithReplace(rethinkDnsEndpoint: RethinkDnsEndpoint) { - rethinkDnsEndpointDao.insertReplace(rethinkDnsEndpoint) + withContext(Dispatchers.IO) { rethinkDnsEndpointDao.insertReplace(rethinkDnsEndpoint) } } suspend fun removeConnectionStatus() { - rethinkDnsEndpointDao.removeConnectionStatus() + withContext(Dispatchers.IO) { rethinkDnsEndpointDao.removeConnectionStatus() } } suspend fun removeAppWiseDns(uid: Int) { - rethinkDnsEndpointDao.removeAppWiseDns(uid) + withContext(Dispatchers.IO) { rethinkDnsEndpointDao.removeAppWiseDns(uid) } } suspend fun isAppWiseDnsEnabled(uid: Int): Boolean { - return rethinkDnsEndpointDao.isAppWiseDnsEnabled(uid) ?: false + return withContext(Dispatchers.IO) { rethinkDnsEndpointDao.isAppWiseDnsEnabled(uid) ?: false } } suspend fun getConnectedEndpoint(): RethinkDnsEndpoint? { - return rethinkDnsEndpointDao.getConnectedEndpoint() + return withContext(Dispatchers.IO) { rethinkDnsEndpointDao.getConnectedEndpoint() } } suspend fun getDefaultRethinkEndpoint(): RethinkDnsEndpoint? { - return rethinkDnsEndpointDao.getDefaultRethinkEndpoint() + return withContext(Dispatchers.IO) { rethinkDnsEndpointDao.getDefaultRethinkEndpoint() } } suspend fun updateConnectionDefault() { - rethinkDnsEndpointDao.removeConnectionStatus() - rethinkDnsEndpointDao.updateConnectionDefault() + withContext(Dispatchers.IO) { + rethinkDnsEndpointDao.removeConnectionStatus() + rethinkDnsEndpointDao.updateConnectionDefault() + } } suspend fun setRethinkPlus() { - rethinkDnsEndpointDao.removeConnectionStatus() - rethinkDnsEndpointDao.setRethinkPlus() + withContext(Dispatchers.IO) { + rethinkDnsEndpointDao.removeConnectionStatus() + rethinkDnsEndpointDao.setRethinkPlus() + } } suspend fun getCount(): Int { - return rethinkDnsEndpointDao.getCount() + return withContext(Dispatchers.IO) { rethinkDnsEndpointDao.getCount() } } suspend fun updatePlusBlocklistCount(count: Int) { - rethinkDnsEndpointDao.updatePlusBlocklistCount(count) + withContext(Dispatchers.IO) { rethinkDnsEndpointDao.updatePlusBlocklistCount(count) } } suspend fun updateEndpoint(name: String, url: String, count: Int) { - rethinkDnsEndpointDao.updateEndpoint(name, url, count) + withContext(Dispatchers.IO) { rethinkDnsEndpointDao.updateEndpoint(name, url, count) } } suspend fun getRethinkPlusEndpoint(): RethinkDnsEndpoint? { - return rethinkDnsEndpointDao.getRethinkPlusEndpoint() + return withContext(Dispatchers.IO) { rethinkDnsEndpointDao.getRethinkPlusEndpoint() } } suspend fun switchToMax() { - rethinkDnsEndpointDao.switchToMax() + withContext(Dispatchers.IO) { rethinkDnsEndpointDao.switchToMax() } } suspend fun switchToSky() { - rethinkDnsEndpointDao.switchToSky() + withContext(Dispatchers.IO) { rethinkDnsEndpointDao.switchToSky() } } } diff --git a/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTag.kt b/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTag.kt index 8be55b98d..d3fb15333 100644 --- a/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTag.kt +++ b/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTag.kt @@ -87,9 +87,9 @@ class RethinkLocalFileTag { "group" -> group = it.value as String "subg" -> subg = it.value as String "url" -> { - if (it.value as Any? is String) { + if (it.value is String) { url = listOf(it.value as String) - } else if (it.value as Any? is List<*>) { + } else if (it.value is List<*>) { @Suppress("UNCHECKED_CAST") url = it.value as List } @@ -97,9 +97,9 @@ class RethinkLocalFileTag { "show" -> show = it.value as Int "entries" -> entries = it.value as Int "pack" -> { - if (it.value as Any? is String) { + if (it.value is String) { pack = listOf(it.value as String) - } else if (it.value as Any? is List<*>) { + } else if (it.value is List<*>) { @Suppress("UNCHECKED_CAST") pack = it.value as List } @@ -107,7 +107,7 @@ class RethinkLocalFileTag { "simpleTagId" -> simpleTagId = it.value as Int "isSelected" -> { isSelected = - if (it.value as Any? is Boolean) { + if (it.value is Boolean) { it.value as Boolean } else { (it.value as Int) == 1 diff --git a/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagDao.kt b/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagDao.kt index 2bab75d05..9c93b187f 100644 --- a/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagDao.kt +++ b/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagDao.kt @@ -94,7 +94,8 @@ interface RethinkLocalFileTagDao { selected: Set ): PagingSource - @Query("Update RethinkLocalFileTag set isSelected = 0") fun clearSelectedTags() + @Query("Update RethinkLocalFileTag set isSelected = 0") + suspend fun clearSelectedTags() @Query( "select value, uname, vname, `group`, subg, url as urls, show, entries, pack, level, simpleTagId, isSelected from RethinkLocalFileTag" diff --git a/app/src/main/java/com/celzero/bravedns/database/RethinkRemoteFileTagDao.kt b/app/src/main/java/com/celzero/bravedns/database/RethinkRemoteFileTagDao.kt index 52b0c1406..364b52b81 100644 --- a/app/src/main/java/com/celzero/bravedns/database/RethinkRemoteFileTagDao.kt +++ b/app/src/main/java/com/celzero/bravedns/database/RethinkRemoteFileTagDao.kt @@ -94,7 +94,8 @@ interface RethinkRemoteFileTagDao { ) fun fileTags(): List - @Query("Update RethinkRemoteFileTag set isSelected = 0") fun clearSelectedTags() + @Query("Update RethinkRemoteFileTag set isSelected = 0") + suspend fun clearSelectedTags() @Query("select value from RethinkRemoteFileTag where isSelected = 1") fun getSelectedTags(): List diff --git a/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt b/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt index 41e6c88bd..d690eb91a 100644 --- a/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt +++ b/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt @@ -929,6 +929,57 @@ interface StatsSummaryDao { ) fun getFlagDetails(flag: String, to: Long): PagingSource + @Query( + """ + SELECT uid AS uid, + '' AS ipAddress, + 0 AS port, + SUM(count) AS count, + flag AS flag, + 0 AS blocked, + appOrDnsName AS appOrDnsName, + Sum(uploadbytes) AS uploadBytes, + Sum(downloadbytes) AS downloadBytes, + Sum(uploadBytes + downloadBytes) AS totalBytes + FROM + ( + -- From ConnectionTracker + SELECT uid, + appName AS appOrDnsName, + COUNT(id) AS count, + flag as flag, + SUM(uploadBytes) AS uploadBytes, + SUM(downloadBytes) AS downloadBytes + FROM ConnectionTracker + WHERE timeStamp > :to + AND flag = :flag + AND isBlocked = 0 + GROUP BY uid + + UNION ALL + + -- From DnsLogs + SELECT uid, + appName AS appOrDnsName, + COUNT(id) AS count, + flag as flag, + 0 AS uploadBytes, + 0 AS downloadBytes + FROM DnsLogs + WHERE time > :to + AND flag = :flag + AND isBlocked = 0 + AND status = 'COMPLETE' + AND queryStr != '' + GROUP BY uid + ) AS combined + GROUP BY uid + ORDER BY count DESC + LIMIT :limit + """ + ) + suspend fun getFlagDetailsLimited(flag: String, to: Long, limit: Int): List + @Query( """ SELECT :uid AS uid, diff --git a/app/src/main/java/com/celzero/bravedns/service/AppsStatsManager.kt b/app/src/main/java/com/celzero/bravedns/service/AppsStatsManager.kt new file mode 100644 index 000000000..5140307dc --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/AppsStatsManager.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 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 androidx.lifecycle.asFlow +import com.celzero.bravedns.database.AppInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +/** + * Manages app statistics for firewall. + * Extracted from BraveVPNService for better separation of concerns. + */ +class AppsStatsManager( + private val firewallManager: FirewallManager, + private val scope: CoroutineScope +) { + + companion object { + private const val TAG = "AppsStats" + } + + private val _appsStats = MutableStateFlow(AppsStats()) + val appsStats: StateFlow = _appsStats.asStateFlow() + + data class AppsStats( + val totalApps: Int = 0, + val allowedApps: Int = 0, + val blockedApps: Int = 0, + val bypassedApps: Int = 0, + val isolatedApps: Int = 0, + val excludedApps: Int = 0 + ) + + init { + observeAppChanges() + } + + private fun observeAppChanges() { + firewallManager.getApplistObserver().asFlow() + .onEach { appList -> + updateStats(appList) + } + .launchIn(scope) + } + + private fun updateStats(appList: Collection) { + val blockedCount = appList.count { appInfo -> + appInfo.connectionStatus != FirewallManager.ConnectionStatus.ALLOW.id + } + val bypassCount = appList.count { appInfo -> + appInfo.firewallStatus == FirewallManager.FirewallStatus.BYPASS_UNIVERSAL.id || + appInfo.firewallStatus == FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL.id + } + val excludedCount = appList.count { appInfo -> + appInfo.firewallStatus == FirewallManager.FirewallStatus.EXCLUDE.id + } + val isolatedCount = appList.count { appInfo -> + appInfo.firewallStatus == FirewallManager.FirewallStatus.ISOLATE.id + } + val totalApps = appList.size + val allowedApps = totalApps - (blockedCount + bypassCount + excludedCount + isolatedCount) + + _appsStats.update { state -> + state.copy( + totalApps = totalApps, + allowedApps = allowedApps, + blockedApps = blockedCount, + bypassedApps = bypassCount, + excludedApps = excludedCount, + isolatedApps = isolatedCount + ) + } + + Logger.d(LOG_TAG_VPN, "$TAG Updated: total=$totalApps, allowed=$allowedApps, blocked=$blockedCount") + } + + suspend fun refreshStats() { + // Stats are updated automatically via observer, no manual refresh needed + Logger.d(LOG_TAG_VPN, "$TAG Stats refresh via observer") + } + + fun getStatsSnapshot(): AppsStats { + return _appsStats.value + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/ConnectionMonitor.kt b/app/src/main/java/com/celzero/bravedns/service/ConnectionMonitor.kt index fcd6132c0..cbc7db4ed 100644 --- a/app/src/main/java/com/celzero/bravedns/service/ConnectionMonitor.kt +++ b/app/src/main/java/com/celzero/bravedns/service/ConnectionMonitor.kt @@ -42,7 +42,7 @@ import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG import com.celzero.bravedns.service.FirewallManager.NOTIF_CHANNEL_ID_FIREWALL_ALERTS import com.celzero.bravedns.service.VpnBuilderPolicy.Companion.getNetworkBehaviourDuration import com.celzero.bravedns.service.WireguardManager.NOTIF_CHANNEL_ID_WIREGUARD_ALERTS -import com.celzero.bravedns.ui.NotificationHandlerActivity +import com.celzero.bravedns.ui.HomeScreenActivity import com.celzero.bravedns.util.ConnectivityCheckHelper import com.celzero.bravedns.util.Constants.Companion.NOTIF_WG_PERMISSION_NAME import com.celzero.bravedns.util.Constants.Companion.NOTIF_WG_PERMISSION_VALUE @@ -448,7 +448,7 @@ class ConnectionMonitor(private val context: Context, private val networkListene } Logger.w(LOG_TAG_VPN, "ssid wgs: missing permissions, show notification") - val intent = Intent(context, NotificationHandlerActivity::class.java) + val intent = Intent(context, HomeScreenActivity::class.java) intent.putExtra( NOTIF_WG_PERMISSION_NAME, NOTIF_WG_PERMISSION_VALUE @@ -464,7 +464,7 @@ class ConnectionMonitor(private val context: Context, private val networkListene var builder: NotificationCompat.Builder if (isAtleastO()) { val name: CharSequence = context.getString(R.string.notif_channel_firewall_alerts) - val description = context.resources.getString(R.string.notif_channel_desc_firewall_alerts) + val description = context.getString(R.string.notif_channel_desc_firewall_alerts) val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(NOTIF_CHANNEL_ID_WIREGUARD_ALERTS, name, importance) channel.description = description @@ -474,7 +474,7 @@ class ConnectionMonitor(private val context: Context, private val networkListene builder = NotificationCompat.Builder(context, NOTIF_CHANNEL_ID_WIREGUARD_ALERTS) } - val contentTitle: String = context.resources.getString(R.string.lbl_action_required) + val contentTitle: String = context.getString(R.string.lbl_action_required) val contentText: String = context.getString(R.string.location_enable_explanation, context.getString(R.string.lbl_ssids)) diff --git a/app/src/main/java/com/celzero/bravedns/service/DnsConfigurationManager.kt b/app/src/main/java/com/celzero/bravedns/service/DnsConfigurationManager.kt new file mode 100644 index 000000000..d019c8090 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/DnsConfigurationManager.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2024 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 android.content.Context +import android.content.SharedPreferences +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Utilities +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Manages DNS configuration and resolver updates. + * Extracted from BraveVPNService for better separation of concerns. + */ +class DnsConfigurationManager( + private val context: Context, + private val persistentState: PersistentState, + private val appConfig: AppConfig, + private val scope: CoroutineScope +) : SharedPreferences.OnSharedPreferenceChangeListener { + + companion object { + private const val TAG = "DnsConfig" + } + + private val _dnsLatency = MutableStateFlow("-- ms") + val dnsLatency: StateFlow = _dnsLatency.asStateFlow() + + private val _connectedDnsName = MutableStateFlow("") + val connectedDnsName: StateFlow = _connectedDnsName.asStateFlow() + + private val _dnsStatus = MutableStateFlow(DnsStatus.Idle) + val dnsStatus: StateFlow = _dnsStatus.asStateFlow() + + sealed class DnsStatus { + data object Idle : DnsStatus() + data object Connecting : DnsStatus() + data class Connected(val name: String, val latency: Long) : DnsStatus() + data class Error(val message: String) : DnsStatus() + } + + init { + persistentState.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + fun updateDnsLatency(latency: Long) { + _dnsLatency.value = if (latency > 0) "${latency}ms" else "-- ms" + } + + fun updateConnectedDnsName(name: String) { + _connectedDnsName.value = name + } + + suspend fun refreshResolvers() { + withContext(Dispatchers.IO) { + try { + // Refresh is handled by the VPN adapter directly + Logger.i(LOG_TAG_VPN, "$TAG Resolvers refresh requested") + } catch (e: Exception) { + Logger.e(LOG_TAG_VPN, "$TAG Failed to refresh resolvers: ${e.message}") + } + } + } + + override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { + when (key) { + PersistentState.DNS_CHANGE -> { + scope.launch(Dispatchers.IO) { + handleDnsTypeChange() + } + } + PersistentState.LOCAL_BLOCK_LIST -> { + scope.launch(Dispatchers.IO) { + handleLocalBlocklistChange() + } + } + PersistentState.REMOTE_BLOCKLIST_UPDATE -> { + scope.launch(Dispatchers.IO) { + handleRemoteBlocklistChange() + } + } + } + } + + private suspend fun handleDnsTypeChange() { + _dnsStatus.value = DnsStatus.Connecting + + when (appConfig.getDnsType()) { + AppConfig.DnsType.DOH -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to DoH") + AppConfig.DnsType.DNSCRYPT -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to DNSCrypt") + AppConfig.DnsType.DNS_PROXY -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to DNS Proxy") + AppConfig.DnsType.RETHINK_REMOTE -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to RDNS Remote") + AppConfig.DnsType.SYSTEM_DNS -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to System DNS") + AppConfig.DnsType.SMART_DNS -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to Smart DNS") + AppConfig.DnsType.DOT -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to DoT") + AppConfig.DnsType.ODOH -> Logger.i(LOG_TAG_VPN, "$TAG DNS type changed to ODoH") + } + } + + private suspend fun handleLocalBlocklistChange() { + Logger.i(LOG_TAG_VPN, "$TAG Local blocklist changed") + } + + private suspend fun handleRemoteBlocklistChange() { + Logger.i(LOG_TAG_VPN, "$TAG Remote blocklist changed") + } + + fun cleanup() { + persistentState.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/FirewallRuleEvaluator.kt b/app/src/main/java/com/celzero/bravedns/service/FirewallRuleEvaluator.kt new file mode 100644 index 000000000..f76b52aca --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/FirewallRuleEvaluator.kt @@ -0,0 +1,523 @@ +/* + * Copyright 2024 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 android.app.KeyguardManager +import android.content.Context +import android.net.NetworkCapabilities +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.data.ConnTrackerMetaData +import com.celzero.bravedns.database.RefreshDatabase +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.IPUtil +import com.celzero.bravedns.util.InternetProtocol +import com.celzero.bravedns.util.KnownPorts +import com.celzero.bravedns.util.OrbotHelper +import com.celzero.bravedns.util.Protocol +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastR +import com.celzero.bravedns.util.Utilities.isMissingOrInvalidUid +import inet.ipaddr.HostName +import inet.ipaddr.IPAddressString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.min +import kotlin.random.Random + +/** + * Evaluates firewall rules for network connections. + * Extracted from BraveVPNService for better separation of concerns and testability. + */ +class FirewallRuleEvaluator( + private val context: Context, + private val persistentState: PersistentState, + private val appConfig: AppConfig, + private val refreshDatabase: RefreshDatabase, + private val networkInfoProvider: NetworkInfoProvider +) { + companion object { + private const val TAG = "FirewallEvaluator" + private const val UID_EVERYBODY = Constants.UID_EVERYBODY + private const val INVALID_UID = Constants.INVALID_UID + + // Backoff configuration + private const val BASE_WAIT_MS = 50L + } + + private val rand = Random + + private var keyguardManager: KeyguardManager? = null + + /** + * Checks if incoming connection is blocked by any user-set firewall rule. + * Returns the appropriate FirewallRuleset based on the evaluation. + */ + suspend fun evaluateFirewallRules( + connInfo: ConnTrackerMetaData, + domains: String?, + anyRealIpBlocked: Boolean = false, + isSplApp: Boolean, + rinr: Boolean, + rethinkUid: Int + ): FirewallRuleset { + val connId = connInfo.connId + + try { + // Skip firewall for Rethink app unless route-in-route is enabled + if (connInfo.uid == rethinkUid && !rinr) { + logd("firewall($connId): rethink uid, $rethinkUid, not processing firewall rules") + return FirewallRuleset.RULE0 + } + + logd("firewall($connId): $connInfo") + val uid = connInfo.uid + val appStatus = FirewallManager.appStatus(uid) + val connectionStatus = FirewallManager.connectionStatus(uid) + val isTempAllowed = FirewallManager.isTempAllowed(uid) + + // Evaluate rules in priority order + return evaluateRulesInOrder( + connInfo, domains, anyRealIpBlocked, isSplApp, rinr, + uid, appStatus, connectionStatus, isTempAllowed + ) + + } catch (ex: Exception) { + Logger.crash(LOG_TAG_VPN, "unexpected err in firewall()($connId), block anyway", ex) + return FirewallRuleset.RULE1C + } + } + + private suspend fun evaluateRulesInOrder( + connInfo: ConnTrackerMetaData, + domains: String?, + anyRealIpBlocked: Boolean, + isSplApp: Boolean, + rinr: Boolean, + uid: Int, + appStatus: FirewallManager.FirewallStatus, + connectionStatus: FirewallManager.ConnectionStatus, + isTempAllowed: Boolean + ): FirewallRuleset { + val connId = connInfo.connId + + // Rule 1: Allow Orbot during setup + if (allowOrbot(uid)) { + return FirewallRuleset.RULE9B + } + + // Rule 2: Block unknown apps if configured + if (unknownAppBlocked(uid)) { + logd("firewall($connId): unknown app blocked, $uid") + return FirewallRuleset.RULE5 + } + + // Rule 3: Handle new/unknown apps + if (appStatus.isUntracked() && uid != INVALID_UID) { + withContext(Dispatchers.IO) { refreshDatabase.addNewApp(uid) } + if (newAppBlocked(uid)) { + logd("firewall($connId): new app blocked, $uid") + return FirewallRuleset.RULE1B + } + } + + // Rule 4: Temporarily allowed apps + if (isTempAllowed) { + logd("firewall($connId): temp allowed, $uid") + return FirewallRuleset.RULE19 + } + + // Rule 5: App-level rules (unmetered, metered connections) + appBlocked(connInfo, connectionStatus)?.let { return it } + + // Rule 6: Lockdown + paused + if (isLockdown() && isAppPaused()) { + logd("firewall($connId): lockdown, app paused, $uid") + return FirewallRuleset.RULE16 + } + + // Rule 7: Domain rules (app-specific) + val domainPair = getDomainRule(domains, uid) + if (!domainPair.second.isNullOrEmpty()) { + connInfo.query = domainPair.second + } + when (domainPair.first) { + DomainRulesManager.Status.BLOCK -> { + logd("firewall($connId): domain blocked, $uid") + return FirewallRuleset.RULE2E + } + DomainRulesManager.Status.TRUST -> { + logd("firewall($connId): domain trusted, $uid") + return FirewallRuleset.RULE2F + } + DomainRulesManager.Status.NONE -> { /* fall-through */ } + } + + // Rule 8: IP rules (app-specific) + when (uidIpStatus(uid, connInfo.destIP, connInfo.destPort)) { + IpRulesManager.IpRuleStatus.BLOCK -> { + logd("firewall($connId): ip blocked, $uid") + return FirewallRuleset.RULE2 + } + IpRulesManager.IpRuleStatus.TRUST -> { + logd("firewall($connId): ip trusted, $uid") + return FirewallRuleset.RULE2B + } + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { /* no-op, pass-through */ } + IpRulesManager.IpRuleStatus.NONE -> { /* no-op, pass-through */ } + } + + // Rule 9: Bypass DNS Firewall mode + if (appStatus.bypassDnsFirewall()) { + logd("firewall($connId): bypass dns firewall, $uid") + return FirewallRuleset.RULE1H + } + + // Rule 10: Isolate mode + if (appStatus.isolate()) { + logd("firewall($connId): isolate mode, $uid") + return FirewallRuleset.RULE1G + } + + // Rule 11: Global domain rules + val globalDomainPair = getDomainRule(domains, UID_EVERYBODY) + val globalDomainRule = globalDomainPair.first + if (!globalDomainPair.second.isNullOrEmpty()) { + connInfo.query = globalDomainPair.second + } + + // Rule 12: Bypass Universal mode + if (appStatus.bypassUniversal()) { + if (anyRealIpBlocked && globalDomainRule != DomainRulesManager.Status.TRUST) { + logd("firewall($connId): bypass universal, dns blocked, $uid, ${connInfo.query}") + return FirewallRuleset.RULE2G + } + return if (dnsProxied(connInfo.destPort)) { + logd("firewall($connId): bypass universal, dns proxied, $uid") + FirewallRuleset.RULE9 + } else { + logd("firewall($connId): bypass universal, $uid") + FirewallRuleset.RULE8 + } + } + + // Rule 13: Global domain rules + when (globalDomainRule) { + DomainRulesManager.Status.TRUST -> { + logd("firewall($connId): global domain trusted, $uid, ${connInfo.query}") + return FirewallRuleset.RULE2I + } + DomainRulesManager.Status.BLOCK -> { + logd("firewall($connId): global domain blocked, $uid, ${connInfo.query}") + return FirewallRuleset.RULE2H + } + DomainRulesManager.Status.NONE -> { /* fall-through */ } + } + + // Rule 14: Global IP rules + when (globalIpRule(connInfo.destIP, connInfo.destPort)) { + IpRulesManager.IpRuleStatus.BLOCK -> { + logd("firewall($connId): global ip blocked, $uid, ${connInfo.destIP}") + return FirewallRuleset.RULE2D + } + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { + logd("firewall($connId): global ip bypass universal, $uid, ${connInfo.destIP}") + return FirewallRuleset.RULE2C + } + IpRulesManager.IpRuleStatus.TRUST -> { /* no-op, pass-through */ } + IpRulesManager.IpRuleStatus.NONE -> { /* no-op, pass-through */ } + } + + // Rule 15: DNS blocked IPs + if (anyRealIpBlocked) { + logd("firewall($connId): dns blocked, $uid, ${connInfo.query}") + return FirewallRuleset.RULE2G + } + + // Rule 16: Special apps (DNS proxy, SOCKS5, HTTP proxy) + if (isSplApp) { + logd("firewall($connId): special app, $uid, ${connInfo.query}") + return FirewallRuleset.RULE0 + } + + // Rule 17-24: Universal firewall rules + return evaluateUniversalRules(connInfo, uid) + } + + private suspend fun evaluateUniversalRules( + connInfo: ConnTrackerMetaData, + uid: Int + ): FirewallRuleset { + val connId = connInfo.connId + val isMetered = isConnectionMetered(connInfo.destIP) + + // Block metered connections + if (persistentState.getBlockMeteredConnections() && isMetered) { + logd("firewall($connId): metered blocked, $uid") + return FirewallRuleset.RULE1F + } + + // Universal lockdown + if (universalLockdown()) { + logd("firewall($connId): universal lockdown, $uid") + return FirewallRuleset.RULE11 + } + + // Block HTTP + if (httpBlocked(connInfo.destPort)) { + logd("firewall($connId): http blocked, $uid") + return FirewallRuleset.RULE10 + } + + // Device locked + if (deviceLocked()) { + logd("firewall($connId): device locked, $uid") + return FirewallRuleset.RULE3 + } + + // Block UDP (except DNS) + if (udpBlocked(uid, connInfo.protocol, connInfo.destPort)) { + logd("firewall($connId): udp blocked, $uid") + return FirewallRuleset.RULE6 + } + + // Block background data + if (blockBackgroundData(uid)) { + logd("firewall($connId): background data blocked, $uid") + return FirewallRuleset.RULE4 + } + + // DNS proxy mode + if (dnsProxied(connInfo.destPort)) { + logd("firewall($connId): dns proxied, $uid") + return FirewallRuleset.RULE9 + } + + // DNS bypass detection + if (dnsBypassed(connInfo.query)) { + logd("firewall($connId): dns bypassed, $uid") + return FirewallRuleset.RULE7 + } + + logd("no firewall rule($connId), uid=${connInfo.uid}") + return FirewallRuleset.RULE0 + } + + // Helper methods + + private fun logd(msg: String) { + Logger.d(LOG_TAG_VPN, "$TAG $msg") + } + + private suspend fun allowOrbot(uid: Int): Boolean { + return OrbotHelper.ORBOT_PACKAGE_NAME == FirewallManager.getPackageNameByUid(uid) + } + + private fun unknownAppBlocked(uid: Int): Boolean { + return persistentState.getBlockUnknownConnections() && isMissingOrInvalidUid(uid) + } + + private suspend fun newAppBlocked(uid: Int): Boolean { + if (!persistentState.getBlockNewlyInstalledApp() || isMissingOrInvalidUid(uid)) { + return false + } + return !waitAndCheckIfUidAllowed(uid) + } + + private suspend fun waitAndCheckIfUidAllowed(uid: Int): Boolean { + var remainingWaitMs = TimeUnit.SECONDS.toMillis(10) + var attempt = 0 + + while (remainingWaitMs > 0) { + if (FirewallManager.hasUid(uid) && !FirewallManager.isUidFirewalled(uid)) { + return true + } + remainingWaitMs = exponentialBackoff(remainingWaitMs, attempt) + attempt++ + } + return false + } + + private fun exponentialBackoff(remainingWaitMs: Long, attempt: Int): Long { + val exponent = exp(attempt) + val randomValue = rand.nextLong(exponent - BASE_WAIT_MS + 1) + BASE_WAIT_MS + val waitTimeMs = min(randomValue, remainingWaitMs) + Thread.sleep(waitTimeMs) + return remainingWaitMs - waitTimeMs + } + + private fun exp(pow: Int): Long { + return if (pow == 0) BASE_WAIT_MS else (1 shl pow) * BASE_WAIT_MS + } + + private fun appBlocked( + connInfo: ConnTrackerMetaData, + connectionStatus: FirewallManager.ConnectionStatus + ): FirewallRuleset? { + if (connectionStatus.blocked()) { + return FirewallRuleset.RULE1 + } + + val isMetered = isConnectionMetered(connInfo.destIP) + if (connectionStatus.wifi() && !isMetered) { + return FirewallRuleset.RULE1D + } + if (connectionStatus.mobileData() && isMetered) { + return FirewallRuleset.RULE1E + } + return null + } + + private fun getDomainRule(domain: String?, uid: Int): Pair { + if (domain.isNullOrEmpty()) { + return Pair(DomainRulesManager.Status.NONE, "") + } + + val domains = if (isAtleastR()) { + val d = domain.lowercase(Locale.getDefault()).split(",").firstOrNull() + if (d.isNullOrEmpty()) return Pair(DomainRulesManager.Status.NONE, "") + listOf(d) + } else { + domain.lowercase(Locale.getDefault()).split(",") + } + + if (domains.isEmpty()) { + return Pair(DomainRulesManager.Status.NONE, "") + } + + for (d in domains) { + val status = DomainRulesManager.status(d, uid) + if (status != DomainRulesManager.Status.NONE) { + return Pair(status, d) + } + } + return Pair(DomainRulesManager.Status.NONE, domains.firstOrNull()) + } + + private fun uidIpStatus(uid: Int, destIp: String, destPort: Int): IpRulesManager.IpRuleStatus { + return ipStatus(uid, destIp, destPort) + } + + private fun globalIpRule(destIp: String, destPort: Int): IpRulesManager.IpRuleStatus { + return ipStatus(UID_EVERYBODY, destIp, destPort) + } + + private fun ipStatus(uid: Int, destIp: String, destPort: Int): IpRulesManager.IpRuleStatus { + if (destIp.isEmpty() || Utilities.isUnspecifiedIp(destIp)) { + return IpRulesManager.IpRuleStatus.NONE + } + + val statusIpPort = IpRulesManager.hasRule(uid, destIp, destPort) + if (statusIpPort != IpRulesManager.IpRuleStatus.NONE) { + return statusIpPort + } + + // Check IPv4-in-IPv6 if configured + if (persistentState.filterIpv4inIpv6) { + val addr = try { + IPAddressString(destIp).address + } catch (_: Exception) { + return IpRulesManager.IpRuleStatus.NONE + } + + val ip4in6 = IPUtil.ip4in6(addr) ?: return IpRulesManager.IpRuleStatus.NONE + val ip4str = ip4in6.toNormalizedString() + return IpRulesManager.hasRule(uid, ip4str, destPort) + } + return statusIpPort + } + + private fun universalLockdown(): Boolean = persistentState.getUniversalLockdown() + + private fun httpBlocked(port: Int): Boolean { + return port == KnownPorts.HTTP_PORT && persistentState.getBlockHttpConnections() + } + + private fun dnsProxied(port: Int): Boolean { + return appConfig.getBraveMode().isDnsFirewallMode() && + appConfig.preventDnsLeaks() && + KnownPorts.isDns(port) + } + + private fun dnsBypassed(query: String?): Boolean { + return persistentState.getDisallowDnsBypass() && query.isNullOrEmpty() + } + + private fun isLockdown(): Boolean = networkInfoProvider.isLockdown() + + private fun isAppPaused(): Boolean = networkInfoProvider.isAppPaused() + + private fun isConnectionMetered(dst: String): Boolean = networkInfoProvider.isConnectionMetered(dst) + + private fun udpBlocked(uid: Int, protocol: Int, port: Int): Boolean { + if (!persistentState.getUdpBlocked()) return false + if (protocol != Protocol.UDP.protocolType) return false + if (KnownPorts.isDns(port)) return false // Allow DNS + + // For NTP, allow from system apps - simplified check + if (KnownPorts.isNtp(port)) { + // Assume non-system apps are blocked for NTP + return false // Allow NTP for now + } + return true + } + + private suspend fun blockBackgroundData(uid: Int): Boolean { + if (!persistentState.getBlockAppWhenBackground()) return false + + if (keyguardManager == null) { + keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + } + return !waitAndCheckIfAppForeground(uid) + } + + private suspend fun waitAndCheckIfAppForeground(uid: Int): Boolean { + var remainingWaitMs = TimeUnit.SECONDS.toMillis(10) + var attempt = 0 + + while (remainingWaitMs > 0) { + if (FirewallManager.isAppForeground(uid, keyguardManager)) { + return true + } + remainingWaitMs = exponentialBackoff(remainingWaitMs, attempt) + attempt++ + } + return false + } + + private fun deviceLocked(): Boolean { + if (!persistentState.getBlockWhenDeviceLocked()) return false + + if (keyguardManager == null) { + keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + } + return keyguardManager?.isKeyguardLocked == true + } +} + +/** + * Provides network information for firewall evaluation. + * Implement this interface to supply network state to the firewall evaluator. + */ +interface NetworkInfoProvider { + fun isLockdown(): Boolean + fun isAppPaused(): Boolean + fun isConnectionMetered(dstIp: String): Boolean +} diff --git a/app/src/main/java/com/celzero/bravedns/service/FirewallRuleset.kt b/app/src/main/java/com/celzero/bravedns/service/FirewallRuleset.kt index fc0ae6d72..c91e3ca8e 100644 --- a/app/src/main/java/com/celzero/bravedns/service/FirewallRuleset.kt +++ b/app/src/main/java/com/celzero/bravedns/service/FirewallRuleset.kt @@ -283,7 +283,7 @@ enum class FirewallRuleset(val id: String, val title: Int, val desc: Int, val ac // TODO: Move ico to enum var like for label and desc fun getRulesIcon(ruleId: String?): Int { return when (ruleId) { - RULE0.id -> R.drawable.ic_whats_new + RULE0.id -> R.drawable.ic_update RULE1.id -> R.drawable.ic_app_info RULE1B.id -> R.drawable.ic_auto_start RULE1C.id -> R.drawable.ic_filter_error diff --git a/app/src/main/java/com/celzero/bravedns/service/FirewallStatsManager.kt b/app/src/main/java/com/celzero/bravedns/service/FirewallStatsManager.kt new file mode 100644 index 000000000..9c81d369f --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/FirewallStatsManager.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2024 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 androidx.lifecycle.asFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +/** + * Manages firewall statistics including rules counts. + * Extracted from BraveVPNService for better separation of concerns. + */ +class FirewallStatsManager( + private val persistentState: PersistentState, + private val ipRulesManager: IpRulesManager, + private val domainRulesManager: DomainRulesManager, + private val scope: CoroutineScope +) { + + companion object { + private const val TAG = "FirewallStats" + } + + private val _universalRulesCount = MutableStateFlow(0) + val universalRulesCount: StateFlow = _universalRulesCount.asStateFlow() + + private val _ipRulesCount = MutableStateFlow(0) + val ipRulesCount: StateFlow = _ipRulesCount.asStateFlow() + + private val _domainRulesCount = MutableStateFlow(0) + val domainRulesCount: StateFlow = _domainRulesCount.asStateFlow() + + private val _firewallStats = MutableStateFlow(FirewallStats()) + val firewallStats: StateFlow = _firewallStats.asStateFlow() + + data class FirewallStats( + val universalRules: Int = 0, + val ipRules: Int = 0, + val domainRules: Int = 0, + val totalRules: Int = 0 + ) + + init { + observeRulesChanges() + } + + private fun observeRulesChanges() { + persistentState.universalRulesCount.asFlow() + .onEach { count -> + _universalRulesCount.update { count } + updateTotalStats() + Logger.d(LOG_TAG_VPN, "$TAG Universal rules: $count") + } + .launchIn(scope) + + ipRulesManager.getCustomIpsLiveData().asFlow() + .onEach { count -> + _ipRulesCount.update { count } + updateTotalStats() + Logger.d(LOG_TAG_VPN, "$TAG IP rules: $count") + } + .launchIn(scope) + + domainRulesManager.getUniversalCustomDomainCount().asFlow() + .onEach { count -> + _domainRulesCount.update { count } + updateTotalStats() + Logger.d(LOG_TAG_VPN, "$TAG Domain rules: $count") + } + .launchIn(scope) + } + + private fun updateTotalStats() { + _firewallStats.update { + it.copy( + universalRules = _universalRulesCount.value, + ipRules = _ipRulesCount.value, + domainRules = _domainRulesCount.value, + totalRules = _universalRulesCount.value + _ipRulesCount.value + _domainRulesCount.value + ) + } + } + + fun getStatsSnapshot(): FirewallStats { + return _firewallStats.value + } + + fun getTotalRulesCount(): Int { + return _firewallStats.value.totalRules + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/LogsCountManager.kt b/app/src/main/java/com/celzero/bravedns/service/LogsCountManager.kt new file mode 100644 index 000000000..1d43d86d4 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/LogsCountManager.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2024 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 androidx.lifecycle.asFlow +import com.celzero.bravedns.data.AppConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +/** + * Manages DNS and network log counts. + * Extracted from BraveVPNService for better separation of concerns. + */ +class LogsCountManager( + private val appConfig: AppConfig, + private val scope: CoroutineScope +) { + + companion object { + private const val TAG = "LogsCount" + } + + private val _dnsLogsCount = MutableStateFlow(0L) + val dnsLogsCount: StateFlow = _dnsLogsCount.asStateFlow() + + private val _networkLogsCount = MutableStateFlow(0L) + val networkLogsCount: StateFlow = _networkLogsCount.asStateFlow() + + init { + observeLogCounts() + } + + private fun observeLogCounts() { + appConfig.dnsLogsCount.asFlow() + .onEach { count -> + _dnsLogsCount.update { count } + Logger.d(LOG_TAG_VPN, "$TAG DNS logs count: $count") + } + .launchIn(scope) + + appConfig.networkLogsCount.asFlow() + .onEach { count -> + _networkLogsCount.update { count } + Logger.d(LOG_TAG_VPN, "$TAG Network logs count: $count") + } + .launchIn(scope) + } + + fun getDnsLogsCount(): Long { + return _dnsLogsCount.value + } + + fun getNetworkLogsCount(): Long { + return _networkLogsCount.value + } + + fun getTotalLogsCount(): Long { + return _dnsLogsCount.value + _networkLogsCount.value + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/NetworkBindingService.kt b/app/src/main/java/com/celzero/bravedns/service/NetworkBindingService.kt new file mode 100644 index 000000000..2802e445e --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/NetworkBindingService.kt @@ -0,0 +1,273 @@ +/* + * Copyright 2024 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 android.net.Network +import android.net.VpnService +import android.os.ParcelFileDescriptor +import com.celzero.bravedns.util.InternetProtocol +import com.celzero.bravedns.util.KnownPorts +import inet.ipaddr.IPAddressString +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.Socket + +/** + * Handles network binding and socket protection for VPN traffic. + * Extracted from BraveVPNService for better separation of concerns. + */ +class NetworkBindingService( + private val vpnService: VpnService, + private val persistentState: PersistentState, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher +) { + companion object { + private const val TAG = "NetworkBinding" + + // Route IPv4 in IPv6 only networks? + private const val ROUTE4IN6 = true + } + + /** + * Data class representing underlying network information + */ + data class UnderlyingNetworks( + val ipv4Net: List = emptyList(), + val ipv6Net: List = emptyList(), + val dnsServers: Map = emptyMap(), + val useActive: Boolean = true, + val vpnLockdown: Boolean = false, + val minMtu: Int = Int.MAX_VALUE, + var lastUpdated: Long = 0L, + var isActiveNetworkMetered: Boolean = false, + var isActiveNetworkCellular: Boolean = false + ) + + data class NetworkProperties( + val network: Network, + val capabilities: android.net.NetworkCapabilities + ) + + // Current underlying networks - should be updated by ConnectionMonitor + var underlyingNetworks: UnderlyingNetworks? = null + + /** + * Binds a socket to an IPv4 network. + * Called from Go code via Bridge interface. + */ + suspend fun bind4(who: String, addrPort: String, fid: Long): Boolean { + return withContext(ioDispatcher) { + var v4Net = underlyingNetworks?.ipv4Net + val isAuto = InternetProtocol.isAuto(persistentState.internetProtocolType) + + // Fall back to IPv6 net if IPv4 is empty and auto mode + if (ROUTE4IN6 && isAuto && v4Net.isNullOrEmpty()) { + v4Net = underlyingNetworks?.ipv6Net + } + + bindAny(who, addrPort, fid, v4Net ?: emptyList()) + } + } + + /** + * Binds a socket to an IPv6 network. + * Called from Go code via Bridge interface. + */ + suspend fun bind6(who: String, addrPort: String, fid: Long): Boolean { + return withContext(ioDispatcher) { + bindAny(who, addrPort, fid, underlyingNetworks?.ipv6Net ?: emptyList()) + } + } + + /** + * Protects a file descriptor from going through the VPN. + * Called from Go code via Bridge interface. + */ + suspend fun protectSocket(who: String?, fd: Long) { + withContext(ioDispatcher) { + if (who == null) { + Logger.w(LOG_TAG_VPN, "$TAG protect: who is null, fd: $fd") + return@withContext + } + + val rinr = persistentState.routeRethinkInRethink + logd("protect: $who, fd: $fd, rinr? $rinr") + + if (rinr && shouldSkipForRethinkInRethink(who)) { + return@withContext + } + + vpnService.protect(fd.toInt()) + } + } + + /** + * Protects a socket from going through the VPN. + */ + fun protectSocket(socket: Socket) { + vpnService.protect(socket) + Logger.v(LOG_TAG_VPN, "$TAG socket protected") + } + + /** + * Binds a file descriptor to a specific network for connectivity checks. + */ + fun bindToNetworkForConnectivityChecks(network: Network, fid: Long): Boolean { + var pfd: ParcelFileDescriptor? = null + try { + pfd = ParcelFileDescriptor.adoptFd(fid.toInt()) + return bindToNetwork(network, pfd, fid) + } catch (e: Exception) { + Logger.i(LOG_TAG_VPN, "$TAG err bindToNetworkForConnectivityChecks, ${e.message}") + } finally { + pfd?.detachFd() + } + return false + } + + /** + * Protects a file descriptor for connectivity checks. + */ + fun protectFdForConnectivityChecks(fd: Long) { + vpnService.protect(fd.toInt()) + Logger.v(LOG_TAG_VPN, "$TAG fd($fd) protected for connectivity checks") + } + + private suspend fun bindAny( + who: String, + addrPort: String, + fid: Long, + networks: List + ): Boolean { + val rinr = persistentState.routeRethinkInRethink + val currentNetworks = underlyingNetworks + + logd("bind: who: $who, addr: $addrPort, fd: $fid, rinr? $rinr") + + if (rinr && shouldSkipForRethinkInRethink(who)) { + return true + } + + // Protect the socket first + vpnService.protect(fid.toInt()) + + if (networks.isEmpty()) { + Logger.w(LOG_TAG_VPN, "$TAG no network to bind, who: $who, fd: $fid, addr: $addrPort") + return false + } + + var pfd: ParcelFileDescriptor? = null + try { + val dest = IpRulesManager.splitHostPort(addrPort) + val destIp = IPAddressString(dest.first).address + val destPort = dest.second.toIntOrNull() + val destAddr = destIp.toInetAddress() + + // Skip binding for zero addresses, loopback, or WireGuard + if (destIp.isZero && !who.startsWith(ProxyManager.ID_WG_BASE)) { + logd("bind: zero addr, who: $who, addr: $addrPort") + return true + } + if (destIp.isZero || destIp.isLoopback) { + logd("bind: invalid destIp: $destIp, who: $who, addr: $addrPort") + return true + } + + pfd = ParcelFileDescriptor.adoptFd(fid.toInt()) + + // Check if destination is DNS port, bind to appropriate network + if (KnownPorts.isDns(destPort)) { + currentNetworks?.dnsServers?.get(destAddr)?.let { net -> + if (bindToNetwork(net, pfd, fid)) { + logd("bind: dns, who: $who, addr: $addrPort, fd: $fid, ok: true") + return true + } + } + } + + // Use active network if configured + if (currentNetworks?.useActive == true) { + logd("bind: use active network is true, who: $who, addr: $addrPort, fd: $fid") + return true + } + + // Try binding to available networks + for (networkProp in networks) { + if (bindToNetwork(networkProp.network, pfd, fid)) { + logd("bind: nw, who: $who, addr: $addrPort, fd: $fid, ok: true") + return true + } + } + } catch (e: Exception) { + Logger.e(LOG_TAG_VPN, "$TAG err bind: who: $who, addr: $addrPort, fd: $fid, ${e.message}", e) + } finally { + pfd?.detachFd() + } + + Logger.e(LOG_TAG_VPN, "$TAG bind failed: who: $who, addr: $addrPort, fd: $fid") + return false + } + + private fun bindToNetwork(network: Network, pfd: ParcelFileDescriptor, fid: Long): Boolean { + return try { + network.bindSocket(pfd.fileDescriptor) + true + } catch (e: IOException) { + val netId = netid(network.networkHandle) + Logger.e(LOG_TAG_VPN, "$TAG err bindToNetwork(netId: $netId, fid: $fid, ${e.message}") + false + } + } + + private fun shouldSkipForRethinkInRethink(who: String): Boolean { + val rethinkUid = try { + vpnService.packageManager.getApplicationInfo(vpnService.packageName, 0).uid + } catch (e: Exception) { + return false + } + + // Simplified check - assume rethink is not bypassed from proxy + val isRethinkBypassedFromProxy = false + + if (!isRethinkBypassedFromProxy) { + if (!ProxyManager.isAnyUserSetProxy(who) && who != com.celzero.firestack.backend.Backend.Exit) { + Logger.vv(LOG_TAG_VPN, "$TAG rinr, bypassed rethink, who: $who") + return true + } + } else if (who != com.celzero.firestack.backend.Backend.Exit) { + Logger.vv(LOG_TAG_VPN, "$TAG rinr, within rethink, who: $who") + return true + } + return false + } + + /** + * Extracts network ID from network handle. + */ + fun netid(nwHandle: Long): Long { + // ref: cs.android.com/android/platform/superproject/main/+/main:packages/modules/Connectivity/framework/src/android/net/Network.java + return nwHandle shr 32 + } + + private fun logd(msg: String) { + Logger.d(LOG_TAG_VPN, "$TAG $msg") + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/PauseStateManager.kt b/app/src/main/java/com/celzero/bravedns/service/PauseStateManager.kt new file mode 100644 index 000000000..3beae179f --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/PauseStateManager.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2024 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 android.app.KeyguardManager +import android.content.Context +import android.content.SharedPreferences +import androidx.lifecycle.Observer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch + +/** + * Manages app pause/resume state and timers. + * Extracted from BraveVPNService for better separation of concerns. + * Wraps the existing PauseTimer for StateFlow-based observation. + */ +class PauseStateManager( + private val context: Context, + private val persistentState: PersistentState, + private val scope: CoroutineScope +) : SharedPreferences.OnSharedPreferenceChangeListener { + + companion object { + private const val TAG = "PauseState" + } + + private val _isPaused = MutableStateFlow(false) + val isPaused: StateFlow = _isPaused.asStateFlow() + + private val _pauseCountdown = MutableStateFlow(0L) + val pauseCountdown: StateFlow = _pauseCountdown.asStateFlow() + + private var keyguardManager: KeyguardManager? = null + + init { + // Observe the existing PauseTimer's LiveData and convert to StateFlow + scope.launch(Dispatchers.Main) { + PauseTimer.getPauseCountDownObserver().toFlow().collect { remaining -> + _pauseCountdown.value = remaining + _isPaused.value = remaining > 0 + } + } + } + + fun startPause() { + PauseTimer.start(PauseTimer.DEFAULT_PAUSE_TIME_MS) + _isPaused.value = true + Logger.i(LOG_TAG_VPN, "$TAG App paused") + } + + fun stopPause() { + PauseTimer.stop() + _isPaused.value = false + Logger.i(LOG_TAG_VPN, "$TAG App resumed") + } + + fun increasePauseDuration(durationMs: Long) { + PauseTimer.addDuration(durationMs) + } + + fun decreasePauseDuration(durationMs: Long) { + PauseTimer.subtractDuration(durationMs) + } + + fun isDeviceLocked(): Boolean { + if (!persistentState.getBlockWhenDeviceLocked()) return false + + if (keyguardManager == null) { + keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + } + return keyguardManager?.isKeyguardLocked == true + } + + override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { + // Handle preference changes if needed + } +} + +// Extension to convert LiveData to Flow +private fun androidx.lifecycle.LiveData.toFlow() = callbackFlow { + val observer = Observer { value -> + trySend(value) + } + observeForever(observer) + awaitClose { removeObserver(observer) } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/ProxyRoutingEngine.kt b/app/src/main/java/com/celzero/bravedns/service/ProxyRoutingEngine.kt new file mode 100644 index 000000000..53f03ab8d --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/ProxyRoutingEngine.kt @@ -0,0 +1,211 @@ +package com.celzero.bravedns.service + +import com.celzero.firestack.backend.Backend + +object ProxyRoutingEngine { + + enum class Reason { + RETHINK_DIRECT, + EXCLUDED_APP, + WIREGUARD, + NO_PROXY_ACTIVE, + ORBOT_DIRECT_APP, + ORBOT_PROXY, + SOCKS5_DIRECT_APP, + SOCKS5_PROXY, + HTTP_DIRECT_APP, + HTTP_PROXY, + DNS_PROXY_DIRECT_APP, + FALLBACK_BASE_OR_EXIT + } + + data class SpecialAppRequest( + val isDnsFirewallMode: Boolean, + val isOrbotProxyEnabled: Boolean, + val isCustomSocks5Enabled: Boolean, + val isCustomHttpProxyEnabled: Boolean, + val isDnsProxyActive: Boolean, + val packageName: String?, + val orbotProxyAppName: String?, + val socks5ProxyAppName: String?, + val httpProxyAppName: String?, + val dnsProxyAppName: String? + ) + + data class RoutingRequest( + val uid: Int, + val rethinkUid: Int, + val rinr: Boolean, + val autoProxyEnabled: Boolean, + val blockedByRule: String, + val appExcludedFromProxy: Boolean, + val baseOrExitProxyId: String, + val wireguardProxyIds: List, + val isProxyEnabled: Boolean, + val isDnsProxyActive: Boolean, + val isOrbotProxyEnabled: Boolean, + val isCustomSocks5Enabled: Boolean, + val isCustomHttpProxyEnabled: Boolean, + val packageName: String?, + val orbotProxyAppName: String?, + val orbotProxyAssignedToApp: Boolean, + val socks5ProxyAppName: String?, + val httpProxyAppName: String?, + val dnsProxyAppName: String? + ) + + data class RoutingDecision( + val proxyIds: String, + val reason: Reason, + val markBlocked: Boolean = false, + val blockedByRuleOverride: String? = null, + val orbotProxyEnabledButAppNotIncluded: Boolean = false + ) + + fun matchesSelectedProxyApp(proxyAppPackageName: String?, packageName: String?): Boolean { + return !proxyAppPackageName.isNullOrBlank() && + !packageName.isNullOrBlank() && + proxyAppPackageName == packageName + } + + fun resolveBaseOrExitProxyId( + doubleLoopback: Boolean, + blockedByRule: String, + rinr: Boolean, + uid: Int, + rethinkUid: Int, + autoProxyEnabled: Boolean + ): String { + val autoOrExit = if (autoProxyEnabled) Backend.Auto else Backend.Exit + var baseOrExit = + if (doubleLoopback || blockedByRule == FirewallRuleset.RULE9.id) { + Backend.Base + } else { + autoOrExit + } + + // When route-rethink-in-rethink is on, rethink itself should not use Base. + if (rinr && uid == rethinkUid) { + baseOrExit = autoOrExit + } + return baseOrExit + } + + fun isSpecialApp(request: SpecialAppRequest): Boolean { + if (!request.isDnsFirewallMode) return false + + val anySpecialProxyEnabled = + request.isOrbotProxyEnabled || + request.isCustomSocks5Enabled || + request.isCustomHttpProxyEnabled || + request.isDnsProxyActive + + if (!anySpecialProxyEnabled) return false + + if (request.isOrbotProxyEnabled && + matchesSelectedProxyApp(request.orbotProxyAppName, request.packageName) + ) { + return true + } + + if (request.isCustomSocks5Enabled && + matchesSelectedProxyApp(request.socks5ProxyAppName, request.packageName) + ) { + return true + } + + if (request.isCustomHttpProxyEnabled && + matchesSelectedProxyApp(request.httpProxyAppName, request.packageName) + ) { + return true + } + + return request.isDnsProxyActive && + matchesSelectedProxyApp(request.dnsProxyAppName, request.packageName) + } + + fun determineRoute(request: RoutingRequest): RoutingDecision { + val autoOrExit = if (request.autoProxyEnabled) Backend.Auto else Backend.Exit + + if (request.uid == request.rethinkUid && !request.rinr) { + return RoutingDecision(proxyIds = autoOrExit, reason = Reason.RETHINK_DIRECT) + } + + if (request.appExcludedFromProxy) { + val overrideRule = + if (request.blockedByRule == FirewallRuleset.RULE0.id) { + FirewallRuleset.RULE15.id + } else { + null + } + + return RoutingDecision( + proxyIds = request.baseOrExitProxyId, + reason = Reason.EXCLUDED_APP, + blockedByRuleOverride = overrideRule + ) + } + + if (request.wireguardProxyIds.isNotEmpty() && + request.wireguardProxyIds.first() != request.baseOrExitProxyId + ) { + val hasBlock = request.wireguardProxyIds.contains(Backend.Block) + val ids = request.wireguardProxyIds.joinToString(",") + + return RoutingDecision( + proxyIds = if (ids.isEmpty()) request.baseOrExitProxyId else ids, + reason = Reason.WIREGUARD, + markBlocked = hasBlock, + blockedByRuleOverride = if (hasBlock) FirewallRuleset.RULE17.id else null + ) + } + + if (!request.isProxyEnabled && !request.isDnsProxyActive) { + return RoutingDecision( + proxyIds = request.baseOrExitProxyId, + reason = Reason.NO_PROXY_ACTIVE + ) + } + + if (request.isOrbotProxyEnabled) { + if (matchesSelectedProxyApp(request.orbotProxyAppName, request.packageName)) { + return RoutingDecision(proxyIds = autoOrExit, reason = Reason.ORBOT_DIRECT_APP) + } + + if (request.orbotProxyAssignedToApp) { + return RoutingDecision( + proxyIds = ProxyManager.ID_ORBOT_BASE, + reason = Reason.ORBOT_PROXY + ) + } + } + + if (request.isCustomSocks5Enabled) { + if (matchesSelectedProxyApp(request.socks5ProxyAppName, request.packageName)) { + return RoutingDecision(proxyIds = autoOrExit, reason = Reason.SOCKS5_DIRECT_APP) + } + + return RoutingDecision(proxyIds = ProxyManager.ID_S5_BASE, reason = Reason.SOCKS5_PROXY) + } + + if (request.isCustomHttpProxyEnabled) { + if (matchesSelectedProxyApp(request.httpProxyAppName, request.packageName)) { + return RoutingDecision(proxyIds = autoOrExit, reason = Reason.HTTP_DIRECT_APP) + } + + return RoutingDecision(proxyIds = ProxyManager.ID_HTTP_BASE, reason = Reason.HTTP_PROXY) + } + + if (request.isDnsProxyActive && + matchesSelectedProxyApp(request.dnsProxyAppName, request.packageName) + ) { + return RoutingDecision(proxyIds = autoOrExit, reason = Reason.DNS_PROXY_DIRECT_APP) + } + + return RoutingDecision( + proxyIds = request.baseOrExitProxyId, + reason = Reason.FALLBACK_BASE_OR_EXIT, + orbotProxyEnabledButAppNotIncluded = request.isOrbotProxyEnabled + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/ProxyStateManager.kt b/app/src/main/java/com/celzero/bravedns/service/ProxyStateManager.kt new file mode 100644 index 000000000..137000fae --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/ProxyStateManager.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2024 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.data.AppConfig +import com.celzero.bravedns.util.UIUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Manages proxy state and status updates. + * Extracted from BraveVPNService for better separation of concerns. + */ +class ProxyStateManager( + private val appConfig: AppConfig, + private val persistentState: PersistentState, + private val scope: CoroutineScope +) { + + companion object { + private const val TAG = "ProxyState" + + suspend fun calculateWireguardProxyStatus(now: Long = System.currentTimeMillis()): ProxyStatus { + val proxies = WireguardManager.getActiveConfigs() + if (proxies.isEmpty()) { + return ProxyStatus(isActive = true, statusText = "Active") + } + + var active = 0 + var failing = 0 + var idle = 0 + + proxies.forEach { config -> + val proxyId = "${ProxyManager.ID_WG_BASE}${config.getId()}" + val stats = VpnController.getProxyStats(proxyId) + val statusPair = VpnController.getProxyStatusById(proxyId) + + when (statusPair.first) { + UIUtils.ProxyStatus.TPU.id -> { + idle++ + } + UIUtils.ProxyStatus.TUP.id -> { + active++ + } + UIUtils.ProxyStatus.TOK.id -> { + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: now + + if (lastOk > 0L && (now - lastOk < WireguardManager.WG_HANDSHAKE_TIMEOUT)) { + active++ + } else if (lastOk > 0L) { + idle++ + } else if (now - since < WireguardManager.WG_UPTIME_THRESHOLD) { + active++ + } else { + failing++ + } + } + else -> { + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: now + + if (lastOk > 0L || now - since < WireguardManager.WG_UPTIME_THRESHOLD) { + idle++ + } else { + failing++ + } + } + } + } + + return ProxyStatus( + isActive = active > 0, + statusText = buildStatusText(active, failing, idle), + activeCount = active, + failingCount = failing, + idleCount = idle + ) + } + + private fun buildStatusText(active: Int, failing: Int, idle: Int): String { + val parts = mutableListOf() + if (active > 0) parts.add("$active Active") + if (failing > 0) parts.add("$failing Failing") + if (idle > 0) parts.add("$idle Idle") + return if (parts.isEmpty()) "Inactive" else parts.joinToString("\n") + } + } + + private val _proxyStatus = MutableStateFlow(ProxyStatus()) + val proxyStatus: StateFlow = _proxyStatus.asStateFlow() + + private val _activeProxiesCount = MutableStateFlow(0) + val activeProxiesCount: StateFlow = _activeProxiesCount.asStateFlow() + + private val _failingProxiesCount = MutableStateFlow(0) + val failingProxiesCount: StateFlow = _failingProxiesCount.asStateFlow() + + data class ProxyStatus( + val isActive: Boolean = false, + val statusText: String = "Inactive", + val activeCount: Int = 0, + val failingCount: Int = 0, + val idleCount: Int = 0 + ) + + suspend fun updateProxyStatus() { + withContext(Dispatchers.IO) { + if (!persistentState.getVpnEnabled()) { + _proxyStatus.value = ProxyStatus(statusText = "Inactive") + return@withContext + } + + val proxyType = AppConfig.ProxyType.of(appConfig.getProxyType()) + + if (!proxyType.isProxyTypeWireguard()) { + val isEnabled = appConfig.isProxyEnabled() + _proxyStatus.value = ProxyStatus( + isActive = isEnabled, + statusText = if (isEnabled) "Active" else "Inactive" + ) + return@withContext + } + + val wgStatus = calculateWireguardProxyStatus() + _proxyStatus.value = wgStatus + _activeProxiesCount.value = wgStatus.activeCount + _failingProxiesCount.value = wgStatus.failingCount + } + } + + fun isProxyEnabled(): Boolean { + return appConfig.isProxyEnabled() + } + + fun getProxyType(): AppConfig.ProxyType { + return AppConfig.ProxyType.of(appConfig.getProxyType()) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt b/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt index 5fd7b8290..81bd98051 100644 --- a/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt +++ b/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt @@ -44,6 +44,7 @@ import com.google.gson.JsonObject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.IOException @@ -316,35 +317,35 @@ object RethinkBlocklistManager : KoinComponent { } suspend fun updateFiletagRemote(remote: RethinkRemoteFileTag) { - remoteFileTagRepository.update(remote) + withContext(Dispatchers.IO) { remoteFileTagRepository.update(remote) } } suspend fun updateFiletagLocal(local: RethinkLocalFileTag) { - localFileTagRepository.update(local) + withContext(Dispatchers.IO) { localFileTagRepository.update(local) } } suspend fun updateFiletagsRemote(values: Set, isSelected: Int) { - remoteFileTagRepository.updateTags(values, isSelected) + withContext(Dispatchers.IO) { remoteFileTagRepository.updateTags(values, isSelected) } } suspend fun updateFiletagsLocal(values: Set, isSelected: Int) { - localFileTagRepository.updateTags(values, isSelected) + withContext(Dispatchers.IO) { localFileTagRepository.updateTags(values, isSelected) } } suspend fun getSelectedFileTagsLocal(): List { - return localFileTagRepository.getSelectedTags() + return withContext(Dispatchers.IO) { localFileTagRepository.getSelectedTags() } } suspend fun getSelectedFileTagsRemote(): List { - return remoteFileTagRepository.getSelectedTags() + return withContext(Dispatchers.IO) { remoteFileTagRepository.getSelectedTags() } } suspend fun clearTagsSelectionRemote() { - remoteFileTagRepository.clearSelectedTags() + withContext(Dispatchers.IO) { remoteFileTagRepository.clearSelectedTags() } } suspend fun clearTagsSelectionLocal() { - localFileTagRepository.clearSelectedTags() + withContext(Dispatchers.IO) { localFileTagRepository.clearSelectedTags() } } fun cpSelectFileTag(localFileTags: RethinkLocalFileTag): Int { diff --git a/app/src/main/java/com/celzero/bravedns/service/UnderlyingNetworkManager.kt b/app/src/main/java/com/celzero/bravedns/service/UnderlyingNetworkManager.kt new file mode 100644 index 000000000..7f61c3c07 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/UnderlyingNetworkManager.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2024 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 android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import com.celzero.bravedns.util.Utilities.isAtleastO +import com.celzero.bravedns.util.Utilities.isAtleastP +import com.celzero.bravedns.util.Utilities.isAtleastS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Manages underlying network monitoring and callbacks. + * Extracted from BraveVPNService for better separation of concerns. + */ +class UnderlyingNetworkManager( + private val context: Context, + private val scope: CoroutineScope +) { + + companion object { + private const val TAG = "UnderlyingNetwork" + } + + private val _activeNetwork = MutableStateFlow(null) + val activeNetwork: StateFlow = _activeNetwork.asStateFlow() + + private val _hasIpv4 = MutableStateFlow(false) + val hasIpv4: StateFlow = _hasIpv4.asStateFlow() + + private val _hasIpv6 = MutableStateFlow(false) + val hasIpv6: StateFlow = _hasIpv6.asStateFlow() + + private val _isNetworkValid = MutableStateFlow(false) + val isNetworkValid: StateFlow = _isNetworkValid.asStateFlow() + + private val _networkCapabilities = MutableStateFlow(null) + val networkCapabilities: StateFlow = _networkCapabilities.asStateFlow() + + private var connectivityManager: ConnectivityManager? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + fun initialize() { + connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + if (isAtleastS()) { + registerNetworkCallback() + } + } + + private fun registerNetworkCallback() { + val cm = connectivityManager ?: return + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + .build() + + networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + Logger.d(LOG_TAG_VPN, "$TAG Network available: $network") + _activeNetwork.value = network + _isNetworkValid.value = true + updateCapabilities(network) + } + + override fun onLost(network: Network) { + Logger.d(LOG_TAG_VPN, "$TAG Network lost: $network") + if (_activeNetwork.value == network) { + _activeNetwork.value = null + _isNetworkValid.value = false + _hasIpv4.value = false + _hasIpv6.value = false + } + } + + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities + ) { + Logger.d(LOG_TAG_VPN, "$TAG Capabilities changed for $network") + if (_activeNetwork.value == network) { + updateCapabilities(network) + _networkCapabilities.value = capabilities + } + } + + override fun onUnavailable() { + Logger.d(LOG_TAG_VPN, "$TAG Network unavailable") + _isNetworkValid.value = false + _activeNetwork.value = null + } + } + + cm.registerNetworkCallback(request, networkCallback!!) + } + + private fun updateCapabilities(network: Network) { + val cm = connectivityManager ?: return + + try { + val caps = if (isAtleastO()) { + cm.getNetworkCapabilities(network) + } else { + null + } + + caps?.let { capabilities -> + _networkCapabilities.value = capabilities + + // Check for IPv4/IPv6 based on link properties + val linkProperties = if (isAtleastP()) { + cm.getLinkProperties(network) + } else { + null + } + + linkProperties?.let { lp -> + val has4 = lp.linkAddresses.any { + it.address.hostAddress?.contains(":") == false + } + val has6 = lp.linkAddresses.any { + it.address.hostAddress?.contains(":") == true + } + _hasIpv4.value = has4 + _hasIpv6.value = has6 + } + } + } catch (e: Exception) { + Logger.e(LOG_TAG_VPN, "$TAG Error updating capabilities: ${e.message}") + } + } + + fun getCurrentNetwork(): Network? { + return connectivityManager?.activeNetwork + } + + fun isNetworkConnected(): Boolean { + val cm = connectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun isMetered(): Boolean { + val cm = connectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + + fun isWifi(): Boolean { + val cm = connectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + + fun isCellular(): Boolean { + val cm = connectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + } + + fun cleanup() { + networkCallback?.let { + try { + connectivityManager?.unregisterNetworkCallback(it) + } catch (e: Exception) { + Logger.w(LOG_TAG_VPN, "$TAG Error unregistering network callback: ${e.message}") + } + } + networkCallback = null + } +} diff --git a/app/src/main/java/com/celzero/bravedns/service/VpnConnectionHandler.kt b/app/src/main/java/com/celzero/bravedns/service/VpnConnectionHandler.kt new file mode 100644 index 000000000..0551e26a3 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/VpnConnectionHandler.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2024 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 android.content.pm.PackageManager +import android.net.VpnService +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.util.InternetProtocol +import com.celzero.bravedns.util.Utilities + +/** + * Handles VPN connection builder configuration. + * Extracted from BraveVPNService for better separation of concerns. + */ +class VpnConnectionHandler( + private val vpnService: VpnService, + private val persistentState: PersistentState, + private val appConfig: AppConfig +) { + companion object { + private const val TAG = "VpnConnHandler" + + // IPv4 VPN constants + const val IPV4_TEMPLATE = "10.111.222.%d" + const val IPV4_PREFIX_LENGTH = 24 + + // IPv6 VPN constants + const val IPV6_TEMPLATE = "fd66:f83a:c650::%d" + const val IPV6_PREFIX_LENGTH = 120 + + const val VPN_INTERFACE_MTU = 1500 + const val MIN_MTU = 1280 + const val MAX_MTU = 10000 + + private const val IPV4_DNS_ADDR = 44 + private const val IPV6_DNS_ADDR = 44 + } + + enum class LanIp(val id: Int) { + DNS(44); + + fun make(template: String): String { + return String.format(template, id) + } + } + + /** + * Creates a new VPN.Builder with appropriate configuration. + */ + fun createBuilder( + underlyingNetworks: NetworkBindingService.UnderlyingNetworks?, + isVpnLockdown: Boolean, + excludedApps: Set, + rethinkUid: Int + ): VpnService.Builder { + var builder = vpnService.Builder() + + val networks = getUnderlyingNetworkArray(underlyingNetworks) + builder.setUnderlyingNetworks(networks) + + if (!isVpnLockdown && !Utilities.isPlayStoreFlavour() && canAllowBypass()) { + Logger.i(LOG_TAG_VPN, "$TAG allow apps to bypass vpn on-demand") + builder = builder.allowBypass() + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + builder.setMetered(persistentState.setVpnBuilderToMetered) + } + + if (!persistentState.routeRethinkInRethink) { + Logger.i(LOG_TAG_VPN, "$TAG builder: exclude rethink app from builder") + addDisallowedApplication(builder, vpnService.packageName) + } + + if (isAppPaused()) { + if (!isVpnLockdown) { + // Simplified: in paused mode, just skip excluded apps handling + Logger.i(LOG_TAG_VPN, "$TAG paused mode, skipping app exclusions") + } + return builder + } + + if (appConfig.determineFirewallMode().isFirewallSinkMode()) { + addAllowedApplication(builder, excludedApps) + } else if (!isVpnLockdown) { + Logger.i(LOG_TAG_VPN, "$TAG builder, vpn is not lockdown, exclude-apps ${excludedApps.size}") + addDisallowedApplications(builder, excludedApps) + } + + return builder + } + + /** + * Configures the VPN interface with addresses and routes. + */ + fun configureInterface( + builder: VpnService.Builder, + mtu: Int, + customLanMode: Boolean + ): VpnService.Builder { + builder.setMtu(mtu) + + val ipType = persistentState.internetProtocolType + + when (ipType) { + InternetProtocol.IPv4.id -> { + configureIPv4(builder, customLanMode) + } + InternetProtocol.IPv6.id -> { + configureIPv6(builder, customLanMode) + } + InternetProtocol.IPv46.id, InternetProtocol.ALWAYSv46.id -> { + configureIPv4(builder, customLanMode) + configureIPv6(builder, customLanMode) + } + } + + return builder + } + + private fun configureIPv4(builder: VpnService.Builder, customLanMode: Boolean) { + val fakeDns = LanIp.DNS.make(IPV4_TEMPLATE) + + if (customLanMode) { + val customDns = persistentState.customLanDnsIpv4.split("/").firstOrNull() ?: "" + if (customDns.isNotEmpty()) { + builder.addAddress(customDns, IPV4_PREFIX_LENGTH) + } + } else { + builder.addAddress(fakeDns, IPV4_PREFIX_LENGTH) + } + + if (!customLanMode) { + builder.addDnsServer(fakeDns) + } + + builder.addRoute("0.0.0.0", 0) + } + + private fun configureIPv6(builder: VpnService.Builder, customLanMode: Boolean) { + val fakeDns = LanIp.DNS.make(IPV6_TEMPLATE) + + if (customLanMode) { + val customDns = persistentState.customLanDnsIpv6.split("/").firstOrNull() ?: "" + if (customDns.isNotEmpty()) { + builder.addAddress(customDns, IPV6_PREFIX_LENGTH) + } + } else { + builder.addAddress(fakeDns, IPV6_PREFIX_LENGTH) + } + + if (!customLanMode) { + builder.addDnsServer(fakeDns) + } + + builder.addRoute("::", 0) + } + + private fun canAllowBypass(): Boolean { + return persistentState.allowBypass && !appConfig.isProxyEnabled() + } + + private fun addDisallowedApplication(builder: VpnService.Builder, packageName: String) { + try { + builder.addDisallowedApplication(packageName) + } catch (e: PackageManager.NameNotFoundException) { + Logger.w(LOG_TAG_VPN, "$TAG failed to exclude $packageName: ${e.message}") + } + } + + private fun addDisallowedApplications(builder: VpnService.Builder, packageNames: Collection) { + packageNames.forEach { addDisallowedApplication(builder, it) } + } + + private fun addAllowedApplication(builder: VpnService.Builder, packageNames: Collection) { + packageNames.forEach { pkg -> + try { + builder.addAllowedApplication(pkg) + } catch (e: PackageManager.NameNotFoundException) { + Logger.w(LOG_TAG_VPN, "$TAG failed to allow $pkg: ${e.message}") + } + } + } + + private fun getUnderlyingNetworkArray( + networks: NetworkBindingService.UnderlyingNetworks? + ): Array? { + if (networks == null) return null + + val allNetworks = mutableListOf() + allNetworks.addAll(networks.ipv4Net.map { it.network }) + allNetworks.addAll(networks.ipv6Net.map { it.network }) + + return if (allNetworks.isEmpty()) null else allNetworks.toTypedArray() + } + + private fun isAppPaused(): Boolean = VpnController.isAppPaused() +} diff --git a/app/src/main/java/com/celzero/bravedns/service/VpnNotificationManager.kt b/app/src/main/java/com/celzero/bravedns/service/VpnNotificationManager.kt new file mode 100644 index 000000000..c242df834 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/service/VpnNotificationManager.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2024 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 android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.celzero.bravedns.R +import com.celzero.bravedns.receiver.NotificationActionReceiver +import com.celzero.bravedns.ui.HomeScreenActivity +import com.celzero.bravedns.util.NotificationActionType +import com.celzero.bravedns.util.Utilities.isAtleastO +import com.celzero.bravedns.util.Utilities.isAtleastU +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages all VPN notification operations. + * Extracted from BraveVPNService for better separation of concerns. + */ +class VpnNotificationManager( + private val context: Context, + private val persistentState: PersistentState +) { + companion object { + const val SERVICE_ID = 1 + const val MEMORY_NOTIFICATION_ID = 29001 + const val NW_ENGINE_NOTIFICATION_ID = 29002 + const val NOTIF_ID_ACCESSIBILITY_FAILURE = 104 + + private const val MAIN_CHANNEL_ID = "vpn" + private const val WARNING_CHANNEL_ID = "warning" + + private const val TAG = "VpnNotifMgr" + } + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private val _connectionState = MutableStateFlow(BraveVPNService.State.NEW) + val connectionState: StateFlow = _connectionState.asStateFlow() + + init { + createNotificationChannels() + } + + private fun createNotificationChannels() { + if (!isAtleastO()) return + + val mainChannel = NotificationChannel( + MAIN_CHANNEL_ID, + context.getString(R.string.notif_channel_vpn_notification), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = context.getString(R.string.notif_channel_desc_vpn_notification) + setShowBadge(false) + } + notificationManager.createNotificationChannel(mainChannel) + + val warningChannel = NotificationChannel( + WARNING_CHANNEL_ID, + context.getString(R.string.notif_channel_vpn_failure), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = context.getString(R.string.notif_channel_desc_vpn_failure) + setShowBadge(false) + } + notificationManager.createNotificationChannel(warningChannel) + } + + fun updateConnectionState(state: BraveVPNService.State) { + _connectionState.value = state + } + + fun buildNotification(): Notification { + return NotificationCompat.Builder(context, MAIN_CHANNEL_ID).apply { + setSmallIcon(R.drawable.ic_notification_icon) + setContentTitle(context.getString(R.string.app_name)) + setContentText(getNotificationContentText()) + setOngoing(true) + setOnlyAlertOnce(true) + setShowWhen(false) + priority = NotificationCompat.PRIORITY_LOW + color = ContextCompat.getColor(context, getAccentColor()) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + setContentIntent(createContentIntent()) + + setStyle(NotificationCompat.BigTextStyle().bigText(getNotificationContentText())) + }.build() + } + + private fun getNotificationContentText(): String { + return when (_connectionState.value) { + BraveVPNService.State.WORKING -> context.getString(R.string.hybrid_mode_notification_title) + BraveVPNService.State.PAUSED -> context.getString(R.string.pause_mode_notification_title) + BraveVPNService.State.NEW -> context.getString(R.string.lbl_starting) + else -> context.getString(R.string.hybrid_mode_notification_title) + } + } + + private fun createContentIntent(): PendingIntent { + val intent = Intent(context, HomeScreenActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun showNotification() { + notificationManager.notify(SERVICE_ID, buildNotification()) + } + + fun dismissNotification(id: Int = SERVICE_ID) { + notificationManager.cancel(id) + } + + fun startForegroundSafely(service: android.app.Service): Boolean { + return try { + if (isAtleastU()) { + ServiceCompat.startForeground( + service, + SERVICE_ID, + buildNotification(), + android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + } else { + service.startForeground(SERVICE_ID, buildNotification()) + } + true + } catch (e: Exception) { + Logger.e(LOG_TAG_VPN, "$TAG startForeground failed", e) + false + } + } + + private fun getAccentColor(): Int { + return com.celzero.bravedns.util.UIUtils.getAccentColor(persistentState.theme) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/TestDialogActivity.kt b/app/src/main/java/com/celzero/bravedns/ui/TestDialogActivity.kt index b03fce9f4..00c859973 100644 --- a/app/src/main/java/com/celzero/bravedns/ui/TestDialogActivity.kt +++ b/app/src/main/java/com/celzero/bravedns/ui/TestDialogActivity.kt @@ -16,16 +16,15 @@ package com.celzero.bravedns.ui import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import androidx.activity.ComponentActivity /** * Minimal test activity for dialog instrumentation tests. * This activity has no dependencies and provides a stable context for showing dialogs. */ -class TestDialogActivity : AppCompatActivity() { +class TestDialogActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // No setContentView needed - we just need a valid activity context for dialogs } } - diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutScreen.kt new file mode 100644 index 000000000..a244e84c7 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutScreen.kt @@ -0,0 +1,855 @@ +/* + * Copyright 2024 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. + */ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.celzero.bravedns.ui.compose.about + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Article +import androidx.compose.material.icons.automirrored.rounded.HelpOutline +import androidx.compose.material.icons.rounded.Backup +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Gavel +import androidx.compose.material.icons.rounded.HelpOutline +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.NewReleases +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Policy +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.VpnKey +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.settings.AppearanceSettingsCard +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkAnimatedSection +import com.celzero.bravedns.ui.compose.theme.RethinkGridTile +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.ui.compose.theme.cardPositionFor + +private data class AboutItem( + val headline: String, + val iconPainter: Painter, + val onClick: () -> Unit, +) + +private fun aboutTopClusterPosition(index: Int, lastIndex: Int): CardPosition { + return when { + lastIndex <= 0 -> CardPosition.Last + index == lastIndex -> CardPosition.Last + else -> CardPosition.Middle + } +} + +@Composable +fun AboutScreen( + uiState: AboutUiState, + onSponsorClick: () -> Unit, + onTelegramClick: () -> Unit, + onBugReportClick: () -> Unit, + onWhatsNewClick: () -> Unit, + onAppUpdateClick: () -> Unit, + onContributorsClick: () -> Unit, + onTranslateClick: () -> Unit, + onWebsiteClick: () -> Unit, + onGithubClick: () -> Unit, + onFaqClick: () -> Unit, + onDocsClick: () -> Unit, + onPrivacyPolicyClick: () -> Unit, + onTermsOfServiceClick: () -> Unit, + onLicenseClick: () -> Unit, + onTwitterClick: () -> Unit, + onEmailClick: () -> Unit, + onRedditClick: () -> Unit, + onElementClick: () -> Unit, + onMastodonClick: () -> Unit, + onGeneralSettingsClick: () -> Unit, + onAppInfoClick: () -> Unit, + onVpnProfileClick: () -> Unit, + onNotificationClick: () -> Unit, + onStatsClick: () -> Unit, + onDbStatsClick: () -> Unit, + onFlightRecordClick: () -> Unit, + onEventLogsClick: () -> Unit, + onTokenClick: () -> Unit, + onTokenDoubleTap: () -> Unit, + onFossClick: () -> Unit, + onFlossFundsClick: () -> Unit, + persistentState: PersistentState, + onThemeModeChanged: ((Int) -> Unit)? = null, + onThemeColorChanged: ((Int) -> Unit)? = null +) { + val aboutTitle = stringResource(id = R.string.title_about) + val appName = stringResource(id = R.string.app_name) + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val expandedTitle = remember(appName) { appName } + val expandedSubtitle = remember(uiState.versionName, uiState.slicedVersion) { + fun withSingleVPrefix(version: String): String { + return if (version.startsWith("v", ignoreCase = true)) version else "v$version" + } + + when { + uiState.versionName.isNotBlank() -> withSingleVPrefix(uiState.versionName) + uiState.slicedVersion.isNotBlank() -> withSingleVPrefix(uiState.slicedVersion) + else -> null + } + } + val topBarTitle by remember(scrollBehavior.state, expandedTitle, aboutTitle) { + derivedStateOf { + if (scrollBehavior.state.collapsedFraction >= 0.55f) { + aboutTitle + } else { + expandedTitle + } + } + } + val topBarSubtitle by remember(scrollBehavior.state, expandedSubtitle) { + derivedStateOf { + if (scrollBehavior.state.collapsedFraction >= 0.55f) { + null + } else { + expandedSubtitle + } + } + } + val quickActionIconTint = MaterialTheme.colorScheme.onPrimaryFixed.copy(alpha = 0.8f) + val telegramTint = Color(0xFF74C5FF) + val bugReportTint = Color(0xFFFF907F) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + RethinkLargeTopBar( + title = topBarTitle, + subtitle = topBarSubtitle, + scrollBehavior = scrollBehavior, + titleTextStyle = MaterialTheme.typography.headlineMedium + ) + } + ) { paddingValues -> + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + // ── Sponsor card (hidden intentionally) ─────────────────── + // item { + // RethinkAnimatedSection(index = 0) { + // SponsorCard(uiState, onSponsorClick) + // } + // } + + // ── Appearance (shared settings component) ─────────────── + item { + RethinkAnimatedSection(index = 1) { + AppearanceSettingsCard( + themePreference = persistentState.theme, + colorPresetId = persistentState.themeColorPreset, + onAppearanceModeSelected = { mode -> + val themeId = mode.toThemePreference() + persistentState.theme = themeId + onThemeModeChanged?.invoke(themeId) + }, + onColorPresetSelected = { preset -> + persistentState.themeColorPreset = preset.id + onThemeColorChanged?.invoke(preset.id) + }, + sectionHeaderColor = MaterialTheme.colorScheme.primary, + showSectionHeader = true + ) + } + } + + // ── Quick actions (Telegram + Bug Report) ───────────────── + item { + RethinkAnimatedSection(index = 2) { + AboutAppSection( + uiState = uiState, + onTelegramClick = onTelegramClick, + onBugReportClick = onBugReportClick, + onWhatsNewClick = onWhatsNewClick, + onAppUpdateClick = onAppUpdateClick, + quickActionIconTint = quickActionIconTint, + telegramTint = telegramTint, + bugReportTint = bugReportTint + ) + } + } + + // ── Web section ─────────────────────────────────────────── + item { + RethinkAnimatedSection(index = 3) { + AboutSection( + title = stringResource(id = R.string.about_web), + accentColor = MaterialTheme.colorScheme.secondary, + items = listOf( + AboutItem( + headline = stringResource(id = R.string.about_website), + iconPainter = rememberVectorPainter(image = Icons.Rounded.Public), + onClick = onWebsiteClick + ), + AboutItem(stringResource(id = R.string.about_github), painterResource(id = R.drawable.ic_github), onGithubClick), + AboutItem( + headline = stringResource(id = R.string.about_faq), + iconPainter = rememberVectorPainter(image = Icons.AutoMirrored.Rounded.HelpOutline), + onClick = onFaqClick + ), + AboutItem( + headline = stringResource(id = R.string.about_docs), + iconPainter = rememberVectorPainter(image = Icons.AutoMirrored.Rounded.Article), + onClick = onDocsClick + ), + AboutItem( + stringResource(id = R.string.about_privacy_policy), + rememberVectorPainter(image = Icons.Rounded.Policy), + onPrivacyPolicyClick + ), + AboutItem( + stringResource(id = R.string.about_terms_of_service), + rememberVectorPainter(image = Icons.Rounded.Gavel), + onTermsOfServiceClick + ), + AboutItem( + stringResource(id = R.string.about_license), + rememberVectorPainter(image = Icons.AutoMirrored.Rounded.Article), + onLicenseClick + ), + ), + ) + } + } + + // ── Connect / community section ─────────────────────────── + item { + RethinkAnimatedSection(index = 4) { + AboutConnectSection( + onTwitterClick = onTwitterClick, + onEmailClick = onEmailClick, + onRedditClick = onRedditClick, + onElementClick = onElementClick, + onMastodonClick = onMastodonClick + ) + } + } + + // ── System settings section ─────────────────────────────── + item { + RethinkAnimatedSection(index = 5) { + AboutSection( + title = stringResource(id = R.string.about_settings), + accentColor = MaterialTheme.colorScheme.primary, + items = listOf( + AboutItem( + stringResource(id = R.string.settings_general_header), + rememberVectorPainter(image = Icons.Rounded.Settings), + onGeneralSettingsClick + ), + AboutItem( + stringResource(id = R.string.about_settings_app_info), + rememberVectorPainter(image = Icons.Rounded.Info), + onAppInfoClick + ), + AboutItem( + stringResource(id = R.string.about_settings_vpn_profile), + rememberVectorPainter(image = Icons.Rounded.VpnKey), + onVpnProfileClick + ), + AboutItem( + stringResource(id = R.string.about_settings_notification), + rememberVectorPainter(image = Icons.Rounded.Notifications), + onNotificationClick + ), + ), + ) + } + } + + // ── Debug / diagnostics section ─────────────────────────── + item { + val items = buildList { + add(AboutItem(stringResource(id = R.string.title_statistics), painterResource(id = R.drawable.ic_log_level), onStatsClick)) + add( + AboutItem( + stringResource(id = R.string.title_database_dump), + rememberVectorPainter(image = Icons.Rounded.Backup), + onDbStatsClick + ) + ) + if (uiState.isDebug) { + add( + AboutItem( + "Flight Recorder", + rememberVectorPainter(image = Icons.Rounded.Backup), + onFlightRecordClick + ) + ) + } + add( + AboutItem( + stringResource(id = R.string.event_logs_title), + rememberVectorPainter(image = Icons.AutoMirrored.Rounded.Article), + onEventLogsClick + ) + ) + } + RethinkAnimatedSection(index = 6) { + AboutSection( + title = stringResource(id = R.string.title_statistics), + accentColor = MaterialTheme.colorScheme.secondary, + items = items, + ) + } + } + + // ── Partner logos ───────────────────────────────────────── + item { + RethinkAnimatedSection(index = 7) { + PartnerLogosCard(onFossClick, onFlossFundsClick) + } + } + + // ── Version footer ──────────────────────────────────────── + item { + RethinkAnimatedSection(index = 8) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (uiState.isFirebaseEnabled && !uiState.isFdroid) { + Text( + text = uiState.firebaseToken, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .alpha(0.5f) + .padding(bottom = Dimensions.spacingMd) + .clickable { onTokenClick() } + ) + } + Text( + text = "${uiState.versionName} · ${uiState.installSource}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .alpha(0.75f) + ) + if (uiState.buildNumber.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = uiState.buildNumber, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .alpha(0.55f) + ) + } + } + } + } + } + } +} + +// ─── Sponsor Card ─────────────────────────────────────────────────────────── + +@Suppress("unused") +@Composable +private fun SponsorCard(uiState: AboutUiState, onSponsorClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.98f else 1f, + animationSpec = tween(100), + label = "sponsor_scale" + ) + + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius3xl), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + onClick = onSponsorClick, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .scale(scale) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(52.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(32.dp) + ) + } + } + Text( + text = stringResource(id = R.string.about_bravedns_explantion), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = stringResource( + id = R.string.sponser_dialog_usage_msg, + uiState.daysSinceInstall, + uiState.sponsoredAmount + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + } + + Button( + onClick = onSponsorClick, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Icon( + imageVector = Icons.Rounded.Favorite, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(id = R.string.about_sponsor_link_text), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +// ─── Section composable with colored icon containers ──────────────────────── + +@Composable +private fun AboutSection( + title: String, + accentColor: Color, + items: List, +) { + Column { + SectionHeader(title = title, color = accentColor) + items.forEachIndexed { index, item -> + RethinkListItem( + headline = item.headline, + leadingIconPainter = item.iconPainter, + leadingIconTint = accentColor, + leadingIconContainerColor = accentColor.copy(alpha = 0.14f), + position = cardPositionFor(index = index, lastIndex = items.lastIndex), + highlightContainerColor = accentColor.copy(alpha = 0.24f), + onClick = item.onClick + ) + } + } +} + +@Composable +private fun AboutAppSection( + uiState: AboutUiState, + onTelegramClick: () -> Unit, + onBugReportClick: () -> Unit, + onWhatsNewClick: () -> Unit, + onAppUpdateClick: () -> Unit, + quickActionIconTint: Color, + telegramTint: Color, + bugReportTint: Color +) { + val accentColor = MaterialTheme.colorScheme.primary + val listItems = buildList { + add( + AboutItem( + headline = stringResource(id = R.string.about_whats_new, uiState.slicedVersion), + iconPainter = rememberVectorPainter(image = Icons.Rounded.NewReleases), + onClick = onWhatsNewClick + ) + ) + if (!uiState.isFdroid) { + add( + AboutItem( + headline = stringResource(id = R.string.about_app_update_check), + iconPainter = painterResource(id = R.drawable.ic_update), + onClick = onAppUpdateClick + ) + ) + } + } + + Column { + SectionHeader(title = stringResource(id = R.string.about_app), color = accentColor) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingGridTile) + ) { + RethinkGridTile( + title = stringResource(id = R.string.about_join_telegram), + iconRes = R.drawable.ic_telegram, + accentColor = telegramTint, + iconTint = quickActionIconTint, + iconContainerColor = telegramTint, + shape = RoundedCornerShape( + topStart = 22.dp, + topEnd = 12.dp, + bottomStart = 6.dp, + bottomEnd = 6.dp + ), + modifier = Modifier.weight(1f), + onClick = onTelegramClick + ) + if (uiState.isBugReportRunning) { + RethinkGridTile( + title = stringResource(id = R.string.collecting_logs_progress_text), + iconRes = R.drawable.ic_android_icon, + accentColor = bugReportTint, + iconTint = quickActionIconTint, + iconContainerColor = bugReportTint, + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 22.dp, + bottomStart = 6.dp, + bottomEnd = 6.dp + ), + modifier = Modifier.weight(1f), + trailing = { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = bugReportTint + ) + } + ) + } else { + RethinkGridTile( + title = stringResource(id = R.string.about_bug_report), + iconRes = R.drawable.ic_android_icon, + accentColor = bugReportTint, + iconTint = quickActionIconTint, + iconContainerColor = bugReportTint, + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 22.dp, + bottomStart = 6.dp, + bottomEnd = 6.dp + ), + modifier = Modifier.weight(1f), + onClick = onBugReportClick + ) + } + } + + listItems.forEachIndexed { index, item -> + RethinkListItem( + headline = item.headline, + leadingIconPainter = item.iconPainter, + leadingIconTint = accentColor, + leadingIconContainerColor = accentColor.copy(alpha = 0.14f), + position = aboutTopClusterPosition(index = index, lastIndex = listItems.lastIndex), + highlightContainerColor = accentColor.copy(alpha = 0.24f), + onClick = item.onClick + ) + } + } +} + +@Composable +private fun AboutConnectSection( + onTwitterClick: () -> Unit, + onEmailClick: () -> Unit, + onRedditClick: () -> Unit, + onElementClick: () -> Unit, + onMastodonClick: () -> Unit +) { + val accentColor = MaterialTheme.colorScheme.tertiary + Column { + SectionHeader(title = stringResource(id = R.string.about_connect), color = accentColor) + + RethinkListItem( + headline = stringResource(id = R.string.about_twitter), + leadingIconPainter = painterResource(id = R.drawable.ic_twitter), + leadingIconTint = accentColor, + leadingIconContainerColor = accentColor.copy(alpha = 0.14f), + position = CardPosition.First, + highlightContainerColor = accentColor.copy(alpha = 0.24f), + onClick = onTwitterClick + ) + + Spacer(modifier = Modifier.height(Dimensions.spacingXs)) + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + RethinkGridTile( + title = stringResource(id = R.string.about_email), + iconRes = R.drawable.ic_mail, + accentColor = accentColor, + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 12.dp, + bottomEnd = 12.dp + ), + modifier = Modifier.weight(1f), + onClick = onEmailClick + ) + RethinkGridTile( + title = stringResource(id = R.string.lbl_reddit), + iconRes = R.drawable.ic_reddit, + accentColor = accentColor, + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 12.dp, + bottomEnd = 12.dp + ), + modifier = Modifier.weight(1f), + onClick = onRedditClick + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + RethinkGridTile( + title = stringResource(id = R.string.lbl_matrix), + iconRes = R.drawable.ic_element, + accentColor = accentColor, + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 28.dp, + bottomEnd = 12.dp + ), + modifier = Modifier.weight(1f), + onClick = onElementClick + ) + RethinkGridTile( + title = stringResource(id = R.string.lbl_mastodon), + iconRes = R.drawable.ic_mastodon, + accentColor = accentColor, + shape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 12.dp, + bottomEnd = 28.dp + ), + modifier = Modifier.weight(1f), + onClick = onMastodonClick + ) + } + } + } +} + +// ─── Partner Logos Card ────────────────────────────────────────────────────── + +@Composable +private fun PartnerLogosCard(onFossClick: () -> Unit, onFlossFundsClick: () -> Unit) { + val isLightTheme = MaterialTheme.colorScheme.surface.luminance() > 0.5f + val cardColor = + if (isLightTheme) Color(0xFF141922) else MaterialTheme.colorScheme.surfaceContainerLow + val cardBorderColor = + if (isLightTheme) Color.White.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + val textColor = + if (isLightTheme) Color(0xFFE8EDF8) else MaterialTheme.colorScheme.onSurfaceVariant + val logoChipColor = + if (isLightTheme) Color.White.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface + val logoChipBorderColor = + if (isLightTheme) Color.White.copy(alpha = 0.22f) + else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) + + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius3xl), + color = cardColor, + border = BorderStroke(1.dp, cardBorderColor), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(id = R.string.about_mozilla), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = textColor, + modifier = Modifier.alpha(0.8f) + ) + Image( + painter = painterResource(id = R.drawable.mozilla), + contentDescription = null, + modifier = Modifier.width(150.dp), + contentScale = ContentScale.FillWidth + ) + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val logoShape = RoundedCornerShape(Dimensions.cornerRadiusMdLg) + Surface( + shape = logoShape, + color = logoChipColor, + border = BorderStroke(1.dp, logoChipBorderColor), + modifier = Modifier + .clip(logoShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + onClick = onFossClick + ) + ) { + Image( + painter = painterResource(id = R.drawable.foss_logo), + contentDescription = null, + modifier = Modifier + .width(126.dp) + .height(46.dp) + .padding(horizontal = 8.dp, vertical = 6.dp), + contentScale = ContentScale.Fit + ) + } + Surface( + shape = logoShape, + color = logoChipColor, + border = BorderStroke(1.dp, logoChipBorderColor), + modifier = Modifier + .clip(logoShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + onClick = onFlossFundsClick + ) + ) { + Image( + painter = painterResource(id = R.drawable.ic_floss_fund_badge), + contentDescription = null, + modifier = Modifier + .width(126.dp) + .height(46.dp) + .padding(horizontal = 8.dp, vertical = 6.dp), + contentScale = ContentScale.Fit + ) + } + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutViewModel.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutViewModel.kt new file mode 100644 index 000000000..f5bf16d42 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/about/AboutViewModel.kt @@ -0,0 +1,164 @@ +package com.celzero.bravedns.ui.compose.about + +import android.content.Context +import android.content.pm.PackageInfo +import android.os.SystemClock +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.celzero.bravedns.R +import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG +import com.celzero.bravedns.database.AppDatabase +import com.celzero.bravedns.scheduler.WorkScheduler +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS +import com.celzero.bravedns.util.FirebaseErrorReporting.TOKEN_LENGTH +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getPackageMetadata +import com.celzero.bravedns.util.Utilities.getRandomString +import com.celzero.bravedns.util.Utilities.isFdroidFlavour +import com.celzero.bravedns.util.Utilities.isPlayStoreFlavour +import com.celzero.firestack.intra.Intra +import kotlinx.coroutines.Dispatchers +import androidx.lifecycle.asFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +data class AboutUiState( + val versionName: String = "", + val installSource: String = "", + val buildNumber: String = "", + val lastUpdated: String = "", + val slicedVersion: String = "", + val daysSinceInstall: String = "", + val sponsoredAmount: String = "", + val firebaseToken: String = "", + val isFirebaseEnabled: Boolean = false, + val isFdroid: Boolean = false, + val isPlayStore: Boolean = false, + val isDebug: Boolean = DEBUG, + val isBugReportRunning: Boolean = false +) + +class AboutViewModel( + private val persistentState: PersistentState, + private val workScheduler: WorkScheduler, + private val context: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(AboutUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var lastAppExitInfoDialogInvokeTime = INIT_TIME_MS + + init { + updateUiState() + observeBugReportWork() + } + + fun updateUiState() { + val version = getVersionName() + val slicedVersion = if (version.length > 6) version.slice(0..6) else version + val installSource = getDownloadSource() + val build = Intra.build(false) + val updatedTs = getLastUpdatedTs() + val sponsorInfo = calculateSponsorInfo() + + _uiState.update { + it.copy( + versionName = version, + slicedVersion = slicedVersion, + installSource = installSource, + buildNumber = build, + lastUpdated = updatedTs, + daysSinceInstall = sponsorInfo.first, + sponsoredAmount = sponsorInfo.second, + firebaseToken = persistentState.firebaseUserToken, + isFirebaseEnabled = persistentState.firebaseErrorReportingEnabled, + isFdroid = isFdroidFlavour(), + isPlayStore = isPlayStoreFlavour() + ) + } + } + + private fun getVersionName(): String { + val pInfo: PackageInfo? = getPackageMetadata(context.packageManager, context.packageName) + return pInfo?.versionName ?: "" + } + + private fun getLastUpdatedTs(): String { + val pInfo: PackageInfo? = getPackageMetadata(context.packageManager, context.packageName) + val updatedTs = pInfo?.lastUpdateTime ?: return "" + return if (updatedTs > 0) { + Utilities.convertLongToTime(updatedTs, com.celzero.bravedns.util.Constants.TIME_FORMAT_4) + } else { + "" + } + } + + private fun getDownloadSource(): String { + return if (isFdroidFlavour()) "F-Droid" + else if (isPlayStoreFlavour()) "Google Play" + else "Website" + } + + private fun calculateSponsorInfo(): Pair { + val installTime = try { + context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + } catch (e: Exception) { + System.currentTimeMillis() + } + val timeDiff = System.currentTimeMillis() - installTime + val millisecondsPerDay = 1000L * 60L * 60L * 24L + val days = (timeDiff / millisecondsPerDay).toDouble() + val month = days / 30.0 + val amount = month * (0.60 + 0.20) + return Pair(days.toInt().toString(), "%.2f".format(amount)) + } + + fun generateNewToken() { + if (isFdroidFlavour()) return + val newToken = getRandomString(TOKEN_LENGTH) + persistentState.firebaseUserToken = newToken + persistentState.firebaseUserTokenTimestamp = System.currentTimeMillis() + _uiState.update { it.copy(firebaseToken = newToken) } + } + + private fun observeBugReportWork() { + val workManager = WorkManager.getInstance(context) + workManager.getWorkInfosByTagLiveData(WorkScheduler.APP_EXIT_INFO_ONE_TIME_JOB_TAG) + .asFlow() + .onEach { workInfoList -> + val workInfo = workInfoList.getOrNull(0) ?: return@onEach + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> { + _uiState.update { it.copy(isBugReportRunning = false) } + } + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + _uiState.update { it.copy(isBugReportRunning = false) } + } + WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { + _uiState.update { it.copy(isBugReportRunning = true) } + } + else -> {} + } + }.launchIn(viewModelScope) + } + + fun triggerBugReport() { + if (WorkScheduler.isWorkRunning(context, WorkScheduler.APP_EXIT_INFO_JOB_TAG)) return + workScheduler.scheduleOneTimeWorkForAppExitInfo() + _uiState.update { it.copy(isBugReportRunning = true) } + } + + fun setBugReportRunning(isRunning: Boolean) { + _uiState.update { it.copy(isBugReportRunning = isRunning) } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/alerts/AlertsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/alerts/AlertsScreen.kt new file mode 100644 index 000000000..02712c1c5 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/alerts/AlertsScreen.kt @@ -0,0 +1,82 @@ +/* + * 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.compose.alerts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.CompactEmptyState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlertsScreen(onBackClick: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(id = R.string.notif_channel_firewall_alerts), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + Surface( + modifier = Modifier.padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ), + shape = androidx.compose.foundation.shape.RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 1.dp + ) { + Column(modifier = Modifier.padding(Dimensions.spacingLg)) { + Text( + text = stringResource(id = R.string.notif_channel_firewall_alerts), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(id = R.string.alerts_empty_state), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + CompactEmptyState( + message = stringResource(id = R.string.alerts_empty_state), + modifier = Modifier.align(Alignment.Center) + ) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/apps/AppListScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/apps/AppListScreen.kt new file mode 100644 index 000000000..c7d406567 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/apps/AppListScreen.kt @@ -0,0 +1,340 @@ +/* + * Copyright 2024 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.compose.apps + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.RefreshDatabase +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.ui.compose.firewall.AppListScreen as FirewallAppListScreen +import com.celzero.bravedns.ui.compose.firewall.BlockType +import com.celzero.bravedns.ui.compose.firewall.Filters +import com.celzero.bravedns.ui.compose.firewall.FirewallFilter +import com.celzero.bravedns.ui.compose.firewall.TopLevelFilter +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.AppInfoViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.compose.runtime.LaunchedEffect + +private fun defaultAppFilters() = Filters(topLevelFilter = TopLevelFilter.INSTALLED) + +/** + * Full App List Screen for navigation integration. + * Manages all state internally and delegates UI to firewall/AppListScreen. + */ +@Composable +fun AppListScreen( + viewModel: AppInfoViewModel, + eventLogger: EventLogger, + refreshDatabase: RefreshDatabase, + onAppClick: ((Int) -> Unit)? = null, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val refreshCompleteText = stringResource(R.string.refresh_complete) + val bypassDnsText = stringResource(R.string.bypass_dns_firewall) + val bypassDnsTooltipText = stringResource(R.string.bypass_dns_firewall_tooltip, bypassDnsText) + + // State + var queryText by remember { mutableStateOf("") } + var selectedFirewallFilter by remember { mutableStateOf(FirewallFilter.ALL) } + var isRefreshing by remember { mutableStateOf(false) } + var currentFilters by remember { mutableStateOf(defaultAppFilters()) } + val latestFilters by remember { derivedStateOf { currentFilters } } + + // Bulk action states + var bulkWifi by remember { mutableStateOf(false) } + var bulkMobile by remember { mutableStateOf(false) } + var bulkBypass by remember { mutableStateOf(false) } + var bulkBypassDns by remember { mutableStateOf(false) } + var bulkExclude by remember { mutableStateOf(false) } + var bulkLockdown by remember { mutableStateOf(false) } + var showBulkUpdateDialog by remember { mutableStateOf(false) } + var bulkDialogTitle by remember { mutableStateOf("") } + var bulkDialogMessage by remember { mutableStateOf("") } + var bulkDialogType by remember { mutableStateOf(null) } + var showInfoDialog by remember { mutableStateOf(false) } + var showBypassToolTip by remember { mutableStateOf(true) } + + val unmeteredBlockDialogTitle = stringResource(id = R.string.fapps_unmetered_block_dialog_title) + val unmeteredUnblockDialogTitle = stringResource(id = R.string.fapps_unmetered_unblock_dialog_title) + val meteredBlockDialogTitle = stringResource(id = R.string.fapps_metered_block_dialog_title) + val meteredUnblockDialogTitle = stringResource(id = R.string.fapps_metered_unblock_dialog_title) + val isolateBlockDialogTitle = stringResource(id = R.string.fapps_isolate_block_dialog_title) + val isolateUnblockDialogTitle = stringResource(id = R.string.fapps_unblock_dialog_title) + val bypassBlockDialogTitle = stringResource(id = R.string.fapps_bypass_block_dialog_title) + val bypassUnblockDialogTitle = stringResource(id = R.string.fapps_unblock_dialog_title) + val excludeBlockDialogTitle = stringResource(id = R.string.fapps_exclude_block_dialog_title) + val excludeUnblockDialogTitle = stringResource(id = R.string.fapps_unblock_dialog_title) + val bypassDnsFirewallBlockDialogTitle = stringResource(id = R.string.fapps_bypass_dns_firewall_dialog_title) + val bypassDnsFirewallUnblockDialogTitle = stringResource(id = R.string.fapps_unblock_dialog_title) + val unmeteredBlockDialogMessage = stringResource(id = R.string.fapps_unmetered_block_dialog_message) + val unmeteredUnblockDialogMessage = stringResource(id = R.string.fapps_unmetered_unblock_dialog_message) + val meteredBlockDialogMessage = stringResource(id = R.string.fapps_metered_block_dialog_message) + val meteredUnblockDialogMessage = stringResource(id = R.string.fapps_metered_unblock_dialog_message) + val isolateBlockDialogMessage = stringResource(id = R.string.fapps_isolate_block_dialog_message) + val bypassBlockDialogMessage = stringResource(id = R.string.fapps_bypass_block_dialog_message) + val bypassDnsFirewallBlockDialogMessage = stringResource(id = R.string.fapps_bypass_dns_firewall_dialog_message) + val excludeBlockDialogMessage = stringResource(id = R.string.fapps_exclude_block_dialog_message) + val unblockDialogMessage = stringResource(id = R.string.fapps_unblock_dialog_message) + + + // Apply filters + fun applyFilters(filters: Filters) { + currentFilters = filters + viewModel.setFilter(filters) + selectedFirewallFilter = filters.firewallFilter + queryText = filters.searchString + } + + fun resetBulkStates(type: BlockType) { + when (type) { + BlockType.UNMETER -> { + bulkMobile = false; bulkBypass = false; bulkBypassDns = false + bulkExclude = false; bulkLockdown = false + } + BlockType.METER -> { + bulkWifi = false; bulkBypass = false; bulkBypassDns = false + bulkExclude = false; bulkLockdown = false + } + BlockType.LOCKDOWN -> { + bulkWifi = false; bulkMobile = false; bulkBypass = false + bulkBypassDns = false; bulkExclude = false + } + BlockType.BYPASS -> { + bulkWifi = false; bulkMobile = false; bulkBypassDns = false + bulkExclude = false; bulkLockdown = false + } + BlockType.BYPASS_DNS_FIREWALL -> { + bulkWifi = false; bulkMobile = false; bulkBypass = false + bulkExclude = false; bulkLockdown = false + } + BlockType.EXCLUDE -> { + bulkWifi = false; bulkMobile = false; bulkBypass = false + bulkBypassDns = false; bulkLockdown = false + } + } + } + + fun logEvent(details: String) { + eventLogger.log(EventType.FW_RULE_MODIFIED, Severity.LOW, "App list, bulk change", EventSource.UI, false, details) + } + + fun updateBulkRules(type: BlockType) { + scope.launch(Dispatchers.IO) { + when (type) { + BlockType.UNMETER -> { + val unmeter = !bulkWifi + viewModel.updateUnmeteredStatus(unmeter) + withContext(Dispatchers.Main) { + bulkWifi = unmeter + resetBulkStates(BlockType.UNMETER) + } + logEvent("Bulk unmetered rule update, isUnmetered: $unmeter") + } + BlockType.METER -> { + val metered = !bulkMobile + viewModel.updateMeteredStatus(metered) + withContext(Dispatchers.Main) { + bulkMobile = metered + resetBulkStates(BlockType.METER) + } + logEvent("Bulk metered rule update, isMetered: $metered") + } + BlockType.LOCKDOWN -> { + val lockdown = !bulkLockdown + viewModel.updateLockdownStatus(lockdown) + withContext(Dispatchers.Main) { + bulkLockdown = lockdown + resetBulkStates(BlockType.LOCKDOWN) + } + logEvent("Bulk lockdown rule update, isLockdown: $lockdown") + } + BlockType.BYPASS -> { + val bypass = !bulkBypass + viewModel.updateBypassStatus(bypass) + withContext(Dispatchers.Main) { + bulkBypass = bypass + resetBulkStates(BlockType.BYPASS) + } + logEvent("Bulk bypass rule update, isBypass: $bypass") + } + BlockType.BYPASS_DNS_FIREWALL -> { + val bypassDns = !bulkBypassDns + viewModel.updateBypassDnsFirewall(bypassDns) + withContext(Dispatchers.Main) { + bulkBypassDns = bypassDns + resetBulkStates(BlockType.BYPASS_DNS_FIREWALL) + } + logEvent("Bulk bypass DNS firewall rule update, isBypassDnsFirewall: $bypassDns") + } + BlockType.EXCLUDE -> { + val exclude = !bulkExclude + viewModel.updateExcludeStatus(exclude) + withContext(Dispatchers.Main) { + bulkExclude = exclude + resetBulkStates(BlockType.EXCLUDE) + } + logEvent("Bulk exclude rule update, isExclude: $exclude") + } + } + } + } + + fun refreshAppList(action: Int = RefreshDatabase.ACTION_REFRESH_INTERACTIVE, showToast: Boolean = true) { + if (isRefreshing) return + + isRefreshing = true + scope.launch(Dispatchers.IO) { + refreshDatabase.refresh(action) { + withContext(Dispatchers.Main) { + isRefreshing = false + if (showToast) { + Utilities.showToastUiCentered( + context, + refreshCompleteText, + Toast.LENGTH_SHORT + ) + } + } + } + } + } + + // Initialize + LaunchedEffect(Unit) { + applyFilters(defaultAppFilters()) + + val appCount = + withContext(Dispatchers.IO) { + viewModel.getAppCount() + } + if (appCount == 0) { + // Bootstrap app entries immediately when local app cache is empty. + refreshAppList(action = RefreshDatabase.ACTION_REFRESH_FORCE, showToast = false) + } + } + + // Delegate to the firewall AppListScreen with all parameters + FirewallAppListScreen( + viewModel = viewModel, + eventLogger = eventLogger, + queryText = queryText, + selectedFirewallFilter = selectedFirewallFilter, + isRefreshing = isRefreshing, + bulkWifi = bulkWifi, + bulkMobile = bulkMobile, + bulkBypass = bulkBypass, + bulkBypassDns = bulkBypassDns, + bulkExclude = bulkExclude, + bulkLockdown = bulkLockdown, + showBulkUpdateDialog = showBulkUpdateDialog, + bulkDialogTitle = bulkDialogTitle, + bulkDialogMessage = bulkDialogMessage, + bulkDialogType = bulkDialogType, + showInfoDialog = showInfoDialog, + currentFilters = currentFilters, + onQueryChange = { query -> + queryText = query + applyFilters(latestFilters.copy(searchString = query)) + }, + onRefreshClick = { refreshAppList() }, + onFilterApply = { applied -> applyFilters(applied) }, + onFilterClear = { cleared -> + applyFilters( + cleared.copy( + topLevelFilter = TopLevelFilter.INSTALLED, + searchString = queryText + ) + ) + }, + onFirewallFilterClick = { filter -> + val updated = currentFilters.copy(firewallFilter = filter) + applyFilters(updated) + }, + onBulkDialogConfirm = { type -> + showBulkUpdateDialog = false + bulkDialogType = null + updateBulkRules(type) + }, + onBulkDialogDismiss = { + showBulkUpdateDialog = false + bulkDialogType = null + }, + onInfoDialogDismiss = { showInfoDialog = false }, + onShowInfoDialog = { showInfoDialog = true }, + onShowBulkDialog = { type -> + when (type) { + BlockType.UNMETER -> { + bulkDialogTitle = if (!bulkWifi) unmeteredBlockDialogTitle else unmeteredUnblockDialogTitle + bulkDialogMessage = + if (!bulkWifi) unmeteredBlockDialogMessage else unmeteredUnblockDialogMessage + } + BlockType.METER -> { + bulkDialogTitle = if (!bulkMobile) meteredBlockDialogTitle else meteredUnblockDialogTitle + bulkDialogMessage = + if (!bulkMobile) meteredBlockDialogMessage else meteredUnblockDialogMessage + } + BlockType.LOCKDOWN -> { + bulkDialogTitle = if (!bulkLockdown) isolateBlockDialogTitle else isolateUnblockDialogTitle + bulkDialogMessage = if (!bulkLockdown) isolateBlockDialogMessage else unblockDialogMessage + } + BlockType.BYPASS -> { + bulkDialogTitle = if (!bulkBypass) bypassBlockDialogTitle else bypassUnblockDialogTitle + bulkDialogMessage = if (!bulkBypass) bypassBlockDialogMessage else unblockDialogMessage + } + BlockType.BYPASS_DNS_FIREWALL -> { + bulkDialogTitle = + if (!bulkBypassDns) bypassDnsFirewallBlockDialogTitle else bypassDnsFirewallUnblockDialogTitle + bulkDialogMessage = + if (!bulkBypassDns) bypassDnsFirewallBlockDialogMessage else unblockDialogMessage + } + BlockType.EXCLUDE -> { + bulkDialogTitle = if (!bulkExclude) excludeBlockDialogTitle else excludeUnblockDialogTitle + bulkDialogMessage = if (!bulkExclude) excludeBlockDialogMessage else unblockDialogMessage + } + } + bulkDialogType = type + showBulkUpdateDialog = true + }, + onBypassDnsTooltip = { + showBypassToolTip = false + Utilities.showToastUiCentered( + context, + bypassDnsTooltipText, + Toast.LENGTH_SHORT + ) + }, + showBypassToolTip = showBypassToolTip, + onAppClick = onAppClick, + onBackClick = onBackClick + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/apps/DiagonalWipeIcon.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/apps/DiagonalWipeIcon.kt new file mode 100644 index 000000000..796928b81 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/apps/DiagonalWipeIcon.kt @@ -0,0 +1,210 @@ +package com.celzero.bravedns.ui.compose.apps + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.material3.Icon +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.graphics.drawscope.clipPath + +/** + * Defaults for [DiagonalWipeIcon]. + * + * Keep these values centralized so callers can reuse the same motion language or override + * a single knob (for example only duration) without rewriting the full component. + */ +@Immutable +object DiagonalWipeIconDefaults { + // "Enable" here means toggling from allowed -> blocked. + const val EnableDurationMillis: Int = 530 + + // "Disable" here means toggling from blocked -> allowed. + const val DisableDurationMillis: Int = 610 + + // Snappy entry to make blocked feel responsive. + val EnableEasing: Easing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) + + // Slightly gentler exit for a softer return to allowed. + val DisableEasing: Easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) + + // Small overlap avoids a visible seam between clipped layers on some densities. + const val SeamOverlapPx: Float = 0.8f +} + +/** + * Two-layer icon morph using one diagonal wipe boundary. + * + * Layer model: + * 1) Allowed icon exits from the non-revealed side. + * 2) Blocked icon enters from the revealed side. + * + * Because both layers use the same path and same progress in the same frame, they remain synced. + * + * @param blocked Target state: true = blocked icon fully visible; false = allowed icon fully visible. + * @param allowedIcon Icon used when state is allowed. + * @param blockedIcon Icon used when state is blocked. + * @param allowedTint Tint for [allowedIcon]. + * @param blockedTint Tint for [blockedIcon]. + * @param contentDescription Optional semantics description. + * @param modifier Standard modifier. + * @param enableDurationMillis Duration when moving from allowed -> blocked. + * @param disableDurationMillis Duration when moving from blocked -> allowed. + * @param enableEasing Easing for allowed -> blocked transition. + * @param disableEasing Easing for blocked -> allowed transition. + * @param seamOverlapPx Tiny overlap to prevent hairline seams. + */ +@Composable +fun DiagonalWipeIcon( + blocked: Boolean, + allowedIcon: ImageVector, + blockedIcon: ImageVector, + allowedTint: Color, + blockedTint: Color, + contentDescription: String?, + modifier: Modifier = Modifier, + enableDurationMillis: Int = DiagonalWipeIconDefaults.EnableDurationMillis, + disableDurationMillis: Int = DiagonalWipeIconDefaults.DisableDurationMillis, + enableEasing: Easing = DiagonalWipeIconDefaults.EnableEasing, + disableEasing: Easing = DiagonalWipeIconDefaults.DisableEasing, + seamOverlapPx: Float = DiagonalWipeIconDefaults.SeamOverlapPx, +) { + // Transition is keyed by the target blocked state. + val transition = updateTransition(targetState = blocked, label = "diagonalWipeIcon") + + // Vector painters are reused across frames for efficient icon drawing in Canvas. + val allowedPainter = rememberVectorPainter(allowedIcon) + val blockedPainter = rememberVectorPainter(blockedIcon) + + // Shared progress for both layers. 0 = fully allowed, 1 = fully blocked. + val blockedRevealProgress by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + tween(durationMillis = enableDurationMillis, easing = enableEasing) + } else { + tween(durationMillis = disableDurationMillis, easing = disableEasing) + } + }, + label = "diagonalWipeReveal", + ) { isBlocked -> + if (isBlocked) 1f else 0f + } + + // Clamp to protect against tiny numeric overshoots. + val blockedProgress = blockedRevealProgress.coerceIn(0f, 1f) + + Box( + modifier = modifier + // Offscreen composition ensures clipping behaves consistently across draws. + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + // Keep accessibility semantics attached to the whole icon. + .semantics { + if (contentDescription != null) { + this.contentDescription = contentDescription + } + }, + ) { + // Fast path: no in-between draw work when fully allowed. + if (blockedProgress <= 0.001f) { + Icon( + imageVector = allowedIcon, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + tint = allowedTint, + ) + return@Box + } + + // Fast path: no in-between draw work when fully blocked. + if (blockedProgress >= 0.999f) { + Icon( + imageVector = blockedIcon, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + tint = blockedTint, + ) + return@Box + } + + Canvas(modifier = Modifier.fillMaxSize()) { + // Convert normalized progress into diagonal coverage (x+y plane), + // then add a tiny overlap so both clipped regions meet without a gap. + val adjustedBlockedProgress = ( + (blockedProgress * (size.width + size.height) + seamOverlapPx) / + (size.width + size.height) + ).coerceIn(0f, 1f) + + // This single path defines both entering and exiting regions. + val blockedRevealPath = buildDiagonalRevealPath( + width = size.width, + height = size.height, + progress = adjustedBlockedProgress, + ) + + // Allowed (first layer) exits while blocked (second layer) enters + // with the exact same boundary from opposite clipped regions. + clipPath(path = blockedRevealPath, clipOp = ClipOp.Difference) { + with(allowedPainter) { + draw(size = size, colorFilter = ColorFilter.tint(allowedTint)) + } + } + clipPath(path = blockedRevealPath, clipOp = ClipOp.Intersect) { + with(blockedPainter) { + draw(size = size, colorFilter = ColorFilter.tint(blockedTint)) + } + } + } + } +} + +/** + * Builds a polygon that grows diagonally from top-left toward bottom-right. + * + * Geometry note: + * - We drive progression along `x + y = constant`. + * - At p=0, the polygon is empty. + * - At p=1, the polygon covers the full icon bounds. + */ +private fun buildDiagonalRevealPath(width: Float, height: Float, progress: Float): Path { + val p = progress.coerceIn(0f, 1f) + return Path().apply { + if (p <= 0f) return@apply + if (p >= 1f) { + moveTo(0f, 0f) + lineTo(width, 0f) + lineTo(width, height) + lineTo(0f, height) + close() + return@apply + } + + val diagonal = (width + height) * p + moveTo(0f, 0f) + lineTo(diagonal.coerceAtMost(width), 0f) + if (diagonal > width) { + lineTo(width, (diagonal - width).coerceAtMost(height)) + } + if (diagonal > height) { + lineTo((diagonal - height).coerceAtMost(width), height) + } + lineTo(0f, diagonal.coerceAtMost(height)) + close() + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/bubble/BubbleScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/bubble/BubbleScreen.kt new file mode 100644 index 000000000..919d0cd07 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/bubble/BubbleScreen.kt @@ -0,0 +1,510 @@ +/* + * 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.compose.bubble + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AllowedAppInfo +import com.celzero.bravedns.data.BlockedAppInfo +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import io.github.aakira.napier.Napier +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +@Composable +fun BubbleScreen( + vpnOn: Boolean, + allowedItems: LazyPagingItems, + blockedItems: LazyPagingItems, + onAllowApp: (BlockedAppInfo, () -> Unit) -> Unit, + onRemoveAllowed: (AllowedAppInfo, () -> Unit) -> Unit +) { + val allowedLoaded = allowedItems.loadState.refresh is LoadState.NotLoading + val allowedCount = allowedItems.itemCount + val showAllowedSection = vpnOn && allowedLoaded && allowedCount > 0 + + val blockedLoading = blockedItems.loadState.refresh is LoadState.Loading + val blockedError = blockedItems.loadState.refresh is LoadState.Error + val blockedLoaded = blockedItems.loadState.refresh is LoadState.NotLoading + val blockedEmpty = blockedLoaded && blockedItems.itemCount == 0 + + val showEmptyState = !vpnOn || blockedError || blockedEmpty + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues( + top = Dimensions.spacingMd, + bottom = Dimensions.spacingXl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + item { + HeaderSection() + } + + if (showAllowedSection) { + item { + AllowedHeader(count = allowedCount) + } + itemsIndexed( + items = List(allowedItems.itemCount) { it }, + key = { index, _ -> allowedItems[index]?.uid ?: index } + ) { index, _ -> + val app = allowedItems[index] ?: return@itemsIndexed + AllowedAppRow( + app = app, + onRemove = { + onRemoveAllowed(app) { + allowedItems.refresh() + blockedItems.refresh() + } + } + ) + } + } + + item { + BlockedHeader() + } + + when { + blockedLoading -> { + item { LoadingCard() } + } + showEmptyState -> { + item { EmptyState() } + } + else -> { + itemsIndexed( + items = List(blockedItems.itemCount) { it }, + key = { index, _ -> blockedItems[index]?.uid ?: index } + ) { index, _ -> + val app = blockedItems[index] ?: return@itemsIndexed + BlockedAppRow( + app = app, + onAllow = { + onAllowApp(app) { + blockedItems.refresh() + allowedItems.refresh() + } + } + ) + } + } + } + } +} + +@Composable +private fun HeaderSection() { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + shape = RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)), + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Dimensions.spacingLg), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_firewall_bubble), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(R.string.firewall_bubble_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.firewall_bubble_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun AllowedHeader(count: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.bubble_allowed_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg) + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + } +} + +@Composable +private fun BlockedHeader() { + SectionHeader( + title = stringResource(R.string.bubble_activity_title), + modifier = Modifier.padding(horizontal = Dimensions.screenPaddingHorizontal) + ) +} + +@Composable +private fun LoadingCard() { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator(modifier = Modifier.size(36.dp)) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = stringResource(R.string.bubble_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun EmptyState() { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 22.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusLg), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Image( + painter = painterResource(id = R.drawable.ic_firewall_shield), + contentDescription = null, + modifier = Modifier.padding(10.dp).size(28.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.bubble_empty_state_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.bubble_empty_state_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + overflow = TextOverflow.Visible, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } +} + +@Composable +fun AllowedAppRow(app: AllowedAppInfo, onRemove: () -> Unit) { + BubbleAppRow( + packageName = app.packageName, + appName = app.appName, + details = { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = allowedTimeRemaining(app), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + trailing = { + TextButton(onClick = onRemove) { + Text( + text = stringResource(R.string.lbl_remove), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold + ) + } + } + ) +} + +@Composable +private fun BubbleAppRow( + packageName: String, + appName: String, + details: @Composable () -> Unit, + trailing: @Composable () -> Unit +) { + BubbleListCard { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon(packageName = packageName) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = appName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + details() + } + trailing() + } + } +} + +@Composable +fun BlockedAppRow(app: BlockedAppInfo, onAllow: () -> Unit) { + val context = LocalContext.current + val bubbleTimeJustNow = stringResource(R.string.bubble_time_just_now) + val bubbleTimeMinutesAgoTemplate = stringResource(R.string.bubble_time_minutes_ago) + val bubbleTimeHoursAgoTemplate = stringResource(R.string.bubble_time_hours_ago) + + BubbleAppRow( + packageName = app.packageName, + appName = app.appName, + details = { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.bubble_blocked_count, app.count), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = timeAgo( + timestamp = app.lastBlocked, + justNowText = bubbleTimeJustNow, + minutesAgoText = { + String.format(Locale.getDefault(), bubbleTimeMinutesAgoTemplate, it) + }, + hoursAgoText = { + String.format(Locale.getDefault(), bubbleTimeHoursAgoTemplate, it) + } + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + trailing = { + Button( + onClick = onAllow, + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 9.dp) + ) { + Text(text = stringResource(R.string.bubble_allow_btn)) + } + } + ) +} + +@Composable +private fun AppIcon(packageName: String) { + val context = LocalContext.current + val icon = remember(packageName) { loadAppIcon(context, packageName) } + Box( + modifier = Modifier + .size(46.dp) + .background( + MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg) + ), + contentAlignment = Alignment.Center + ) { + val painter = rememberDrawablePainter(icon) + painter?.let { + Image( + painter = it, + contentDescription = null, + modifier = Modifier.size(26.dp) + ) + } + } +} + +@Composable +private fun BubbleListCard(content: @Composable RowScope.() -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)), + tonalElevation = 1.dp + ) { + Row(content = content) + } +} + +private fun loadAppIcon(context: android.content.Context, packageName: String): Drawable { + return try { + if (packageName != "Unknown") { + context.packageManager.getApplicationIcon(packageName) + } else { + ContextCompat.getDrawable(context, R.drawable.ic_launcher_foreground)!! + } + } catch (_: Exception) { + Napier.e("App icon not found for $packageName") + ContextCompat.getDrawable(context, R.drawable.ic_launcher_foreground)!! + } +} + +private fun allowedTimeRemaining(app: AllowedAppInfo): String { + val now = System.currentTimeMillis() + val expiresAt = app.allowedAt + (15 * 60 * 1000) + val remaining = (expiresAt - now) / 1000 / 60 + return if (remaining > 0) { + "$remaining min${if (remaining != 1L) "s" else ""} remaining" + } else { + "Expired" + } +} + +private fun timeAgo( + timestamp: Long, + justNowText: String, + minutesAgoText: (Long) -> String, + hoursAgoText: (Long) -> String +): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + return when { + diff < TimeUnit.MINUTES.toMillis(1) -> justNowText + diff < TimeUnit.HOURS.toMillis(1) -> { + val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) + minutesAgoText(minutes) + } + diff < TimeUnit.DAYS.toMillis(1) -> { + val hours = TimeUnit.MILLISECONDS.toHours(diff) + hoursAgoText(hours) + } + else -> { + val dateFormat = SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()) + dateFormat.format(Date(timestamp)) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/configure/ConfigureScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/configure/ConfigureScreen.kt new file mode 100644 index 000000000..6d85a18e6 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/configure/ConfigureScreen.kt @@ -0,0 +1,867 @@ +/* + * Copyright 2024 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.compose.configure + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkGridTile +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeaderWithSubtitle +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import kotlinx.coroutines.delay +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.celzero.bravedns.ui.compose.theme.RethinkAnimatedSection + +private data class ConfigureEntry( + val id: String, + val title: String, + val subtitle: String? = null, + val iconTint: Color, + val iconRes: Int, + val onClick: () -> Unit, + val keywords: List = emptyList(), +) + +private data class ConfigureSectionModel( + val title: String, + val subtitle: String? = null, + val accentColor: Color, + val layout: ConfigureSectionLayout, + val entries: List, +) + +private enum class ConfigureSectionLayout { + GridFour, + GridPairThenList, + List +} + +private val ConfigureTileGap = 2.dp + +private data class ConfigureSearchTarget( + val id: String, + val title: String, + val path: String, + val iconRes: Int, + val iconTint: Color, + val onClick: () -> Unit, + val keywords: List, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigureScreen( + isDebug: Boolean, + onAppsClick: () -> Unit, + onDnsClick: () -> Unit, + onFirewallClick: () -> Unit, + onProxyClick: () -> Unit, + onNetworkClick: () -> Unit, + onOthersClick: () -> Unit, + onLogsClick: () -> Unit, + onAntiCensorshipClick: () -> Unit, + onAdvancedClick: () -> Unit, + onSearchDestinationClick: ((SettingsSearchDestination) -> Unit)? = null +) { + var query by rememberSaveable { mutableStateOf("") } + var isSearchOpen by rememberSaveable { mutableStateOf(false) } + val searchFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val lifecycleOwner = LocalLifecycleOwner.current + val largeTopBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val searchTopBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val activeTopBarScrollBehavior = + if (isSearchOpen) searchTopBarScrollBehavior else largeTopBarScrollBehavior + val closeSearch = { + isSearchOpen = false + query = "" + } + + LaunchedEffect(isSearchOpen) { + if (isSearchOpen) { + delay(120) + searchFocusRequester.requestFocus() + keyboardController?.show() + } else { + keyboardController?.hide() + } + } + + BackHandler(enabled = isSearchOpen) { + closeSearch() + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + isSearchOpen = false + query = "" + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val protectionTitle = stringResource(R.string.lbl_protection) + val systemTitle = stringResource(R.string.lbl_system) + val advancedTitle = stringResource(R.string.lbl_advanced) + + val appsTitle = stringResource(R.string.lbl_apps) + val dnsTitle = stringResource(R.string.lbl_dns) + val firewallTitle = stringResource(R.string.lbl_firewall) + val proxyTitle = stringResource(R.string.lbl_proxy) + val networkTitle = stringResource(R.string.lbl_network) + val settingsTitle = stringResource(R.string.title_settings) + val logsTitle = stringResource(R.string.lbl_logs) + val antiCensorshipTitle = stringResource(R.string.anti_censorship_title) + val iconTints = rememberConfigureIconTints() + + val sections = buildList { + add( + ConfigureSectionModel( + title = protectionTitle, + accentColor = MaterialTheme.colorScheme.primary, + layout = ConfigureSectionLayout.GridFour, + entries = listOf( + ConfigureEntry( + id = "apps", + title = appsTitle, + iconTint = iconTints.apps, + iconRes = R.drawable.ic_app_info_accent, + onClick = onAppsClick, + keywords = listOf("apps", "application", "app list") + ), + ConfigureEntry( + id = "dns", + title = dnsTitle, + iconTint = iconTints.dns, + iconRes = R.drawable.dns_home_screen, + onClick = onDnsClick, + keywords = listOf("dns", "doh", "dot", "dnscrypt", "resolver", "blocklist") + ), + ConfigureEntry( + id = "firewall", + title = firewallTitle, + iconTint = iconTints.firewall, + iconRes = R.drawable.firewall_home_screen, + onClick = onFirewallClick, + keywords = listOf("firewall", "allow", "block", "rules", "wifi", "mobile") + ), + ConfigureEntry( + id = "proxy", + title = proxyTitle, + iconTint = iconTints.proxy, + iconRes = R.drawable.ic_proxy, + onClick = onProxyClick, + keywords = listOf("proxy", "socks5", "http proxy", "wireguard", "orbot", "tor") + ) + ) + ) + ) + + add( + ConfigureSectionModel( + title = systemTitle, + accentColor = MaterialTheme.colorScheme.secondary, + layout = ConfigureSectionLayout.GridPairThenList, + entries = listOf( + ConfigureEntry( + id = "network", + title = networkTitle, + iconTint = iconTints.network, + iconRes = R.drawable.ic_network_tunnel, + onClick = onNetworkClick, + keywords = listOf("network", "vpn", "tunnel", "metered") + ), + ConfigureEntry( + id = "settings", + title = settingsTitle, + iconTint = iconTints.settings, + iconRes = R.drawable.ic_other_settings, + onClick = onOthersClick, + keywords = listOf("settings", "general", "theme", "appearance", "backup", "restore") + ), + ConfigureEntry( + id = "logs", + title = logsTitle, + subtitle = stringResource(R.string.settings_enable_logs_desc), + iconTint = iconTints.logs, + iconRes = R.drawable.ic_logs_accent, + onClick = onLogsClick, + keywords = listOf("logs", "events", "network logs", "console logs") + ) + ) + ) + ) + + add( + ConfigureSectionModel( + title = advancedTitle, + accentColor = MaterialTheme.colorScheme.tertiary, + layout = ConfigureSectionLayout.List, + entries = buildList { + add( + ConfigureEntry( + id = "anti-censorship", + title = antiCensorshipTitle, + subtitle = stringResource(R.string.anti_censorship_desc), + iconTint = iconTints.antiCensorship, + iconRes = R.drawable.ic_anti_dpi, + onClick = onAntiCensorshipClick, + keywords = listOf("anti censorship", "dpi", "evasion") + ) + ) + if (isDebug) { + add( + ConfigureEntry( + id = "advanced", + title = advancedTitle, + subtitle = stringResource(R.string.adv_set_experimental_desc), + iconTint = iconTints.advanced, + iconRes = R.drawable.ic_advanced_settings, + onClick = onAdvancedClick, + keywords = listOf("advanced", "experimental", "debug") + ) + ) + } + } + ) + ) + } + + fun openDestination(destination: SettingsSearchDestination) { + if (onSearchDestinationClick != null) { + onSearchDestinationClick(destination) + return + } + + when (destination) { + SettingsSearchDestination.Apps -> onAppsClick() + is SettingsSearchDestination.Dns -> onDnsClick() + is SettingsSearchDestination.Firewall -> onFirewallClick() + is SettingsSearchDestination.Proxy -> onProxyClick() + is SettingsSearchDestination.Network -> onNetworkClick() + is SettingsSearchDestination.General -> onOthersClick() + SettingsSearchDestination.Logs -> onLogsClick() + SettingsSearchDestination.AntiCensorship -> onAntiCensorshipClick() + SettingsSearchDestination.Advanced -> onAdvancedClick() + } + } + + val deepSearchTargets = + buildSettingsSearchIndex(isDebug = isDebug).map { entry -> + ConfigureSearchTarget( + id = entry.id, + title = entry.title, + path = entry.path, + iconRes = entry.iconRes, + iconTint = iconTintForDestination(entry.destination, iconTints), + onClick = { openDestination(entry.destination) }, + keywords = buildList { + addAll(entry.keywords) + add(entry.title) + add(entry.subtitle) + add(entry.path) + } + ) + } + + val topLevelSearchTargets = sections.flatMap { section -> + section.entries.map { entry -> + ConfigureSearchTarget( + id = "top-level.${entry.id}", + title = entry.title, + path = "${section.title} > ${entry.title}", + iconRes = entry.iconRes, + iconTint = entry.iconTint, + onClick = entry.onClick, + keywords = buildList { + addAll(entry.keywords) + add(section.title) + add(entry.title) + entry.subtitle?.let { add(it) } + } + ) + } + } + + val normalizedQuery = if (isSearchOpen) query.normalizeSearchQuery() else "" + val searchTargets = (deepSearchTargets + topLevelSearchTargets).distinctBy { it.id } + + val searchResults = remember(normalizedQuery, searchTargets) { + if (normalizedQuery.isBlank()) { + emptyList() + } else { + searchTargets + .mapNotNull { target -> + val score = target.searchScore(normalizedQuery) + if (score > 0) target to score else null + } + .sortedWith( + compareByDescending> { it.second } + .thenBy { it.first.title } + ) + .map { it.first } + } + } + + Scaffold( + modifier = Modifier.nestedScroll(activeTopBarScrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + if (isSearchOpen) { + TopAppBar( + navigationIcon = { + IconButton( + onClick = { closeSearch() } + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.configure_search_close), + ) + } + }, + title = { + TextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.spacingSm) + .focusRequester(searchFocusRequester), + singleLine = true, + placeholder = { + Text( + text = stringResource(id = R.string.configure_search_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) + }, + actions = { + if (query.isNotEmpty()) { + IconButton(onClick = { query = "" }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(id = R.string.cd_clear_search) + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + scrollBehavior = searchTopBarScrollBehavior + ) + } else { + LargeTopAppBar( + title = { + Text( + text = stringResource(id = R.string.lbl_configure), + color = MaterialTheme.colorScheme.onSurface + ) + }, + actions = { + IconButton(onClick = { isSearchOpen = true }) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(id = R.string.configure_search_open) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + scrollBehavior = largeTopBarScrollBehavior + ) + } + } + ) { paddingValues -> + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacingLg + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + if (normalizedQuery.isBlank()) { + sections.forEachIndexed { index, section -> + item { + RethinkAnimatedSection(index = index) { + ConfigureSection(section) + } + } + } + item { + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + } + } else { + item { + if (searchResults.isEmpty()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(id = R.string.configure_search_empty_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(id = R.string.configure_search_empty_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + SearchResultsGroup( + results = searchResults, + query = normalizedQuery, + onResultClick = { target -> + target.onClick() + } + ) + } + } + } + } + } +} + +@Composable +private fun ConfigureSection(section: ConfigureSectionModel) { + Column { + SectionHeaderWithSubtitle( + title = section.title, + subtitle = section.subtitle, + color = section.accentColor + ) + + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + + when (section.layout) { + ConfigureSectionLayout.GridFour -> { + ConfigureGrid( + entries = section.entries + ) + } + + ConfigureSectionLayout.GridPairThenList -> { + when (section.entries.size) { + 3 -> { + ConfigureTriadGrid( + entries = section.entries + ) + } + + 2 -> { + ConfigurePairGrid( + first = section.entries[0], + second = section.entries[1] + ) + } + + in 4..Int.MAX_VALUE -> { + ConfigurePairGrid( + first = section.entries[0], + second = section.entries[1] + ) + Spacer(modifier = Modifier.height(Dimensions.spacingXs)) + ConfigureSectionList( + entries = section.entries.drop(2) + ) + } + + else -> { + ConfigureSectionList( + entries = section.entries + ) + } + } + } + + ConfigureSectionLayout.List -> { + ConfigureSectionList( + entries = section.entries + ) + } + } + } +} + +@Composable +private fun ConfigureGrid( + entries: List +) { + if (entries.size != 4) return + + Column(verticalArrangement = Arrangement.spacedBy(ConfigureTileGap)) { + ConfigureTopRowTiles( + first = entries[0], + second = entries[1] + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ConfigureTileGap) + ) { + ConfigureGridTile( + entry = entries[2], + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp, bottomStart = 28.dp, bottomEnd = 12.dp), + modifier = Modifier.weight(1f) + ) + ConfigureGridTile( + entry = entries[3], + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp, bottomStart = 12.dp, bottomEnd = 28.dp), + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun ConfigurePairGrid( + first: ConfigureEntry, + second: ConfigureEntry +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ConfigureTileGap) + ) { + ConfigureGridTile( + entry = first, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 12.dp, bottomStart = 28.dp, bottomEnd = 12.dp), + modifier = Modifier.weight(1f) + ) + ConfigureGridTile( + entry = second, + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 28.dp, bottomStart = 12.dp, bottomEnd = 28.dp), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ConfigureTriadGrid( + entries: List +) { + if (entries.size != 3) return + + Column(verticalArrangement = Arrangement.spacedBy(ConfigureTileGap)) { + ConfigureTopRowTiles( + first = entries[0], + second = entries[1] + ) + + ConfigureGridTile( + entry = entries[2], + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp, bottomStart = 28.dp, bottomEnd = 28.dp), + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun ConfigureTopRowTiles( + first: ConfigureEntry, + second: ConfigureEntry, + itemGap: Dp = ConfigureTileGap +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(itemGap) + ) { + ConfigureGridTile( + entry = first, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 12.dp, bottomStart = 12.dp, bottomEnd = 12.dp), + modifier = Modifier.weight(1f) + ) + ConfigureGridTile( + entry = second, + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 28.dp, bottomStart = 12.dp, bottomEnd = 12.dp), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ConfigureSectionList( + entries: List +) { + val iconTint = MaterialTheme.colorScheme.onPrimaryFixed.copy(alpha = 0.8f) + + Column { + entries.forEachIndexed { index, entry -> + RethinkListItem( + headline = entry.title, + leadingIconPainter = painterResource(id = entry.iconRes), + leadingIconTint = iconTint, + leadingIconContainerColor = entry.iconTint, + position = cardPositionFor(index = index, lastIndex = entries.lastIndex), + highlightContainerColor = entry.iconTint.copy(alpha = 0.22f), + showTrailingChevron = false, + onClick = entry.onClick, + ) + } + } +} + +@Composable +private fun ConfigureGridTile( + entry: ConfigureEntry, + shape: RoundedCornerShape, + modifier: Modifier = Modifier +) { + val iconTint = MaterialTheme.colorScheme.onPrimaryFixed.copy(alpha = 0.8f) + + RethinkGridTile( + title = entry.title, + iconRes = entry.iconRes, + accentColor = entry.iconTint, + shape = shape, + modifier = modifier, + iconTint = iconTint, + iconContainerColor = entry.iconTint, + onClick = entry.onClick + ) +} + +private data class ConfigureIconTints( + val apps: Color, + val dns: Color, + val firewall: Color, + val proxy: Color, + val network: Color, + val settings: Color, + val logs: Color, + val antiCensorship: Color, + val advanced: Color, +) + +@Composable +private fun rememberConfigureIconTints(): ConfigureIconTints { + return ConfigureIconTints( + apps = Color(0xFF74C5FF), + dns = Color(0xFFC5ACFF), + firewall = Color(0xFFFF907F), + proxy = Color(0xFF46EBC8), + network = Color(0xFFA3BCFF), + settings = Color(0xFFFFD878), + logs = Color(0xFF7EED92), + antiCensorship = Color(0xFFFFA7E0), + advanced = Color(0xFFFFE182) + ) +} + +private fun iconTintForDestination( + destination: SettingsSearchDestination, + iconTints: ConfigureIconTints +): Color { + return when (destination) { + SettingsSearchDestination.Apps -> iconTints.apps + is SettingsSearchDestination.Dns -> iconTints.dns + is SettingsSearchDestination.Firewall -> iconTints.firewall + is SettingsSearchDestination.Proxy -> iconTints.proxy + is SettingsSearchDestination.Network -> iconTints.network + is SettingsSearchDestination.General -> iconTints.settings + SettingsSearchDestination.Logs -> iconTints.logs + SettingsSearchDestination.AntiCensorship -> iconTints.antiCensorship + SettingsSearchDestination.Advanced -> iconTints.advanced + } +} + +@Composable +private fun SearchResultsGroup( + results: List, + query: String, + onResultClick: (ConfigureSearchTarget) -> Unit +) { + val limitedResults = results.take(12) + val highlightColor = MaterialTheme.colorScheme.primary + val iconTint = MaterialTheme.colorScheme.onPrimaryFixed.copy(alpha = 0.8f) + + Column { + limitedResults.forEachIndexed { index, target -> + val highlightedTitle = remember(target.title, query, highlightColor) { + target.title.highlightMatches(query = query, highlightColor = highlightColor) + } + val highlightedPath = remember(target.path, query, highlightColor) { + target.path.highlightMatches(query = query, highlightColor = highlightColor) + } + RethinkListItem( + headline = target.title, + headlineAnnotated = highlightedTitle, + supporting = target.path, + supportingAnnotated = highlightedPath, + leadingIconPainter = painterResource(id = target.iconRes), + leadingIconTint = iconTint, + leadingIconContainerColor = target.iconTint, + position = cardPositionFor(index = index, lastIndex = limitedResults.lastIndex), + highlightContainerColor = target.iconTint.copy(alpha = 0.22f), + showTrailingChevron = false, + onClick = { onResultClick(target) } + ) + } + } +} + +private fun String.normalizeSearchQuery(): String { + return lowercase().trim().replace(Regex("\\s+"), " ") +} + +private fun ConfigureSearchTarget.searchScore(query: String): Int { + if (query.isBlank()) return 0 + + val titleNorm = title.normalizeSearchQuery() + val pathNorm = path.normalizeSearchQuery() + val keywordNorm = keywords.joinToString(" ").normalizeSearchQuery() + + var score = 0 + if (titleNorm.startsWith(query)) score += 10 + if (titleNorm.contains(query)) score += 7 + if (keywordNorm.contains(query)) score += 6 + if (pathNorm.contains(query)) score += 4 + + return score +} + +private fun String.highlightMatches(query: String, highlightColor: Color): AnnotatedString { + if (isBlank() || query.isBlank()) return AnnotatedString(this) + + val ranges = mutableListOf>() + val tokens = query.split(Regex("\\s+")).map { it.trim() }.filter { it.isNotBlank() }.distinct() + + tokens.forEach { token -> + var startIndex = 0 + while (startIndex < length) { + val index = indexOf(token, startIndex = startIndex, ignoreCase = true) + if (index < 0) break + ranges += index to (index + token.length) + startIndex = index + token.length + } + } + + if (ranges.isEmpty()) return AnnotatedString(this) + + val merged = ranges + .sortedBy { it.first } + .fold(mutableListOf>()) { acc, range -> + if (acc.isEmpty()) { + acc += range + return@fold acc + } + val last = acc.last() + if (range.first <= last.second) { + acc[acc.lastIndex] = last.first to maxOf(last.second, range.second) + } else { + acc += range + } + acc + } + + return buildAnnotatedString { + append(this@highlightMatches) + merged.forEach { (start, end) -> + addStyle( + style = SpanStyle(color = highlightColor, fontWeight = FontWeight.SemiBold), + start = start, + end = end + ) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/configure/SettingsSearchIndex.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/configure/SettingsSearchIndex.kt new file mode 100644 index 000000000..075d4dc9f --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/configure/SettingsSearchIndex.kt @@ -0,0 +1,248 @@ +/* + * 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.compose.configure + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R + +sealed interface SettingsSearchDestination { + data object Apps : SettingsSearchDestination + data class Dns(val focusKey: String = "") : SettingsSearchDestination + data class Firewall(val focusKey: String = "") : SettingsSearchDestination + data class Proxy(val focusKey: String = "") : SettingsSearchDestination + data class Network(val focusKey: String = "") : SettingsSearchDestination + data class General(val focusKey: String = "") : SettingsSearchDestination + data object Logs : SettingsSearchDestination + data object AntiCensorship : SettingsSearchDestination + data object Advanced : SettingsSearchDestination +} + +data class SettingsSearchIndexEntry( + val id: String, + val title: String, + val subtitle: String, + val path: String, + val iconRes: Int, + val destination: SettingsSearchDestination, + val keywords: List = emptyList(), +) + +@Composable +fun buildSettingsSearchIndex(isDebug: Boolean): List { + val protection = stringResource(R.string.lbl_protection) + val system = stringResource(R.string.lbl_system) + val advanced = stringResource(R.string.lbl_advanced) + val dns = stringResource(R.string.lbl_dns) + val proxy = stringResource(R.string.lbl_proxy) + val network = stringResource(R.string.lbl_network) + val general = stringResource(R.string.settings_general_header) + val firewall = stringResource(R.string.lbl_firewall) + + val topLevel = + listOf( + SettingsSearchIndexEntry( + id = "top.apps", + title = stringResource(R.string.lbl_apps), + subtitle = stringResource(R.string.apps_info_title), + path = "$protection > ${stringResource(R.string.lbl_apps)}", + iconRes = R.drawable.ic_app_info_accent, + destination = SettingsSearchDestination.Apps, + keywords = listOf("apps", "application", "app list") + ), + SettingsSearchIndexEntry( + id = "top.dns", + title = dns, + subtitle = stringResource(R.string.dns_mode_info_title), + path = "$protection > $dns", + iconRes = R.drawable.dns_home_screen, + destination = SettingsSearchDestination.Dns(), + keywords = listOf("dns", "resolver", "blocklist") + ), + SettingsSearchIndexEntry( + id = "top.firewall", + title = firewall, + subtitle = stringResource(R.string.firewall_mode_info_title), + path = "$protection > $firewall", + iconRes = R.drawable.firewall_home_screen, + destination = SettingsSearchDestination.Firewall(), + keywords = listOf("firewall", "rules", "allow", "block") + ), + SettingsSearchIndexEntry( + id = "top.proxy", + title = proxy, + subtitle = stringResource(R.string.cd_custom_dns_proxy_name_default), + path = "$protection > $proxy", + iconRes = R.drawable.ic_proxy, + destination = SettingsSearchDestination.Proxy(), + keywords = listOf("proxy", "socks5", "http proxy", "orbot", "wireguard") + ), + SettingsSearchIndexEntry( + id = "top.network", + title = network, + subtitle = stringResource(R.string.firewall_act_network_monitor_tab), + path = "$system > $network", + iconRes = R.drawable.ic_network_tunnel, + destination = SettingsSearchDestination.Network(), + keywords = listOf("network", "vpn", "metered", "lan") + ), + SettingsSearchIndexEntry( + id = "top.general", + title = stringResource(R.string.title_settings), + subtitle = general, + path = "$system > ${stringResource(R.string.title_settings)}", + iconRes = R.drawable.ic_other_settings, + destination = SettingsSearchDestination.General(), + keywords = listOf("settings", "theme", "backup", "appearance") + ), + SettingsSearchIndexEntry( + id = "top.logs", + title = stringResource(R.string.lbl_logs), + subtitle = stringResource(R.string.settings_enable_logs_desc), + path = "$system > ${stringResource(R.string.lbl_logs)}", + iconRes = R.drawable.ic_logs_accent, + destination = SettingsSearchDestination.Logs, + keywords = listOf("logs", "events", "network logs") + ), + SettingsSearchIndexEntry( + id = "top.anti_censorship", + title = stringResource(R.string.anti_censorship_title), + subtitle = stringResource(R.string.anti_censorship_desc), + path = "$advanced > ${stringResource(R.string.anti_censorship_title)}", + iconRes = R.drawable.ic_anti_dpi, + destination = SettingsSearchDestination.AntiCensorship, + keywords = listOf("anti censorship", "dpi") + ) + ) + + val dnsEntries = + listOf( + searchEntry("dns.mode.system", R.string.network_dns, R.string.dc_other_dns_heading, "$protection > $dns > ${stringResource(R.string.dc_other_dns_heading)}", R.drawable.ic_network, SettingsSearchDestination.Dns("dns_mode_system"), listOf("system dns")), + searchEntry("dns.mode.custom", R.string.dc_custom_dns_radio, R.string.dc_other_dns_heading, "$protection > $dns > ${stringResource(R.string.dc_other_dns_heading)}", R.drawable.ic_filter, SettingsSearchDestination.Dns("dns_mode_custom"), listOf("custom dns")), + searchEntry("dns.mode.rethink", R.string.dc_rethink_dns_radio, R.string.dc_other_dns_heading, "$protection > $dns > ${stringResource(R.string.dc_other_dns_heading)}", R.drawable.ic_rethink_plus, SettingsSearchDestination.Dns("dns_mode_rethink"), listOf("rethink dns")), + searchEntry("dns.mode.smart", R.string.smart_dns, R.string.dc_other_dns_heading, "$protection > $dns > ${stringResource(R.string.dc_other_dns_heading)}", R.drawable.ic_dns_cache, SettingsSearchDestination.Dns("dns_mode_smart"), listOf("smart dns")), + searchEntry("dns.block.local", R.string.dc_local_block_heading, R.string.dc_block_heading, "$protection > $dns > ${stringResource(R.string.dc_block_heading)}", R.drawable.ic_local_blocklist, SettingsSearchDestination.Dns("dns_block_local"), listOf("local blocklist")), + searchEntry("dns.block.custom_downloader", R.string.settings_custom_downloader_heading, R.string.dc_block_heading, "$protection > $dns > ${stringResource(R.string.dc_block_heading)}", R.drawable.ic_update, SettingsSearchDestination.Dns("dns_block_custom_downloader")), + searchEntry("dns.block.periodic_updates", R.string.dc_check_update_heading, R.string.dc_block_heading, "$protection > $dns > ${stringResource(R.string.dc_block_heading)}", R.drawable.ic_blocklist_update_check, SettingsSearchDestination.Dns("dns_block_periodic_updates")), + searchEntry("dns.filter.alg", R.string.cd_dns_alg_heading, R.string.dc_filtering_heading, "$protection > $dns > ${stringResource(R.string.dc_filtering_heading)}", R.drawable.ic_adv_dns_filter, SettingsSearchDestination.Dns("dns_filter_alg")), + searchEntry("dns.filter.split", R.string.cd_split_dns_heading, R.string.dc_filtering_heading, "$protection > $dns > ${stringResource(R.string.dc_filtering_heading)}", R.drawable.ic_split_dns, SettingsSearchDestination.Dns("dns_filter_split")), + searchEntry("dns.filter.rules_as_firewall", R.string.cd_treat_dns_rules_firewall_heading, R.string.dc_filtering_heading, "$protection > $dns > ${stringResource(R.string.dc_filtering_heading)}", R.drawable.ic_dns_rules_as_firewall, SettingsSearchDestination.Dns("dns_filter_rules_as_firewall")), + searchEntry("dns.filter.record_types", R.string.cd_allowed_dns_record_types_heading, R.string.dc_filtering_heading, "$protection > $dns > ${stringResource(R.string.dc_filtering_heading)}", R.drawable.ic_allow_dns_records, SettingsSearchDestination.Dns("dns_filter_record_types"), listOf("record types")), + searchEntry("dns.advanced.favicon", R.string.dc_dns_website_heading, R.string.lbl_advanced, "$protection > $dns > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_fav_icon, SettingsSearchDestination.Dns("dns_advanced_favicon")), + searchEntry("dns.advanced.cache", R.string.dc_setting_dns_cache_heading, R.string.lbl_advanced, "$protection > $dns > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_auto_start, SettingsSearchDestination.Dns("dns_advanced_cache")), + searchEntry("dns.advanced.proxy_dns", R.string.dc_proxy_dns_heading, R.string.lbl_advanced, "$protection > $dns > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_proxy, SettingsSearchDestination.Dns("dns_advanced_proxy_dns")), + searchEntry("dns.advanced.undelegated", R.string.dc_use_sys_dns_undelegated_heading, R.string.lbl_advanced, "$protection > $dns > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_split_dns, SettingsSearchDestination.Dns("dns_advanced_undelegated")), + searchEntry("dns.advanced.fallback", R.string.use_fallback_dns_to_bypass, R.string.lbl_advanced, "$protection > $dns > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_use_fallback_bypass, SettingsSearchDestination.Dns("dns_advanced_fallback")), + searchEntry("dns.advanced.leaks", R.string.dc_dns_leaks_heading, R.string.lbl_advanced, "$protection > $dns > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_prevent_dns_leaks, SettingsSearchDestination.Dns("dns_advanced_leaks")) + ) + + val firewallEntries = + listOf( + searchEntry("firewall.universal", R.string.univ_firewall_heading, R.string.firewall_act_universal_tab, "$protection > $firewall > ${stringResource(R.string.firewall_act_universal_tab)}", R.drawable.universal_firewall, SettingsSearchDestination.Firewall("firewall_universal_main")), + searchEntry("firewall.universal.blocked", R.string.univ_view_blocked_ip, R.string.firewall_act_universal_tab, "$protection > $firewall > ${stringResource(R.string.firewall_act_universal_tab)}", R.drawable.universal_ip_rule, SettingsSearchDestination.Firewall("firewall_universal_blocked")), + searchEntry("firewall.apps.rules", R.string.app_ip_domain_rules, R.string.lbl_app_wise, "$protection > $firewall > ${stringResource(R.string.lbl_app_wise)}", R.drawable.ic_ip_address, SettingsSearchDestination.Firewall("firewall_apps_rules")) + ) + + val proxyEntries = + listOf( + searchEntry("proxy.wireguard", R.string.setup_wireguard, R.string.settings_proxy_header, "$protection > $proxy > ${stringResource(R.string.setup_wireguard)}", R.drawable.ic_wireguard_icon, SettingsSearchDestination.Proxy("proxy_wireguard"), listOf("wg", "wireguard")), + searchEntry("proxy.socks5", R.string.settings_socks5_heading, R.string.settings_proxy_header, "$protection > $proxy > ${stringResource(R.string.settings_socks5_heading)}", R.drawable.ic_socks5, SettingsSearchDestination.Proxy("proxy_socks")), + searchEntry("proxy.http", R.string.settings_https_heading, R.string.settings_proxy_header, "$protection > $proxy > ${stringResource(R.string.settings_https_heading)}", R.drawable.ic_http, SettingsSearchDestination.Proxy("proxy_http")), + searchEntry("proxy.orbot", R.string.orbot, R.string.settings_proxy_header, "$protection > $proxy > ${stringResource(R.string.orbot)}", R.drawable.ic_orbot, SettingsSearchDestination.Proxy("proxy_orbot"), listOf("tor")), + searchEntry("proxy.orbot.notification", R.string.settings_orbot_notification_action, R.string.orbot, "$protection > $proxy > ${stringResource(R.string.orbot)}", R.drawable.ic_right_arrow_small, SettingsSearchDestination.Proxy("proxy_orbot_open_app")) + ) + + val networkEntries = + listOf( + searchEntry("network.allow_bypass", R.string.settings_allow_bypass_heading, R.string.lbl_network, "$system > $network > ${stringResource(R.string.lbl_network)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_allow_bypass")), + searchEntry("network.fail_open", R.string.fail_open_network_title, R.string.lbl_network, "$system > $network > ${stringResource(R.string.lbl_network)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_fail_open")), + searchEntry("network.allow_lan", R.string.settings_allow_lan_heading, R.string.lbl_network, "$system > $network > ${stringResource(R.string.lbl_network)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_allow_lan")), + searchEntry("network.all_networks", R.string.settings_network_all_networks, R.string.lbl_network, "$system > $network > ${stringResource(R.string.lbl_network)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_all_networks")), + searchEntry("network.exclude_apps_proxy", R.string.settings_exclude_apps_in_proxy, R.string.lbl_network, "$system > $network > ${stringResource(R.string.lbl_network)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_exclude_apps_proxy")), + searchEntry("network.protocol_translation", R.string.settings_protocol_translation, R.string.lbl_network, "$system > $network > ${stringResource(R.string.lbl_network)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_protocol_translation")), + searchEntry("network.default_dns", R.string.settings_default_dns_heading, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_default_dns")), + searchEntry("network.vpn_policy", R.string.vpn_policy_title, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_vpn_policy")), + searchEntry("network.ip_protocol", R.string.settings_ip_dialog_title, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_ip_protocol")), + searchEntry("network.connectivity_checks", R.string.settings_connectivity_checks, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_connectivity_checks")), + searchEntry("network.ping_ips", R.string.settings_ping_ips, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_network, SettingsSearchDestination.Network("network_ping_ips")), + searchEntry("network.mobile_metered", R.string.settings_treat_mobile_metered, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_mobile_metered")), + searchEntry("network.wg_listen_port", R.string.settings_wg_listen_port, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_wg_listen_port")), + searchEntry("network.wg_lockdown", R.string.settings_wg_lockdown, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_wg_lockdown")), + searchEntry("network.endpoint_independence", R.string.settings_endpoint_independence, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_endpoint_independence")), + searchEntry("network.allow_incoming_wg", R.string.settings_allow_incoming_wg_packets, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_allow_incoming_wg")), + searchEntry("network.tcp_keep_alive", R.string.settings_tcp_keep_alive, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_tcp_keep_alive")), + searchEntry("network.jumbo_packets", R.string.settings_jumbo_packets, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_jumbo_packets")), + searchEntry("network.vpn_metered", R.string.settings_vpn_builder_metered, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_vpn_metered")), + searchEntry("network.custom_lan_ip", R.string.custom_lan_ip_title, R.string.lbl_advanced, "$system > $network > ${stringResource(R.string.lbl_advanced)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_custom_lan_ip")), + searchEntry("network.dial_timeout", R.string.settings_dial_timeout, R.string.lbl_network, "$system > $network > ${stringResource(R.string.settings_dial_timeout)}", R.drawable.ic_settings, SettingsSearchDestination.Network("network_dial_timeout"), listOf("timeout")) + ) + + val generalEntries = + listOf( + searchEntry("general.appearance", R.string.settings_theme_heading, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_theme_heading)}", R.drawable.ic_appearance, SettingsSearchDestination.General("general_theme_mode"), listOf("theme", "light", "dark")), + searchEntry("general.color_style", R.string.settings_theme_color_heading, R.string.settings_theme_color_desc, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_theme_heading)}", R.drawable.ic_appearance, SettingsSearchDestination.General("general_theme_color"), listOf("color", "palette", "dynamic color")), + searchEntry("general.backup", R.string.brbs_backup_title, R.string.settings_import_export_desc, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.brbs_title)}", R.drawable.ic_backup, SettingsSearchDestination.General("general_backup"), listOf("backup", "restore")), + searchEntry("general.logs", R.string.settings_enable_logs, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_logs_accent, SettingsSearchDestination.General("general_logs"), listOf("logs")), + searchEntry("general.autostart", R.string.settings_autostart_bootup_heading, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_auto_start, SettingsSearchDestination.General("general_autostart")), + searchEntry("general.tombstone", R.string.tombstone_app_title, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_tombstone, SettingsSearchDestination.General("general_tombstone"), listOf("remember uninstalled")), + searchEntry("general.firewall_bubble", R.string.firewall_bubble_title, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_firewall_bubble, SettingsSearchDestination.General("general_firewall_bubble")), + searchEntry("general.ip_info", R.string.download_ip_info_title, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_ip_info, SettingsSearchDestination.General("general_ip_info")), + searchEntry("general.app_updates", R.string.settings_check_update_heading, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_update, SettingsSearchDestination.General("general_app_updates"), listOf("updates")), + searchEntry("general.crash_reports", R.string.settings_firebase_error_reporting_heading, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_settings, SettingsSearchDestination.General("general_crash_reports"), listOf("firebase", "error reporting")), + searchEntry("general.custom_downloader", R.string.settings_custom_downloader_heading, R.string.settings_general_customize, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.settings_general_header)}", R.drawable.ic_settings, SettingsSearchDestination.General("general_custom_downloader")), + searchEntry("general.website", R.string.about_website, R.string.title_about, "$system > ${stringResource(R.string.title_settings)} > ${stringResource(R.string.title_about)}", R.drawable.ic_other_settings, SettingsSearchDestination.General("general_website"), listOf("website")) + ) + + val advancedEntries = + if (isDebug) { + listOf( + SettingsSearchIndexEntry( + id = "top.advanced", + title = stringResource(R.string.lbl_advanced), + subtitle = stringResource(R.string.adv_set_experimental_desc), + path = "$advanced > ${stringResource(R.string.lbl_advanced)}", + iconRes = R.drawable.ic_advanced_settings, + destination = SettingsSearchDestination.Advanced, + keywords = listOf("experimental", "debug") + ) + ) + } else { + emptyList() + } + + return topLevel + dnsEntries + firewallEntries + proxyEntries + networkEntries + generalEntries + advancedEntries +} + +@Composable +private fun searchEntry( + id: String, + titleRes: Int, + subtitleRes: Int, + path: String, + iconRes: Int, + destination: SettingsSearchDestination, + keywords: List = emptyList() +): SettingsSearchIndexEntry { + return SettingsSearchIndexEntry( + id = id, + title = stringResource(id = titleRes), + subtitle = stringResource(id = subtitleRes), + path = path, + iconRes = iconRes, + destination = destination, + keywords = keywords + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/database/DatabaseScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/database/DatabaseScreen.kt new file mode 100644 index 000000000..b0358032d --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/database/DatabaseScreen.kt @@ -0,0 +1,691 @@ +/* + * 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.compose.database + +import android.database.Cursor +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.database.AppDatabase +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkSearchField +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private data class DatabaseTablePreview( + val table: String, + val rowCount: Int, + val columnCount: Int, + val dumpPreview: String, + val isTruncated: Boolean +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DatabaseScreen( + onBackClick: () -> Unit, + appDatabase: AppDatabase +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var query by remember { mutableStateOf("") } + var tables by remember { mutableStateOf>(emptyList()) } + var selectedTable by remember { mutableStateOf(null) } + var preview by remember { mutableStateOf(null) } + var loadingPreview by remember { mutableStateOf(false) } + var loadingCopy by remember { mutableStateOf(false) } + var errorText by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + val filteredTables = remember(tables, query) { + val q = query.trim() + if (q.isEmpty()) tables else tables.filter { it.contains(q, ignoreCase = true) } + } + + fun loadDatabaseTables() { + scope.launch(Dispatchers.IO) { + val db = appDatabase.openHelper.readableDatabase + val cursor = db.query("SELECT name FROM sqlite_master WHERE type='table'") + val tableList = mutableListOf() + while (cursor.moveToNext()) { + val tableName = cursor.getString(0) + if (tableName != "android_metadata" && tableName != "room_master_table") { + tableList.add(tableName) + } + } + cursor.close() + withContext(Dispatchers.Main) { + tables = tableList + if (selectedTable == null && tableList.isNotEmpty()) { + selectedTable = tableList.first() + } + isLoading = false + } + } + } + + fun refreshSelection() { + val table = selectedTable ?: return + loadingPreview = true + errorText = null + scope.launch(Dispatchers.IO) { + runCatching { loadTablePreview(appDatabase, table) } + .onSuccess { + withContext(Dispatchers.Main) { + preview = it + loadingPreview = false + } + } + .onFailure { + withContext(Dispatchers.Main) { + errorText = it.message ?: context.getString(R.string.blocklist_update_check_failure) + loadingPreview = false + } + } + } + } + + fun copySelectionToClipboard() { + val table = selectedTable ?: return + loadingCopy = true + scope.launch(Dispatchers.IO) { + val fullDump = buildTableDump(appDatabase, table) + withContext(Dispatchers.Main) { + copyToClipboard(context, "db_dump", fullDump) + loadingCopy = false + } + } + } + + LaunchedEffect(Unit) { + loadDatabaseTables() + } + + LaunchedEffect(selectedTable) { + val table = selectedTable ?: return@LaunchedEffect + if (preview?.table != table || preview?.rowCount == -1) { + refreshSelection() + } + } + + val navBarBottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + RethinkTopBar( + title = stringResource(id = R.string.title_database_dump), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior, + actions = { + IconButton( + enabled = selectedTable != null && preview != null && !loadingCopy, + onClick = { copySelectionToClipboard() } + ) { + if (loadingCopy) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = stringResource(id = R.string.database_inspector_copy_full) + ) + } + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = Dimensions.screenPaddingHorizontal) + .padding(bottom = navBarBottomPadding), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val isWide = maxWidth >= 860.dp + DatabaseControlsDeck { + RethinkSearchField( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.database_inspector_search_hint), + modifier = Modifier.fillMaxWidth(), + onClearQuery = { query = "" }, + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + if (!isWide) { + DatabaseInlineTableSelector( + tables = filteredTables, + selectedTable = selectedTable, + onSelect = { selectedTable = it } + ) + } + } + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + val isWide = maxWidth >= 860.dp + if (isWide) { + Row(horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd)) { + DatabaseTableListPane( + tables = filteredTables, + selectedTable = selectedTable, + totalCount = tables.size, + modifier = Modifier.widthIn(min = 300.dp, max = 380.dp), + onSelect = { selectedTable = it } + ) + DatabaseTableDetailPane( + preview = preview, + loadingPreview = loadingPreview, + loadingCopy = loadingCopy, + errorText = errorText, + selectedTable = selectedTable, + modifier = Modifier.weight(1f), + onRefresh = { refreshSelection() }, + onCopy = { copySelectionToClipboard() } + ) + } + } else { + DatabaseTableDetailPane( + preview = preview, + loadingPreview = loadingPreview, + loadingCopy = loadingCopy, + errorText = errorText, + selectedTable = selectedTable, + modifier = Modifier.fillMaxSize(), + onRefresh = { refreshSelection() }, + onCopy = { copySelectionToClipboard() } + ) + } + } + } + } + } +} + +@Composable +private fun DatabaseInlineTableSelector( + tables: List, + selectedTable: String?, + onSelect: (String) -> Unit +) { + if (tables.isEmpty()) { + return + } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = Dimensions.spacingXs), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + tables.forEachIndexed { index, table -> + val isSelected = selectedTable == table + DatabaseTableToggle( + table = table, + index = index, + lastIndex = tables.lastIndex, + selected = isSelected, + onSelect = onSelect + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun DatabaseTableToggle( + table: String, + index: Int, + lastIndex: Int, + selected: Boolean, + onSelect: (String) -> Unit +) { + ToggleButton( + checked = selected, + onCheckedChange = { checked -> + if (checked && !selected) onSelect(table) + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.94f), + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier + .widthIn(max = 220.dp) + .semantics { role = Role.RadioButton } + ) { + Text( + text = table, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } +} + +@Composable +private fun DatabaseControlsDeck( + content: @Composable () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + content() + } +} + +@Composable +private fun DatabaseMetaChip( + text: String, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) + ) + } +} + +@Composable +private fun DatabaseTableListPane( + tables: List, + selectedTable: String?, + totalCount: Int, + modifier: Modifier = Modifier, + onSelect: (String) -> Unit +) { + Surface( + modifier = modifier.fillMaxSize(), + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + if (tables.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.database_inspector_no_tables), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingMd, vertical = Dimensions.spacingSm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Storage, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Text( + text = stringResource(R.string.database_inspector_tables_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + } + DatabaseMetaChip(text = "${tables.size}/$totalCount") + } + + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f)) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = Dimensions.spacingSm, vertical = Dimensions.spacingXs), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingGridTile) + ) { + itemsIndexed(tables) { index, table -> + val isSelected = selectedTable == table + RethinkListItem( + headline = table, + leadingIconPainter = painterResource(id = R.drawable.ic_backup), + leadingIconTint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + leadingIconContainerColor = + if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) + else MaterialTheme.colorScheme.surfaceContainerHighest, + position = cardPositionFor(index = index, lastIndex = tables.lastIndex), + highlighted = isSelected, + showTrailingChevron = false, + trailing = if (isSelected) { + { + Icon( + painter = painterResource(id = R.drawable.ic_tick), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } else { + null + }, + onClick = { onSelect(table) } + ) + } + } + } + } + } +} + +@Composable +private fun DatabaseTableDetailPane( + preview: DatabaseTablePreview?, + loadingPreview: Boolean, + loadingCopy: Boolean, + errorText: String?, + selectedTable: String?, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onCopy: () -> Unit +) { + Surface( + modifier = modifier.fillMaxSize(), + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(Dimensions.spacingMd), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + if (preview == null && !loadingPreview) { + return@Column + } + + if (preview != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = preview.table, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onRefresh, enabled = !loadingPreview) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.database_inspector_refresh) + ) + } + TextButton( + onClick = onCopy, + enabled = selectedTable != null && !loadingCopy + ) { + Text( + text = if (loadingCopy) { + stringResource(R.string.database_inspector_copying) + } else { + stringResource(R.string.database_inspector_copy_full) + } + ) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm)) { + DatabaseMetaChip(text = stringResource(R.string.database_inspector_rows, preview.rowCount.toString())) + DatabaseMetaChip(text = stringResource(R.string.database_inspector_columns, preview.columnCount.toString())) + } + + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) + } + + if (!errorText.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusMd), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) + ) { + Text( + text = errorText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(horizontal = Dimensions.spacingMd, vertical = Dimensions.spacingSm) + ) + } + } + + if (loadingPreview) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + val text = preview?.dumpPreview.orEmpty() + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(Dimensions.spacingSm) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace) + ) + if (preview?.isTruncated == true) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(Dimensions.spacingSm)) + Text( + text = stringResource(R.string.database_inspector_preview_truncated), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} + +private fun loadTablePreview(appDatabase: AppDatabase, table: String, maxRows: Int = 140): DatabaseTablePreview { + val rowCount = getTableRowCount(appDatabase, table) + val columnCount = getTableColumnCount(appDatabase, table) + val preview = buildTableDump(appDatabase, table, maxRows = maxRows) + return DatabaseTablePreview( + table = table, + rowCount = rowCount, + columnCount = columnCount, + dumpPreview = preview, + isTruncated = rowCount > maxRows + ) +} + +private fun getTableRowCount(appDatabase: AppDatabase, table: String): Int { + val safeTable = table.replace("`", "``") + val db = appDatabase.openHelper.readableDatabase + val cursor = db.query("SELECT COUNT(*) FROM `$safeTable`") + val count = if (cursor.moveToFirst()) cursor.getInt(0) else 0 + cursor.close() + return count +} + +private fun getTableColumnCount(appDatabase: AppDatabase, table: String): Int { + val safeTable = table.replace("`", "``") + val db = appDatabase.openHelper.readableDatabase + val cursor = db.query("SELECT * FROM `$safeTable` LIMIT 1") + val count = cursor.columnCount + cursor.close() + return count +} + +private fun buildTableDump(appDatabase: AppDatabase, table: String, maxRows: Int? = null): String { + val safeTable = table.replace("`", "``") + val db = appDatabase.openHelper.readableDatabase + val cursor = db.query("SELECT * FROM `$safeTable`") + val columnNames = cursor.columnNames + val result = StringBuilder() + result.append("Table: $table\n") + result.append(columnNames.joinToString(separator = "\t")) + result.append("\n") + var rowCount = 0 + var isTruncated = false + while (cursor.moveToNext()) { + if (maxRows != null && rowCount >= maxRows) { + isTruncated = true + break + } + for (i in columnNames.indices) { + result.append(cursorValueAsText(cursor, i)).append("\t") + } + result.append("\n") + rowCount++ + } + cursor.close() + if (isTruncated) { + result.append("…\n") + } + return result.toString() +} + +private fun cursorValueAsText(cursor: Cursor, index: Int): String { + return when (cursor.getType(index)) { + Cursor.FIELD_TYPE_NULL -> "null" + Cursor.FIELD_TYPE_BLOB -> { + val size = cursor.getBlob(index)?.size ?: 0 + "[blob:$size]" + } + else -> cursor.getString(index).orEmpty() + } +} + +private fun copyToClipboard(context: android.content.Context, label: String, text: String) { + val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) + android.widget.Toast.makeText(context, context.getString(R.string.copied_clipboard), android.widget.Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureOtherDnsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureOtherDnsScreen.kt new file mode 100644 index 000000000..c46404058 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureOtherDnsScreen.kt @@ -0,0 +1,1170 @@ +/* + * Copyright 2024 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.compose.dns + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.asFlow +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.DnsCryptRow +import com.celzero.bravedns.adapter.DnsProxyEndpointRow +import com.celzero.bravedns.adapter.DoHEndpointRow +import com.celzero.bravedns.adapter.DoTEndpointRow +import com.celzero.bravedns.adapter.ODoHEndpointRow +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.database.DnsCryptEndpoint +import com.celzero.bravedns.database.DnsCryptRelayEndpoint +import com.celzero.bravedns.database.DnsProxyEndpoint +import com.celzero.bravedns.database.DoHEndpoint +import com.celzero.bravedns.database.DoTEndpoint +import com.celzero.bravedns.database.ODoHEndpoint +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.DnsCryptEndpointViewModel +import com.celzero.bravedns.viewmodel.DnsCryptRelayEndpointViewModel +import com.celzero.bravedns.viewmodel.DnsProxyEndpointViewModel +import com.celzero.bravedns.viewmodel.DoHEndpointViewModel +import com.celzero.bravedns.viewmodel.DoTEndpointViewModel +import com.celzero.bravedns.viewmodel.ODoHEndpointViewModel +import inet.ipaddr.IPAddressString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.MalformedURLException +import java.net.URL + +enum class DnsScreenType(val index: Int) { + DOH(0), + DNS_PROXY(1), + DNS_CRYPT(2), + DOT(3), + ODOH(4); + + companion object { + fun fromIndex(index: Int): DnsScreenType { + return entries.find { it.index == index } ?: DOH + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigureOtherDnsScreen( + dnsType: DnsScreenType, + appConfig: AppConfig, + persistentState: PersistentState, + dohViewModel: DoHEndpointViewModel, + dotViewModel: DoTEndpointViewModel, + dnsProxyViewModel: DnsProxyEndpointViewModel, + dnsCryptViewModel: DnsCryptEndpointViewModel, + dnsCryptRelayViewModel: DnsCryptRelayEndpointViewModel, + oDohViewModel: ODoHEndpointViewModel, + onBackClick: () -> Unit +) { + val scope = rememberCoroutineScope() + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + RethinkLargeTopBar( + title = getDnsTypeName(dnsType), + subtitle = getDnsTypeSubtitle(dnsType), + onBackClick = onBackClick, + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = Dimensions.screenPaddingHorizontal) + ) { + OtherDnsListContent( + dnsType = dnsType, + paddingValues = PaddingValues( + horizontal = 0.dp, + vertical = Dimensions.spacingXs + ), + appConfig = appConfig, + persistentState = persistentState, + dohViewModel = dohViewModel, + dotViewModel = dotViewModel, + dnsProxyViewModel = dnsProxyViewModel, + dnsCryptViewModel = dnsCryptViewModel, + dnsCryptRelayViewModel = dnsCryptRelayViewModel, + oDohViewModel = oDohViewModel, + scope = scope + ) + } + } +} + +@Composable +private fun getDnsTypeName(type: DnsScreenType): String { + return when (type) { + DnsScreenType.DOH -> stringResource(R.string.other_dns_list_tab1) + DnsScreenType.DNS_CRYPT -> stringResource(R.string.dc_dns_crypt) + DnsScreenType.DNS_PROXY -> stringResource(R.string.other_dns_list_tab3) + DnsScreenType.DOT -> stringResource(R.string.lbl_dot) + DnsScreenType.ODOH -> stringResource(R.string.lbl_odoh) + } +} + +@Composable +private fun getDnsTypeSubtitle(type: DnsScreenType): String { + return when (type) { + DnsScreenType.DOH -> + stringResource(R.string.cd_doh_dialog_resolver_url) + DnsScreenType.DNS_CRYPT -> + stringResource(R.string.cd_dns_crypt_dialog_stamp) + DnsScreenType.DNS_PROXY -> + stringResource(R.string.dns_proxy_ip_address) + DnsScreenType.DOT -> + stringResource(R.string.lbl_dot_abbr) + DnsScreenType.ODOH -> + stringResource(R.string.lbl_odoh_abbr) + } +} + +@Composable +private fun OtherDnsListContent( + dnsType: DnsScreenType, + paddingValues: PaddingValues, + appConfig: AppConfig, + persistentState: PersistentState, + dohViewModel: DoHEndpointViewModel, + dotViewModel: DoTEndpointViewModel, + dnsProxyViewModel: DnsProxyEndpointViewModel, + dnsCryptViewModel: DnsCryptEndpointViewModel, + dnsCryptRelayViewModel: DnsCryptRelayEndpointViewModel, + oDohViewModel: ODoHEndpointViewModel, + scope: CoroutineScope +) { + when (dnsType) { + DnsScreenType.DOH -> DohListContent(paddingValues, appConfig, dohViewModel, scope) + DnsScreenType.DNS_PROXY -> DnsProxyListContent( + paddingValues, + appConfig, + persistentState, + dnsProxyViewModel, + scope + ) + + DnsScreenType.DNS_CRYPT -> DnsCryptListContent( + paddingValues, + appConfig, + dnsCryptViewModel, + dnsCryptRelayViewModel, + scope + ) + + DnsScreenType.DOT -> DotListContent(paddingValues, appConfig, dotViewModel, scope) + DnsScreenType.ODOH -> OdohListContent(paddingValues, appConfig, oDohViewModel, scope) + } +} + +@Composable +private fun DnsEndpointListWithFab( + paddingValues: PaddingValues, + items: LazyPagingItems, + onFabClick: () -> Unit, + itemContent: @Composable (T) -> Unit +) { + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = Dimensions.spacingXs, bottom = 84.dp), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + items(items.itemCount) { index -> + val item = items[index] ?: return@items + itemContent(item) + } + } + ExtendedFloatingActionButton( + onClick = onFabClick, + modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp) + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.lbl_create) + ) + Spacer(modifier = Modifier.width(Dimensions.spacingSm)) + Text(text = stringResource(R.string.lbl_create)) + } + } +} + +@Composable +private fun DohListContent( + paddingValues: PaddingValues, + appConfig: AppConfig, + dohViewModel: DoHEndpointViewModel, + scope: CoroutineScope +) { + val context = LocalContext.current + val items = dohViewModel.dohEndpointList.asFlow().collectAsLazyPagingItems() + var showDialog by remember { mutableStateOf(false) } + val heading = stringResource(R.string.cd_doh_dialog_heading) + val nameLabel = stringResource(R.string.cd_doh_dialog_resolver_name) + val urlLabel = stringResource(R.string.cd_doh_dialog_resolver_url) + val defaultName = stringResource(R.string.cd_custom_doh_url_name_default) + val checkboxLabel = stringResource(R.string.cd_doh_dialog_checkbox_desc) + val invalidUrlMessage = stringResource(R.string.custom_url_error_invalid_url) + + DnsEndpointListWithFab( + paddingValues = paddingValues, + items = items, + onFabClick = { showDialog = true } + ) { endpoint -> + DoHEndpointRow(endpoint, appConfig) + } + + if (showDialog) { + FullWidthDialog(onDismiss = { showDialog = false }) { + val dohNameTemplate = stringResource(R.string.cd_custom_doh_url_name) + CustomDohDialogContent( + title = heading, + nameLabel = nameLabel, + urlLabel = urlLabel, + defaultName = defaultName, + initialUrl = "https://", + checkboxLabel = checkboxLabel, + loadNextIndex = { appConfig.getDohCount().plus(1) }, + nameForIndex = { index -> + String.format(dohNameTemplate, index.toString()) + }, + onSubmit = { name, url, isSecure -> + if (checkUrl(url)) { + scope.launch(Dispatchers.IO) { + insertDoHEndpoint(appConfig, name, url, isSecure) + } + showDialog = false + null + } else { + invalidUrlMessage + } + }, + invalidUrlMessage = invalidUrlMessage, + onDismiss = { showDialog = false } + ) + } + } +} + +private suspend fun insertDoHEndpoint(appConfig: AppConfig, name: String, url: String, isSecure: Boolean) { + var dohName = name + if (name.isBlank()) { + dohName = url + } + val doHEndpoint = DoHEndpoint( + id = 0, + dohName, + url, + dohExplanation = "", + isSelected = false, + isCustom = true, + isSecure = isSecure, + modifiedDataTime = 0, + latency = 0 + ) + appConfig.insertDohEndpoint(doHEndpoint) +} + +private fun checkUrl(url: String): Boolean { + return try { + val parsed = URL(url) + parsed.protocol == "https" && + parsed.host.isNotEmpty() && + parsed.path.isNotEmpty() && + parsed.query == null && + parsed.ref == null + } catch (e: MalformedURLException) { + false + } +} + +@Composable +private fun DotListContent( + paddingValues: PaddingValues, + appConfig: AppConfig, + dotViewModel: DoTEndpointViewModel, + scope: CoroutineScope +) { + val context = LocalContext.current + val items = dotViewModel.dohEndpointList.asFlow().collectAsLazyPagingItems() + var showDialog by remember { mutableStateOf(false) } + val heading = stringResource( + R.string.two_argument_space, + stringResource(R.string.lbl_add).replaceFirstChar(Char::titlecase), + stringResource(R.string.lbl_dot) + ) + val nameLabel = stringResource(R.string.cd_doh_dialog_resolver_name) + val urlLabel = stringResource(R.string.cd_doh_dialog_resolver_url) + val dotName = stringResource(R.string.lbl_dot) + val checkboxLabel = stringResource(R.string.cd_doh_dialog_checkbox_desc) + + DnsEndpointListWithFab( + paddingValues = paddingValues, + items = items, + onFabClick = { showDialog = true } + ) { endpoint -> + DoTEndpointRow(endpoint, appConfig) + } + + if (showDialog) { + val title = heading + FullWidthDialog(onDismiss = { showDialog = false }) { + CustomDohDialogContent( + title = title, + nameLabel = nameLabel, + urlLabel = urlLabel, + defaultName = dotName, + initialUrl = "", + checkboxLabel = checkboxLabel, + loadNextIndex = { appConfig.getDoTCount().plus(1) }, + nameForIndex = { index -> dotName + index.toString() }, + onSubmit = { name, url, isSecure -> + scope.launch(Dispatchers.IO) { + insertDotEndpoint(appConfig, name, url, isSecure) + } + showDialog = false + null + }, + invalidUrlMessage = "", + onDismiss = { showDialog = false } + ) + } + } +} + +private suspend fun insertDotEndpoint(appConfig: AppConfig, name: String, url: String, isSecure: Boolean) { + var dotName = name + if (name.isBlank()) { + dotName = url + } + val endpoint = DoTEndpoint( + id = 0, + dotName, + url, + desc = "", + isSelected = false, + isCustom = true, + isSecure = isSecure, + modifiedDataTime = 0, + latency = 0 + ) + appConfig.insertDoTEndpoint(endpoint) +} + +@Composable +private fun DnsProxyListContent( + paddingValues: PaddingValues, + appConfig: AppConfig, + persistentState: PersistentState, + dnsProxyViewModel: DnsProxyEndpointViewModel, + scope: CoroutineScope +) { + val context = LocalContext.current + val items = dnsProxyViewModel.dnsProxyEndpointList.asFlow().collectAsLazyPagingItems() + var showDialog by remember { mutableStateOf(false) } + var appNames by remember { mutableStateOf>(emptyList()) } + var nextIndex by remember { mutableStateOf(0) } + val defaultAppName = stringResource(R.string.settings_app_list_default_app) + + DnsEndpointListWithFab( + paddingValues = paddingValues, + items = items, + onFabClick = { + scope.launch { + val names = withContext(Dispatchers.IO) { + val list: MutableList = ArrayList() + list.add(defaultAppName) + list.addAll(FirewallManager.getAllAppNamesSortedByVpnPermission(context)) + list + } + appNames = names + nextIndex = appConfig.getDnsProxyCount().plus(1) + showDialog = true + } + } + ) { endpoint -> + DnsProxyEndpointRow(endpoint, appConfig) + } + + if (showDialog && appNames.isNotEmpty()) { + FullWidthDialog(onDismiss = { showDialog = false }) { + DnsProxyDialogContent( + appNames = appNames, + nextIndex = nextIndex, + appConfig = appConfig, + persistentState = persistentState, + scope = scope, + onDismiss = { showDialog = false } + ) + } + } +} + +@Composable +private fun DnsProxyDialogContent( + appNames: List, + nextIndex: Int, + appConfig: AppConfig, + persistentState: PersistentState, + scope: CoroutineScope, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var selectedAppIndex by remember { mutableStateOf(0) } + var appMenuExpanded by remember { mutableStateOf(false) } + val dnsProxyDefaultTemplate = stringResource(R.string.cd_custom_dns_proxy_name) + val dnsProxyDefaultIp = stringResource(R.string.cd_custom_dns_proxy_default_ip) + val modeInternal = stringResource(R.string.cd_dns_proxy_mode_internal) + var proxyName by remember { + mutableStateOf(String.format(dnsProxyDefaultTemplate, nextIndex.toString())) + } + var ipAddress by remember { mutableStateOf(dnsProxyDefaultIp) } + var portText by remember { mutableStateOf("") } + var errorText by remember { mutableStateOf("") } + var excludeAppsChecked by remember { mutableStateOf(!persistentState.excludeAppsInProxy) } + val headerText = stringResource(R.string.dns_proxy_dialog_header_dns) + val lockdownModeText = stringResource(R.string.settings_lock_down_mode_desc) + val appText = stringResource(R.string.settings_dns_proxy_dialog_app) + val dnsProxyNameLabel = stringResource(R.string.dns_proxy_name) + val dnsProxyIpAddressLabel = stringResource(R.string.dns_proxy_ip_address) + val dnsProxyPortLabel = stringResource(R.string.dns_proxy_port) + val excludeHeading = stringResource(R.string.settings_exclude_proxy_apps_heading) + val cancelText = stringResource(R.string.lbl_cancel) + val addText = stringResource(R.string.lbl_add) + val modeExternal = stringResource(R.string.cd_dns_proxy_mode_external) + val errorText1 = stringResource(R.string.cd_dns_proxy_error_text_1) + val errorText2 = stringResource(R.string.cd_dns_proxy_error_text_2) + val errorText3 = stringResource(R.string.cd_dns_proxy_error_text_3) + + val lockdown = VpnController.isVpnLockdown() + + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = headerText, + style = MaterialTheme.typography.titleMedium + ) + + if (lockdown) { + TextButton(onClick = { onDismiss(); UIUtils.openVpnProfile(context) }) { + Text(text = lockdownModeText) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = appText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(0.3f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(0.7f)) { + TextButton(onClick = { appMenuExpanded = true }) { + Text(text = appNames.getOrNull(selectedAppIndex) ?: "") + } + DropdownMenu(expanded = appMenuExpanded, onDismissRequest = { appMenuExpanded = false }) { + appNames.forEachIndexed { index, name -> + DropdownMenuItem( + text = { Text(text = name) }, + onClick = { + selectedAppIndex = index + appMenuExpanded = false + } + ) + } + } + } + } + + OutlinedTextField( + value = proxyName, + onValueChange = { proxyName = it }, + label = { Text(text = dnsProxyNameLabel) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = ipAddress, + onValueChange = { ipAddress = it }, + label = { Text(text = dnsProxyIpAddressLabel) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = portText, + onValueChange = { portText = it }, + label = { Text(text = dnsProxyPortLabel) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + if (errorText.isNotBlank()) { + Text(text = errorText, color = MaterialTheme.colorScheme.error) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = excludeHeading) + Checkbox( + checked = excludeAppsChecked, + onCheckedChange = { if (!lockdown) excludeAppsChecked = it }, + enabled = !lockdown + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(text = cancelText) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + val mode = modeExternal + val appName = appNames.getOrNull(selectedAppIndex).orEmpty() + val ipAddresses = ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } + if (ipAddresses.isEmpty()) { + errorText = errorText1 + return@Button + } + + val invalidIps = mutableListOf() + val validIps = mutableListOf() + for (ip in ipAddresses) { + if (IPAddressString(ip).isIPAddress) { + validIps.add(ip) + } else { + invalidIps.add(ip) + } + } + + if (invalidIps.isNotEmpty()) { + errorText = errorText1 + + ": ${invalidIps.joinToString(", ")}" + return@Button + } + + val port = portText.toIntOrNull() + if (port == null) { + errorText = errorText3 + return@Button + } + + var isPortValid = true + for (ip in validIps) { + if (Utilities.isLanIpv4(ip) && !Utilities.isValidLocalPort(port)) { + isPortValid = false + break + } + } + + if (!isPortValid) { + errorText = errorText2 + return@Button + } + + val ipString = validIps.joinToString(",") + val isModeInternal = mode == modeInternal + scope.launch(Dispatchers.IO) { + insertDNSProxyEndpointDB( + context, + appConfig, + mode, + proxyName, + appName, + ipString, + port, + defaultApp = appNames.firstOrNull().orEmpty(), + isInternalMode = isModeInternal + ) + } + persistentState.excludeAppsInProxy = !excludeAppsChecked + onDismiss() + } + ) { + Text(text = addText) + } + } + } +} + +private suspend fun insertDNSProxyEndpointDB( + context: android.content.Context, + appConfig: AppConfig, + mode: String, + name: String, + appName: String?, + ip: String, + port: Int, + defaultApp: String, + isInternalMode: Boolean +) { + if (appName == null) return + + val packageName = if (appName == defaultApp) { + "" + } else { + FirewallManager.getPackageNameByAppName(appName) ?: "" + } + var proxyName = name + if (proxyName.isBlank()) { + proxyName = if (isInternalMode) { + appName + } else ip + } + val endpoint = DnsProxyEndpoint( + id = 0, + proxyName, + mode, + packageName, + ip, + port, + isSelected = false, + isCustom = true, + modifiedDataTime = 0L, + latency = 0 + ) + appConfig.insertDnsproxyEndpoint(endpoint) +} + +@Composable +private fun DnsCryptListContent( + paddingValues: PaddingValues, + appConfig: AppConfig, + dnsCryptViewModel: DnsCryptEndpointViewModel, + dnsCryptRelayViewModel: DnsCryptRelayEndpointViewModel, + scope: CoroutineScope +) { + val items = dnsCryptViewModel.dnsCryptEndpointList.asFlow().collectAsLazyPagingItems() + var showDialog by remember { mutableStateOf(false) } + var showRelaysDialog by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxSize().padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.cd_dns_crypt_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp) + ) + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + onClick = { showRelaysDialog = true } + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.cd_dnscrypt_relay_heading), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.lbl_configure), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } + DnsEndpointListWithFab( + paddingValues = PaddingValues(0.dp), + items = items, + onFabClick = { showDialog = true } + ) { endpoint -> + DnsCryptRow(endpoint, appConfig) + } + } + + if (showDialog) { + FullWidthDialog(onDismiss = { showDialog = false }) { + DnsCryptDialogContent( + appConfig = appConfig, + scope = scope, + onDismiss = { showDialog = false } + ) + } + } + + if (showRelaysDialog) { + com.celzero.bravedns.ui.dialog.DnsCryptRelaysDialog( + appConfig = appConfig, + relays = dnsCryptRelayViewModel.dnsCryptRelayEndpointList, + onDismiss = { showRelaysDialog = false } + ) + } +} + +@Composable +private fun DnsCryptDialogContent( + appConfig: AppConfig, + scope: CoroutineScope, + onDismiss: () -> Unit +) { + var isServer by remember { mutableStateOf(true) } + var dnscryptNextIndex by remember { mutableStateOf(0) } + var relayNextIndex by remember { mutableStateOf(0) } + val dnsCryptServerNameTemplate = stringResource(R.string.cd_dns_crypt_name) + val dnsCryptRelayNameTemplate = stringResource(R.string.cd_dns_crypt_relay_name) + val dnsCryptNameDefault = stringResource(R.string.cd_dns_crypt_name_default) + var name by remember { mutableStateOf(dnsCryptNameDefault) } + var url by remember { mutableStateOf("") } + var desc by remember { mutableStateOf("") } + var errorText by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + dnscryptNextIndex = appConfig.getDnscryptCount().plus(1) + relayNextIndex = appConfig.getDnscryptRelayCount().plus(1) + } + + LaunchedEffect(isServer, dnscryptNextIndex, relayNextIndex) { + name = if (isServer) { + String.format(dnsCryptServerNameTemplate, dnscryptNextIndex.toString()) + } else { + String.format(dnsCryptRelayNameTemplate, relayNextIndex.toString()) + } + } + val dialogHeading = stringResource(R.string.cd_dns_crypt_dialog_heading) + val resolverHeading = stringResource(R.string.cd_dns_crypt_resolver_heading) + val relayHeading = stringResource(R.string.cd_dns_crypt_relay_heading) + val dialogNameLabel = stringResource(R.string.cd_dns_crypt_dialog_name) + val dialogStampLabel = stringResource(R.string.cd_dns_crypt_dialog_stamp) + val dialogDescLabel = stringResource(R.string.cd_dns_crypt_dialog_desc) + val cancelText = stringResource(R.string.lbl_cancel) + val addText = stringResource(R.string.lbl_add) + val invalidUrlError = stringResource(R.string.custom_url_error_invalid_url) + + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = dialogHeading, + style = MaterialTheme.typography.titleMedium + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { isServer = true }) { + Text(text = resolverHeading) + } + Spacer(modifier = Modifier.width(10.dp)) + TextButton(onClick = { isServer = false }) { + Text(text = relayHeading) + } + } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(text = dialogNameLabel) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(text = dialogStampLabel) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = desc, + onValueChange = { desc = it }, + label = { Text(text = dialogDescLabel) }, + modifier = Modifier.fillMaxWidth() + ) + + if (errorText.isNotBlank()) { + Text(text = errorText, color = MaterialTheme.colorScheme.error) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(text = cancelText) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (name.isBlank() || url.isBlank()) { + errorText = invalidUrlError + return@Button + } + + if (isServer) { + scope.launch(Dispatchers.IO) { + insertDnsCrypt(appConfig, name, url, desc) + } + } else { + scope.launch(Dispatchers.IO) { + insertDnsCryptRelay(appConfig, name, url, desc) + } + } + onDismiss() + } + ) { + Text(text = addText) + } + } + } +} + +private suspend fun insertDnsCrypt(appConfig: AppConfig, name: String, url: String, desc: String) { + var dnscryptName = name + if (name.isBlank()) { + dnscryptName = url + } + val endpoint = DnsCryptEndpoint( + id = 0, + dnscryptName, + url, + desc, + isSelected = false, + isCustom = true, + modifiedDataTime = 0, + latency = 0 + ) + appConfig.insertDnscryptEndpoint(endpoint) +} + +private suspend fun insertDnsCryptRelay(appConfig: AppConfig, name: String, url: String, desc: String) { + var relayName = name + if (name.isBlank()) { + relayName = url + } + val endpoint = DnsCryptRelayEndpoint( + id = 0, + relayName, + url, + desc, + isSelected = false, + isCustom = true, + modifiedDataTime = 0, + latency = 0 + ) + appConfig.insertDnscryptRelayEndpoint(endpoint) +} + +@Composable +private fun OdohListContent( + paddingValues: PaddingValues, + appConfig: AppConfig, + oDohViewModel: ODoHEndpointViewModel, + scope: CoroutineScope +) { + val context = LocalContext.current + val items = oDohViewModel.dohEndpointList.asFlow().collectAsLazyPagingItems() + var showDialog by remember { mutableStateOf(false) } + val title = stringResource( + R.string.two_argument_space, + stringResource(R.string.lbl_add).replaceFirstChar(Char::uppercase), + stringResource(R.string.lbl_odoh) + ) + val nameLabel = stringResource(R.string.cd_doh_dialog_resolver_name) + val proxyLabel = stringResource(R.string.settings_proxy_header) + stringResource(R.string.lbl_optional) + val resolverLabel = stringResource(R.string.cd_doh_dialog_resolver_url) + val defaultName = stringResource(R.string.lbl_odoh) + val invalidUrlMessage = stringResource(R.string.custom_url_error_invalid_url) + + DnsEndpointListWithFab( + paddingValues = paddingValues, + items = items, + onFabClick = { showDialog = true } + ) { endpoint -> + ODoHEndpointRow(endpoint, appConfig) + } + + if (showDialog) { + FullWidthDialog(onDismiss = { showDialog = false }) { + CustomOdohDialogContent( + title = title, + nameLabel = nameLabel, + proxyLabel = proxyLabel, + resolverLabel = resolverLabel, + defaultName = defaultName, + initialResolver = "https://", + loadNextIndex = { appConfig.getODoHCount().plus(1) }, + invalidUrlMessage = invalidUrlMessage, + onSubmit = { name, proxy, resolver -> + if (checkUrl(resolver)) { + scope.launch(Dispatchers.IO) { + insertOdoh(appConfig, name, proxy, resolver) + } + showDialog = false + null + } else { + invalidUrlMessage + } + }, + onDismiss = { showDialog = false } + ) + } + } +} + +@Composable +private fun FullWidthDialog( + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + tonalElevation = 6.dp + ) { + content() + } + } +} + +@Composable +private fun CustomDohDialogContent( + title: String, + nameLabel: String, + urlLabel: String, + defaultName: String, + initialUrl: String, + checkboxLabel: String, + loadNextIndex: suspend () -> Int, + nameForIndex: (Int) -> String, + onSubmit: (String, String, Boolean) -> String?, + invalidUrlMessage: String, + onDismiss: () -> Unit + ) { + var name by remember { mutableStateOf(defaultName) } + var url by remember { mutableStateOf(initialUrl) } + var insecureChecked by remember { mutableStateOf(false) } + var errorText by remember { mutableStateOf("") } + val cancelText = stringResource(R.string.lbl_cancel) + val addText = stringResource(R.string.lbl_add) + + LaunchedEffect(Unit) { + val nextIndex = withContext(Dispatchers.IO) { loadNextIndex() } + name = nameForIndex(nextIndex) + } + + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(text = nameLabel) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(text = urlLabel) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = insecureChecked, onCheckedChange = { insecureChecked = it }) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = checkboxLabel) + } + if (errorText.isNotBlank()) { + Text(text = errorText, color = MaterialTheme.colorScheme.error) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text(text = cancelText) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + val isSecure = !insecureChecked + val error = onSubmit(name, url, isSecure) + if (error != null) { + errorText = error.ifBlank { invalidUrlMessage } + } + } + ) { + Text(text = addText) + } + } + } +} + +@Composable +private fun CustomOdohDialogContent( + title: String, + nameLabel: String, + proxyLabel: String, + resolverLabel: String, + defaultName: String, + initialResolver: String, + loadNextIndex: suspend () -> Int, + invalidUrlMessage: String, + onSubmit: (String, String, String) -> String?, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf(defaultName) } + var proxy by remember { mutableStateOf("") } + var resolver by remember { mutableStateOf(initialResolver) } + var errorText by remember { mutableStateOf("") } + val cancelText = stringResource(R.string.lbl_cancel) + val addText = stringResource(R.string.lbl_add) + + LaunchedEffect(Unit) { + val nextIndex = withContext(Dispatchers.IO) { loadNextIndex() } + name = defaultName + nextIndex.toString() + } + + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(text = nameLabel) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = proxy, + onValueChange = { proxy = it }, + label = { Text(text = proxyLabel) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = resolver, + onValueChange = { resolver = it }, + label = { Text(text = resolverLabel) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + if (errorText.isNotBlank()) { + Text(text = errorText, color = MaterialTheme.colorScheme.error) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text(text = cancelText) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + val error = onSubmit(name, proxy, resolver) + if (error != null) { + errorText = error.ifBlank { invalidUrlMessage } + } + } + ) { + Text(text = addText) + } + } + } +} + +private suspend fun insertOdoh(appConfig: AppConfig, name: String, proxy: String, resolver: String) { + var odohName = name + if (name.isBlank()) { + odohName = resolver + } + val endpoint = ODoHEndpoint( + id = 0, + odohName, + proxy, + resolver, + proxyIps = "", + desc = "", + isSelected = false, + isCustom = true, + modifiedDataTime = 0, + latency = 0 + ) + appConfig.insertODoHEndpoint(endpoint) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureRethinkBasicScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureRethinkBasicScreen.kt new file mode 100644 index 000000000..289952143 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/ConfigureRethinkBasicScreen.kt @@ -0,0 +1,1315 @@ +/* + * Copyright 2024 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.compose.dns + +import android.content.Context +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.LocalAdvancedBlocklistRow +import com.celzero.bravedns.adapter.LocalSimpleBlocklistRow +import com.celzero.bravedns.adapter.RemoteAdvancedBlocklistRow +import com.celzero.bravedns.adapter.RemoteSimpleBlocklistRow +import com.celzero.bravedns.adapter.RethinkEndpointRow +import com.celzero.bravedns.customdownloader.LocalBlocklistCoordinator.Companion.CUSTOM_DOWNLOAD +import com.celzero.bravedns.customdownloader.RemoteBlocklistCoordinator +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.data.FileTag +import com.celzero.bravedns.database.LocalBlocklistPacksMap +import com.celzero.bravedns.database.RemoteBlocklistPacksMap +import com.celzero.bravedns.database.RethinkLocalFileTag +import com.celzero.bravedns.database.RethinkRemoteFileTag +import com.celzero.bravedns.download.AppDownloadManager +import com.celzero.bravedns.download.DownloadConstants.Companion.DOWNLOAD_TAG +import com.celzero.bravedns.download.DownloadConstants.Companion.FILE_TAG +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.RethinkBlocklistManager +import com.celzero.bravedns.service.RethinkBlocklistManager.getStamp +import com.celzero.bravedns.service.RethinkBlocklistManager.getTagsFromStamp +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetActionRow +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkFilterChip +import com.celzero.bravedns.ui.compose.theme.RethinkMultiActionDialog +import com.celzero.bravedns.ui.compose.theme.RethinkSecondaryActionStyle +import com.celzero.bravedns.ui.compose.theme.RethinkTwoOptionSegmentedRow +import com.celzero.bravedns.ui.rethink.RethinkBlocklistState +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Constants.Companion.DEAD_PACK +import com.celzero.bravedns.util.Constants.Companion.DEFAULT_RDNS_REMOTE_DNS_NAMES +import com.celzero.bravedns.util.Constants.Companion.MAX_ENDPOINT +import com.celzero.bravedns.util.Constants.Companion.RETHINK_STAMP_VERSION +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getRemoteBlocklistStamp +import com.celzero.bravedns.util.Utilities.hasLocalBlocklists +import com.celzero.bravedns.util.Utilities.hasRemoteBlocklists +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import com.celzero.bravedns.viewmodel.LocalBlocklistPacksMapViewModel +import com.celzero.bravedns.viewmodel.RemoteBlocklistPacksMapViewModel +import com.celzero.bravedns.viewmodel.RethinkEndpointViewModel +import com.celzero.bravedns.viewmodel.RethinkLocalFileTagViewModel +import com.celzero.bravedns.viewmodel.RethinkRemoteFileTagViewModel +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkModalBottomSheet +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.regex.Pattern + +enum class ConfigureRethinkScreenType { + REMOTE, + LOCAL, + DB_LIST +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigureRethinkBasicScreen( + screenType: ConfigureRethinkScreenType, + remoteName: String = "", + remoteUrl: String = "", + uid: Int = Constants.MISSING_UID, + persistentState: PersistentState, + appConfig: AppConfig, + appDownloadManager: AppDownloadManager, + rethinkEndpointViewModel: RethinkEndpointViewModel, + remoteFileTagViewModel: RethinkRemoteFileTagViewModel, + localFileTagViewModel: RethinkLocalFileTagViewModel, + remoteBlocklistPacksMapViewModel: RemoteBlocklistPacksMapViewModel, + localBlocklistPacksMapViewModel: LocalBlocklistPacksMapViewModel, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val localBlocklistVersionTemplate = stringResource(R.string.settings_local_blocklist_version) + val downloadSuccessMessage = stringResource(R.string.download_update_dialog_message_success) + val downloadFailureMessage = stringResource(R.string.download_update_dialog_failure_message) + val filterDescriptionTemplate = stringResource(R.string.rt_filter_desc) + val filterDescriptionSubgroupsTemplate = stringResource(R.string.rt_filter_desc_subgroups) + + val blocklistType = remember { + if (screenType == ConfigureRethinkScreenType.LOCAL) { + RethinkBlocklistManager.RethinkBlocklistType.LOCAL + } else { + RethinkBlocklistManager.RethinkBlocklistType.REMOTE + } + } + + val filters = remember { MutableLiveData(RethinkBlocklistState.Filters()) } + + var showDownload by remember { mutableStateOf(false) } + var showConfigure by remember { mutableStateOf(false) } + var isDownloading by remember { mutableStateOf(false) } + var showRemoteProgress by remember { mutableStateOf(false) } + var activeView by remember { mutableStateOf(RethinkBlocklistState.BlocklistView.PACKS) } + var updateAvailable by remember { mutableStateOf(false) } + var checkUpdateVisible by remember { mutableStateOf(false) } + var redownloadVisible by remember { mutableStateOf(false) } + var checkUpdateInProgress by remember { mutableStateOf(false) } + var updateInProgress by remember { mutableStateOf(false) } + var isMax by remember { mutableStateOf(false) } + var filterLabelText by remember { mutableStateOf("") } + var showPlusFilterSheet by remember { mutableStateOf(false) } + var showLockdownDialog by remember { mutableStateOf(false) } + var lockdownDialogType by remember { mutableStateOf(null) } + var showApplyChangesDialog by remember { mutableStateOf(false) } + var plusFilterTags by remember { mutableStateOf>(emptyList()) } + var modifiedStamp by remember { mutableStateOf("") } + + fun getStampValue(): String { + return if (blocklistType.isLocal()) { + persistentState.localBlocklistStamp + } else { + getRemoteBlocklistStamp(remoteUrl) + } + } + + fun getRemoteUrl(stamp: String): String { + return if (remoteUrl.contains(MAX_ENDPOINT)) { + Constants.RETHINK_BASE_URL_MAX + stamp + } else { + Constants.RETHINK_BASE_URL_SKY + stamp + } + } + + fun isStampChanged(): Boolean { + if (DEFAULT_RDNS_REMOTE_DNS_NAMES.contains(remoteName)) { + return false + } + return getStampValue() != modifiedStamp + } + + fun hasBlocklists(): Boolean { + return if (blocklistType.isLocal()) { + hasLocalBlocklists(context, persistentState.localBlocklistTimestamp) + } else { + hasRemoteBlocklists(context, persistentState.remoteBlocklistTimestamp) + } + } + + fun setStamp(stamp: String?) { + Napier.i("set stamp for blocklist type: ${blocklistType.name} with $stamp") + if (stamp == null) { + Napier.i("stamp is null") + return + } + + scope.launch(Dispatchers.IO) { + val blocklistCount = getTagsFromStamp(stamp, blocklistType).size + if (blocklistType.isLocal()) { + persistentState.localBlocklistStamp = stamp + persistentState.numberOfLocalBlocklists = blocklistCount + persistentState.blocklistEnabled = true + } else { + appConfig.updateRethinkEndpoint( + Constants.RETHINK_DNS_PLUS, + getRemoteUrl(stamp), + blocklistCount + ) + appConfig.enableRethinkDnsPlus() + } + } + } + + suspend fun processSelectedFileTags(stamp: String) { + val list = RethinkBlocklistManager.getTagsFromStamp(stamp, blocklistType) + updateSelectedFileTags(list.toMutableSet(), blocklistType) + } + + fun onDownloadStart() { + isDownloading = true + showDownload = true + showConfigure = false + } + + fun onDownloadFail() { + isDownloading = false + showDownload = true + showConfigure = false + showRemoteProgress = false + } + + fun onDownloadSuccess() { + isDownloading = false + showDownload = false + showConfigure = true + showRemoteProgress = false + showToastUiCentered( + context, + downloadSuccessMessage, + Toast.LENGTH_SHORT + ) + } + + fun handleDownloadStatus(status: AppDownloadManager.DownloadManagerStatus) { + when (status) { + AppDownloadManager.DownloadManagerStatus.IN_PROGRESS -> {} + AppDownloadManager.DownloadManagerStatus.STARTED -> onDownloadStart() + AppDownloadManager.DownloadManagerStatus.FAILURE -> onDownloadFail() + AppDownloadManager.DownloadManagerStatus.NOT_AVAILABLE -> { + showToastUiCentered( + context, + "Download latest version to update the blocklists", + Toast.LENGTH_SHORT + ) + } + + else -> {} + } + } + + fun proceedWithBlocklistDownload(type: RethinkBlocklistManager.RethinkBlocklistType) { + scope.launch { + if (type.isLocal()) { + val status = withContext(Dispatchers.IO) { + appDownloadManager.downloadLocalBlocklist( + persistentState.localBlocklistTimestamp, + isRedownload = false + ) + } + handleDownloadStatus(status) + } else { + withContext(Dispatchers.IO) { + appDownloadManager.downloadRemoteBlocklist( + persistentState.remoteBlocklistTimestamp, + isRedownload = true + ) + } + showRemoteProgress = false + // Refresh blocklist availability + val blocklistsExist = withContext(Dispatchers.IO) { hasBlocklists() } + if (blocklistsExist) { + showConfigure = true + showDownload = false + } else { + showConfigure = false + showDownload = true + } + } + } + } + + fun downloadBlocklist(type: RethinkBlocklistManager.RethinkBlocklistType) { + if (VpnController.isVpnLockdown() && !persistentState.useCustomDownloadManager) { + lockdownDialogType = type + showLockdownDialog = true + return + } + proceedWithBlocklistDownload(type) + } + + fun cancelDownload() { + appDownloadManager.cancelDownload(type = RethinkBlocklistManager.DownloadType.LOCAL) + } + + fun refreshBlocklistAvailability() { + scope.launch { + val blocklistsExist = withContext(Dispatchers.IO) { hasBlocklists() } + if (blocklistsExist) { + showConfigure = true + showDownload = false + return@launch + } + + showConfigure = false + showDownload = true + if (!blocklistType.isLocal()) { + showRemoteProgress = true + downloadBlocklist(blocklistType) + } + } + } + + fun updateMaxSwitchUi() { + scope.launch { + var endpointUrl: String? = null + withContext(Dispatchers.IO) { endpointUrl = appConfig.getRethinkPlusEndpoint()?.url } + isMax = endpointUrl?.contains(Constants.MAX_ENDPOINT) == true + } + } + + fun isBlocklistUpdateAvailable(): Boolean { + Napier.d( + "Update available? newest: ${persistentState.newestRemoteBlocklistTimestamp}, available: ${persistentState.remoteBlocklistTimestamp}" + ) + return (persistentState.newestRemoteBlocklistTimestamp != Constants.INIT_TIME_MS && + persistentState.newestRemoteBlocklistTimestamp > persistentState.remoteBlocklistTimestamp) + } + + fun refreshUpdateUi() { + if (persistentState.remoteBlocklistTimestamp == Constants.INIT_TIME_MS) { + updateAvailable = false + checkUpdateVisible = false + redownloadVisible = false + return + } + + if (isBlocklistUpdateAvailable()) { + updateAvailable = true + checkUpdateVisible = false + redownloadVisible = false + return + } + + updateAvailable = false + checkUpdateVisible = true + redownloadVisible = false + } + + fun checkBlocklistUpdate() { + scope.launch(Dispatchers.IO) { + appDownloadManager.isDownloadRequired(RethinkBlocklistManager.DownloadType.REMOTE) + } + } + + fun download(timestamp: Long, isRedownload: Boolean) { + scope.launch(Dispatchers.IO) { + val initiated = appDownloadManager.downloadRemoteBlocklist(timestamp, isRedownload) + if (!initiated) { + withContext(Dispatchers.Main) { + showToastUiCentered( + context, + downloadFailureMessage, + Toast.LENGTH_SHORT + ) + } + } + } + } + + fun applyChangesAndFinish() { + setStamp(modifiedStamp) + onBackClick?.invoke() + } + + fun revertChangesAndFinish() { + scope.launch { + val stamp = getStampValue() + val list = RethinkBlocklistManager.getTagsFromStamp(stamp, blocklistType) + updateSelectedFileTags(list.toMutableSet(), blocklistType) + setStamp(stamp) + onBackClick?.invoke() + } + } + + // Back handler for unsaved changes + if (screenType != ConfigureRethinkScreenType.DB_LIST) { + BackHandler { + if (!isStampChanged()) { + onBackClick?.invoke() + return@BackHandler + } + showApplyChangesDialog = true + } + } + + // Dialogs + if (showLockdownDialog && lockdownDialogType != null) { + val type = lockdownDialogType ?: return + RethinkConfirmDialog( + onDismissRequest = { showLockdownDialog = false }, + title = stringResource(R.string.lockdown_download_enable_inapp), + message = stringResource(R.string.lockdown_download_message), + confirmText = stringResource(R.string.lockdown_download_enable_inapp), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + showLockdownDialog = false + persistentState.useCustomDownloadManager = true + downloadBlocklist(type) + }, + onDismiss = { + showLockdownDialog = false + proceedWithBlocklistDownload(type) + } + ) + } + + if (showApplyChangesDialog) { + RethinkMultiActionDialog( + onDismissRequest = { showApplyChangesDialog = false }, + title = stringResource(R.string.rt_dialog_title), + message = stringResource(R.string.rt_dialog_message), + primaryText = stringResource(R.string.lbl_apply), + onPrimary = { + showApplyChangesDialog = false + setStamp(modifiedStamp) + onBackClick?.invoke() + }, + secondaryText = stringResource(R.string.rt_dialog_neutral), + onSecondary = { showApplyChangesDialog = false }, + tertiaryText = stringResource(R.string.notif_dialog_pause_dialog_negative), + onTertiary = { + showApplyChangesDialog = false + onBackClick?.invoke() + } + ) + } + + val title = when (screenType) { + ConfigureRethinkScreenType.DB_LIST -> stringResource(R.string.dc_rethink_dns_radio) + ConfigureRethinkScreenType.LOCAL -> stringResource(R.string.dc_local_block_heading) + ConfigureRethinkScreenType.REMOTE -> stringResource(R.string.dc_rethink_dns_radio) + } + val subtitle = when (screenType) { + ConfigureRethinkScreenType.LOCAL, + ConfigureRethinkScreenType.REMOTE -> stringResource(R.string.dns_desc) + else -> null + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + RethinkLargeTopBar( + title = title, + subtitle = subtitle, + onBackClick = if (onBackClick == null) null else { + { + if (screenType != ConfigureRethinkScreenType.DB_LIST && isStampChanged()) { + showApplyChangesDialog = true + } else { + onBackClick() + } + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + when (screenType) { + ConfigureRethinkScreenType.DB_LIST -> { + RethinkListContent( + modifier = Modifier.weight(1f), + context = context, + persistentState = persistentState, + appConfig = appConfig, + appDownloadManager = appDownloadManager, + rethinkEndpointViewModel = rethinkEndpointViewModel, + uid = uid, + isMax = isMax, + updateAvailable = updateAvailable, + checkUpdateVisible = checkUpdateVisible, + redownloadVisible = redownloadVisible, + checkUpdateInProgress = checkUpdateInProgress, + updateInProgress = updateInProgress, + onMaxChanged = { isMax = it }, + onUpdateMaxSwitchUi = { updateMaxSwitchUi() }, + onRefreshUpdateUi = { refreshUpdateUi() }, + onCheckUpdateInProgressChanged = { checkUpdateInProgress = it }, + onUpdateInProgressChanged = { updateInProgress = it }, + onCheckBlocklistUpdate = { checkBlocklistUpdate() }, + onDownload = { timestamp, isRedownload -> download(timestamp, isRedownload) }, + localBlocklistVersionTemplate = localBlocklistVersionTemplate, + downloadFailureMessage = downloadFailureMessage + ) + } + + else -> { + RethinkBlocklistContent( + modifier = Modifier.weight(1f), + context = context, + blocklistType = blocklistType, + filters = filters, + remoteFileTagViewModel = remoteFileTagViewModel, + localFileTagViewModel = localFileTagViewModel, + remoteBlocklistPacksMapViewModel = remoteBlocklistPacksMapViewModel, + localBlocklistPacksMapViewModel = localBlocklistPacksMapViewModel, + showDownload = showDownload, + showConfigure = showConfigure, + isDownloading = isDownloading, + showRemoteProgress = showRemoteProgress, + activeView = activeView, + filterLabelText = filterLabelText, + showPlusFilterSheet = showPlusFilterSheet, + plusFilterTags = plusFilterTags, + onActiveViewChanged = { activeView = it }, + onFilterLabelTextChanged = { filterLabelText = it }, + onShowPlusFilterSheetChanged = { showPlusFilterSheet = it }, + onPlusFilterTagsChanged = { plusFilterTags = it }, + onDownloadBlocklist = { downloadBlocklist(blocklistType) }, + onCancelDownload = { cancelDownload(); onBackClick?.invoke() }, + onApplyChanges = { applyChangesAndFinish() }, + onRevertChanges = { revertChangesAndFinish() }, + onRefreshBlocklistAvailability = { refreshBlocklistAvailability() }, + filterDescriptionTemplate = filterDescriptionTemplate, + filterDescriptionSubgroupsTemplate = filterDescriptionSubgroupsTemplate, + onProcessSelectedFileTags = { stamp -> + scope.launch { processSelectedFileTags(stamp) } + }, + onModifiedStampChanged = { modifiedStamp = it }, + getStampValue = { getStampValue() }, + onDownloadStart = { onDownloadStart() }, + onDownloadFail = { onDownloadFail() }, + onDownloadSuccess = { onDownloadSuccess() } + ) + } + } + } + } + + if (showPlusFilterSheet) { + RethinkPlusFilterSheet( + fileTags = plusFilterTags, + filters = filters, + onDismiss = { showPlusFilterSheet = false } + ) + } +} + +@Composable +private fun RethinkListContent( + modifier: Modifier = Modifier, + context: Context, + persistentState: PersistentState, + appConfig: AppConfig, + appDownloadManager: AppDownloadManager, + rethinkEndpointViewModel: RethinkEndpointViewModel, + uid: Int, + isMax: Boolean, + updateAvailable: Boolean, + checkUpdateVisible: Boolean, + redownloadVisible: Boolean, + checkUpdateInProgress: Boolean, + updateInProgress: Boolean, + onMaxChanged: (Boolean) -> Unit, + onUpdateMaxSwitchUi: () -> Unit, + onRefreshUpdateUi: () -> Unit, + onCheckUpdateInProgressChanged: (Boolean) -> Unit, + onUpdateInProgressChanged: (Boolean) -> Unit, + onCheckBlocklistUpdate: () -> Unit, + onDownload: (Long, Boolean) -> Unit, + localBlocklistVersionTemplate: String, + downloadFailureMessage: String +) { + val scope = rememberCoroutineScope() + val pagingItems = rethinkEndpointViewModel.rethinkEndpointList.asFlow().collectAsLazyPagingItems() + val workInfos by WorkManager.getInstance(context) + .getWorkInfosByTagLiveData(RemoteBlocklistCoordinator.REMOTE_DOWNLOAD_WORKER) + .asFlow() + .collectAsState(initial = emptyList()) + + LaunchedEffect(Unit) { + rethinkEndpointViewModel.setFilter(uid) + onUpdateMaxSwitchUi() + onRefreshUpdateUi() + } + + LaunchedEffect(workInfos) { + val workInfo = workInfos.getOrNull(0) ?: return@LaunchedEffect + Napier.i("Remote blocklist worker state: ${workInfo.state}") + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> { + onCheckUpdateInProgressChanged(false) + onUpdateInProgressChanged(false) + onRefreshUpdateUi() + } + + WorkInfo.State.CANCELLED, + WorkInfo.State.FAILED -> { + onCheckUpdateInProgressChanged(false) + onUpdateInProgressChanged(false) + onRefreshUpdateUi() + Utilities.showToastUiCentered( + context, + downloadFailureMessage, + Toast.LENGTH_SHORT + ) + } + + else -> {} + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + val actionText = + when { + updateAvailable -> stringResource(id = R.string.rt_chip_update_available) + checkUpdateVisible -> stringResource(id = R.string.rt_chip_check_update) + redownloadVisible -> stringResource(id = R.string.rt_re_download) + else -> null + } + val actionInProgress = + when { + updateAvailable || redownloadVisible -> updateInProgress + checkUpdateVisible -> checkUpdateInProgress + else -> false + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius3xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f)), + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (persistentState.remoteBlocklistTimestamp != Constants.INIT_TIME_MS || actionText != null) { + Text( + text = localBlocklistVersionTemplate.format( + Utilities.convertLongToTime( + persistentState.remoteBlocklistTimestamp, + Constants.TIME_FORMAT_2 + ) + ), + style = MaterialTheme.typography.bodyMedium + ) + } + + if (actionText != null) { + FilledTonalButton( + onClick = { + when { + updateAvailable -> { + onUpdateInProgressChanged(true) + onDownload(persistentState.remoteBlocklistTimestamp, false) + } + checkUpdateVisible -> { + onCheckUpdateInProgressChanged(true) + onCheckBlocklistUpdate() + } + redownloadVisible -> { + onUpdateInProgressChanged(true) + onDownload(persistentState.remoteBlocklistTimestamp, true) + } + } + }, + enabled = !actionInProgress + ) { + if (actionInProgress) { + CircularProgressIndicator( + modifier = Modifier.height(16.dp).width(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text(text = actionText) + } + } + } + } + + RethinkTwoOptionSegmentedRow( + leftLabel = stringResource(id = R.string.radio_sky_btn), + rightLabel = stringResource(id = R.string.radio_max_btn), + leftSelected = !isMax, + onLeftClick = { + scope.launch(Dispatchers.IO) { appConfig.switchRethinkDnsToSky() } + onMaxChanged(false) + }, + onRightClick = { + scope.launch(Dispatchers.IO) { appConfig.switchRethinkDnsToMax() } + onMaxChanged(true) + } + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Text( + text = if (isMax) { + stringResource(id = R.string.rethink_max_desc) + } else { + stringResource(id = R.string.rethink_sky_desc) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) + ) + } + + Text( + text = stringResource(R.string.dc_rethink_dns_radio), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 2.dp) + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = Dimensions.spacing2xl), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(count = pagingItems.itemCount) { index -> + val item = pagingItems[index] ?: return@items + RethinkEndpointRow(endpoint = item, appConfig = appConfig) + } + } + } +} + +@Composable +private fun RethinkBlocklistContent( + modifier: Modifier = Modifier, + context: Context, + blocklistType: RethinkBlocklistManager.RethinkBlocklistType, + filters: MutableLiveData, + remoteFileTagViewModel: RethinkRemoteFileTagViewModel, + localFileTagViewModel: RethinkLocalFileTagViewModel, + remoteBlocklistPacksMapViewModel: RemoteBlocklistPacksMapViewModel, + localBlocklistPacksMapViewModel: LocalBlocklistPacksMapViewModel, + showDownload: Boolean, + showConfigure: Boolean, + isDownloading: Boolean, + showRemoteProgress: Boolean, + activeView: RethinkBlocklistState.BlocklistView, + filterLabelText: String, + showPlusFilterSheet: Boolean, + plusFilterTags: List, + onActiveViewChanged: (RethinkBlocklistState.BlocklistView) -> Unit, + onFilterLabelTextChanged: (String) -> Unit, + onShowPlusFilterSheetChanged: (Boolean) -> Unit, + onPlusFilterTagsChanged: (List) -> Unit, + onDownloadBlocklist: () -> Unit, + onCancelDownload: () -> Unit, + onApplyChanges: () -> Unit, + onRevertChanges: () -> Unit, + onRefreshBlocklistAvailability: () -> Unit, + onProcessSelectedFileTags: (String) -> Unit, + onModifiedStampChanged: (String) -> Unit, + getStampValue: () -> String, + onDownloadStart: () -> Unit, + onDownloadFail: () -> Unit, + onDownloadSuccess: () -> Unit, + filterDescriptionTemplate: String, + filterDescriptionSubgroupsTemplate: String +) { + val scope = rememberCoroutineScope() + val filterState by filters.asFlow().collectAsState(initial = filters.value) + val selectedTags by RethinkBlocklistState.selectedFileTags.asFlow() + .collectAsState(initial = RethinkBlocklistState.selectedFileTags.value) + + // Observe blocklist download state + val workManager = WorkManager.getInstance(context) + val customDownload by workManager.getWorkInfosByTagLiveData(CUSTOM_DOWNLOAD).asFlow() + .collectAsState(initial = emptyList()) + val downloadTag by workManager.getWorkInfosByTagLiveData(DOWNLOAD_TAG).asFlow() + .collectAsState(initial = emptyList()) + val fileTag by workManager.getWorkInfosByTagLiveData(FILE_TAG).asFlow() + .collectAsState(initial = emptyList()) + + LaunchedEffect(Unit) { + val stamp = getStampValue() + onModifiedStampChanged(stamp) + onProcessSelectedFileTags(stamp) + onRefreshBlocklistAvailability() + } + + LaunchedEffect(filterState) { + val filter = filterState ?: return@LaunchedEffect + if (blocklistType.isRemote()) { + remoteFileTagViewModel.setFilter(filter) + } else { + localFileTagViewModel.setFilter(filter) + } + onFilterLabelTextChanged( + buildFilterDescription( + filter, + filterDescriptionTemplate, + filterDescriptionSubgroupsTemplate + ) + ) + } + + LaunchedEffect(selectedTags) { + val tags = selectedTags ?: emptySet() + val stamp = getStamp(tags, blocklistType) + onModifiedStampChanged(stamp) + } + + LaunchedEffect(customDownload) { + val workInfo = customDownload.getOrNull(0) ?: return@LaunchedEffect + when (workInfo.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING -> onDownloadStart() + + WorkInfo.State.SUCCEEDED -> onDownloadSuccess() + WorkInfo.State.CANCELLED, + WorkInfo.State.FAILED -> onDownloadFail() + + else -> Unit + } + } + + LaunchedEffect(downloadTag) { + val workInfo = downloadTag.getOrNull(0) ?: return@LaunchedEffect + when (workInfo.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING -> onDownloadStart() + + WorkInfo.State.CANCELLED, + WorkInfo.State.FAILED -> onDownloadFail() + + else -> Unit + } + } + + LaunchedEffect(fileTag) { + val workInfo = fileTag.getOrNull(0) ?: return@LaunchedEffect + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> onDownloadSuccess() + WorkInfo.State.CANCELLED, + WorkInfo.State.FAILED -> onDownloadFail() + + else -> Unit + } + } + + fun isRethinkStampSearch(t: String): Boolean { + if (!t.contains(Constants.RETHINKDNS_DOMAIN)) return false + + val split = t.split("/") + split.forEach { + if (it.contains("$RETHINK_STAMP_VERSION:") && isBase64(it)) { + scope.launch(Dispatchers.IO) { onProcessSelectedFileTags(it) } + showToastUiCentered(context, "Blocklists restored", Toast.LENGTH_SHORT) + return true + } + } + return false + } + + fun addQueryToFilters(query: String) { + val current = filters.value + if (current == null) { + val temp = RethinkBlocklistState.Filters() + temp.query = formatQuery(query) + filters.postValue(temp) + return + } + current.query = formatQuery(query) + filters.postValue(current) + } + + fun applyFilter(tag: Any) { + val a = filters.value ?: RethinkBlocklistState.Filters() + when (tag) { + RethinkBlocklistState.BlocklistSelectionFilter.ALL.id -> { + a.filterSelected = RethinkBlocklistState.BlocklistSelectionFilter.ALL + } + + RethinkBlocklistState.BlocklistSelectionFilter.SELECTED.id -> { + a.filterSelected = RethinkBlocklistState.BlocklistSelectionFilter.SELECTED + } + } + filters.postValue(a) + } + + fun openFilterBottomSheet() { + scope.launch { + val tags = withContext(Dispatchers.IO) { + if (blocklistType.isLocal()) { + localFileTagViewModel.allFileTags() + } else { + remoteFileTagViewModel.allFileTags() + } + } + onPlusFilterTagsChanged(tags) + onShowPlusFilterSheetChanged(true) + } + } + + fun toggleRemoteFiletag(filetag: RethinkRemoteFileTag, selected: Boolean) { + scope.launch(Dispatchers.IO) { + filetag.isSelected = selected + RethinkBlocklistManager.updateFiletagRemote(filetag) + val list = RethinkBlocklistManager.getSelectedFileTagsRemote().toSet() + RethinkBlocklistState.updateFileTagList(list) + } + } + + fun toggleLocalFiletag(filetag: RethinkLocalFileTag, selected: Boolean) { + scope.launch(Dispatchers.IO) { + filetag.isSelected = selected + RethinkBlocklistManager.updateFiletagLocal(filetag) + val list = RethinkBlocklistManager.getSelectedFileTagsLocal().toSet() + RethinkBlocklistState.updateFileTagList(list) + } + } + + fun toggleLocalSimplePack(map: LocalBlocklistPacksMap, selected: Boolean) { + scope.launch(Dispatchers.IO) { + RethinkBlocklistManager.updateFiletagsLocal(map.blocklistIds.toSet(), if (selected) 1 else 0) + val list = RethinkBlocklistManager.getSelectedFileTagsLocal().toSet() + RethinkBlocklistState.updateFileTagList(list) + } + } + + fun toggleRemoteSimplePack(map: RemoteBlocklistPacksMap, selected: Boolean) { + scope.launch(Dispatchers.IO) { + RethinkBlocklistManager.updateFiletagsRemote(map.blocklistIds.toSet(), if (selected) 1 else 0) + val list = RethinkBlocklistManager.getSelectedFileTagsRemote().toSet() + RethinkBlocklistState.updateFileTagList(list) + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + if (showDownload) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius3xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(id = R.string.rt_download_desc), + style = MaterialTheme.typography.bodyMedium + ) + if (showRemoteProgress || isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + FilledTonalButton( + onClick = onDownloadBlocklist, + enabled = !isDownloading + ) { + Text(text = stringResource(id = R.string.rt_download)) + } + TextButton(onClick = onCancelDownload) { + Text(text = stringResource(id = R.string.lbl_cancel)) + } + } + } + } + } + + if (showConfigure) { + RethinkTwoOptionSegmentedRow( + leftLabel = stringResource(id = R.string.rt_list_simple_btn_txt), + rightLabel = stringResource(id = R.string.lbl_advanced), + leftSelected = activeView == RethinkBlocklistState.BlocklistView.PACKS, + onLeftClick = { onActiveViewChanged(RethinkBlocklistState.BlocklistView.PACKS) }, + onRightClick = { onActiveViewChanged(RethinkBlocklistState.BlocklistView.ADVANCED) } + ) + + if (activeView == RethinkBlocklistState.BlocklistView.ADVANCED) { + OutlinedTextField( + value = filterState?.query?.replace("%", "") ?: "", + onValueChange = { query -> + if (!isRethinkStampSearch(query)) { + addQueryToFilters(query) + } + }, + label = { Text(text = stringResource(id = R.string.lbl_search)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RethinkFilterChip( + label = stringResource(id = R.string.lbl_all), + selected = filterState?.filterSelected == RethinkBlocklistState.BlocklistSelectionFilter.ALL, + onClick = { applyFilter(RethinkBlocklistState.BlocklistSelectionFilter.ALL.id) } + ) + RethinkFilterChip( + label = stringResource(id = R.string.rt_filter_parent_selected), + selected = filterState?.filterSelected == RethinkBlocklistState.BlocklistSelectionFilter.SELECTED, + onClick = { applyFilter(RethinkBlocklistState.BlocklistSelectionFilter.SELECTED.id) } + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { openFilterBottomSheet() }) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = stringResource(id = R.string.cd_filter) + ) + } + } + Text( + text = filterLabelText.ifEmpty { stringResource(id = R.string.rt_filter_hint) }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (blocklistType.isLocal()) { + val advancedItems = localFileTagViewModel.localFiletags.asFlow().collectAsLazyPagingItems() + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 6.dp) + ) { + items(count = advancedItems.itemCount) { index -> + val item = advancedItems[index] ?: return@items + val previous = if (index > 0) advancedItems.peek(index - 1) else null + val showHeader = previous?.group != item.group + LocalAdvancedBlocklistRow( + filetag = item, + showHeader = showHeader + ) { isSelected -> + toggleLocalFiletag(item, isSelected) + } + } + } + } else { + val advancedItems = remoteFileTagViewModel.remoteFileTags.asFlow().collectAsLazyPagingItems() + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 6.dp) + ) { + items(count = advancedItems.itemCount) { index -> + val item = advancedItems[index] ?: return@items + val previous = if (index > 0) advancedItems.peek(index - 1) else null + val showHeader = previous?.group != item.group + RemoteAdvancedBlocklistRow( + filetag = item, + showHeader = showHeader + ) { isSelected -> + toggleRemoteFiletag(item, isSelected) + } + } + } + } + } else { + if (blocklistType.isLocal()) { + val simpleItems = localBlocklistPacksMapViewModel.simpleTags.asFlow().collectAsLazyPagingItems() + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 6.dp) + ) { + items(count = simpleItems.itemCount) { index -> + val item = simpleItems[index] ?: return@items + val previous = if (index > 0) simpleItems.peek(index - 1) else null + val showHeader = previous?.group != item.group + val valid = !item.pack.contains(DEAD_PACK) && item.pack.isNotEmpty() + if (!valid) return@items + LocalSimpleBlocklistRow( + map = item, + showHeader = showHeader + ) { isSelected -> + toggleLocalSimplePack(item, isSelected) + } + } + } + } else { + val simpleItems = remoteBlocklistPacksMapViewModel.simpleTags.asFlow().collectAsLazyPagingItems() + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 6.dp) + ) { + items(count = simpleItems.itemCount) { index -> + val item = simpleItems[index] ?: return@items + val previous = if (index > 0) simpleItems.peek(index - 1) else null + val showHeader = previous?.group != item.group + val valid = !item.pack.contains(DEAD_PACK) && item.pack.isNotEmpty() + if (!valid) return@items + RemoteSimpleBlocklistRow( + map = item, + showHeader = showHeader + ) { isSelected -> + toggleRemoteSimplePack(item, isSelected) + } + } + } + } + } + + RethinkBottomSheetActionRow( + secondaryText = stringResource(id = R.string.notif_dialog_pause_dialog_negative), + primaryText = stringResource(id = R.string.lbl_apply), + onSecondaryClick = onRevertChanges, + onPrimaryClick = onApplyChanges, + secondaryStyle = RethinkSecondaryActionStyle.TEXT + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RethinkPlusFilterSheet( + fileTags: List, + filters: MutableLiveData, + onDismiss: () -> Unit +) { + val subGroups = remember(fileTags) { + fileTags + .map { it.subg.trim() } + .filter { it.isNotBlank() } + .distinct() + .sorted() + } + val initialFilters = filters.value + var selectedSubgroups by remember { mutableStateOf(initialFilters?.subGroups?.toSet() ?: emptySet()) } + val selectedCount = selectedSubgroups.size + + RethinkModalBottomSheet(onDismissRequest = onDismiss, includeBottomSpacer = true) { + RethinkBottomSheetCard(contentPadding = PaddingValues(Dimensions.cardPadding)) { + Text( + text = stringResource(R.string.bsrf_sub_group_heading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "${stringResource(R.string.rt_filter_parent_selected)}: $selectedCount", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + subGroups.forEach { label -> + val selected = selectedSubgroups.contains(label) + RethinkFilterChip( + label = label, + selected = selected, + onClick = { + selectedSubgroups = toggleSelection(selectedSubgroups, label) + } + ) + } + } + } + + RethinkBottomSheetActionRow( + secondaryText = stringResource(R.string.bsrf_clear_filter), + primaryText = stringResource(R.string.lbl_apply), + onSecondaryClick = { + filters.postValue(RethinkBlocklistState.Filters()) + onDismiss() + }, + onPrimaryClick = { + val updated = initialFilters ?: RethinkBlocklistState.Filters() + updated.subGroups.clear() + updated.subGroups.addAll(selectedSubgroups) + filters.postValue(updated) + onDismiss() + }, + primaryEnabled = true, + secondaryStyle = RethinkSecondaryActionStyle.TEXT + ) + } +} + +private fun toggleSelection(current: Set, value: String): Set { + return if (current.contains(value)) { + current - value + } else { + current + value + } +} + +private suspend fun updateSelectedFileTags( + selectedTags: MutableSet, + blocklistType: RethinkBlocklistManager.RethinkBlocklistType +) { + if (selectedTags.isEmpty()) { + if (blocklistType.isLocal()) { + RethinkBlocklistManager.clearTagsSelectionLocal() + } else { + RethinkBlocklistManager.clearTagsSelectionRemote() + } + return + } + + if (blocklistType.isLocal()) { + RethinkBlocklistManager.clearTagsSelectionLocal() + RethinkBlocklistManager.updateFiletagsLocal(selectedTags, 1) + val list = RethinkBlocklistManager.getSelectedFileTagsLocal().toSet() + RethinkBlocklistState.updateFileTagList(list) + } else { + RethinkBlocklistManager.clearTagsSelectionRemote() + RethinkBlocklistManager.updateFiletagsRemote(selectedTags, 1) + val list = RethinkBlocklistManager.getSelectedFileTagsRemote().toSet() + RethinkBlocklistState.updateFileTagList(list) + } +} + +private fun formatQuery(q: String): String { + return "%$q%" +} + +private fun isBase64(stamp: String): Boolean { + val whitespaceRegex = "\\s" + val pattern = Pattern.compile( + "^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$" + ) + + val versionSplit = stamp.split(":").getOrNull(1) ?: return false + if (versionSplit.isEmpty()) return false + + val result = versionSplit.replace(whitespaceRegex, "") + return pattern.matcher(result).matches() +} + +private fun buildFilterDescription( + filter: RethinkBlocklistState.Filters, + filterDescriptionTemplate: String, + filterDescriptionSubgroupsTemplate: String +): String { + val text = if (filter.subGroups.isEmpty()) { + filterDescriptionTemplate.format(filter.filterSelected.name.lowercase()) + } else { + filterDescriptionSubgroupsTemplate.format( + filter.filterSelected.name.lowercase(), + "", + filter.subGroups + ) + } + return HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsListScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsListScreen.kt new file mode 100644 index 000000000..79185b750 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsListScreen.kt @@ -0,0 +1,323 @@ +/* + * Copyright 2024 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.compose.dns + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.net.doh.Transaction +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.firestack.backend.Backend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private enum class DnsCapabilityDot { + Fast, + Private, + Secure, + Anonymous +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DnsListScreen( + appConfig: AppConfig, + onConfigureOtherDns: (Int) -> Unit, + onConfigureRethinkBasic: (Int) -> Unit, + onBackClick: (() -> Unit)? = null +) { + var selectedType by remember { mutableStateOf(null) } + var selectedWorking by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val (dnsType, isWorking) = withContext(Dispatchers.IO) { + val id = if (appConfig.isSmartDnsEnabled()) Backend.Plus else Backend.Preferred + val state = VpnController.getDnsStatus(id) + val working = if (state == null) false else when (Transaction.Status.fromId(state)) { + Transaction.Status.COMPLETE, Transaction.Status.START -> true + else -> false + } + appConfig.getDnsType() to working + } + selectedType = dnsType + selectedWorking = isWorking + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.lbl_dns_servers), + subtitle = stringResource(R.string.dns_desc), + onBackClick = onBackClick, + ) + } + ) { paddingValues -> + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacingLg + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + // DNS protocol cards grid + item { + SectionHeader(title = stringResource(R.string.lbl_configure)) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + DnsCard( + label = stringResource(R.string.dc_doh), + title = stringResource(R.string.cd_custom_doh_url_name_default), + dots = listOf(DnsCapabilityDot.Private, DnsCapabilityDot.Secure), + type = AppConfig.DnsType.DOH, + selectedType = selectedType, + selectedWorking = selectedWorking, + modifier = Modifier.weight(1f), + onClick = { onConfigureOtherDns(DnsScreenType.DOH.index) } + ) + DnsCard( + label = stringResource(R.string.lbl_dot_abbr), + title = stringResource(R.string.lbl_dot), + dots = listOf(DnsCapabilityDot.Private, DnsCapabilityDot.Secure), + type = AppConfig.DnsType.DOT, + selectedType = selectedType, + selectedWorking = selectedWorking, + modifier = Modifier.weight(1f), + onClick = { onConfigureOtherDns(DnsScreenType.DOT.index) } + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + DnsCard( + label = stringResource(R.string.dc_dns_crypt), + title = stringResource(R.string.cd_dns_crypt_name_default), + dots = listOf(DnsCapabilityDot.Private, DnsCapabilityDot.Secure, DnsCapabilityDot.Anonymous), + type = AppConfig.DnsType.DNSCRYPT, + selectedType = selectedType, + selectedWorking = selectedWorking, + modifier = Modifier.weight(1f), + onClick = { onConfigureOtherDns(DnsScreenType.DNS_CRYPT.index) } + ) + DnsCard( + label = stringResource(R.string.lbl_dp_abbr), + title = stringResource(R.string.lbl_dp), + dots = listOf(DnsCapabilityDot.Fast), + type = AppConfig.DnsType.DNS_PROXY, + selectedType = selectedType, + selectedWorking = selectedWorking, + modifier = Modifier.weight(1f), + onClick = { onConfigureOtherDns(DnsScreenType.DNS_PROXY.index) } + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + DnsCard( + label = stringResource(R.string.lbl_odoh_abbr), + title = stringResource(R.string.lbl_odoh), + dots = listOf(DnsCapabilityDot.Private, DnsCapabilityDot.Secure, DnsCapabilityDot.Anonymous), + type = AppConfig.DnsType.ODOH, + selectedType = selectedType, + selectedWorking = selectedWorking, + modifier = Modifier.weight(1f), + onClick = { onConfigureOtherDns(DnsScreenType.ODOH.index) } + ) + DnsCard( + label = stringResource(R.string.dc_rethink_dns_radio), + title = stringResource(R.string.lbl_rdns), + dots = listOf(DnsCapabilityDot.Fast, DnsCapabilityDot.Private, DnsCapabilityDot.Secure), + type = AppConfig.DnsType.RETHINK_REMOTE, + selectedType = selectedType, + selectedWorking = selectedWorking, + modifier = Modifier.weight(1f), + onClick = { onConfigureRethinkBasic(0) } + ) + } + } + } + + // Legend + item { LegendRow() } + } + } +} + +// ─── DNS Card ───────────────────────────────────────────────────────────────── + +@Composable +private fun DnsCard( + label: String, + title: String, + dots: List, + type: AppConfig.DnsType, + selectedType: AppConfig.DnsType?, + selectedWorking: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val isSelected = selectedType == type + val containerColor = when { + isSelected && selectedWorking -> MaterialTheme.colorScheme.primaryContainer + isSelected && !selectedWorking -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.surfaceContainerLow + } + val labelColor = when { + isSelected && selectedWorking -> MaterialTheme.colorScheme.onPrimaryContainer + isSelected && !selectedWorking -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val titleColor = when { + isSelected && selectedWorking -> MaterialTheme.colorScheme.primary + isSelected && !selectedWorking -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + } + + Surface( + modifier = modifier.aspectRatio(1.24f), + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = containerColor, + tonalElevation = 0.dp, + onClick = onClick + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly + ) { + // Protocol name — small label + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = labelColor, + textAlign = TextAlign.Center + ) + + // Abbreviation — large and prominent + Text( + text = label, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + color = titleColor, + textAlign = TextAlign.Center + ) + + // Capability dots + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + dots.forEach { dot -> + DotIndicator(dot = dot) + } + } + } + } +} + +// ─── Legend Row ─────────────────────────────────────────────────────────────── + +@Composable +private fun LegendRow() { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusLg), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingLg, vertical = Dimensions.spacingMd) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + LegendItem(DnsCapabilityDot.Fast, stringResource(R.string.lbl_fast)) + LegendItem(DnsCapabilityDot.Private, stringResource(R.string.lbl_private)) + LegendItem(DnsCapabilityDot.Secure, stringResource(R.string.lbl_secure)) + LegendItem(DnsCapabilityDot.Anonymous, stringResource(R.string.lbl_anonymous)) + } + } +} + +@Composable +private fun LegendItem(dot: DnsCapabilityDot, label: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + DotIndicator(dot = dot) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun DotIndicator(dot: DnsCapabilityDot, modifier: Modifier = Modifier) { + val color = + when (dot) { + DnsCapabilityDot.Fast -> MaterialTheme.colorScheme.error + DnsCapabilityDot.Private -> MaterialTheme.colorScheme.tertiary + DnsCapabilityDot.Secure -> MaterialTheme.colorScheme.primary + DnsCapabilityDot.Anonymous -> MaterialTheme.colorScheme.secondary + } + + Box( + modifier = + modifier + .size(10.dp) + .background(color = color, shape = CircleShape) + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsScreen.kt new file mode 100644 index 000000000..c3b7ab72a --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsScreen.kt @@ -0,0 +1,702 @@ +/* + * Copyright 2024 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.compose.dns + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.RethinkRadioListItem +import com.celzero.bravedns.ui.compose.theme.RethinkToggleListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.ui.compose.theme.rememberReducedMotion +import kotlinx.coroutines.delay +import java.net.URI +import kotlin.math.roundToInt + +private fun dnsFocusSectionIndex(focusKey: String): Int? { + return when (focusKey) { + "dns_mode", + "dns_mode_system", + "dns_mode_custom", + "dns_mode_rethink", + "dns_mode_smart" -> 0 + "dns_blocklist", + "dns_block_local", + "dns_block_custom_downloader", + "dns_block_periodic_updates" -> 1 + "dns_filtering", + "dns_filter_alg", + "dns_filter_split", + "dns_filter_rules_as_firewall", + "dns_filter_record_types" -> 2 + "dns_advanced", + "dns_advanced_favicon", + "dns_advanced_cache", + "dns_advanced_proxy_dns", + "dns_advanced_undelegated", + "dns_advanced_fallback", + "dns_advanced_leaks" -> 3 + else -> null + } +} + +private fun dnsFocusTarget( + focusKey: String, + isShowSplitDns: Boolean, + isShowBypassDnsBlock: Boolean +): Pair? { + val rowHeight = 82 + val groupStart = 62 + + fun groupOffset(row: Int): Int = groupStart + (rowHeight * row) + + val modeRow = + when (focusKey) { + "dns_mode_system" -> 0 + "dns_mode_custom" -> 1 + "dns_mode_rethink" -> 2 + "dns_mode_smart" -> 3 + else -> null + } + if (modeRow != null) return 0 to groupOffset(modeRow) + + val blockRow = + when (focusKey) { + "dns_block_local" -> 0 + "dns_block_custom_downloader" -> 1 + "dns_block_periodic_updates" -> 2 + else -> null + } + if (blockRow != null) return 1 to groupOffset(blockRow) + + val filteringRow = + when (focusKey) { + "dns_filter_alg" -> 0 + "dns_filter_split" -> if (isShowSplitDns) 1 else null + "dns_filter_rules_as_firewall" -> + when { + isShowSplitDns && isShowBypassDnsBlock -> 2 + !isShowSplitDns && isShowBypassDnsBlock -> 1 + else -> null + } + "dns_filter_record_types" -> + when { + isShowSplitDns && isShowBypassDnsBlock -> 3 + isShowSplitDns || isShowBypassDnsBlock -> 2 + else -> 1 + } + else -> null + } + if (filteringRow != null) return 2 to groupOffset(filteringRow) + + val advancedRow = + when (focusKey) { + "dns_advanced_favicon" -> 0 + "dns_advanced_cache" -> 1 + "dns_advanced_proxy_dns" -> 2 + "dns_advanced_undelegated" -> 3 + "dns_advanced_fallback" -> 4 + "dns_advanced_leaks" -> 5 + else -> null + } + if (advancedRow != null) return 3 to groupOffset(advancedRow) + + return null +} + +private fun connectedDnsDisplayName(raw: String): String { + val name = raw.substringBefore(",").trim() + return if (name.isBlank()) "--" else name +} + +private fun connectedDnsEndpoint(raw: String): String { + return raw.substringAfter(",", "").trim() +} + +private fun endpointHost(value: String): String { + if (value.isBlank()) return "" + return runCatching { URI(value).host.orEmpty() }.getOrDefault("") +} + +private fun connectedProtocolLabel(dnsType: AppConfig.DnsType, endpoint: String): String { + return when (dnsType) { + AppConfig.DnsType.DOH, + AppConfig.DnsType.RETHINK_REMOTE, + AppConfig.DnsType.SMART_DNS -> { + if (endpoint.startsWith("https://", true)) "HTTPS" + else if (endpoint.startsWith("http://", true)) "HTTP" + else "DNS" + } + AppConfig.DnsType.DOT -> "DoT" + AppConfig.DnsType.ODOH -> "ODoH" + AppConfig.DnsType.DNSCRYPT -> "DNSCrypt" + AppConfig.DnsType.DNS_PROXY -> "DNS Proxy" + AppConfig.DnsType.SYSTEM_DNS -> "System DNS" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DnsSettingsScreen( + uiState: DnsSettingsUiState, + initialFocusKey: String? = null, + onRefreshClick: () -> Unit, + onSystemDnsClick: () -> Unit, + onSystemDnsInfoClick: () -> Unit, + onCustomDnsClick: () -> Unit, + onRethinkPlusDnsClick: () -> Unit, + onSmartDnsClick: () -> Unit, + onSmartDnsInfoClick: () -> Unit, + onLocalBlocklistClick: () -> Unit, + onCustomDownloaderChange: (Boolean) -> Unit, + onPeriodicUpdateChange: (Boolean) -> Unit, + onDnsAlgChange: (Boolean) -> Unit, + onSplitDnsChange: (Boolean) -> Unit, + onBypassDnsBlockChange: (Boolean) -> Unit, + onAllowedRecordTypesClick: () -> Unit, + onFavIconChange: (Boolean) -> Unit, + onDnsCacheChange: (Boolean) -> Unit, + onProxyDnsChange: (Boolean) -> Unit, + onUndelegatedDomainsChange: (Boolean) -> Unit, + onFallbackChange: (Boolean) -> Unit, + onPreventLeaksChange: (Boolean) -> Unit +) { + val listState = rememberLazyListState() + val density = LocalDensity.current + val initialFocus = initialFocusKey?.trim().orEmpty() + var pendingFocusKey by rememberSaveable(initialFocus) { mutableStateOf(initialFocus) } + var activeFocusKey by rememberSaveable(initialFocus) { + mutableStateOf(initialFocus.ifBlank { null }) + } + val reducedMotion = rememberReducedMotion() + val refreshRotation by animateFloatAsState( + targetValue = if (uiState.isRefreshing && !reducedMotion) 360f else 0f, + animationSpec = if (uiState.isRefreshing && !reducedMotion) { + infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + } else { + tween(durationMillis = 0) + }, + label = "dnsRefreshRotation" + ) + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + LaunchedEffect(pendingFocusKey, uiState.isShowSplitDns, uiState.isShowBypassDnsBlock) { + val key = pendingFocusKey.trim() + if (key.isBlank()) return@LaunchedEffect + activeFocusKey = key + val target = + dnsFocusTarget( + focusKey = key, + isShowSplitDns = uiState.isShowSplitDns, + isShowBypassDnsBlock = uiState.isShowBypassDnsBlock + ) + if (target != null) { + val (index, offsetDp) = target + val offsetPx = with(density) { offsetDp.dp.toPx().roundToInt() } + listState.animateScrollToItem(index, offsetPx) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + pendingFocusKey = "" + return@LaunchedEffect + } + + val index = dnsFocusSectionIndex(key) + if (index != null) { + listState.animateScrollToItem(index) + delay(750) + if (activeFocusKey == key) { + activeFocusKey = null + } + } + pendingFocusKey = "" + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + RethinkLargeTopBar( + title = stringResource(id = R.string.lbl_dns), + scrollBehavior = scrollBehavior, + actions = { + IconButton(onClick = onRefreshClick) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_refresh_white), + contentDescription = stringResource(id = R.string.rules_load_failure_reload), + modifier = Modifier.rotate(refreshRotation) + ) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacing2xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + item { + SectionHeader(title = stringResource(id = R.string.dc_other_dns_heading)) + RethinkListGroup { + val dnsModeItemCount = 4 + + DnsRadioButtonItem( + title = stringResource(id = R.string.network_dns), + description = stringResource(id = R.string.dns_mode_system_desc), + selected = uiState.isSystemDnsEnabled, + onClick = onSystemDnsClick, + onInfoClick = onSystemDnsInfoClick, + iconId = R.drawable.ic_network, + highlighted = activeFocusKey == "dns_mode_system", + position = cardPositionFor(0, dnsModeItemCount - 1) + ) + DnsRadioButtonItem( + title = stringResource(id = R.string.dc_custom_dns_radio), + description = stringResource(id = R.string.dns_mode_other_desc), + selected = !uiState.isSystemDnsEnabled && !uiState.isRethinkDnsConnected && !uiState.isSmartDnsEnabled, + onClick = onCustomDnsClick, + iconId = R.drawable.ic_filter, + highlighted = activeFocusKey == "dns_mode_custom", + position = cardPositionFor(1, dnsModeItemCount - 1) + ) + DnsRadioButtonItem( + title = stringResource(id = R.string.dc_rethink_dns_radio), + description = stringResource(id = R.string.dns_mode_rethink_desc), + selected = uiState.isRethinkDnsConnected, + onClick = onRethinkPlusDnsClick, + iconId = R.drawable.ic_rethink_plus, + highlighted = activeFocusKey == "dns_mode_rethink", + position = cardPositionFor(2, dnsModeItemCount - 1) + ) + DnsRadioButtonItem( + title = stringResource(id = R.string.smart_dns), + description = stringResource(id = R.string.dns_mode_smart_desc), + selected = uiState.isSmartDnsEnabled, + onClick = onSmartDnsClick, + onInfoClick = onSmartDnsInfoClick, + iconId = R.drawable.ic_dns_cache, + highlighted = activeFocusKey == "dns_mode_smart", + position = cardPositionFor(3, dnsModeItemCount - 1) + ) + } + } + + if (uiState.isRethinkDnsConnected) { + item { + RethinkDnsStatusCard( + connectedDnsRaw = uiState.connectedDnsName, + dnsType = uiState.dnsType, + latency = uiState.dnsLatency, + highlighted = activeFocusKey == "dns_mode_rethink" + ) + } + } + + item { + SectionHeader(title = stringResource(id = R.string.dc_block_heading)) + RethinkListGroup { + RethinkListItem( + headline = stringResource(id = R.string.dc_local_block_heading), + supporting = if (uiState.blocklistEnabled) { + stringResource( + id = R.string.settings_local_blocklist_in_use, + uiState.numberOfLocalBlocklists + ) + } else { + stringResource(id = R.string.dc_local_block_desc_1) + }, + leadingIconPainter = painterResource(id = R.drawable.ic_local_blocklist), + position = CardPosition.First, + highlighted = activeFocusKey == "dns_block_local", + onClick = onLocalBlocklistClick, + trailing = { + Text( + text = if (uiState.blocklistEnabled) { + stringResource(id = R.string.dc_local_block_enabled) + } else { + stringResource(id = R.string.lbl_disabled) + }, + style = MaterialTheme.typography.labelMedium, + color = if (uiState.blocklistEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + fontWeight = FontWeight.Bold + ) + } + ) + ToggleListItem( + title = stringResource(id = R.string.settings_custom_downloader_heading), + description = stringResource(id = R.string.settings_custom_downloader_desc), + iconId = R.drawable.ic_update, + checked = uiState.useCustomDownloadManager, + position = CardPosition.Middle, + highlighted = activeFocusKey == "dns_block_custom_downloader", + onCheckedChange = onCustomDownloaderChange + ) + ToggleListItem( + title = stringResource(id = R.string.dc_check_update_heading), + description = stringResource(id = R.string.dc_check_update_desc_compact), + iconId = R.drawable.ic_blocklist_update_check, + checked = uiState.periodicallyCheckBlocklistUpdate, + position = CardPosition.Last, + highlighted = activeFocusKey == "dns_block_periodic_updates", + onCheckedChange = onPeriodicUpdateChange + ) + } + } + + item { + SectionHeader(title = stringResource(id = R.string.dc_filtering_heading)) + RethinkListGroup { + ToggleListItem( + title = stringResource(id = R.string.cd_dns_alg_heading), + description = stringResource(id = R.string.cd_dns_alg_desc), + iconId = R.drawable.ic_adv_dns_filter, + checked = uiState.enableDnsAlg, + position = CardPosition.First, + highlighted = activeFocusKey == "dns_filter_alg", + onCheckedChange = onDnsAlgChange + ) + if (uiState.isShowSplitDns) { + ToggleListItem( + title = stringResource(id = R.string.cd_split_dns_heading), + description = stringResource(id = R.string.cd_split_dns_desc), + iconId = R.drawable.ic_split_dns, + checked = uiState.splitDns, + position = CardPosition.Middle, + highlighted = activeFocusKey == "dns_filter_split", + onCheckedChange = onSplitDnsChange + ) + } + if (uiState.isShowBypassDnsBlock) { + ToggleListItem( + title = stringResource(id = R.string.cd_treat_dns_rules_firewall_heading), + description = stringResource(id = R.string.cd_treat_dns_rules_firewall_desc), + iconId = R.drawable.ic_dns_rules_as_firewall, + checked = uiState.bypassBlockInDns, + position = CardPosition.Middle, + highlighted = activeFocusKey == "dns_filter_rules_as_firewall", + onCheckedChange = onBypassDnsBlockChange + ) + } + RethinkActionListItem( + title = stringResource(id = R.string.cd_allowed_dns_record_types_heading), + description = stringResource(id = R.string.cd_allowed_dns_record_types_desc), + iconPainter = painterResource(id = R.drawable.ic_allow_dns_records), + position = CardPosition.Last, + highlighted = activeFocusKey == "dns_filter_record_types", + onClick = onAllowedRecordTypesClick, + trailing = { + Text( + text = if (uiState.dnsRecordTypesAutoMode) { + stringResource(id = R.string.dns_record_types_auto_mode_status) + } else { + uiState.allowedDnsRecordTypesSize.toString() + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + ) + } + } + + item { + SectionHeader(title = stringResource(id = R.string.lbl_advanced)) + RethinkListGroup { + ToggleListItem( + title = stringResource(id = R.string.dc_dns_website_heading), + description = stringResource(id = R.string.dc_dns_website_desc), + iconId = R.drawable.ic_fav_icon, + checked = uiState.fetchFavIcon, + position = CardPosition.First, + highlighted = activeFocusKey == "dns_advanced_favicon", + onCheckedChange = onFavIconChange + ) + ToggleListItem( + title = stringResource(id = R.string.dc_setting_dns_cache_heading), + description = stringResource(id = R.string.dc_setting_dns_cache_desc), + iconId = R.drawable.ic_auto_start, + checked = uiState.enableDnsCache, + position = CardPosition.Middle, + highlighted = activeFocusKey == "dns_advanced_cache", + onCheckedChange = onDnsCacheChange + ) + ToggleListItem( + title = stringResource(id = R.string.dc_proxy_dns_heading), + description = stringResource(id = R.string.dc_proxy_dns_desc), + iconId = R.drawable.ic_proxy, + checked = !uiState.proxyDns, + position = CardPosition.Middle, + highlighted = activeFocusKey == "dns_advanced_proxy_dns", + onCheckedChange = { onProxyDnsChange(!it) } + ) + ToggleListItem( + title = stringResource(id = R.string.dc_use_sys_dns_undelegated_heading), + description = stringResource(id = R.string.dc_use_sys_dns_undelegated_desc), + iconId = R.drawable.ic_split_dns, + checked = uiState.useSystemDnsForUndelegatedDomains, + position = CardPosition.Middle, + highlighted = activeFocusKey == "dns_advanced_undelegated", + onCheckedChange = onUndelegatedDomainsChange + ) + ToggleListItem( + title = stringResource(id = R.string.use_fallback_dns_to_bypass), + description = stringResource(id = R.string.use_fallback_dns_to_bypass_desc), + iconId = R.drawable.ic_use_fallback_bypass, + checked = uiState.useFallbackDnsToBypass, + position = CardPosition.Middle, + highlighted = activeFocusKey == "dns_advanced_fallback", + onCheckedChange = onFallbackChange + ) + ToggleListItem( + title = stringResource(id = R.string.dc_dns_leaks_heading), + description = stringResource(id = R.string.dc_dns_leaks_desc), + iconId = R.drawable.ic_prevent_dns_leaks, + checked = uiState.preventDnsLeaks, + position = CardPosition.Last, + highlighted = activeFocusKey == "dns_advanced_leaks", + onCheckedChange = onPreventLeaksChange + ) + } + } + } + } +} + +@Composable +private fun RethinkDnsStatusCard( + connectedDnsRaw: String, + dnsType: AppConfig.DnsType, + latency: String, + highlighted: Boolean +) { + val endpoint = connectedDnsEndpoint(connectedDnsRaw) + val host = endpointHost(endpoint) + val protocol = connectedProtocolLabel(dnsType, endpoint) + val latencyText = latency.removePrefix("(").removeSuffix(")") + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingSm, vertical = Dimensions.spacingXs), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + Surface( + shape = RoundedCornerShape(Dimensions.iconContainerRadius), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Icon( + painter = painterResource(id = R.drawable.ic_rethink_plus), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(8.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(id = R.string.dc_rethink_dns_radio), + style = MaterialTheme.typography.labelMedium, + color = if (highlighted) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + Text( + text = connectedDnsDisplayName(connectedDnsRaw), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (latencyText.isNotBlank()) { + DnsStatusPill( + text = latencyText, + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + + Text( + text = stringResource(id = R.string.rethink_sky_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + DnsStatusPill( + text = protocol, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + if (host.isNotBlank()) { + DnsStatusPill( + text = host, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun DnsStatusPill( + text: String, + containerColor: androidx.compose.ui.graphics.Color, + contentColor: androidx.compose.ui.graphics.Color +) { + Surface( + shape = RoundedCornerShape(999.dp), + color = containerColor + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = contentColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +fun DnsRadioButtonItem( + title: String, + description: String? = null, + selected: Boolean, + onClick: () -> Unit, + iconId: Int, + highlighted: Boolean = false, + onInfoClick: (() -> Unit)? = null, + position: CardPosition = CardPosition.Middle +) { + RethinkRadioListItem( + title = title, + description = description, + selected = selected, + onSelect = onClick, + iconRes = iconId, + position = position, + highlighted = highlighted, + onInfoClick = onInfoClick + ) +} + +@Composable +fun ToggleListItem( + title: String, + description: String, + iconId: Int, + checked: Boolean, + highlighted: Boolean = false, + position: CardPosition = CardPosition.Middle, + onCheckedChange: (Boolean) -> Unit +) { + RethinkToggleListItem( + title = title, + description = description, + checked = checked, + onCheckedChange = onCheckedChange, + iconRes = iconId, + position = position, + highlighted = highlighted + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsViewModel.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsViewModel.kt new file mode 100644 index 000000000..e7ea0c886 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/dns/DnsSettingsViewModel.kt @@ -0,0 +1,194 @@ +package com.celzero.bravedns.ui.compose.dns + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.scheduler.WorkScheduler +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.Utilities.isAtleastR +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class DnsSettingsUiState( + val connectedDnsName: String = "--", + val connectedDnsType: String = "--", + val dnsLatency: String = "", + val dnsType: AppConfig.DnsType = AppConfig.DnsType.DOH, + val isSmartDnsEnabled: Boolean = false, + val isSystemDnsEnabled: Boolean = false, + val isRethinkDnsConnected: Boolean = false, + val fetchFavIcon: Boolean = false, + val preventDnsLeaks: Boolean = false, + val enableDnsAlg: Boolean = false, + val periodicallyCheckBlocklistUpdate: Boolean = false, + val useCustomDownloadManager: Boolean = false, + val enableDnsCache: Boolean = false, + val proxyDns: Boolean = false, + val useSystemDnsForUndelegatedDomains: Boolean = false, + val useFallbackDnsToBypass: Boolean = false, + val blocklistEnabled: Boolean = false, + val numberOfLocalBlocklists: Int = 0, + val bypassBlockInDns: Boolean = false, + val splitDns: Boolean = false, + val dnsRecordTypesAutoMode: Boolean = false, + val allowedDnsRecordTypesSize: Int = 0, + val isShowSplitDns: Boolean = false, + val isShowBypassDnsBlock: Boolean = false, + val isRefreshing: Boolean = false +) + +class DnsSettingsViewModel( + private val persistentState: PersistentState, + private val appConfig: AppConfig, + private val workScheduler: WorkScheduler +) : ViewModel() { + + private val _uiState = MutableStateFlow(DnsSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + updateUiState() + // We might want to observe appConfig.getConnectedDnsObservable() + // but for now we'll just pull it. + } + + fun updateUiState() { + viewModelScope.launch { + val type = appConfig.getDnsType() + val isSmart = appConfig.isSmartDnsEnabled() + val isSystem = appConfig.isSystemDns() + val isRethink = appConfig.isRethinkDnsConnected() + val connectedName = persistentState.connectedDnsName + + _uiState.update { + it.copy( + dnsType = type, + isSmartDnsEnabled = isSmart, + isSystemDnsEnabled = isSystem, + isRethinkDnsConnected = isRethink, + connectedDnsName = connectedName, + fetchFavIcon = persistentState.fetchFavIcon, + preventDnsLeaks = persistentState.preventDnsLeaks, + enableDnsAlg = persistentState.enableDnsAlg, + periodicallyCheckBlocklistUpdate = persistentState.periodicallyCheckBlocklistUpdate, + useCustomDownloadManager = persistentState.useCustomDownloadManager, + enableDnsCache = persistentState.enableDnsCache, + proxyDns = persistentState.proxyDns, + useSystemDnsForUndelegatedDomains = persistentState.useSystemDnsForUndelegatedDomains, + useFallbackDnsToBypass = persistentState.useFallbackDnsToBypass, + blocklistEnabled = persistentState.blocklistEnabled, + numberOfLocalBlocklists = persistentState.numberOfLocalBlocklists, + bypassBlockInDns = persistentState.bypassBlockInDns, + splitDns = persistentState.splitDns, + dnsRecordTypesAutoMode = persistentState.dnsRecordTypesAutoMode, + allowedDnsRecordTypesSize = persistentState.getAllowedDnsRecordTypes().size, + isShowSplitDns = isAtleastR() || persistentState.enableDnsAlg, + isShowBypassDnsBlock = persistentState.enableDnsAlg + ) + } + updateLatency() + } + } + + private fun updateLatency() { + viewModelScope.launch(Dispatchers.IO) { + val p50 = VpnController.p50("preferred") // simplified + if (p50 > 0) { + _uiState.update { it.copy(dnsLatency = "($p50 ms)") } + } else { + _uiState.update { it.copy(dnsLatency = "") } + } + } + } + + fun refreshDns() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + VpnController.refresh() + delay(2000) + _uiState.update { it.copy(isRefreshing = false) } + updateUiState() + } + } + + fun setFavIconEnabled(enabled: Boolean) { + persistentState.fetchFavIcon = enabled + updateUiState() + } + + fun setPreventDnsLeaksEnabled(enabled: Boolean) { + persistentState.preventDnsLeaks = enabled + updateUiState() + } + + fun setDnsAlgEnabled(enabled: Boolean) { + persistentState.enableDnsAlg = enabled + updateUiState() + } + + fun setPeriodicallyCheckBlocklistUpdate(enabled: Boolean) { + persistentState.periodicallyCheckBlocklistUpdate = enabled + if (enabled) { + workScheduler.scheduleBlocklistUpdateCheckJob() + } else { + workScheduler.cancelBlocklistUpdateCheckJob() + } + updateUiState() + } + + fun setUseCustomDownloadManager(enabled: Boolean) { + persistentState.useCustomDownloadManager = enabled + updateUiState() + } + + fun setEnableDnsCache(enabled: Boolean) { + persistentState.enableDnsCache = enabled + updateUiState() + } + + fun setProxyDns(enabled: Boolean) { + persistentState.proxyDns = enabled + updateUiState() + } + + fun setUseSystemDnsForUndelegatedDomains(enabled: Boolean) { + persistentState.useSystemDnsForUndelegatedDomains = enabled + updateUiState() + } + + fun setUseFallbackDnsToBypass(enabled: Boolean) { + persistentState.useFallbackDnsToBypass = enabled + updateUiState() + } + + fun setBypassBlockInDns(enabled: Boolean) { + persistentState.bypassBlockInDns = enabled + updateUiState() + } + + fun setSplitDns(enabled: Boolean) { + persistentState.splitDns = enabled + updateUiState() + } + + fun enableSystemDns() { + viewModelScope.launch(Dispatchers.IO) { + appConfig.enableSystemDns() + withContext(Dispatchers.Main) { updateUiState() } + } + } + + fun enableSmartDns() { + viewModelScope.launch(Dispatchers.IO) { + appConfig.enableSmartDns() + withContext(Dispatchers.Main) { updateUiState() } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/events/EventsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/events/EventsScreen.kt new file mode 100644 index 000000000..b9e80035f --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/events/EventsScreen.kt @@ -0,0 +1,420 @@ +/* + * 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.compose.events + +import androidx.compose.foundation.Image +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.EventCard +import com.celzero.bravedns.adapter.EventCardPosition +import com.celzero.bravedns.adapter.copyEventToClipboard +import com.celzero.bravedns.database.Event +import com.celzero.bravedns.database.EventDao +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkFilterChip +import com.celzero.bravedns.ui.compose.theme.RethinkSearchField +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar +import com.celzero.bravedns.viewmodel.EventsViewModel +import com.celzero.bravedns.viewmodel.EventsViewModel.TopLevelFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun EventsScreen( + viewModel: EventsViewModel, + eventDao: EventDao, + onBackClick: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var query by remember { mutableStateOf(viewModel.getCurrentQuery()) } + var filterSources by remember { mutableStateOf(viewModel.getCurrentSources()) } + var filterSeverity by remember { mutableStateOf(viewModel.getCurrentSeverity()) } + var filterType by remember { mutableStateOf(viewModel.getFilterType()) } + var showDeleteDialog by remember { mutableStateOf(false) } + + val items = viewModel.eventsFlow.collectAsLazyPagingItems() + val isLoading = items.loadState.refresh is LoadState.Loading && items.itemCount == 0 + val showEmpty = items.itemCount == 0 && items.loadState.refresh is LoadState.NotLoading + + fun applyFilter( + sources: Set = filterSources, + severity: Severity? = filterSeverity, + type: TopLevelFilter = filterType + ) { + filterSources = sources + filterSeverity = severity + filterType = type + viewModel.setFilterType(type) + viewModel.setFilter(query, sources, severity) + } + + LaunchedEffect(Unit) { + snapshotFlow { query } + .debounce(QUERY_TEXT_DELAY) + .distinctUntilChanged() + .collect { value -> + viewModel.setFilter(value, filterSources, filterSeverity) + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + RethinkTopBar( + title = stringResource(id = R.string.event_logs_title), + onBackClick = onBackClick, + actions = { + IconButton(onClick = { items.refresh() }) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(id = R.string.cd_refresh) + ) + } + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(id = R.string.lbl_delete) + ) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column(modifier = Modifier.fillMaxSize()) { + EventControlsCard( + query = query, + onQueryChange = { query = it }, + filterType = filterType, + filterSeverity = filterSeverity, + filterSources = filterSources, + onClearQuery = { query = "" }, + onAllClick = { applyFilter(emptySet(), null, TopLevelFilter.ALL) }, + onSeverityModeClick = { applyFilter(emptySet(), filterSeverity, TopLevelFilter.SEVERITY) }, + onSourceModeClick = { applyFilter(filterSources, null, TopLevelFilter.SOURCE) }, + onSeverityClick = { applyFilter(emptySet(), it, TopLevelFilter.SEVERITY) }, + onSourceToggle = { source -> + val updated = + if (filterSources.contains(source)) { + filterSources - source + } else { + filterSources + source + } + applyFilter(updated, null, TopLevelFilter.SOURCE) + } + ) + + EventsList( + modifier = Modifier.weight(1f), + items = items, + query = query, + onCopy = { copyEventToClipboard(context, it) } + ) + } + + when { + isLoading -> LoadingState() + showEmpty -> EmptyState() + } + } + } + + if (showDeleteDialog) { + RethinkConfirmDialog( + onDismissRequest = { showDeleteDialog = false }, + title = stringResource(id = R.string.ada_delete_logs_dialog_title), + message = stringResource(id = R.string.ada_delete_logs_dialog_desc), + confirmText = stringResource(id = R.string.lbl_delete), + dismissText = stringResource(id = R.string.lbl_cancel), + onConfirm = { + showDeleteDialog = false + scope.launch(Dispatchers.IO) { eventDao.deleteAll() } + items.refresh() + }, + onDismiss = { showDeleteDialog = false }, + isConfirmDestructive = true + ) + } +} + +@Composable +private fun EventControlsCard( + query: String, + onQueryChange: (String) -> Unit, + filterType: TopLevelFilter, + filterSeverity: Severity?, + filterSources: Set, + onClearQuery: () -> Unit, + onAllClick: () -> Unit, + onSeverityModeClick: () -> Unit, + onSourceModeClick: () -> Unit, + onSeverityClick: (Severity) -> Unit, + onSourceToggle: (EventSource) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm + ) + ) { + RethinkSearchField( + query = query, + onQueryChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(id = R.string.search_event_logs), + onClearQuery = onClearQuery, + clearQueryContentDescription = stringResource(id = R.string.cd_clear_search), + shape = RoundedCornerShape(Dimensions.cornerRadiusLg), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) + + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + EventFilterChip( + label = stringResource(id = R.string.lbl_all), + selected = filterType == TopLevelFilter.ALL, + onClick = onAllClick + ) + EventFilterChip( + label = "Severity", + selected = filterType == TopLevelFilter.SEVERITY, + onClick = onSeverityModeClick + ) + EventFilterChip( + label = stringResource(id = R.string.events_filter_source), + selected = filterType == TopLevelFilter.SOURCE, + onClick = onSourceModeClick + ) + } + + when (filterType) { + TopLevelFilter.SEVERITY -> { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + EventFilterChip( + label = stringResource(id = R.string.events_severity_low), + selected = filterSeverity == Severity.LOW, + onClick = { onSeverityClick(Severity.LOW) } + ) + EventFilterChip( + label = stringResource(id = R.string.events_severity_medium), + selected = filterSeverity == Severity.MEDIUM, + onClick = { onSeverityClick(Severity.MEDIUM) } + ) + EventFilterChip( + label = stringResource(id = R.string.events_severity_high), + selected = filterSeverity == Severity.HIGH, + onClick = { onSeverityClick(Severity.HIGH) } + ) + EventFilterChip( + label = stringResource(id = R.string.events_severity_critical), + selected = filterSeverity == Severity.CRITICAL, + onClick = { onSeverityClick(Severity.CRITICAL) } + ) + } + } + + TopLevelFilter.SOURCE -> { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + EventSource.entries.forEach { source -> + EventFilterChip( + label = source.name.lowercase().replace('_', ' ').replaceFirstChar { it.titlecase() }, + selected = filterSources.contains(source), + onClick = { onSourceToggle(source) } + ) + } + } + } + + TopLevelFilter.ALL -> Unit + } + } +} + +@Composable +private fun EventFilterChip( + label: String, + selected: Boolean, + onClick: () -> Unit +) { + RethinkFilterChip( + label = label, + selected = selected, + onClick = onClick, + selectedLabelWeight = FontWeight.Medium, + defaultLabelWeight = FontWeight.Medium + ) +} + +@Composable +private fun EventsList( + modifier: Modifier = Modifier, + items: androidx.paging.compose.LazyPagingItems, + query: String, + onCopy: (String) -> Unit +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(count = items.itemCount, key = { index -> items[index]?.id ?: index }) { index -> + val item = items[index] ?: return@items + val position = when { + items.itemCount == 1 -> EventCardPosition.Single + index == 0 -> EventCardPosition.First + index == items.itemCount - 1 -> EventCardPosition.Last + else -> EventCardPosition.Middle + } + EventCard(event = item, onCopy = onCopy, query = query, position = position) + } + } +} + +@Composable +private fun LoadingState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + } +} + +@Composable +private fun EmptyState() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.size(96.dp) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = R.drawable.ic_event_note), + contentDescription = null, + modifier = Modifier.size(52.dp) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.no_events_recorded), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.no_events_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + +private const val QUERY_TEXT_DELAY: Long = 350 diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListModels.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListModels.kt new file mode 100644 index 000000000..9b8db2636 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListModels.kt @@ -0,0 +1,136 @@ +/* + * 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.compose.firewall + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R +import com.celzero.bravedns.service.FirewallManager.ConnectionStatus +import com.celzero.bravedns.service.FirewallManager.FirewallStatus + +enum class BlockType { + UNMETER, + METER, + BYPASS, + LOCKDOWN, + EXCLUDE, + BYPASS_DNS_FIREWALL +} + +enum class TopLevelFilter(val id: Int) { + ALL(0), + INSTALLED(1), + SYSTEM(2); + + @Composable + fun getLabel(): String { + return when (this) { + ALL -> "" + INSTALLED -> stringResource(id = R.string.fapps_filter_parent_installed) + SYSTEM -> stringResource(id = R.string.fapps_filter_parent_system) + } + } +} + +@Suppress("MagicNumber") +enum class FirewallFilter(val id: Int) { + ALL(0), + ALLOWED(1), + BLOCKED(2), + BLOCKED_WIFI(3), + BLOCKED_MOBILE_DATA(4), + BYPASS(5), + EXCLUDED(6), + LOCKDOWN(7); + + fun getFilter(): Set { + return when (this) { + ALL -> FirewallStatus.values().map { it.id }.toSet() + setOf(0, 1) + ALLOWED -> setOf(FirewallStatus.NONE.id) + BLOCKED_WIFI -> setOf(FirewallStatus.NONE.id) + BLOCKED_MOBILE_DATA -> setOf(FirewallStatus.NONE.id) + BLOCKED -> setOf(FirewallStatus.NONE.id) + BYPASS -> setOf(FirewallStatus.BYPASS_UNIVERSAL.id, FirewallStatus.BYPASS_DNS_FIREWALL.id) + EXCLUDED -> setOf(FirewallStatus.EXCLUDE.id) + LOCKDOWN -> setOf(FirewallStatus.ISOLATE.id) + } + } + + fun getConnectionStatusFilter(): Set { + return when (this) { + ALL -> ConnectionStatus.values().map { it.id }.toSet() + ALLOWED -> setOf(ConnectionStatus.ALLOW.id) + BLOCKED_WIFI -> setOf(ConnectionStatus.UNMETERED.id) + BLOCKED_MOBILE_DATA -> setOf(ConnectionStatus.METERED.id) + BLOCKED -> + setOf( + ConnectionStatus.UNMETERED.id, + ConnectionStatus.METERED.id, + ConnectionStatus.BOTH.id + ) + BYPASS -> ConnectionStatus.values().map { it.id }.toSet() + EXCLUDED -> ConnectionStatus.values().map { it.id }.toSet() + LOCKDOWN -> ConnectionStatus.values().map { it.id }.toSet() + } + } + + @Composable + fun getLabel(): String { + return when (this) { + ALL -> stringResource(id = R.string.lbl_all) + ALLOWED -> stringResource(id = R.string.lbl_allowed) + BLOCKED_WIFI -> + stringResource( + R.string.two_argument_colon, + stringResource(id = R.string.lbl_blocked), + stringResource(id = R.string.firewall_rule_block_unmetered) + ) + BLOCKED_MOBILE_DATA -> + stringResource( + R.string.two_argument_colon, + stringResource(id = R.string.lbl_blocked), + stringResource(id = R.string.firewall_rule_block_metered) + ) + BLOCKED -> stringResource(id = R.string.lbl_blocked) + BYPASS -> stringResource(id = R.string.fapps_firewall_filter_bypass_universal) + EXCLUDED -> stringResource(id = R.string.fapps_firewall_filter_excluded) + LOCKDOWN -> stringResource(id = R.string.fapps_firewall_filter_isolate) + } + } + + companion object { + fun filter(id: Int): FirewallFilter { + return when (id) { + ALL.id -> ALL + ALLOWED.id -> ALLOWED + BLOCKED_WIFI.id -> BLOCKED_WIFI + BLOCKED_MOBILE_DATA.id -> BLOCKED_MOBILE_DATA + BLOCKED.id -> BLOCKED + BYPASS.id -> BYPASS + EXCLUDED.id -> EXCLUDED + LOCKDOWN.id -> LOCKDOWN + else -> ALL + } + } + } +} + +data class Filters( + val categoryFilters: Set = emptySet(), + val topLevelFilter: TopLevelFilter = TopLevelFilter.INSTALLED, + val firewallFilter: FirewallFilter = FirewallFilter.ALL, + val searchString: String = "" +) diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListScreen.kt new file mode 100644 index 000000000..70bd1a66a --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/AppListScreen.kt @@ -0,0 +1,1295 @@ +/* + * Copyright 2024 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.compose.firewall +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.FirewallAppRow +import com.celzero.bravedns.adapter.FirewallRowPosition +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkModalBottomSheet +import com.celzero.bravedns.ui.compose.theme.RethinkSearchField +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.viewmodel.AppInfoViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Locale +import androidx.compose.foundation.ExperimentalFoundationApi +import com.celzero.bravedns.database.AppInfo + +private const val ANIMATION_DURATION = 750 +private val FAST_SCROLLER_LIST_END_PADDING = 32.dp + +private fun performSelectionHaptic(hapticFeedback: HapticFeedback) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppListScreen( + viewModel: AppInfoViewModel, + eventLogger: EventLogger, + queryText: String, + selectedFirewallFilter: FirewallFilter, + isRefreshing: Boolean, + bulkWifi: Boolean, + bulkMobile: Boolean, + bulkBypass: Boolean, + bulkBypassDns: Boolean, + bulkExclude: Boolean, + bulkLockdown: Boolean, + showBulkUpdateDialog: Boolean, + bulkDialogTitle: String, + bulkDialogMessage: String, + bulkDialogType: BlockType?, + showInfoDialog: Boolean, + currentFilters: Filters?, + onQueryChange: (String) -> Unit, + onRefreshClick: () -> Unit, + onFilterApply: (Filters) -> Unit, + onFilterClear: (Filters) -> Unit, + onFirewallFilterClick: (FirewallFilter) -> Unit, + onBulkDialogConfirm: (BlockType) -> Unit, + onBulkDialogDismiss: () -> Unit, + onInfoDialogDismiss: () -> Unit, + onShowInfoDialog: () -> Unit, + onShowBulkDialog: (BlockType) -> Unit, + onBypassDnsTooltip: () -> Unit, + showBypassToolTip: Boolean, + onAppClick: ((Int) -> Unit)? = null, + onBackClick: (() -> Unit)? = null +) { + val items by viewModel.appInfo.collectAsState() + val refreshRotation = rememberInfiniteTransition(label = "refresh").animateFloat( + initialValue = 0f, + targetValue = if (isRefreshing) 360f else 0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = ANIMATION_DURATION, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "refreshRotation" + ) + + var showFilterSheet by remember { mutableStateOf(false) } + var showRulesSheet by remember { mutableStateOf(false) } + val effectiveFilters = currentFilters ?: Filters(topLevelFilter = TopLevelFilter.INSTALLED) + val hasActiveFilters = + effectiveFilters.topLevelFilter != TopLevelFilter.INSTALLED || + effectiveFilters.categoryFilters.isNotEmpty() || + selectedFirewallFilter != FirewallFilter.ALL + val shouldConsumeBackForSearch = + queryText.isNotBlank() && + !showFilterSheet && + !showRulesSheet && + !showBulkUpdateDialog && + !showInfoDialog + + BackHandler(enabled = shouldConsumeBackForSearch) { + onQueryChange("") + } + + if (showFilterSheet) { + FirewallAppFilterSheet( + initialFilters = currentFilters, + firewallFilter = selectedFirewallFilter, + onDismiss = { showFilterSheet = false }, + onApply = onFilterApply, + onClear = onFilterClear + ) + } + + if (showRulesSheet) { + FirewallBulkActionsSheet( + appliedAppsCount = items.size, + bulkWifi = bulkWifi, + bulkMobile = bulkMobile, + bulkBypass = bulkBypass, + bulkBypassDns = bulkBypassDns, + bulkExclude = bulkExclude, + bulkLockdown = bulkLockdown, + showBypassToolTip = showBypassToolTip, + onDismiss = { showRulesSheet = false }, + onBypassDnsTooltip = onBypassDnsTooltip, + onAction = { action -> + showRulesSheet = false + onShowBulkDialog(action) + } + ) + } + + if (showBulkUpdateDialog && bulkDialogType != null) { + RethinkConfirmDialog( + onDismissRequest = onBulkDialogDismiss, + title = bulkDialogTitle, + message = bulkDialogMessage, + confirmText = stringResource(R.string.lbl_apply), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { onBulkDialogConfirm(bulkDialogType) }, + onDismiss = onBulkDialogDismiss + ) + } + + if (showInfoDialog) { + RethinkConfirmDialog( + onDismissRequest = onInfoDialogDismiss, + text = { FirewallInfoDialogContent() }, + confirmText = stringResource(R.string.fapps_info_dialog_positive_btn), + onConfirm = onInfoDialogDismiss + ) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + RethinkTopBar( + title = stringResource(R.string.apps_info_title), + onBackClick = onBackClick, + actions = { + IconButton( + onClick = onRefreshClick, + enabled = !isRefreshing + ) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.cd_refresh), + modifier = Modifier.rotate(if (isRefreshing) refreshRotation.value else 0f) + ) + } + IconButton(onClick = { showFilterSheet = true }) { + Box { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = stringResource(R.string.cd_filter) + ) + if (hasActiveFilters) { + Box( + modifier = Modifier + .size(8.dp) + .align(Alignment.TopEnd) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) + } + } + } + IconButton(onClick = { showRulesSheet = true }) { + Icon( + imageVector = Icons.Rounded.Tune, + contentDescription = stringResource(R.string.lbl_rules) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + AppListControlDeck( + queryText = queryText, + displayedAppsCount = items.size, + selectedFirewallFilter = selectedFirewallFilter, + onQueryChange = onQueryChange, + onFirewallFilterClick = onFirewallFilterClick + ) + + AppListRecycler( + modifier = Modifier.weight(1f), + items = items, + eventLogger = eventLogger, + searchQuery = queryText, + onAppClick = onAppClick + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun AppListControlDeck( + queryText: String, + displayedAppsCount: Int, + selectedFirewallFilter: FirewallFilter, + onQueryChange: (String) -> Unit, + onFirewallFilterClick: (FirewallFilter) -> Unit +) { + val hapticFeedback = LocalHapticFeedback.current + val quickFilters = listOf( + FirewallFilter.ALL, + FirewallFilter.ALLOWED, + FirewallFilter.BLOCKED, + FirewallFilter.BYPASS, + FirewallFilter.EXCLUDED, + FirewallFilter.LOCKDOWN + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal) + .padding(bottom = Dimensions.spacingXs), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + RethinkSearchField( + query = queryText, + onQueryChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(R.string.search_apps_count_placeholder, displayedAppsCount), + onClearQuery = { onQueryChange("") }, + clearQueryContentDescription = stringResource(R.string.cd_clear_search), + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + iconSize = 20.dp, + trailingIconSize = 16.dp, + trailingIconButtonSize = 32.dp + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) + ) { + quickFilters.forEachIndexed { index, filter -> + val selected = selectedFirewallFilter == filter + ToggleButton( + checked = selected, + onCheckedChange = { checked -> + if (checked && !selected) { + performSelectionHaptic(hapticFeedback) + onFirewallFilterClick(filter) + } + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + quickFilters.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.94f), + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier.semantics { role = Role.RadioButton } + ) { + FirewallFilterIcon(filter = filter, selected = selected, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + Text( + text = filter.getLabel(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ConnectedToggleButtonRow( + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable (option: T, selected: Boolean) -> Unit)? = null, + label: @Composable (option: T, selected: Boolean) -> Unit +) { + val hapticFeedback = LocalHapticFeedback.current + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) + ) { + options.forEachIndexed { index, option -> + val isSelected = option == selectedOption + ToggleButton( + checked = isSelected, + onCheckedChange = { checked -> + if (checked && !isSelected) { + performSelectionHaptic(hapticFeedback) + onOptionSelected(option) + } + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.94f), + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier + .weight(1f) + .semantics { role = Role.RadioButton } + ) { + icon?.invoke(option, isSelected) + if (icon != null) { + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + } + label(option, isSelected) + } + } + } +} + +@Composable +private fun SheetSectionCard( + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.92f), + content: @Composable () -> Unit +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = shape, + color = containerColor, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + content() + } + } +} + +@Composable +private fun SheetSectionTitle( + title: String, + count: Int = 0 +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + if (count > 0) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.84f) + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun FirewallBulkActionsSheet( + appliedAppsCount: Int, + bulkWifi: Boolean, + bulkMobile: Boolean, + bulkBypass: Boolean, + bulkBypassDns: Boolean, + bulkExclude: Boolean, + bulkLockdown: Boolean, + showBypassToolTip: Boolean, + onDismiss: () -> Unit, + onBypassDnsTooltip: () -> Unit, + onAction: (BlockType) -> Unit +) { + val ruleItems = listOf( + RuleActionItem( + type = BlockType.UNMETER, + titleRes = R.string.ada_app_unmetered, + descriptionRes = R.string.fapps_info_unmetered_msg, + iconRes = if (bulkWifi) R.drawable.ic_firewall_wifi_off else R.drawable.ic_firewall_wifi_on_grey, + selected = bulkWifi + ), + RuleActionItem( + type = BlockType.METER, + titleRes = R.string.lbl_mobile_data, + descriptionRes = R.string.fapps_info_metered_msg, + iconRes = if (bulkMobile) R.drawable.ic_firewall_data_off else R.drawable.ic_firewall_data_on_grey, + selected = bulkMobile + ), + RuleActionItem( + type = BlockType.BYPASS, + titleRes = R.string.fapps_firewall_filter_bypass_universal, + descriptionRes = R.string.fapps_info_bypass_msg, + iconRes = if (bulkBypass) R.drawable.ic_firewall_bypass_on else R.drawable.ic_firewall_bypass_off, + selected = bulkBypass + ), + RuleActionItem( + type = BlockType.BYPASS_DNS_FIREWALL, + titleRes = R.string.bypass_dns_firewall, + descriptionRes = R.string.fapps_info_bypass_dns_firewall_msg, + iconRes = if (bulkBypassDns) R.drawable.ic_bypass_dns_firewall_on else R.drawable.ic_bypass_dns_firewall_off, + selected = bulkBypassDns + ), + RuleActionItem( + type = BlockType.EXCLUDE, + titleRes = R.string.fapps_firewall_filter_excluded, + descriptionRes = R.string.fapps_info_exclude_msg, + iconRes = if (bulkExclude) R.drawable.ic_firewall_exclude_on else R.drawable.ic_firewall_exclude_off, + selected = bulkExclude + ), + RuleActionItem( + type = BlockType.LOCKDOWN, + titleRes = R.string.fapps_firewall_filter_isolate, + descriptionRes = R.string.fapps_info_isolate_msg, + iconRes = if (bulkLockdown) R.drawable.ic_firewall_lockdown_on else R.drawable.ic_firewall_lockdown_off, + selected = bulkLockdown + ) + ) + + RethinkModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + contentPadding = PaddingValues(0.dp), + verticalSpacing = 0.dp, + includeBottomSpacer = true + ) { + Column( + modifier = Modifier + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + RulesSheetHeader(appliedAppsCount = appliedAppsCount) + Text( + text = stringResource(R.string.fapps_info_dialog_message), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RethinkListGroup { + ruleItems.forEachIndexed { index, item -> + RuleActionListItem( + item = item, + position = cardPositionFor(index, ruleItems.lastIndex), + onClick = { + if (item.type == BlockType.BYPASS_DNS_FIREWALL && showBypassToolTip) { + onBypassDnsTooltip() + } else { + onAction(item.type) + } + } + ) + } + } + } + } +} + +@Composable +private fun RulesSheetHeader(appliedAppsCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.lbl_rules), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusSmMd), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.84f) + ) { + Text( + text = stringResource( + R.string.two_argument_colon, + stringResource(R.string.lbl_apply), + appliedAppsCount.toString() + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + } +} + +private data class RuleActionItem( + val type: BlockType, + val titleRes: Int, + val descriptionRes: Int, + val iconRes: Int, + val selected: Boolean +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun RuleActionListItem( + item: RuleActionItem, + position: CardPosition, + onClick: () -> Unit +) { + val accentColor = + when (item.type) { + BlockType.LOCKDOWN -> MaterialTheme.colorScheme.error + BlockType.EXCLUDE -> MaterialTheme.colorScheme.secondary + BlockType.BYPASS, + BlockType.BYPASS_DNS_FIREWALL -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + } + + RethinkActionListItem( + title = stringResource(item.titleRes), + description = stringResource(item.descriptionRes), + iconRes = item.iconRes, + accentColor = accentColor, + position = position, + highlighted = item.selected, + trailing = { RuleSelectionBadge(selected = item.selected) }, + onClick = onClick + ) +} + +@Composable +private fun RuleSelectionBadge(selected: Boolean) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = + if (selected) { + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.92f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + ) { + Text( + text = + stringResource( + if (selected) R.string.lbbs_enabled else R.string.lbl_disabled + ), + style = MaterialTheme.typography.labelMedium, + color = + if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp) + ) + } +} + +@Composable +private fun AppListRecycler( + modifier: Modifier = Modifier, + items: List, + eventLogger: EventLogger, + searchQuery: String, + onAppClick: ((Int) -> Unit)? = null +) { + val listState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + + if (items.isEmpty()) { + Box( + modifier = modifier + .fillMaxSize() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.fapps_empty_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.fapps_empty_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + return + } + + val showFastScroller = items.size >= 8 + val fastScrollerKeys = remember(items) { buildFastScrollerIndexKeys(items) } + val density = LocalDensity.current + val navBarBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + + Box(modifier = modifier.fillMaxSize()) { + AppListContent( + loadedItems = items, + listState = listState, + eventLogger = eventLogger, + searchQuery = searchQuery, + showFastScroller = showFastScroller, + onAppClick = onAppClick + ) + + if (showFastScroller) { + IndexedFastScroller( + items = fastScrollerKeys, + listState = listState, + getIndexKey = { it }, + scrollItemOffset = 2, + minItemCount = 8, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = Dimensions.spacingSm, bottom = navBarBottomInset) + .padding(end = 2.dp), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AppListContent( + loadedItems: List, + listState: androidx.compose.foundation.lazy.LazyListState, + eventLogger: EventLogger, + searchQuery: String, + showFastScroller: Boolean, + onAppClick: ((Int) -> Unit)? = null +) { + val density = LocalDensity.current + val navBarBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal + if (showFastScroller) FAST_SCROLLER_LIST_END_PADDING else 8.dp, + top = Dimensions.spacingXs, + bottom = Dimensions.screenPaddingHorizontal + navBarBottomInset + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + loadedItems.forEachIndexed { index, item -> + val currentInitial = appInitial(item.appName, item.packageName) + val previousItem = loadedItems.getOrNull(index - 1) + val previousInitial = + previousItem?.let { appInitial(it.appName, it.packageName) } + val nextItem = loadedItems.getOrNull(index + 1) + val nextInitial = + nextItem?.let { appInitial(it.appName, it.packageName) } + val isFirstInGroup = previousInitial == null || currentInitial != previousInitial + val isLastInGroup = nextInitial == null || currentInitial != nextInitial + + val rowPosition = + when { + isFirstInGroup && isLastInGroup -> FirewallRowPosition.Single + isFirstInGroup -> FirewallRowPosition.First + isLastInGroup -> FirewallRowPosition.Last + else -> FirewallRowPosition.Middle + } + + if (index == 0 || isFirstInGroup) { + stickyHeader(key = "header_$currentInitial") { + AppListLetterHeader(letter = currentInitial) + } + } + + item(key = "app_${item.uid}_${item.packageName}") { + FirewallAppRow( + appInfo = item, + eventLogger = eventLogger, + searchQuery = searchQuery, + rowPosition = rowPosition, + onAppClick = onAppClick + ) + } + } + } +} + +@Composable +private fun AppListLetterHeader(letter: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(start = 20.dp, top = 20.dp, bottom = 4.dp) + ) { + Text( + text = letter, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } +} + +private fun appInitial(appName: String, packageName: String): String { + val source = appName.ifBlank { packageName }.trim() + if (source.isEmpty()) return "#" + val first = source.first() + return if (first.isLetter()) { + first.uppercaseChar().toString() + } else { + source.first().toString().uppercase(Locale.getDefault()) + } +} + +private fun buildFastScrollerIndexKeys(loadedItems: List): List { + val indexKeys = mutableListOf() + var previousInitial: String? = null + + loadedItems.forEach { item -> + val initial = appInitial(item.appName, item.packageName) + if (initial != previousInitial) { + indexKeys.add(initial) // sticky header index + previousInitial = initial + } + indexKeys.add(item.appName.ifBlank { item.packageName }) // app row index + } + + return indexKeys +} + +@Composable +private fun FirewallInfoDialogContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Dimensions.spacingLg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Text( + text = stringResource(R.string.fapps_info_dialog_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + HorizontalDivider( + thickness = Dimensions.dividerThickness, + color = MaterialTheme.colorScheme.outlineVariant + ) + InfoRow(R.drawable.ic_firewall_wifi_on_grey, stringResource(R.string.fapps_info_unmetered_msg)) + InfoRow(R.drawable.ic_firewall_data_on_grey, stringResource(R.string.fapps_info_metered_msg)) + InfoRow(R.drawable.ic_firewall_bypass_off, stringResource(R.string.fapps_info_bypass_msg)) + InfoRow(R.drawable.ic_bypass_dns_firewall_off, stringResource(R.string.fapps_info_bypass_dns_firewall_msg)) + InfoRow(R.drawable.ic_firewall_exclude_off, stringResource(R.string.fapps_info_exclude_msg)) + InfoRow(R.drawable.ic_firewall_lockdown_off, stringResource(R.string.fapps_info_isolate_msg)) + } +} + +@Composable +private fun InfoRow(icon: Int, text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(36.dp) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun FirewallAppFilterSheet( + initialFilters: Filters?, + firewallFilter: FirewallFilter, + onDismiss: () -> Unit, + onApply: (Filters) -> Unit, + onClear: (Filters) -> Unit +) { + val hapticFeedback = LocalHapticFeedback.current + var topFilter by remember { mutableStateOf(initialFilters?.topLevelFilter ?: TopLevelFilter.INSTALLED) } + var selectedFirewallFilter by remember { mutableStateOf(firewallFilter) } + val searchString = initialFilters?.searchString.orEmpty() + val selectedCategories = remember { + mutableStateListOf().apply { + if (initialFilters != null) addAll(initialFilters.categoryFilters) + } + } + val categories = remember { mutableStateListOf() } + val statusOptions = listOf( + FirewallFilter.ALL, + FirewallFilter.ALLOWED, + FirewallFilter.BLOCKED, + FirewallFilter.BYPASS, + FirewallFilter.EXCLUDED, + FirewallFilter.LOCKDOWN, + FirewallFilter.BLOCKED_WIFI, + FirewallFilter.BLOCKED_MOBILE_DATA + ) + + LaunchedEffect(topFilter, initialFilters?.categoryFilters) { + val result = fetchCategories(topFilter) + categories.clear() + categories.addAll(result) + selectedCategories.retainAll(result.toSet()) + } + + fun currentFilters( + top: TopLevelFilter = topFilter, + status: FirewallFilter = selectedFirewallFilter, + categoryFilters: Set = selectedCategories.toSet() + ): Filters { + return Filters( + categoryFilters = categoryFilters, + topLevelFilter = top, + firewallFilter = status, + searchString = searchString + ) + } + + val isDefaultSelection = + topFilter == TopLevelFilter.INSTALLED && + selectedFirewallFilter == FirewallFilter.ALL && + selectedCategories.isEmpty() + + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(0.96f), + shape = RoundedCornerShape(Dimensions.cornerRadiusLg), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 520.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding( + start = 0.dp, + end = 0.dp, + top = 0.dp, + bottom = 0.dp + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { + performSelectionHaptic(hapticFeedback) + selectedCategories.clear() + topFilter = TopLevelFilter.INSTALLED + selectedFirewallFilter = FirewallFilter.ALL + onClear( + Filters( + topLevelFilter = TopLevelFilter.INSTALLED, + firewallFilter = FirewallFilter.ALL, + searchString = searchString + ) + ) + }, + enabled = !isDefaultSelection, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.52f) + ) + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.fapps_filter_clear_btn), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + + SheetSectionCard(shape = RoundedCornerShape(0.dp)) { + SheetSectionTitle(title = stringResource(R.string.lbl_view)) + ConnectedToggleButtonRow( + options = listOf(TopLevelFilter.INSTALLED, TopLevelFilter.SYSTEM, TopLevelFilter.ALL), + selectedOption = topFilter, + onOptionSelected = { + topFilter = it + selectedCategories.clear() + onApply(currentFilters(top = it, categoryFilters = emptySet())) + }, + modifier = Modifier.fillMaxWidth(), + label = { option, selected -> + Text( + text = + when (option) { + TopLevelFilter.INSTALLED -> stringResource(R.string.fapps_filter_parent_installed) + TopLevelFilter.SYSTEM -> stringResource(R.string.fapps_filter_parent_system) + TopLevelFilter.ALL -> stringResource(R.string.lbl_all) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } + ) + } + + SheetSectionCard(shape = RoundedCornerShape(0.dp)) { + SheetSectionTitle(title = stringResource(R.string.lbl_status)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + statusOptions.forEach { option -> + val selected = selectedFirewallFilter == option + ToggleButton( + checked = selected, + onCheckedChange = { checked -> + if (checked && !selected) { + performSelectionHaptic(hapticFeedback) + selectedFirewallFilter = option + onApply(currentFilters(status = option)) + } + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.94f), + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier + .clip(RoundedCornerShape(Dimensions.cornerRadiusMd)) + .sizeIn(minHeight = Dimensions.touchTargetSm) + .semantics { role = Role.RadioButton } + ) { + FirewallFilterIcon( + filter = option, + selected = selected, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + Text( + text = option.getLabel(), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } + } + } + } + + SheetSectionCard(shape = RoundedCornerShape(0.dp)) { + SheetSectionTitle( + title = stringResource(R.string.fapps_filter_categories_heading), + count = selectedCategories.size + ) + + if (categories.isEmpty()) { + Text( + text = stringResource(R.string.fapps_empty_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 6.dp) + ) + } else { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + modifier = Modifier.fillMaxWidth() + ) { + categories.forEach { category -> + val isSelected = selectedCategories.contains(category) + ToggleButton( + checked = isSelected, + onCheckedChange = { checked -> + performSelectionHaptic(hapticFeedback) + if (checked) { + if (!isSelected) selectedCategories.add(category) + } else { + selectedCategories.remove(category) + } + onApply(currentFilters()) + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.94f), + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier + .clip(RoundedCornerShape(Dimensions.cornerRadiusMd)) + .sizeIn(minHeight = Dimensions.touchTargetSm) + .semantics { role = Role.Checkbox } + ) { + val iconRes = categoryFilterIconRes(category) + if (iconRes != null) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + } + Text( + text = category, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + } + } + } +} + +} + +@Composable +private fun FirewallFilterIcon( + filter: FirewallFilter, + selected: Boolean = false, + modifier: Modifier = Modifier +) { + when (filter) { + FirewallFilter.ALL -> Icon( + imageVector = Icons.Rounded.Tune, + contentDescription = null, + modifier = modifier + ) + FirewallFilter.ALLOWED -> Icon( + imageVector = if (selected) Icons.Rounded.CheckCircle else Icons.Outlined.CheckCircle, + contentDescription = null, + modifier = modifier + ) + FirewallFilter.BLOCKED -> Icon( + imageVector = Icons.Rounded.Block, + contentDescription = null, + modifier = modifier + ) + FirewallFilter.BYPASS -> Icon( + painter = painterResource(id = R.drawable.ic_firewall_bypass_off), + contentDescription = null, + modifier = modifier + ) + FirewallFilter.EXCLUDED -> Icon( + painter = painterResource(id = R.drawable.ic_firewall_exclude_off), + contentDescription = null, + modifier = modifier + ) + FirewallFilter.LOCKDOWN -> Icon( + painter = painterResource(id = R.drawable.ic_firewall_lockdown_off), + contentDescription = null, + modifier = modifier + ) + FirewallFilter.BLOCKED_WIFI -> Icon( + painter = painterResource(id = R.drawable.ic_firewall_wifi_off), + contentDescription = null, + modifier = modifier + ) + FirewallFilter.BLOCKED_MOBILE_DATA -> Icon( + painter = painterResource(id = R.drawable.ic_firewall_data_off), + contentDescription = null, + modifier = modifier + ) + } +} + +private fun categoryFilterIconRes(category: String): Int? { + val normalized = category.trim().lowercase(Locale.getDefault()) + return when { + normalized.contains("system component") -> R.drawable.ic_settings + normalized.contains("system service") || normalized.contains("non app") -> + R.drawable.ic_network + normalized.contains("system app") -> R.drawable.ic_android_icon + normalized.contains("installed") -> R.drawable.ic_app_info + normalized.contains("other") -> R.drawable.ic_other_settings + normalized.contains("game") -> R.drawable.ic_firewall_lockdown_off + normalized.contains("social") || normalized.contains("communication") -> + R.drawable.ic_notification + normalized.contains("news") -> R.drawable.ic_notification + normalized.contains("photo") || + normalized.contains("image") || + normalized.contains("camera") -> R.drawable.ic_visibility + normalized.contains("video") || normalized.contains("movie") -> + R.drawable.ic_visibility + normalized.contains("audio") || normalized.contains("music") -> R.drawable.ic_logs + normalized.contains("map") || normalized.contains("travel") -> R.drawable.ic_location_on_24 + normalized.contains("productivity") || + normalized.contains("business") || + normalized.contains("tools") -> R.drawable.ic_settings + normalized.contains("education") || normalized.contains("book") -> R.drawable.ic_about + normalized.contains("health") || normalized.contains("fitness") -> R.drawable.ic_heart + normalized.contains("finance") -> R.drawable.ic_backup + else -> null + } +} + +private suspend fun fetchCategories(filter: TopLevelFilter): List { + return withContext(Dispatchers.IO) { + when (filter) { + TopLevelFilter.ALL -> FirewallManager.getAllCategories() + TopLevelFilter.INSTALLED -> FirewallManager.getCategoriesForInstalledApps() + TopLevelFilter.SYSTEM -> FirewallManager.getCategoriesForSystemApps() + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/CustomRulesScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/CustomRulesScreen.kt new file mode 100644 index 000000000..00f4404a3 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/CustomRulesScreen.kt @@ -0,0 +1,890 @@ +/* + * Copyright 2024 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.compose.firewall + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.asFlow +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.database.CustomDomain +import com.celzero.bravedns.database.CustomIp +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkSearchField +import com.celzero.bravedns.ui.compose.theme.RethinkTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkConnectedChoiceButtonRow +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.util.Constants.Companion.UNSPECIFIED_PORT +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import com.celzero.bravedns.viewmodel.CustomDomainViewModel +import com.celzero.bravedns.viewmodel.CustomIpViewModel +import inet.ipaddr.IPAddressString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.compose.runtime.rememberCoroutineScope + +enum class RulesTab(val value: Int) { + IP(0), + DOMAIN(1); + + companion object { + fun fromValue(value: Int): RulesTab { + return entries.firstOrNull { it.value == value } ?: IP + } + } +} + +enum class RulesMode(val value: Int) { + ALL_RULES(0), + APP_SPECIFIC(1); + + companion object { + fun fromValue(value: Int): RulesMode { + return entries.firstOrNull { it.value == value } ?: APP_SPECIFIC + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomRulesScreen( + uid: Int = UID_EVERYBODY, + initialTab: RulesTab = RulesTab.IP, + initialMode: RulesMode = RulesMode.APP_SPECIFIC, + domainViewModel: CustomDomainViewModel, + ipViewModel: CustomIpViewModel, + eventLogger: EventLogger, + onBackClick: (() -> Unit)? = null +) { + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val navBarBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + var selectedTab by rememberSaveable(uid, initialTab) { mutableStateOf(initialTab) } + var selectedMode by rememberSaveable(uid, initialMode) { mutableStateOf(initialMode) } + var showAddDialog by remember { mutableStateOf(false) } + var ipQuery by rememberSaveable { mutableStateOf("") } + var domainQuery by rememberSaveable { mutableStateOf("") } + val canSwitchScope = uid == UID_EVERYBODY + val effectiveMode = if (canSwitchScope) selectedMode else RulesMode.APP_SPECIFIC + val isUniversalRules = uid == UID_EVERYBODY && effectiveMode == RulesMode.APP_SPECIFIC + val showAddButton = effectiveMode == RulesMode.APP_SPECIFIC + + LaunchedEffect(effectiveMode) { + if (effectiveMode != RulesMode.APP_SPECIFIC) { + showAddDialog = false + } + } + + Scaffold( + topBar = { + RethinkTopBar( + title = + if (isUniversalRules) { + stringResource(R.string.univ_view_blocked_ip) + } else if (effectiveMode == RulesMode.ALL_RULES) { + stringResource(R.string.lbl_app_wise) + } else { + stringResource(R.string.app_ip_domain_rules) + }, + onBackClick = onBackClick + ) + }, + floatingActionButton = { + if (showAddButton) { + ExtendedFloatingActionButton( + onClick = { showAddDialog = true }, + icon = { + Icon(imageVector = Icons.Default.Add, contentDescription = null) + }, + text = { + Text(text = stringResource(R.string.lbl_add)) + }, + modifier = Modifier.padding(bottom = navBarBottomInset + 6.dp) + ) + } + }, + floatingActionButtonPosition = FabPosition.Center, + containerColor = MaterialTheme.colorScheme.background + ) { padding -> + when (selectedTab) { + RulesTab.IP -> + IpRulesContent( + modifier = Modifier.padding(padding), + uid = uid, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it }, + rulesMode = effectiveMode, + canSwitchScope = canSwitchScope, + onRulesModeChange = { selectedMode = it }, + query = ipQuery, + onQueryChange = { ipQuery = it }, + viewModel = ipViewModel, + eventLogger = eventLogger + ) + + RulesTab.DOMAIN -> + DomainRulesContent( + modifier = Modifier.padding(padding), + uid = uid, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it }, + rulesMode = effectiveMode, + canSwitchScope = canSwitchScope, + onRulesModeChange = { selectedMode = it }, + query = domainQuery, + onQueryChange = { domainQuery = it }, + viewModel = domainViewModel, + eventLogger = eventLogger + ) + } + } + + if (showAddDialog && showAddButton) { + AddRuleDialog( + isIpRule = selectedTab == RulesTab.IP, + onDismiss = { showAddDialog = false }, + onAddIpRule = { ip -> + scope.launch(Dispatchers.IO) { + val ipAddress = IPAddressString(ip).address ?: return@launch + IpRulesManager.addIpRule(uid, ipAddress, null, IpRulesManager.IpRuleStatus.BLOCK, "", "") + } + eventLogger.log( + EventType.FW_RULE_MODIFIED, + Severity.LOW, + "Added IP rule", + EventSource.UI, + false, + "IP: $ip" + ) + showAddDialog = false + }, + onAddDomainRule = { domain -> + scope.launch(Dispatchers.IO) { + DomainRulesManager.addDomainRule( + domain, + DomainRulesManager.Status.BLOCK, + DomainRulesManager.DomainType.DOMAIN, + uid = uid + ) + } + eventLogger.log( + EventType.FW_RULE_MODIFIED, + Severity.LOW, + "Added domain rule", + EventSource.UI, + false, + "Domain: $domain" + ) + showAddDialog = false + } + ) + } +} + +@OptIn(FlowPreview::class) +@Composable +private fun IpRulesContent( + modifier: Modifier = Modifier, + uid: Int, + selectedTab: RulesTab, + onTabSelected: (RulesTab) -> Unit, + rulesMode: RulesMode, + canSwitchScope: Boolean, + onRulesModeChange: (RulesMode) -> Unit, + query: String, + onQueryChange: (String) -> Unit, + viewModel: CustomIpViewModel, + eventLogger: EventLogger +) { + val items = + when (rulesMode) { + RulesMode.APP_SPECIFIC -> viewModel.customIpDetails.asFlow().collectAsLazyPagingItems() + RulesMode.ALL_RULES -> viewModel.allIpRules.asFlow().collectAsLazyPagingItems() + } + + RulesContent( + modifier = modifier, + uid = uid, + selectedTab = selectedTab, + onTabSelected = onTabSelected, + rulesMode = rulesMode, + canSwitchScope = canSwitchScope, + onRulesModeChange = onRulesModeChange, + query = query, + onQueryChange = onQueryChange, + hint = stringResource(R.string.lbl_ip_rules), + emptyText = stringResource(R.string.rules_load_failure_desc), + items = items, + setUid = { modeUid -> viewModel.setUid(modeUid) }, + setFilter = { filter -> viewModel.setFilter(filter) }, + groupBy = { it.uid }, + onDeleteRule = { item -> IpRulesManager.removeIpRule(item.uid, item.ipAddress, item.port) }, + deleteEventMessage = "Removed IP rule", + deleteEventDetails = { item -> "IP: ${item.ipAddress}" }, + eventLogger = eventLogger + ) { item, position, onDelete -> + IpRuleListItem( + rule = item, + position = position, + onDelete = onDelete + ) + } +} + +@Composable +private fun IpRuleListItem( + rule: CustomIp, + position: CardPosition, + onDelete: () -> Unit +) { + val status = IpRulesManager.IpRuleStatus.getStatus(rule.status) + val statusLabelRes = + when (status) { + IpRulesManager.IpRuleStatus.BLOCK -> R.string.ci_block + IpRulesManager.IpRuleStatus.TRUST -> R.string.ci_trust_rule + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> R.string.firewall_status_whitelisted + IpRulesManager.IpRuleStatus.NONE -> R.string.ci_no_rule + } + val statusColor = + when (status) { + IpRulesManager.IpRuleStatus.BLOCK -> MaterialTheme.colorScheme.error + IpRulesManager.IpRuleStatus.TRUST -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val headline = if (rule.port == UNSPECIFIED_PORT) rule.ipAddress else "${rule.ipAddress}:${rule.port}" + + RuleListItem( + headline = headline, + supporting = stringResource(statusLabelRes), + iconRes = R.drawable.ic_ip_address, + accent = statusColor, + position = position, + onDelete = onDelete + ) +} + +@OptIn(FlowPreview::class) +@Composable +private fun DomainRulesContent( + modifier: Modifier = Modifier, + uid: Int, + selectedTab: RulesTab, + onTabSelected: (RulesTab) -> Unit, + rulesMode: RulesMode, + canSwitchScope: Boolean, + onRulesModeChange: (RulesMode) -> Unit, + query: String, + onQueryChange: (String) -> Unit, + viewModel: CustomDomainViewModel, + eventLogger: EventLogger +) { + val items = + when (rulesMode) { + RulesMode.APP_SPECIFIC -> viewModel.customDomains.asFlow().collectAsLazyPagingItems() + RulesMode.ALL_RULES -> viewModel.allDomainRules.asFlow().collectAsLazyPagingItems() + } + + RulesContent( + modifier = modifier, + uid = uid, + selectedTab = selectedTab, + onTabSelected = onTabSelected, + rulesMode = rulesMode, + canSwitchScope = canSwitchScope, + onRulesModeChange = onRulesModeChange, + query = query, + onQueryChange = onQueryChange, + hint = stringResource(R.string.lbl_domain_rules), + emptyText = stringResource(R.string.cd_no_rules_text), + items = items, + setUid = { modeUid -> viewModel.setUid(modeUid) }, + setFilter = { filter -> viewModel.setFilter(filter) }, + groupBy = { it.uid }, + onDeleteRule = { item -> DomainRulesManager.deleteDomain(item) }, + deleteEventMessage = "Removed domain rule", + deleteEventDetails = { item -> "Domain: ${item.domain}" }, + eventLogger = eventLogger + ) { item, position, onDelete -> + DomainRuleListItem( + rule = item, + position = position, + onDelete = onDelete + ) + } +} + +@OptIn(FlowPreview::class) +@Composable +private fun RulesContent( + modifier: Modifier, + uid: Int, + selectedTab: RulesTab, + onTabSelected: (RulesTab) -> Unit, + rulesMode: RulesMode, + canSwitchScope: Boolean, + onRulesModeChange: (RulesMode) -> Unit, + query: String, + onQueryChange: (String) -> Unit, + hint: String, + emptyText: String, + items: LazyPagingItems, + setUid: (Int) -> Unit, + setFilter: (String) -> Unit, + groupBy: (T) -> Int, + onDeleteRule: suspend (T) -> Unit, + deleteEventMessage: String, + deleteEventDetails: (T) -> String, + eventLogger: EventLogger, + row: @Composable (item: T, position: CardPosition, onDelete: () -> Unit) -> Unit +) { + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val navBarBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + + LaunchedEffect(uid, rulesMode) { + setUid(if (rulesMode == RulesMode.APP_SPECIFIC) uid else UID_EVERYBODY) + } + + LaunchedEffect(Unit) { + snapshotFlow { query } + .debounce(250) + .distinctUntilChanged() + .collect { q -> setFilter(q) } + } + + val isRefreshing = items.loadState.refresh is LoadState.Loading + val isEmpty = !isRefreshing && items.itemCount == 0 + + Column( + modifier = modifier.fillMaxSize() + ) { + RulesControlDeck( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + rulesMode = rulesMode, + canSwitchScope = canSwitchScope, + onRulesModeChange = onRulesModeChange, + query = query, + onQueryChange = onQueryChange, + hint = hint + ) + + if (isRefreshing) { + RulesInfoRow( + text = stringResource(R.string.lbl_loading), + modifier = Modifier + .fillMaxWidth() + .padding(top = Dimensions.spacingSm) + ) + } else if (isEmpty) { + RulesEmptyState( + selectedTab = selectedTab, + text = emptyText, + modifier = Modifier + .fillMaxSize() + .padding( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + bottom = navBarBottomInset + ) + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = if (rulesMode == RulesMode.APP_SPECIFIC) { + 112.dp + navBarBottomInset + } else { + Dimensions.spacing3xl + navBarBottomInset + } + ), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + items(items.itemCount) { index -> + val item = items[index] ?: return@items + val showHeader = + rulesMode == RulesMode.ALL_RULES && + shouldShowGroupHeader(items, index, groupBy) + if (showHeader) { + if (index > 0) { + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + } + RulesAppHeader(uid = groupBy(item)) + Spacer(modifier = Modifier.height(4.dp)) + } + val position = + if (rulesMode == RulesMode.ALL_RULES) { + groupedCardPosition(items, index, item, groupBy) + } else { + cardPositionFor(index, items.itemCount - 1) + } + + row( + item, + position, + { + scope.launch(Dispatchers.IO) { + onDeleteRule(item) + } + eventLogger.log( + EventType.FW_RULE_MODIFIED, + Severity.LOW, + deleteEventMessage, + EventSource.UI, + false, + deleteEventDetails(item) + ) + } + ) + } + } + } + } +} + +@Composable +private fun RulesEmptyState( + selectedTab: RulesTab, + text: String, + modifier: Modifier = Modifier +) { + val iconRes = + when (selectedTab) { + RulesTab.IP -> R.drawable.ic_ip_address + RulesTab.DOMAIN -> R.drawable.ic_undelegated_domain + } + val accent = + when (selectedTab) { + RulesTab.IP -> MaterialTheme.colorScheme.primary + RulesTab.DOMAIN -> MaterialTheme.colorScheme.tertiary + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = accent.copy(alpha = 0.14f) + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = accent, + modifier = Modifier + .padding(10.dp) + .size(20.dp) + ) + } + Text( + text = text, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun DomainRuleListItem( + rule: CustomDomain, + position: CardPosition, + onDelete: () -> Unit +) { + val status = DomainRulesManager.Status.getStatus(rule.status) + val statusLabelRes = + when (status) { + DomainRulesManager.Status.BLOCK -> R.string.ci_block + DomainRulesManager.Status.TRUST -> R.string.ci_trust_rule + DomainRulesManager.Status.NONE -> R.string.ci_no_rule + } + val statusColor = + when (status) { + DomainRulesManager.Status.BLOCK -> MaterialTheme.colorScheme.error + DomainRulesManager.Status.TRUST -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + RuleListItem( + headline = rule.domain, + supporting = stringResource(statusLabelRes), + iconRes = R.drawable.ic_undelegated_domain, + accent = statusColor, + position = position, + onDelete = onDelete + ) +} + +@Composable +private fun RuleListItem( + headline: String, + supporting: String, + iconRes: Int, + accent: Color, + position: CardPosition, + onDelete: () -> Unit +) { + RethinkListItem( + headline = headline, + supporting = null, + leadingIconPainter = painterResource(id = iconRes), + leadingIconTint = accent, + leadingIconContainerColor = accent.copy(alpha = 0.14f), + position = position, + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = accent.copy(alpha = 0.14f) + ) { + Text( + text = supporting, + style = MaterialTheme.typography.labelSmall, + color = accent, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + IconButton(onClick = onDelete) { + Icon(imageVector = Icons.Filled.Delete, contentDescription = stringResource(R.string.lbl_delete)) + } + } + } + ) +} + +@Composable +private fun RulesAppHeader(uid: Int) { + val label by + produceState(initialValue = "UID $uid", key1 = uid) { + value = + withContext(Dispatchers.IO) { + val appName = FirewallManager.getAppNameByUid(uid).orEmpty().trim() + if (appName.isEmpty()) { + "UID $uid" + } else { + appName + } + } + } + + val supporting = if (label == "UID $uid") null else "UID $uid" + RethinkListItem( + headline = label, + supporting = supporting, + leadingIcon = Icons.Rounded.Apps, + position = CardPosition.Single, + defaultContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) +} + +private fun shouldShowGroupHeader( + items: LazyPagingItems, + index: Int, + groupBy: (T) -> Int +): Boolean { + if (index == 0) return true + val current = items[index] ?: return true + val prev = items[index - 1] ?: return true + return groupBy(prev) != groupBy(current) +} + +private fun groupedCardPosition( + items: LazyPagingItems, + index: Int, + item: T, + groupBy: (T) -> Int +): CardPosition { + val itemGroup = groupBy(item) + val hasPrevSame = index > 0 && items[index - 1]?.let(groupBy) == itemGroup + val hasNextSame = index < items.itemCount - 1 && items[index + 1]?.let(groupBy) == itemGroup + return when { + !hasPrevSame && !hasNextSame -> CardPosition.Single + !hasPrevSame -> CardPosition.First + !hasNextSame -> CardPosition.Last + else -> CardPosition.Middle + } +} + +@Composable +private fun RulesControlDeck( + selectedTab: RulesTab, + onTabSelected: (RulesTab) -> Unit, + rulesMode: RulesMode, + canSwitchScope: Boolean, + onRulesModeChange: (RulesMode) -> Unit, + query: String, + onQueryChange: (String) -> Unit, + hint: String +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal) + .padding(top = Dimensions.spacingXs), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + if (canSwitchScope) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + RuleTypeSelector( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + modifier = Modifier.weight(1f), + compact = true + ) + RuleScopeSelector( + rulesMode = rulesMode, + onRulesModeChange = onRulesModeChange, + modifier = Modifier.weight(1f), + compact = true + ) + } + } else { + RuleTypeSelector( + selectedTab = selectedTab, + onTabSelected = onTabSelected + ) + } + + RulesSearchField( + query = query, + onQueryChange = onQueryChange, + hint = hint + ) + } +} + +@Composable +private fun RuleTypeSelector( + selectedTab: RulesTab, + onTabSelected: (RulesTab) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false +) { + RethinkConnectedChoiceButtonRow( + options = listOf(RulesTab.IP, RulesTab.DOMAIN), + selectedOption = selectedTab, + onOptionSelected = { tab -> onTabSelected(tab) }, + modifier = modifier.fillMaxWidth(), + buttonMinHeight = if (compact) 40.dp else 0.dp, + label = { option, selected -> + val labelRes = + when (option) { + RulesTab.IP -> if (compact) R.string.lbl_ip else R.string.lbl_ip_rules + RulesTab.DOMAIN -> if (compact) R.string.lbl_domain else R.string.lbl_domain_rules + } + Text( + text = stringResource(labelRes), + modifier = Modifier.fillMaxWidth(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } + ) +} + +@Composable +private fun RuleScopeSelector( + rulesMode: RulesMode, + onRulesModeChange: (RulesMode) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false +) { + RethinkConnectedChoiceButtonRow( + options = listOf(RulesMode.APP_SPECIFIC, RulesMode.ALL_RULES), + selectedOption = rulesMode, + onOptionSelected = { mode -> onRulesModeChange(mode) }, + modifier = modifier.fillMaxWidth(), + buttonMinHeight = if (compact) 40.dp else 0.dp, + label = { option, _ -> + Text( + text = + stringResource( + when (option) { + RulesMode.APP_SPECIFIC -> R.string.firewall_act_universal_tab + RulesMode.ALL_RULES -> R.string.lbl_app_wise + } + ), + modifier = Modifier.fillMaxWidth(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.labelLarge, + fontWeight = if (option == rulesMode) FontWeight.SemiBold else FontWeight.Medium + ) + } + ) +} + +@Composable +private fun RulesSearchField( + query: String, + onQueryChange: (String) -> Unit, + hint: String +) { + RethinkSearchField( + query = query, + onQueryChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(R.string.two_argument_colon, stringResource(R.string.lbl_search), hint), + onClearQuery = { onQueryChange("") }, + clearQueryContentDescription = stringResource(R.string.cd_clear_search), + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) +} + +@Composable +private fun RulesInfoRow( + text: String, + modifier: Modifier = Modifier +) { + RethinkListGroup { + RethinkListItem( + headline = text, + position = CardPosition.Single, + enabled = false, + modifier = modifier.padding(horizontal = Dimensions.screenPaddingHorizontal) + ) + } +} + +@Composable +private fun AddRuleDialog( + isIpRule: Boolean, + onDismiss: () -> Unit, + onAddIpRule: (String) -> Unit, + onAddDomainRule: (String) -> Unit +) { + var ruleValue by remember { mutableStateOf("") } + val title = + if (isIpRule) { + stringResource(R.string.lbl_ip_rules) + } else { + stringResource(R.string.lbl_domain_rules) + } + + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = title, + text = { + OutlinedTextField( + value = ruleValue, + onValueChange = { ruleValue = it }, + label = { Text(text = title) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmText = stringResource(R.string.lbl_add), + dismissText = stringResource(R.string.lbl_cancel), + confirmEnabled = ruleValue.isNotBlank(), + onConfirm = { + if (ruleValue.isNotBlank()) { + if (isIpRule) onAddIpRule(ruleValue.trim()) + else onAddDomainRule(ruleValue.trim()) + } + }, + onDismiss = onDismiss + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FastScroller.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FastScroller.kt new file mode 100644 index 000000000..09b053c31 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FastScroller.kt @@ -0,0 +1,267 @@ +package com.celzero.bravedns.ui.compose.firewall + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt + +@Composable +fun IndexedFastScroller( + items: List, + listState: LazyListState, + getIndexKey: (T) -> String, + modifier: Modifier = Modifier, + minItemCount: Int = 24, + onInteractionStart: () -> Unit = {}, + onInteractionEnd: () -> Unit = {}, + scrollItemOffset: Int = 0, +) { + if (items.size < minItemCount) return + + val density = LocalDensity.current + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val labelToIndexMap = + remember(items, items.hashCode()) { + val map = mutableMapOf() + items.forEachIndexed { index, item -> + val label = normalizeIndexLabel(getIndexKey(item)) + if (!map.containsKey(label)) { + map[label] = index + } + } + map + } + val labels = + remember(labelToIndexMap) { + labelToIndexMap.keys.sortedWith( + compareBy { if (it == "#") 0 else 1 }.thenBy { it }, + ) + } + + if (labels.isEmpty()) return + + val trackInset = 14.dp + val bubbleSize = 72.dp + val bubbleOffsetX = (-60).dp + val trackInsetPx = with(density) { trackInset.toPx() } + val bubbleSizePx = with(density) { bubbleSize.toPx() } + + var trackSize by remember { mutableStateOf(IntSize.Zero) } + var currentDragY by remember { mutableFloatStateOf(0f) } + var currentLabel by remember { mutableStateOf("") } + var previousLabel by remember { mutableStateOf("") } + var interacting by remember { mutableStateOf(false) } + + fun scrollToLabel(label: String) { + val targetIndex = labelToIndexMap[label] ?: return + coroutineScope.launch { + listState.scrollToItem((targetIndex - scrollItemOffset).coerceAtLeast(0)) + } + } + + fun selectFromPosition(y: Float) { + if (trackSize.height <= 0 || labels.isEmpty()) return + val trackHeight = trackSize.height.toFloat() + val clampedY = y.coerceIn(trackInsetPx, trackHeight - trackInsetPx) + val usableHeight = (trackHeight - (trackInsetPx * 2f)).coerceAtLeast(1f) + val progress = ((clampedY - trackInsetPx) / usableHeight).coerceIn(0f, 1f) + val index = (progress * (labels.size - 1)).roundToInt().coerceIn(0, labels.size - 1) + val selected = labels[index] + + if (selected != previousLabel) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + previousLabel = selected + } + + currentLabel = selected + currentDragY = + if (labels.size > 1) { + trackInsetPx + (index / (labels.size - 1).toFloat()) * usableHeight + } else { + trackHeight / 2f + } + scrollToLabel(selected) + } + + Box( + modifier = + modifier + .width(44.dp) + .fillMaxHeight(), + ) { + Column( + modifier = + Modifier + .fillMaxHeight() + .padding(vertical = trackInset) + .onGloballyPositioned { trackSize = it.size } + .pointerInput(labels, items.hashCode()) { + awaitPointerEventScope { + while (true) { + val down = awaitFirstDown() + interacting = true + onInteractionStart() + selectFromPosition(down.position.y) + + val change = + awaitTouchSlopOrCancellation(down.id) { pointerChange, _ -> + pointerChange.consume() + } + + if (change != null) { + drag(change.id) { dragChange -> + selectFromPosition(dragChange.position.y) + } + } + + interacting = false + previousLabel = "" + onInteractionEnd() + } + } + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val usableHeight = (trackSize.height.toFloat() - (trackInsetPx * 2f)).coerceAtLeast(1f) + val adjustedDragY = (currentDragY - trackInsetPx).coerceIn(0f, usableHeight) + val dragProgress = if (usableHeight > 0f) adjustedDragY / usableHeight else 0f + + labels.forEachIndexed { index, label -> + val labelPosition = + if (labels.size > 1) index.toFloat() / (labels.size - 1) else 0.5f + val distance = abs(labelPosition - dragProgress) + + val scale by animateFloatAsState( + targetValue = + if (interacting) { + when { + distance < 0.06f -> 1.45f + distance < 0.12f -> 1.2f + else -> 0.95f + } + } else { + 1f + }, + animationSpec = spring(dampingRatio = 0.82f), + label = "fastScrollerScale_$index", + ) + val alpha by animateFloatAsState( + targetValue = + if (interacting) { + when { + distance < 0.06f -> 1f + distance < 0.12f -> 0.85f + else -> 0.55f + } + } else { + 0.72f + }, + animationSpec = spring(), + label = "fastScrollerAlpha_$index", + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + }, + ) + } + } + } + + if (interacting && currentLabel.isNotEmpty()) { + val bubbleYPx = (currentDragY - bubbleSizePx / 2f) + .coerceIn(0f, (trackSize.height.toFloat() - bubbleSizePx).coerceAtLeast(0f)) + Surface( + modifier = + Modifier + .align(Alignment.TopStart) + .size(bubbleSize) + .offset { + IntOffset( + x = with(density) { bubbleOffsetX.roundToPx() }, + y = bubbleYPx.roundToInt(), + ) + }, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(16.dp), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = currentLabel, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +private fun normalizeIndexLabel(raw: String): String { + val key = raw.trim() + if (key.isEmpty()) return "#" + + if (key.all { it.isDigit() } && key.length <= 3) { + return key + } + + val first = key.first().uppercaseChar() + return if (first.isLetter()) first.toString() else "#" +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FirewallSettingsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FirewallSettingsScreen.kt new file mode 100644 index 000000000..e7e9ca0a7 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/FirewallSettingsScreen.kt @@ -0,0 +1,144 @@ +package com.celzero.bravedns.ui.compose.firewall + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.GppBad +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import kotlin.math.roundToInt +import kotlinx.coroutines.delay +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.SectionHeader + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FirewallSettingsScreen( + onUniversalFirewallClick: () -> Unit, + onCustomIpDomainClick: () -> Unit, + onAppWiseIpDomainClick: () -> Unit, + initialFocusKey: String? = null, + onBackClick: (() -> Unit)? = null +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val listState = rememberLazyListState() + val density = LocalDensity.current + val initialFocus = initialFocusKey?.trim().orEmpty() + var pendingFocusKey by rememberSaveable(initialFocus) { mutableStateOf(initialFocus) } + var activeFocusKey by rememberSaveable(initialFocus) { + mutableStateOf(initialFocus.ifBlank { null }) + } + + LaunchedEffect(pendingFocusKey) { + val key = pendingFocusKey.trim() + if (key.isBlank()) return@LaunchedEffect + activeFocusKey = key + val target = + when (key) { + "firewall_universal", + "firewall_universal_main" -> 0 to 0 + "firewall_universal_blocked" -> 0 to 108 + "firewall_apps", + "firewall_apps_rules" -> 1 to 0 + else -> null + } + if (target != null) { + val (index, offsetDp) = target + val offsetPx = with(density) { offsetDp.dp.toPx().roundToInt() } + listState.animateScrollToItem(index, offsetPx) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + } + pendingFocusKey = "" + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + RethinkLargeTopBar( + title = stringResource(id = R.string.firewall_mode_info_title), + subtitle = stringResource(id = R.string.universal_firewall_explanation), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + item { + SectionHeader(title = stringResource(id = R.string.firewall_act_universal_tab)) + RethinkListGroup { + RethinkActionListItem( + title = stringResource(id = R.string.univ_firewall_heading), + description = stringResource(id = R.string.universal_firewall_explanation), + icon = Icons.Rounded.Public, + position = CardPosition.First, + highlighted = activeFocusKey == "firewall_universal_main", + onClick = onUniversalFirewallClick + ) + RethinkActionListItem( + title = stringResource(id = R.string.univ_view_blocked_ip), + description = stringResource(id = R.string.univ_view_blocked_ip_desc), + icon = Icons.Rounded.GppBad, + position = CardPosition.Last, + highlighted = activeFocusKey == "firewall_universal_blocked", + onClick = onCustomIpDomainClick + ) + } + } + + item { + SectionHeader(title = stringResource(id = R.string.lbl_app_wise)) + RethinkListGroup { + RethinkActionListItem( + title = stringResource(id = R.string.app_ip_domain_rules), + description = stringResource(id = R.string.app_ip_domain_rules_desc), + icon = Icons.Rounded.Apps, + position = CardPosition.Single, + highlighted = activeFocusKey == "firewall_apps_rules", + onClick = onAppWiseIpDomainClick + ) + } + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/UniversalFirewallSettingsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/UniversalFirewallSettingsScreen.kt new file mode 100644 index 000000000..65bacee89 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/firewall/UniversalFirewallSettingsScreen.kt @@ -0,0 +1,411 @@ +/* + * Copyright 2024 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.compose.firewall + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.database.ConnectionTrackerRepository +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallRuleset +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkTopBarLazyColumnScreen +import com.celzero.bravedns.ui.compose.theme.RethinkToggleListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.util.BackgroundAccessibilityService +import com.celzero.bravedns.util.Utilities +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class UniversalFirewallStatEntry( + val ruleId: String, + val count: Int +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UniversalFirewallSettingsScreen( + persistentState: PersistentState, + eventLogger: EventLogger, + connTrackerRepository: ConnectionTrackerRepository, + onNavigateToLogs: (String) -> Unit, + onOpenAccessibilitySettings: () -> Unit, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var stats by remember { mutableStateOf>(emptyList()) } + var isLoadingStats by remember { mutableStateOf(true) } + + var blockWhenDeviceLocked by remember { mutableStateOf(persistentState.getBlockWhenDeviceLocked()) } + var blockAppWhenBackground by remember { mutableStateOf(persistentState.getBlockAppWhenBackground()) } + var udpBlocked by remember { mutableStateOf(persistentState.getUdpBlocked()) } + var blockUnknownConnections by remember { mutableStateOf(persistentState.getBlockUnknownConnections()) } + var disallowDnsBypass by remember { mutableStateOf(persistentState.getDisallowDnsBypass()) } + var blockNewApp by remember { mutableStateOf(persistentState.getBlockNewlyInstalledApp()) } + var blockMeteredConnections by remember { mutableStateOf(persistentState.getBlockMeteredConnections()) } + var blockHttpConnections by remember { mutableStateOf(persistentState.getBlockHttpConnections()) } + var universalLockdown by remember { mutableStateOf(persistentState.getUniversalLockdown()) } + var showPermissionDialog by remember { mutableStateOf(false) } + + fun loadStats() { + isLoadingStats = true + scope.launch(Dispatchers.IO) { + val blockedUniversalRules = connTrackerRepository.getBlockedUniversalRulesCount() + val deviceLocked = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE3.id) } + val backgroundMode = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE4.id) } + val unknown = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE5.id) } + val udp = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE6.id) } + val dnsBypass = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE7.id) } + val newApp = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE1B.id) } + val metered = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE1F.id) } + val http = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE10.id) } + val lockdown = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE11.id) } + + val updatedStats = listOf( + UniversalFirewallStatEntry( + FirewallRuleset.RULE3.id, + deviceLocked.size + ), + UniversalFirewallStatEntry( + FirewallRuleset.RULE4.id, + backgroundMode.size + ), + UniversalFirewallStatEntry(FirewallRuleset.RULE5.id, unknown.size), + UniversalFirewallStatEntry(FirewallRuleset.RULE6.id, udp.size), + UniversalFirewallStatEntry( + FirewallRuleset.RULE7.id, + dnsBypass.size + ), + UniversalFirewallStatEntry(FirewallRuleset.RULE1B.id, newApp.size), + UniversalFirewallStatEntry(FirewallRuleset.RULE1F.id, metered.size), + UniversalFirewallStatEntry(FirewallRuleset.RULE10.id, http.size), + UniversalFirewallStatEntry(FirewallRuleset.RULE11.id, lockdown.size) + ) + + withContext(Dispatchers.Main) { + stats = updatedStats + isLoadingStats = false + } + } + } + + fun logEvent(details: String) { + eventLogger.log( + EventType.FW_RULE_MODIFIED, + Severity.LOW, + "Univ firewall setting", + EventSource.UI, + false, + details + ) + } + + fun statsFor(ruleId: String): UniversalFirewallStatEntry? { + return stats.firstOrNull { it.ruleId == ruleId } + } + + fun handleStatsClick(ruleId: String) { + val size = statsFor(ruleId)?.count ?: 0 + if (size > 0) { + onNavigateToLogs(ruleId) + } + } + + fun handleBackgroundToggle(enabled: Boolean) { + if (!enabled) { + blockAppWhenBackground = false + persistentState.setBlockAppWhenBackground(false) + logEvent("Univ firewall background mode changed toggled to false") + return + } + + val isAccessibilityServiceRunning = Utilities.isAccessibilityServiceEnabled( + context, + BackgroundAccessibilityService::class.java + ) + val isAccessibilityServiceEnabled = Utilities.isAccessibilityServiceEnabledViaSettingsSecure( + context, + BackgroundAccessibilityService::class.java + ) + val isAccessibilityServiceFunctional = isAccessibilityServiceRunning && isAccessibilityServiceEnabled + + if (isAccessibilityServiceFunctional) { + blockAppWhenBackground = true + persistentState.setBlockAppWhenBackground(true) + logEvent("Univ firewall background mode changed toggled to true") + return + } + + showPermissionDialog = true + blockAppWhenBackground = false + persistentState.setBlockAppWhenBackground(false) + logEvent("Univ firewall background mode change to true failed due to accessibility service not enabled") + } + + LaunchedEffect(Unit) { + loadStats() + } + + val blockedTotal = stats.sumOf { it.count } + val topBarSubtitle = + if (isLoadingStats) { + stringResource(R.string.universal_firewall_explanation) + } else { + stringResource( + R.string.two_argument_colon, + stringResource(R.string.lbl_blocked), + blockedTotal.toString() + ) + } + + RethinkTopBarLazyColumnScreen( + title = stringResource(R.string.univ_firewall_heading), + subtitle = topBarSubtitle, + onBackClick = onBackClick + ) { + item { + SectionHeader(title = stringResource(R.string.univ_firewall_heading)) + RethinkListGroup { + ToggleWithStats( + iconRes = R.drawable.ic_device_lock, + label = stringResource(R.string.univ_firewall_rule_1), + checked = blockWhenDeviceLocked, + onCheckedChange = { + blockWhenDeviceLocked = it + persistentState.setBlockWhenDeviceLocked(it) + logEvent("Univ firewall device locked mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE3.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE3.id) }, + position = CardPosition.First + ) + ToggleWithStats( + iconRes = R.drawable.ic_foreground, + label = stringResource(R.string.univ_firewall_rule_2), + checked = blockAppWhenBackground, + onCheckedChange = { handleBackgroundToggle(it) }, + stats = statsFor(FirewallRuleset.RULE4.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE4.id) }, + position = CardPosition.Middle + ) + ToggleWithStats( + iconRes = R.drawable.ic_unknown_app, + label = stringResource(R.string.univ_firewall_rule_3), + checked = blockUnknownConnections, + onCheckedChange = { + blockUnknownConnections = it + persistentState.setBlockUnknownConnections(it) + logEvent("Univ firewall unknown connection mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE5.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE5.id) }, + position = CardPosition.Middle + ) + ToggleWithStats( + iconRes = R.drawable.ic_udp, + label = stringResource(R.string.univ_firewall_rule_4), + checked = udpBlocked, + onCheckedChange = { + udpBlocked = it + persistentState.setUdpBlocked(it) + logEvent("Univ firewall UDP connection mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE6.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE6.id) }, + position = CardPosition.Middle + ) + ToggleWithStats( + iconRes = R.drawable.ic_prevent_dns_leaks, + label = stringResource(R.string.univ_firewall_rule_5), + checked = disallowDnsBypass, + onCheckedChange = { + disallowDnsBypass = it + persistentState.setDisallowDnsBypass(it) + logEvent("Univ firewall DNS bypass mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE7.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE7.id) }, + position = CardPosition.Middle + ) + ToggleWithStats( + iconRes = R.drawable.ic_app_info, + label = stringResource(R.string.univ_firewall_rule_6), + checked = blockNewApp, + onCheckedChange = { + blockNewApp = it + persistentState.setBlockNewlyInstalledApp(it) + logEvent("Univ firewall new app block mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE1B.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE1B.id) }, + position = CardPosition.Middle + ) + ToggleWithStats( + iconRes = R.drawable.ic_univ_metered, + label = stringResource(R.string.univ_firewall_rule_9), + checked = blockMeteredConnections, + onCheckedChange = { + blockMeteredConnections = it + persistentState.setBlockMeteredConnections(it) + logEvent("Univ firewall metered connection block mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE1F.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE1F.id) }, + position = CardPosition.Middle + ) + ToggleWithStats( + iconRes = R.drawable.ic_http, + label = stringResource(R.string.univ_firewall_rule_8), + checked = blockHttpConnections, + onCheckedChange = { + blockHttpConnections = it + persistentState.setBlockHttpConnections(it) + logEvent("Univ firewall HTTP block mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE10.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE10.id) }, + position = CardPosition.Middle + ) + ToggleWithStats( + iconRes = R.drawable.ic_global_lockdown, + label = stringResource(R.string.univ_firewall_rule_10), + checked = universalLockdown, + onCheckedChange = { + universalLockdown = it + persistentState.setUniversalLockdown(it) + logEvent("Univ firewall universal lockdown mode changed toggled to $it") + }, + stats = statsFor(FirewallRuleset.RULE11.id), + loading = isLoadingStats, + onStatsClick = { handleStatsClick(FirewallRuleset.RULE11.id) }, + position = CardPosition.Last + ) + } + } + } + + if (showPermissionDialog) { + RethinkConfirmDialog( + onDismissRequest = { showPermissionDialog = false }, + title = stringResource(R.string.alert_permission_accessibility), + message = stringResource(R.string.alert_firewall_accessibility_explanation), + confirmText = stringResource(R.string.univ_accessibility_dialog_positive), + dismissText = stringResource(R.string.univ_accessibility_dialog_negative), + onConfirm = { + showPermissionDialog = false + onOpenAccessibilitySettings() + }, + onDismiss = { showPermissionDialog = false } + ) + } +} + +@Composable +private fun ToggleWithStats( + iconRes: Int, + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + stats: UniversalFirewallStatEntry?, + loading: Boolean, + onStatsClick: () -> Unit, + position: CardPosition = CardPosition.Middle +) { + val blockedCount = stats?.count ?: 0 + val supportingText = + if (loading) { + stringResource(R.string.lbl_loading) + } else { + stringResource( + R.string.two_argument_colon, + stringResource(R.string.lbl_blocked), + blockedCount.toString() + ) + } + + RethinkToggleListItem( + title = label, + description = supportingText, + iconRes = iconRes, + checked = checked, + onCheckedChange = onCheckedChange, + accentColor = MaterialTheme.colorScheme.onSurfaceVariant, + position = position, + trailingPrefix = { + if (!loading && blockedCount > 0) { + IconButton( + onClick = onStatsClick, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = stringResource(R.string.lbl_logs), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeComponents.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeComponents.kt new file mode 100644 index 000000000..88f2b5a02 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeComponents.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2024 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.compose.home + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions + +@Composable +fun StartStopButton( + isPlaying: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.94f else 1f, + animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium), + label = "buttonScale" + ) + + val containerColor = if (isPlaying) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary + val contentColor = if (isPlaying) MaterialTheme.colorScheme.onError + else MaterialTheme.colorScheme.onPrimary + + val text = if (isPlaying) { + stringResource(R.string.lbl_stop) + } else { + stringResource(R.string.lbl_start) + } + + val icon = if (isPlaying) Icons.Rounded.Stop else Icons.Rounded.PlayArrow + val cornerRadius by animateDpAsState( + targetValue = if (isPlaying) Dimensions.cornerRadiusSmMd else Dimensions.buttonCornerRadius, + animationSpec = spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMediumLow), + label = "startStopCornerRadius" + ) + + Button( + onClick = onClick, + modifier = modifier + .height(44.dp) + .scale(scale), + interactionSource = interactionSource, + shape = RoundedCornerShape(cornerRadius), + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor + ), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp, pressedElevation = 6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(Dimensions.iconSizeSm) + ) + Spacer(modifier = Modifier.width(Dimensions.spacingXs)) + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = contentColor + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreen.kt new file mode 100644 index 000000000..7206a1d13 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreen.kt @@ -0,0 +1,630 @@ +/* + * Copyright 2024 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.compose.home + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Shield +import androidx.compose.material.icons.rounded.ShieldMoon +import androidx.compose.material.icons.rounded.WarningAmber +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkTheme +import com.celzero.bravedns.ui.compose.theme.cardPositionFor + +data class HomeScreenUiState( + val isVpnActive: Boolean = false, + val dnsLatency: String = "-- ms", + val dnsConnectedName: String = "", + val firewallUniversalRules: Int = 0, + val firewallIpRules: Int = 0, + val firewallDomainRules: Int = 0, + val proxyStatus: String = "", + val networkLogsCount: Long = 0, + val dnsLogsCount: Long = 0, + val appsAllowed: Int = 0, + val appsBlocked: Int = 0, + val appsTotal: Int = 0, + val appsBypassed: Int = 0, + val appsIsolated: Int = 0, + val appsExcluded: Int = 0, + val protectionStatus: String = "", + val isProtectionFailing: Boolean = false +) + +private data class StatusItem( + val headline: String, + val supporting: String, + val iconPainter: Painter, + val iconAccentColor: Color, + val onClick: () -> Unit, +) + +private data class HomeStatusIconTints( + val apps: Color, + val dns: Color, + val firewall: Color, + val proxy: Color, + val logs: Color +) + +private val HomePrimaryCardShape = + RoundedCornerShape(Dimensions.heroCornerRadius) +private val HomeSecondaryCardShape = + RoundedCornerShape(Dimensions.heroCornerRadius) + +@Composable +private fun rememberHomeStatusIconTints(): HomeStatusIconTints { + return HomeStatusIconTints( + apps = Color(0xFF74C5FF), + dns = Color(0xFFC5ACFF), + firewall = Color(0xFFFF907F), + proxy = Color(0xFF46EBC8), + logs = Color(0xFF7EED92) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + uiState: HomeScreenUiState, + onStartStopClick: () -> Unit, + onDnsClick: () -> Unit, + onFirewallClick: () -> Unit, + onProxyClick: () -> Unit, + onLogsClick: () -> Unit, + onAppsClick: () -> Unit, + onSponsorClick: () -> Unit +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val iconTints = rememberHomeStatusIconTints() + + val dnsSummary = if (uiState.dnsConnectedName.isNotBlank()) { + "${uiState.dnsConnectedName} · ${uiState.dnsLatency}" + } else { + stringResource(R.string.lbl_inactive) + } + + val firewallSummary = + "${uiState.firewallUniversalRules} ${stringResource(R.string.lbl_universal_rules)}" + + " · ${uiState.firewallIpRules} IP · ${uiState.firewallDomainRules} domain" + + val proxySummary = uiState.proxyStatus.ifEmpty { stringResource(R.string.lbl_inactive) } + + val logsSummary = + "${uiState.networkLogsCount} ${stringResource(R.string.lbl_network)}" + + " · ${uiState.dnsLogsCount} DNS" + + val appsSummary = + "${uiState.appsAllowed}/${uiState.appsTotal} ${stringResource(R.string.lbl_allowed)}" + + " · ${uiState.appsBlocked} ${stringResource(R.string.lbl_blocked)}" + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + LargeTopAppBar( + title = { + Text( + text = stringResource(R.string.txt_home), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + item { + ProtectionCard(uiState = uiState, onStartStopClick = onStartStopClick) + } + + item { + StatusSection( + title = stringResource(R.string.lbl_status), + accentColor = MaterialTheme.colorScheme.primary, + items = listOf( + StatusItem( + headline = stringResource(R.string.lbl_dns), + supporting = dnsSummary, + iconPainter = painterResource(id = R.drawable.dns_home_screen), + iconAccentColor = iconTints.dns, + onClick = onDnsClick, + ), + StatusItem( + headline = stringResource(R.string.lbl_firewall), + supporting = firewallSummary, + iconPainter = painterResource(id = R.drawable.firewall_home_screen), + iconAccentColor = iconTints.firewall, + onClick = onFirewallClick, + ), + StatusItem( + headline = stringResource(R.string.lbl_proxy), + supporting = proxySummary, + iconPainter = painterResource(id = R.drawable.ic_vpn), + iconAccentColor = iconTints.proxy, + onClick = onProxyClick, + ), + StatusItem( + headline = stringResource(R.string.lbl_logs), + supporting = logsSummary, + iconPainter = painterResource(id = R.drawable.ic_logs_accent), + iconAccentColor = iconTints.logs, + onClick = onLogsClick, + ) + ), + ) + } + + item { + AppsHealthCard( + uiState = uiState, + onClick = onAppsClick + ) + } + } + } +} + +// ─── Status Section ─────────────────────────────────────────────────────── + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun StatusSection( + title: String, + accentColor: Color, + items: List, +) { + val iconTint = MaterialTheme.colorScheme.onPrimaryFixed.copy(alpha = 0.8f) + val statusIconShape = MaterialShapes.Cookie9Sided.toShape() + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + letterSpacing = androidx.compose.ui.unit.TextUnit( + 0.8f, + androidx.compose.ui.unit.TextUnitType.Sp + ), + color = accentColor, + modifier = Modifier.padding( + start = Dimensions.spacingLg, + bottom = Dimensions.spacingSm, + ), + ) + + Column(modifier = Modifier.fillMaxWidth()) { + items.forEachIndexed { index, item -> + RethinkListItem( + headline = item.headline, + supporting = item.supporting, + leadingIconPainter = item.iconPainter, + leadingIconTint = iconTint, + leadingIconContainerColor = item.iconAccentColor, + leadingIconContainerShape = statusIconShape, + position = cardPositionFor(index = index, lastIndex = items.lastIndex), + onClick = item.onClick + ) + } + } + } +} + +// ─── Protection Status Card ─────────────────────────────────────────────── + +@Composable +private fun ProtectionCard( + uiState: HomeScreenUiState, + onStartStopClick: () -> Unit +) { + val statusColors = rememberHomeStatusIconTints() + val iconGlyphTint = MaterialTheme.colorScheme.onPrimaryFixed.copy(alpha = 0.8f) + val outlineVariant = MaterialTheme.colorScheme.outlineVariant + val error = MaterialTheme.colorScheme.error + + val targetIcon = when { + uiState.isProtectionFailing -> Icons.Rounded.WarningAmber + uiState.isVpnActive -> Icons.Rounded.Shield + else -> Icons.Rounded.ShieldMoon + } + + val targetAccentColor = when { + uiState.isProtectionFailing -> statusColors.firewall + uiState.isVpnActive -> statusColors.proxy + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + val targetContainerColor = when { + uiState.isProtectionFailing -> statusColors.firewall + uiState.isVpnActive -> statusColors.proxy + else -> MaterialTheme.colorScheme.surfaceContainerHighest + } + + val targetBorderColor = when { + uiState.isProtectionFailing -> statusColors.firewall.copy(alpha = 0.34f) + uiState.isVpnActive -> statusColors.proxy.copy(alpha = 0.34f) + else -> outlineVariant.copy(alpha = 0.36f) + } + + val accentColor by animateColorAsState( + targetValue = targetAccentColor, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "protectionCardAccent" + ) + + val containerColor by animateColorAsState( + targetValue = targetContainerColor, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "protectionCardIconContainer" + ) + + val borderColor by animateColorAsState( + targetValue = targetBorderColor, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "protectionCardBorder" + ) + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.98f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessHigh), + label = "pressScale" + ) + + val statusLabel = uiState.protectionStatus.ifEmpty { + if (uiState.isVpnActive) "Protected" else "Not active" + } + + Surface( + onClick = onStartStopClick, + shape = HomePrimaryCardShape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, borderColor), + tonalElevation = 0.dp, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + ) { + Column(modifier = Modifier.padding(Dimensions.cardPadding)) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Surface( + shape = RoundedCornerShape(Dimensions.iconContainerRadius), + color = containerColor, + modifier = Modifier.size(Dimensions.iconContainerLg) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + imageVector = targetIcon, + contentDescription = null, + tint = if (uiState.isVpnActive || uiState.isProtectionFailing) { + iconGlyphTint + } else { + accentColor + }, + modifier = Modifier.size(Dimensions.iconSizeMd) + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Protection", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = statusLabel, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + StartStopButton( + isPlaying = uiState.isVpnActive, + onClick = onStartStopClick + ) + } + + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + MetricChip( + label = "Latency", + value = uiState.dnsLatency, + modifier = Modifier.weight(1f) + ) + MetricChip( + label = stringResource(R.string.lbl_network), + value = uiState.networkLogsCount.toString(), + modifier = Modifier.weight(1f) + ) + MetricChip( + label = stringResource(R.string.lbl_blocked), + value = uiState.appsBlocked.toString(), + valueColor = if (uiState.appsBlocked > 0) statusColors.firewall + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +private fun MetricChip( + label: String, + value: String, + modifier: Modifier = Modifier, + valueColor: Color = MaterialTheme.colorScheme.onSurface +) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = valueColor + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } +} + +// ─── Apps Health Card ───────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppsHealthCard( + uiState: HomeScreenUiState, + onClick: () -> Unit +) { + val statusColors = rememberHomeStatusIconTints() + val iconGlyphTint = MaterialTheme.colorScheme.onPrimaryFixed.copy(alpha = 0.8f) + val appsProgress = remember(uiState.appsAllowed, uiState.appsTotal) { + if (uiState.appsTotal > 0) uiState.appsAllowed.toFloat() / uiState.appsTotal.toFloat() + else 0f + } + + Surface( + onClick = onClick, + shape = HomeSecondaryCardShape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.36f)) + ) { + Column(modifier = Modifier.padding(Dimensions.cardPadding)) { + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(Dimensions.iconContainerRadius), + color = statusColors.apps, + modifier = Modifier.size(Dimensions.iconContainerMd) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + painter = painterResource(id = R.drawable.ic_app_info_accent), + contentDescription = null, + tint = iconGlyphTint, + modifier = Modifier.size(Dimensions.iconSizeSm) + ) + } + } + + Text( + text = stringResource(R.string.lbl_apps), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = statusColors.apps + ) { + Text( + text = uiState.appsTotal.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = iconGlyphTint, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + } + + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + + LinearProgressIndicator( + progress = { appsProgress }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(RoundedCornerShape(Dimensions.cornerRadiusPill)), + color = statusColors.apps, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeCap = StrokeCap.Round + ) + + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + AppStat( + label = stringResource(R.string.lbl_allowed), + value = uiState.appsAllowed.toString(), + color = MaterialTheme.colorScheme.secondary + ) + AppStat( + label = stringResource(R.string.lbl_blocked), + value = uiState.appsBlocked.toString(), + color = if (uiState.appsBlocked > 0) statusColors.firewall + else MaterialTheme.colorScheme.onSurfaceVariant + ) + AppStat( + label = stringResource(R.string.lbl_bypassed), + value = uiState.appsBypassed.toString(), + color = statusColors.dns + ) + } + } + } +} + +@Composable +private fun AppStat(label: String, value: String, color: Color) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +// ─── Preview ────────────────────────────────────────────────────────────── + +@Preview(showBackground = true) +@Composable +fun HomeScreenPreview() { + RethinkTheme { + HomeScreen( + uiState = HomeScreenUiState( + isVpnActive = true, + dnsLatency = "24ms", + dnsConnectedName = "Cloudflare", + firewallUniversalRules = 12, + firewallIpRules = 3, + firewallDomainRules = 8, + appsTotal = 120, + appsAllowed = 115, + appsBlocked = 5, + appsBypassed = 2, + networkLogsCount = 4320, + dnsLogsCount = 1230, + protectionStatus = "Protected" + ), + onStartStopClick = {}, + onDnsClick = {}, + onFirewallClick = {}, + onProxyClick = {}, + onLogsClick = {}, + onAppsClick = {}, + onSponsorClick = {} + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreenViewModel.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreenViewModel.kt new file mode 100644 index 000000000..8e39bdebd --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/home/HomeScreenViewModel.kt @@ -0,0 +1,233 @@ +package com.celzero.bravedns.ui.compose.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.Utilities +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.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import android.content.Context +import com.celzero.bravedns.service.BraveVPNService +import com.celzero.bravedns.service.DnsLogTracker +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.ProxyStateManager +import com.celzero.bravedns.service.WireguardManager +import com.celzero.firestack.backend.Backend + +class HomeScreenViewModel( + private val persistentState: PersistentState, + private val appConfig: AppConfig +) : ViewModel() { + + private val _uiState = MutableStateFlow(HomeScreenUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var proxyStateListenerJob: Job? = null + private var dnsLatencyJob: Job? = null + + init { + observeVpnState() + observeDnsStates() + observeFirewallStates() + observeLogsCount() + observeProxyStates() + observeAppStates() + } + + private fun observeVpnState() { + persistentState.vpnEnabledLiveData.asFlow().onEach { enabled -> + _uiState.update { it.copy(isVpnActive = enabled) } + syncDnsStatus() + }.launchIn(viewModelScope) + + VpnController.connectionStatus.asFlow().onEach { + if (VpnController.isAppPaused()) return@onEach + syncDnsStatus() + }.launchIn(viewModelScope) + } + + private fun observeDnsStates() { + appConfig.getConnectedDnsObservable().asFlow().onEach { dnsName -> + _uiState.update { it.copy(dnsConnectedName = dnsName) } + }.launchIn(viewModelScope) + + // DNS Latency polling + dnsLatencyJob?.cancel() + dnsLatencyJob = viewModelScope.launch(Dispatchers.IO) { + while (true) { + if (appConfig.getBraveMode().isDnsActive()) { + val dnsId = getDnsId() + val p50 = VpnController.p50(dnsId) + val latencyText = formatLatency(p50) + _uiState.update { it.copy(dnsLatency = latencyText) } + } else { + _uiState.update { it.copy(dnsConnectedName = "Disabled", dnsLatency = "-- ms") } + } + delay(5000L) + } + } + } + + private fun getDnsId(): String { + return if (WireguardManager.oneWireGuardEnabled()) { + val id = WireguardManager.getOneWireGuardProxyId() + if (id == null) { + if (appConfig.isSmartDnsEnabled()) Backend.Plus else Backend.Preferred + } else { + "${ProxyManager.ID_WG_BASE}${id}" + } + } else { + if (appConfig.isSmartDnsEnabled()) Backend.Plus else Backend.Preferred + } + } + + private fun formatLatency(p50: Long): String { + return when (p50) { + in 0L..19L -> "Very Fast" + in 20L..50L -> "Fast" + in 51L..100L -> "Slow" + else -> "Very Slow" + } + } + + private fun observeFirewallStates() { + persistentState.universalRulesCount.asFlow().onEach { count -> + _uiState.update { it.copy(firewallUniversalRules = count) } + }.launchIn(viewModelScope) + + IpRulesManager.getCustomIpsLiveData().asFlow().onEach { count -> + _uiState.update { it.copy(firewallIpRules = count) } + }.launchIn(viewModelScope) + + DomainRulesManager.getUniversalCustomDomainCount().asFlow().onEach { count -> + _uiState.update { it.copy(firewallDomainRules = count) } + }.launchIn(viewModelScope) + } + + private fun observeLogsCount() { + appConfig.dnsLogsCount.asFlow().onEach { count -> + _uiState.update { it.copy(dnsLogsCount = count) } + }.launchIn(viewModelScope) + + appConfig.networkLogsCount.asFlow().onEach { count -> + _uiState.update { it.copy(networkLogsCount = count) } + }.launchIn(viewModelScope) + } + + private fun observeProxyStates() { + persistentState.getProxyStatus().asFlow().distinctUntilChanged().onEach { status -> + proxyStateListenerJob?.cancel() + if (status != -1) { + proxyStateListenerJob = viewModelScope.launch(Dispatchers.IO) { + while (true) { + updateUiWithProxyStates() + delay(1500L) + } + } + } else { + _uiState.update { it.copy(proxyStatus = "Inactive") } + } + }.launchIn(viewModelScope) + } + + private suspend fun updateUiWithProxyStates() { + if (!persistentState.getVpnEnabled()) { + _uiState.update { it.copy(proxyStatus = "Inactive") } + return + } + + val proxyType = AppConfig.ProxyType.of(appConfig.getProxyType()) + if (proxyType.isProxyTypeWireguard()) { + val status = ProxyStateManager.calculateWireguardProxyStatus() + _uiState.update { it.copy(proxyStatus = status.statusText) } + } else { + val status = if (appConfig.isProxyEnabled()) "Active" else "Inactive" + _uiState.update { it.copy(proxyStatus = status) } + } + } + + private fun observeAppStates() { + FirewallManager.getApplistObserver().asFlow().onEach { list -> + val blockedCount = list.count { it.connectionStatus != FirewallManager.ConnectionStatus.ALLOW.id } + val bypassCount = list.count { it.firewallStatus == FirewallManager.FirewallStatus.BYPASS_UNIVERSAL.id || it.firewallStatus == FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL.id } + val excludedCount = list.count { it.firewallStatus == FirewallManager.FirewallStatus.EXCLUDE.id } + val isolatedCount = list.count { it.firewallStatus == FirewallManager.FirewallStatus.ISOLATE.id } + val allApps = list.size + val allowedApps = allApps - (blockedCount + bypassCount + excludedCount + isolatedCount) + + _uiState.update { + it.copy( + appsAllowed = allowedApps, + appsTotal = allApps, + appsBlocked = blockedCount, + appsBypassed = bypassCount, + appsExcluded = excludedCount, + appsIsolated = isolatedCount + ) + } + }.launchIn(viewModelScope) + } + + fun syncDnsStatus() { + viewModelScope.launch { + val vpnState = VpnController.state() + val isEch = vpnState.serverName?.contains(DnsLogTracker.ECH, true) == true + var isFailing = false + var statusString = "Protected" + + if (vpnState.on) { + when (vpnState.connectionState) { + BraveVPNService.State.APP_ERROR, + BraveVPNService.State.DNS_ERROR, + BraveVPNService.State.DNS_SERVER_DOWN, + BraveVPNService.State.NO_INTERNET -> { + isFailing = true + statusString = "Failing" + } + BraveVPNService.State.WORKING, + BraveVPNService.State.NEW -> { + statusString = if (isEch) "Ultra Secure" else "Protected" + } + else -> { + isFailing = true + statusString = "Failing" + } + } + } else if (persistentState.getVpnEnabled()) { + isFailing = true + statusString = "Waiting" + } else { + isFailing = true + statusString = "Exposed" + } + + if (VpnController.isUnderlyingVpnNetworkEmpty()) { + isFailing = true + statusString = "No Network" + } + + _uiState.update { + it.copy(protectionStatus = statusString, isProtectionFailing = isFailing) + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/home/PauseScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/home/PauseScreen.kt new file mode 100644 index 000000000..57d8c3612 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/home/PauseScreen.kt @@ -0,0 +1,315 @@ +/* + * Copyright 2024 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.compose.home + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Observer +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.celzero.bravedns.R +import com.celzero.bravedns.database.AppInfo +import com.celzero.bravedns.service.BraveVPNService +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.PauseTimer.PAUSE_VPN_EXTRA_MILLIS +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PauseScreen(onFinish: () -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + var timerText by remember { mutableStateOf("00:00:00") } + var timerDesc by remember { mutableStateOf("") } + var autoOp by remember { mutableStateOf(AutoOp.NONE) } + var longPressJob by remember { mutableStateOf(null) } + + DisposableEffect(lifecycleOwner) { + val connectionObserver = Observer { state -> + if (state != BraveVPNService.State.PAUSED) onFinish() + } + val timerObserver = Observer { millis -> + val ss = (TimeUnit.MILLISECONDS.toSeconds(millis) % 60).toString().padStart(2, '0') + val mm = (TimeUnit.MILLISECONDS.toMinutes(millis) % 60).toString().padStart(2, '0') + val hh = TimeUnit.MILLISECONDS.toHours(millis).toString().padStart(2, '0') + timerText = "$hh:$mm:$ss" + } + val appListObserver = Observer> { list -> + timerDesc = list.count { a -> a.connectionStatus != FirewallManager.ConnectionStatus.ALLOW.id }.toString() + } + + VpnController.connectionStatus.observe(lifecycleOwner, connectionObserver) + VpnController.getPauseCountDownObserver()?.observe(lifecycleOwner, timerObserver) + FirewallManager.getApplistObserver().observe(lifecycleOwner, appListObserver) + if (!VpnController.isAppPaused()) onFinish() + + onDispose { + VpnController.connectionStatus.removeObserver(connectionObserver) + VpnController.getPauseCountDownObserver()?.removeObserver(timerObserver) + FirewallManager.getApplistObserver().removeObserver(appListObserver) + longPressJob?.cancel() + } + } + + fun handleLongPress() { + if (longPressJob?.isActive == true) return + longPressJob = scope.launch(Dispatchers.Main) { + while (autoOp != AutoOp.NONE) { + when (autoOp) { + AutoOp.INCREASE -> { + delay(200); VpnController.increasePauseDuration(PAUSE_VPN_EXTRA_MILLIS) + } + + AutoOp.DECREASE -> { + delay(200); VpnController.decreasePauseDuration(PAUSE_VPN_EXTRA_MILLIS) + } + + else -> {} + } + } + } + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.pause_text), + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = Dimensions.screenPaddingHorizontal), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + + // ── Timer card ──────────────────────────────────────────────── + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius5xl), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Label chip + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + text = stringResource(R.string.pause_text).uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + letterSpacing = androidx.compose.ui.unit.TextUnit( + 1.2f, + androidx.compose.ui.unit.TextUnitType.Sp + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Monospace countdown timer — big, bold, beautiful + Text( + text = timerText, + style = MaterialTheme.typography.displayLarge, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Blocked apps count + if (timerDesc.isNotEmpty()) { + Text( + text = stringResource(R.string.pause_desc, timerDesc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + // ── Controls ────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + PauseControlButton( + icon = R.drawable.ic_minus, + size = 52.dp, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + onClick = { VpnController.decreasePauseDuration(PAUSE_VPN_EXTRA_MILLIS) }, + onLongClick = { autoOp = AutoOp.DECREASE; handleLongPress() }, + onRelease = { autoOp = AutoOp.NONE } + ) + Spacer(modifier = Modifier.width(16.dp)) + // Resume button — large, prominent, error tint + PauseControlButton( + icon = R.drawable.ic_stop, + size = 72.dp, + containerColor = MaterialTheme.colorScheme.errorContainer, + iconTintIsOnError = true, + onClick = { VpnController.resumeApp(); onFinish() }, + onLongClick = {}, + onRelease = {} + ) + Spacer(modifier = Modifier.width(16.dp)) + PauseControlButton( + icon = R.drawable.ic_plus, + size = 52.dp, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + onClick = { VpnController.increasePauseDuration(PAUSE_VPN_EXTRA_MILLIS) }, + onLongClick = { autoOp = AutoOp.INCREASE; handleLongPress() }, + onRelease = { autoOp = AutoOp.NONE } + ) + } + } + } + + // Resume bottom button — full width + ElevatedButton( + onClick = { VpnController.resumeApp(); onFinish() }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + colors = ButtonDefaults.elevatedButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_stop), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.notif_dialog_pause_dialog_positive), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@Composable +private fun PauseControlButton( + icon: Int, + size: Dp, + containerColor: androidx.compose.ui.graphics.Color, + iconTintIsOnError: Boolean = false, + onClick: () -> Unit, + onLongClick: () -> Unit, + onRelease: () -> Unit +) { + val iconTint = if (iconTintIsOnError) MaterialTheme.colorScheme.onErrorContainer + else MaterialTheme.colorScheme.onSurfaceVariant + + Surface( + modifier = Modifier.size(size), + shape = RoundedCornerShape(size / 3), + color = containerColor, + tonalElevation = 0.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onTap = { onClick() }, + onLongPress = { onLongClick() }, + onPress = { tryAwaitRelease(); onRelease() } + ) + } + ) { + Icon( + painter = androidx.compose.ui.res.painterResource(id = icon), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(size * 0.44f) + ) + } + } +} + +private enum class AutoOp { INCREASE, DECREASE, NONE } diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/home/WelcomeScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/home/WelcomeScreen.kt new file mode 100644 index 000000000..8e93dfb00 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/home/WelcomeScreen.kt @@ -0,0 +1,281 @@ +/* + * 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.compose.home + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions +import kotlinx.coroutines.launch + +@Composable +fun WelcomeScreen(onFinish: () -> Unit) { + val slides = remember { + listOf( + WelcomeSlide(R.drawable.ic_launcher, R.string.slide_2_title, R.string.slide_2_desc), + WelcomeSlide(R.drawable.ic_wireguard_welcome, R.string.wireguard_title, R.string.wireguard_desc), + WelcomeSlide(R.drawable.ic_firewall_welcome, R.string.firewall_title, R.string.firewall_desc), + WelcomeSlide(R.drawable.ic_dns_welcome, R.string.dns_title, R.string.dns_desc) + ) + } + + val pagerState = rememberPagerState { slides.size } + val scope = rememberCoroutineScope() + val isLastPage = pagerState.currentPage >= slides.lastIndex + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Skip link — top right, subtle + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal, vertical = 8.dp), + horizontalArrangement = Arrangement.End + ) { + AnimatedVisibility( + visible = !isLastPage, + enter = expandVertically() + fadeIn(), + exit = fadeOut() + shrinkVertically() + ) { + TextButton(onClick = onFinish) { + Text( + text = stringResource(R.string.skip), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Full slide pager — takes all available space + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .padding(horizontal = Dimensions.screenPaddingHorizontal), + pageSpacing = 12.dp, + beyondViewportPageCount = 1 + ) { page -> + WelcomeSlideContent(slide = slides[page]) + } + + // Bottom nav area + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal) + .padding(top = 20.dp) + .padding(bottom = 12.dp) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + PillDotIndicator(count = slides.size, pagerState = pagerState) + + Button( + onClick = { + if (isLastPage) onFinish() + else scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + if (isLastPage) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.finish), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } else { + Text( + text = stringResource(R.string.next), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + } +} + +@Composable +private fun WelcomeSlideContent(slide: WelcomeSlide) { + // Each slide fills the allocated space — M3 tonal card, no border + Surface( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(Dimensions.cornerRadius5xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 28.dp, vertical = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Feature image in a tinted circular container + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(180.dp) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = slide.image), + contentDescription = null, + modifier = Modifier.size(110.dp) + ) + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + Text( + text = stringResource(slide.title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(slide.desc), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun PillDotIndicator(count: Int, pagerState: PagerState) { + val activeColor = MaterialTheme.colorScheme.primary + val inactiveColor = MaterialTheme.colorScheme.surfaceContainerHighest + val dotSize = 8.dp + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + repeat(count) { index -> + val isSelected = pagerState.currentPage == index + val width by animateDpAsState( + targetValue = if (isSelected) 24.dp else dotSize, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "dotWidth" + ) + val color by animateColorAsState( + targetValue = if (isSelected) activeColor else inactiveColor, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "dotColor" + ) + Box( + modifier = Modifier + .height(dotSize) + .width(width) + .clip(RoundedCornerShape(Dimensions.cornerRadiusXs)) + .background(color) + ) + } + } +} + +private data class WelcomeSlide(val image: Int, val title: Int, val desc: Int) diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseDomainLogsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseDomainLogsScreen.kt new file mode 100644 index 000000000..ddd62420f --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseDomainLogsScreen.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2024 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.compose.logs + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.asFlow +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.CloseConnsDialog +import com.celzero.bravedns.adapter.DomainRow +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.ui.bottomsheet.AppDomainRulesSheet +import com.celzero.bravedns.util.Constants.Companion.INVALID_UID +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.AppConnectionsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun AppWiseDomainLogsScreen( + uid: Int, + viewModel: AppConnectionsViewModel, + eventLogger: EventLogger, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val twoArgumentColonTemplate = stringResource(R.string.two_argument_colon) + val twoArgumentSpaceTemplate = stringResource(R.string.two_argument_space) + val appOtherAppsTemplate = stringResource(R.string.ctbs_app_other_apps, "", "") + val searchLabel = stringResource(R.string.lbl_search) + val serviceProviderLabel = stringResource(R.string.lbl_service_providers) + val universalIpLabel = stringResource(R.string.search_universal_ips) + var appName by remember(uid) { mutableStateOf("") } + var searchHint by remember { mutableStateOf("") } + var appIcon by remember { mutableStateOf(null) } + var selectedCategory by remember { mutableStateOf(AppConnectionsViewModel.TimeCategory.SEVEN_DAYS) } + var showDeleteDialog by remember { mutableStateOf(false) } + + LaunchedEffect(uid) { + if (uid == INVALID_UID) { + onBackClick?.invoke() + return@LaunchedEffect + } + + viewModel.setUid(uid) + viewModel.setFilter("", AppConnectionsViewModel.FilterType.DOMAIN) + viewModel.timeCategoryChanged(selectedCategory, true) + + withContext(Dispatchers.IO) { + val meta = resolveAppWiseLogsHeader( + context = context, + uid = uid, + isAsn = false, + appOtherAppsTemplate = appOtherAppsTemplate, + twoArgumentColonTemplate = twoArgumentColonTemplate, + twoArgumentSpaceTemplate = twoArgumentSpaceTemplate, + searchLabel = searchLabel, + serviceProvidersLabel = serviceProviderLabel, + universalIpsLabel = universalIpLabel + ) + if (meta == null) { + withContext(Dispatchers.Main) { onBackClick?.invoke() } + return@withContext + } + withContext(Dispatchers.Main) { + appName = meta.appName + searchHint = meta.searchHint + appIcon = meta.appIcon + } + } + } + + val items = remember(uid) { viewModel.appDomainLogs.asFlow() }.collectAsLazyPagingItems() + + AppWiseLogsScaffold( + title = appName, + onBackClick = onBackClick + ) { paddingValues -> + if (showDeleteDialog) { + AppWiseLogsDeleteDialog( + onDismiss = { showDeleteDialog = false }, + onConfirm = { viewModel.deleteLogs(uid) } + ) + } + + AppWiseLogsScreenContent( + title = appName.ifBlank { stringResource(R.string.lbl_logs) }, + searchHint = searchHint, + appIcon = appIcon ?: Utilities.getDefaultIcon(context), + showToggleGroup = true, + selectedCategory = selectedCategory, + onCategorySelected = { category -> + selectedCategory = category + viewModel.timeCategoryChanged(category, true) + }, + defaultHintRes = R.string.search_custom_domains, + showDeleteIcon = true, + onDeleteClick = { showDeleteDialog = true }, + onQueryChange = { query -> + viewModel.setFilter(query, AppConnectionsViewModel.FilterType.DOMAIN) + }, + modifier = Modifier.fillMaxSize().padding(paddingValues) + ) { + AppWiseDomainList( + items = items, + uid = uid, + eventLogger = eventLogger + ) + } + } +} + +@Composable +private fun AppWiseDomainList( + items: androidx.paging.compose.LazyPagingItems, + uid: Int, + eventLogger: EventLogger +) { + var showDomainRulesSheet by remember { mutableStateOf(false) } + var selectedDomain by remember { mutableStateOf("") } + var refreshToken by remember { mutableStateOf(0) } + var pendingCloseDialog by remember { mutableStateOf(null) } + + pendingCloseDialog?.let { conn -> + CloseConnsDialog( + conn = conn, + onConfirm = { pendingCloseDialog = null }, + onDismiss = { pendingCloseDialog = null } + ) + } + + if (showDomainRulesSheet && selectedDomain.isNotEmpty()) { + AppDomainRulesSheet( + uid = uid, + domain = selectedDomain, + eventLogger = eventLogger, + onDismiss = { showDomainRulesSheet = false }, + onUpdated = { refreshToken++ } + ) + } + + AppWiseLogsPagedList(items = items) { item -> + DomainRow( + conn = item, + uid = uid, + isActiveConn = false, + refreshToken = refreshToken, + onIpClick = { conn -> + selectedDomain = conn.appOrDnsName.orEmpty() + if (selectedDomain.isNotEmpty()) { + showDomainRulesSheet = true + } + } + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseIpLogsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseIpLogsScreen.kt new file mode 100644 index 000000000..bf2d20a4b --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseIpLogsScreen.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2024 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.compose.logs + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.asFlow +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.IpRow +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.ui.bottomsheet.AppIpRulesSheet +import com.celzero.bravedns.util.Constants.Companion.INVALID_UID +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.removeBeginningTrailingCommas +import com.celzero.bravedns.viewmodel.AppConnectionsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun AppWiseIpLogsScreen( + uid: Int, + isAsn: Boolean, + viewModel: AppConnectionsViewModel, + eventLogger: EventLogger, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val twoArgumentColonTemplate = stringResource(R.string.two_argument_colon) + val twoArgumentSpaceTemplate = stringResource(R.string.two_argument_space) + val appOtherAppsTemplate = stringResource(R.string.ctbs_app_other_apps, "", "") + val searchLabel = stringResource(R.string.lbl_search) + val serviceProviderLabel = stringResource(R.string.lbl_service_providers) + val universalIpLabel = stringResource(R.string.search_universal_ips) + var appName by remember(uid) { mutableStateOf("") } + var searchHint by remember { mutableStateOf("") } + var appIcon by remember { mutableStateOf(null) } + val showDeleteIcon = remember { !isAsn } + var showDeleteDialog by remember { mutableStateOf(false) } + var selectedCategory by remember { mutableStateOf(AppConnectionsViewModel.TimeCategory.SEVEN_DAYS) } + var isRethinkApp by remember(uid) { mutableStateOf(false) } + + LaunchedEffect(uid, isAsn) { + if (uid == INVALID_UID) { + onBackClick?.invoke() + return@LaunchedEffect + } + viewModel.timeCategoryChanged(selectedCategory, isDomain = false) + withContext(Dispatchers.IO) { + val meta = resolveAppWiseLogsHeader( + context = context, + uid = uid, + isAsn = isAsn, + appOtherAppsTemplate = appOtherAppsTemplate, + twoArgumentColonTemplate = twoArgumentColonTemplate, + twoArgumentSpaceTemplate = twoArgumentSpaceTemplate, + searchLabel = searchLabel, + serviceProvidersLabel = serviceProviderLabel, + universalIpsLabel = universalIpLabel + ) + if (meta == null) { + withContext(Dispatchers.Main) { onBackClick?.invoke() } + return@withContext + } + withContext(Dispatchers.Main) { + appName = meta.appName + searchHint = meta.searchHint + appIcon = meta.appIcon + isRethinkApp = meta.isRethinkApp + } + } + } + + AppWiseLogsScaffold( + title = appName, + onBackClick = onBackClick + ) { paddingValues -> + if (showDeleteDialog) { + AppWiseLogsDeleteDialog( + onDismiss = { showDeleteDialog = false }, + onConfirm = { viewModel.deleteLogs(uid) } + ) + } + + AppWiseLogsScreenContent( + title = appName.ifBlank { stringResource(R.string.lbl_logs) }, + searchHint = searchHint, + appIcon = appIcon ?: Utilities.getDefaultIcon(context), + showToggleGroup = true, + selectedCategory = selectedCategory, + onCategorySelected = { category -> + selectedCategory = category + viewModel.timeCategoryChanged(category, isDomain = false) + }, + defaultHintRes = R.string.search_universal_ips, + showDeleteIcon = showDeleteIcon, + onDeleteClick = { showDeleteDialog = true }, + onQueryChange = { query -> + val type = + if (isAsn) { + AppConnectionsViewModel.FilterType.ASN + } else { + AppConnectionsViewModel.FilterType.IP + } + viewModel.setFilter(query, type) + }, + modifier = Modifier.fillMaxSize().padding(paddingValues), + queryEnabled = !isAsn + ) { + AppWiseIpList( + viewModel = viewModel, + uid = uid, + isAsn = isAsn, + isRethinkApp = isRethinkApp, + eventLogger = eventLogger + ) + } + } +} + +@Composable +private fun AppWiseIpList( + viewModel: AppConnectionsViewModel, + uid: Int, + isAsn: Boolean, + isRethinkApp: Boolean, + eventLogger: EventLogger +) { + var showIpRulesSheet by remember { mutableStateOf(false) } + var selectedIp by remember { mutableStateOf("") } + var selectedDomains by remember { mutableStateOf("") } + var refreshToken by remember { mutableStateOf(0) } + + if (showIpRulesSheet && selectedIp.isNotEmpty()) { + AppIpRulesSheet( + uid = uid, + ipAddress = selectedIp, + domains = selectedDomains, + eventLogger = eventLogger, + onDismiss = { showIpRulesSheet = false }, + onUpdated = { refreshToken++ } + ) + } + + LaunchedEffect(uid, isRethinkApp) { + if (!isRethinkApp) viewModel.setUid(uid) + } + + val flow = remember(isRethinkApp, isAsn) { + if (isRethinkApp) viewModel.rinrIpLogs else if (isAsn) viewModel.asnLogs else viewModel.appIpLogs + } + val items = flow.asFlow().collectAsLazyPagingItems() + + AppWiseLogsPagedList(items = items) { item -> + IpRow( + conn = item, + isAsn = isAsn, + refreshToken = refreshToken, + onIpClick = { conn -> + if (!isAsn) { + selectedIp = conn.ipAddress + selectedDomains = + removeBeginningTrailingCommas(conn.appOrDnsName ?: "").replace(",,", ",").replace(",", ", ") + showIpRulesSheet = true + } + } + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseLogsShared.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseLogsShared.kt new file mode 100644 index 000000000..7137e5543 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/AppWiseLogsShared.kt @@ -0,0 +1,440 @@ +/* + * 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.compose.logs + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.paging.compose.LazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.util.Constants.Companion.INVALID_UID +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.AppConnectionsViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +internal fun AppWiseLogsDeleteDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.ada_delete_logs_dialog_title), + message = stringResource(R.string.ada_delete_logs_dialog_desc), + confirmText = stringResource(R.string.lbl_proceed), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + onDismiss() + onConfirm() + }, + onDismiss = onDismiss, + isConfirmDestructive = true + ) +} + +internal data class AppWiseLogsHeader( + val appName: String, + val searchHint: String, + val appIcon: Drawable?, + val isRethinkApp: Boolean +) + +internal suspend fun resolveAppWiseLogsHeader( + context: Context, + uid: Int, + isAsn: Boolean, + appOtherAppsTemplate: String, + twoArgumentColonTemplate: String, + twoArgumentSpaceTemplate: String, + searchLabel: String, + serviceProvidersLabel: String, + universalIpsLabel: String +): AppWiseLogsHeader? { + if (uid == INVALID_UID) return null + + val info = FirewallManager.getAppInfoByUid(uid) ?: return null + val packageNames = FirewallManager.getPackageNamesByUid(uid) + val isRethinkApp = packageNames.any { it == context.packageName } + + val visibleName = + if (packageNames.size >= 2) { + String.format( + appOtherAppsTemplate, + info.appName, + (packageNames.size - 1).toString() + ) + } else { + info.appName + } + val truncated = visibleName.substring(0, visibleName.length.coerceAtMost(10)) + val hint = + if (isAsn) { + val txt = + String.format(twoArgumentSpaceTemplate, searchLabel, serviceProvidersLabel) + String.format(twoArgumentColonTemplate, truncated, txt) + } else { + String.format(twoArgumentColonTemplate, truncated, universalIpsLabel) + } + + return AppWiseLogsHeader( + appName = visibleName, + searchHint = hint, + appIcon = Utilities.getIcon(context, info.packageName, info.appName), + isRethinkApp = isRethinkApp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AppWiseLogsScaffold( + title: String, + onBackClick: (() -> Unit)? = null, + content: @Composable (paddingValues: PaddingValues) -> Unit +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = title, + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + content(paddingValues) + } +} + +@Composable +internal fun AppWiseLogsScreenContent( + title: String, + searchHint: String, + appIcon: Drawable?, + showToggleGroup: Boolean, + selectedCategory: AppConnectionsViewModel.TimeCategory, + onCategorySelected: (AppConnectionsViewModel.TimeCategory) -> Unit, + defaultHintRes: Int, + showDeleteIcon: Boolean, + onDeleteClick: () -> Unit, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + queryEnabled: Boolean = true, + content: @Composable () -> Unit +) { + Column(modifier = modifier.fillMaxSize()) { + Surface( + modifier = + Modifier.padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ), + shape = RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier.padding(Dimensions.spacingLg), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = searchHint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Column( + modifier = + Modifier.padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingMd + ) + ) { + if (showToggleGroup) { + AppWiseTimeCategoryToggleRow( + selectedCategory = selectedCategory, + onCategorySelected = onCategorySelected + ) + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + } + + AppWiseSearchHeaderRow( + appIcon = appIcon, + searchHint = searchHint, + defaultHintRes = defaultHintRes, + showDeleteIcon = showDeleteIcon, + onDeleteClick = onDeleteClick, + queryEnabled = queryEnabled, + onQueryChange = onQueryChange + ) + } + + content() + } +} + +@Composable +internal fun AppWiseLogsPagedList( + items: LazyPagingItems, + modifier: Modifier = Modifier, + row: @Composable (T) -> Unit +) { + androidx.compose.foundation.lazy.LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = + PaddingValues( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + items(count = items.itemCount) { index -> + val item = items[index] ?: return@items + row(item) + } + } +} + +@Composable +internal fun AppWiseTimeCategoryToggleRow( + selectedCategory: AppConnectionsViewModel.TimeCategory, + onCategorySelected: (AppConnectionsViewModel.TimeCategory) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.spacingSm), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + listOf( + AppConnectionsViewModel.TimeCategory.ONE_HOUR to + stringResource(R.string.ci_desc, "1", stringResource(R.string.lbl_hour)), + AppConnectionsViewModel.TimeCategory.TWENTY_FOUR_HOUR to + stringResource(R.string.ci_desc, "24", stringResource(R.string.lbl_hour)), + AppConnectionsViewModel.TimeCategory.SEVEN_DAYS to + stringResource(R.string.ci_desc, "7", stringResource(R.string.lbl_day)) + ).forEach { (category, label) -> + TimeCategoryToggleButton( + label = label, + selected = selectedCategory == category, + onClick = { onCategorySelected(category) }, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun TimeCategoryToggleButton( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = onClick, + modifier = modifier.height(36.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + contentColor = + if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ), + shape = RoundedCornerShape(Dimensions.buttonCornerRadiusLarge) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@OptIn(FlowPreview::class) +@Composable +internal fun AppWiseSearchHeaderRow( + appIcon: Drawable?, + searchHint: String, + defaultHintRes: Int, + showDeleteIcon: Boolean, + onDeleteClick: () -> Unit, + onQueryChange: (String) -> Unit, + queryEnabled: Boolean = true +) { + val clearSearchContentDescription = stringResource(R.string.cd_clear_search) + val deleteContentDescription = stringResource(R.string.lbl_delete) + var query by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + snapshotFlow { query } + .debounce(500L) + .distinctUntilChanged() + .collect { value -> onQueryChange(value) } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Row( + modifier = Modifier.padding(horizontal = Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .padding(Dimensions.spacingSm) + .size(Dimensions.iconSizeMd) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center + ) { + val bitmap = remember(appIcon) { appIcon?.toBitmap(width = 48, height = 48) } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } + + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.weight(1f), + singleLine = true, + enabled = queryEnabled, + placeholder = { + Text( + text = searchHint.ifEmpty { stringResource(defaultHintRes) }, + style = MaterialTheme.typography.bodyMedium + ) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ) + ) + + AnimatedVisibility(visible = query.isNotEmpty()) { + IconButton(onClick = { query = "" }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = clearSearchContentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimensions.iconSizeSm) + ) + } + } + + if (showDeleteIcon) { + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = deleteContentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimensions.iconSizeMd) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/logs/NetworkLogsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/NetworkLogsScreen.kt new file mode 100644 index 000000000..c25ecd56e --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/logs/NetworkLogsScreen.kt @@ -0,0 +1,1532 @@ +/* + * Copyright 2024 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.compose.logs + +import android.graphics.drawable.Drawable +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.NetworkPing +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.NetworkPing +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Shield +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.asFlow +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.ConnectionRow +import com.celzero.bravedns.adapter.DnsLogRow +import com.celzero.bravedns.database.ConnectionTracker +import com.celzero.bravedns.database.ConnectionTrackerRepository +import com.celzero.bravedns.database.DnsLog +import com.celzero.bravedns.database.DnsLogRepository +import com.celzero.bravedns.database.LogAppCount +import com.celzero.bravedns.database.RethinkLogRepository +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallRuleset +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.theme.RethinkBottomSheetCard +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkModalBottomSheet +import com.celzero.bravedns.ui.compose.theme.RethinkSearchField +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.ConnectionTrackerViewModel +import com.celzero.bravedns.viewmodel.DnsLogViewModel +import com.celzero.bravedns.viewmodel.RethinkLogViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class LogTab { CONNECTION, DNS } + +private data class LogsTabSpec( + val tab: LogTab, + val title: Int +) + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NetworkLogsScreen( + connectionTrackerViewModel: ConnectionTrackerViewModel, + dnsLogViewModel: DnsLogViewModel, + rethinkLogViewModel: RethinkLogViewModel, + connectionTrackerRepository: ConnectionTrackerRepository, + dnsLogRepository: DnsLogRepository, + rethinkLogRepository: RethinkLogRepository, + persistentState: PersistentState, + eventLogger: EventLogger, + onBackClick: (() -> Unit)? = null +) { + val tabs = + listOf( + LogsTabSpec( + tab = LogTab.CONNECTION, + title = R.string.firewall_act_network_monitor_tab + ), + LogsTabSpec( + tab = LogTab.DNS, + title = R.string.dns_mode_info_title + ) + ) + val selectedTab = remember { mutableIntStateOf(0) } + + var selectedDns by remember { mutableStateOf(null) } + var onRefreshLogs by remember { mutableStateOf<(() -> Unit)?>(null) } + var onClearLogs by remember { mutableStateOf<(() -> Unit)?>(null) } + + LaunchedEffect(selectedTab.intValue) { + onRefreshLogs = null + onClearLogs = null + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.lbl_logs), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior, + actions = { + LogsInlineTabSwitch( + selectedTabIndex = selectedTab.intValue, + onTabSelected = { selectedTab.intValue = it } + ) + LogsTopBarOverflowActions( + onRefresh = onRefreshLogs, + onDelete = onClearLogs + ) + } + ) + } + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + when (tabs[selectedTab.intValue].tab) { + LogTab.CONNECTION -> { + ConnectionLogsContent( + viewModel = connectionTrackerViewModel, + repository = connectionTrackerRepository, + persistentState = persistentState, + onTopBarActionsChange = { refreshAction, clearAction -> + if (onRefreshLogs !== refreshAction) onRefreshLogs = refreshAction + if (onClearLogs !== clearAction) onClearLogs = clearAction + } + ) + } + + LogTab.DNS -> { + DnsLogsContent( + viewModel = dnsLogViewModel, + repository = dnsLogRepository, + persistentState = persistentState, + onTopBarActionsChange = { refreshAction, clearAction -> + if (onRefreshLogs !== refreshAction) onRefreshLogs = refreshAction + if (onClearLogs !== clearAction) onClearLogs = clearAction + }, + onShowDnsLog = { selectedDns = it } + ) + } + } + } + } + + if (selectedDns != null) { + DnsLogDetailsSheet( + log = selectedDns!!, + onDismiss = { selectedDns = null } + ) + } +} + +@OptIn(FlowPreview::class) +@Composable +private fun ConnectionLogsContent( + viewModel: ConnectionTrackerViewModel, + repository: ConnectionTrackerRepository, + persistentState: PersistentState, + onTopBarActionsChange: (refreshAction: (() -> Unit)?, clearAction: (() -> Unit)?) -> Unit +) { + val logsFlow = remember(viewModel) { viewModel.connectionTrackerList.asFlow() } + val items = logsFlow.collectAsLazyPagingItems() + + var showDeleteDialog by remember { mutableStateOf(false) } + var showRulesDialog by remember { mutableStateOf(false) } + var showAppFilterDialog by remember { mutableStateOf(false) } + var selectedAppFilter by remember { mutableStateOf(null) } + var appPickerQuery by remember { mutableStateOf("") } + var appFilterOptions by remember { mutableStateOf>(emptyList()) } + var appFilterOptionsLoading by remember { mutableStateOf(false) } + var parentFilter by remember { mutableStateOf(ConnectionTrackerViewModel.TopLevelFilter.ALL) } + var childFilters by remember { mutableStateOf(setOf()) } + + val filterOptions = listOf( + LogsFilterOption( + value = ConnectionTrackerViewModel.TopLevelFilter.ALL, + label = stringResource(R.string.lbl_all), + selectedIcon = Icons.Rounded.Public, + unselectedIcon = Icons.Outlined.Public + ), + LogsFilterOption( + value = ConnectionTrackerViewModel.TopLevelFilter.ALLOWED, + label = stringResource(R.string.lbl_allowed), + selectedIcon = Icons.Rounded.CheckCircle, + unselectedIcon = Icons.Outlined.CheckCircle + ), + LogsFilterOption( + value = ConnectionTrackerViewModel.TopLevelFilter.BLOCKED, + label = stringResource(R.string.lbl_blocked), + selectedIcon = Icons.Rounded.Block, + unselectedIcon = Icons.Outlined.Block + ) + ) + val refreshAction = remember(items) { { items.refresh() } } + val clearAction = remember { { showDeleteDialog = true } } + val openAppFilterAction = remember { { showAppFilterDialog = true } } + + BackHandler(enabled = selectedAppFilter != null && !showDeleteDialog && !showRulesDialog && !showAppFilterDialog) { + selectedAppFilter = null + } + + LaunchedEffect(refreshAction, clearAction, persistentState.logsEnabled, items.itemCount) { + onTopBarActionsChange( + refreshAction.takeIf { persistentState.logsEnabled }, + clearAction.takeIf { persistentState.logsEnabled && items.itemCount > 0 } + ) + } + + LaunchedEffect(Unit) { + // Reset stale filters when re-entering the screen to avoid empty/hidden results. + viewModel.setFilter("", emptySet(), ConnectionTrackerViewModel.TopLevelFilter.ALL) + } + + LaunchedEffect(Unit) { + snapshotFlow { Triple(selectedAppFilter.orEmpty(), parentFilter, childFilters) } + .debounce(300) + .distinctUntilChanged() + .collect { (q, type, filters) -> + viewModel.setFilter(q, filters, type) + } + } + + if (!persistentState.logsEnabled) { + LogsDisabledState() + return + } + + val ruleFilters = when (parentFilter) { + ConnectionTrackerViewModel.TopLevelFilter.BLOCKED -> FirewallRuleset.getBlockedRules() + ConnectionTrackerViewModel.TopLevelFilter.ALLOWED -> FirewallRuleset.getAllowedRules() + ConnectionTrackerViewModel.TopLevelFilter.ALL -> FirewallRuleset.entries.toList() + } + val hasRulesFilter = ruleFilters.isNotEmpty() + + LaunchedEffect(showAppFilterDialog, parentFilter, childFilters, persistentState.logsEnabled) { + if (!showAppFilterDialog || !persistentState.logsEnabled) return@LaunchedEffect + appFilterOptionsLoading = true + appFilterOptions = + withContext(Dispatchers.IO) { + when (parentFilter) { + ConnectionTrackerViewModel.TopLevelFilter.ALL -> + repository.getAllLoggedAppsWithCount(childFilters) + ConnectionTrackerViewModel.TopLevelFilter.ALLOWED -> + repository.getAllowedLoggedAppsWithCount(childFilters) + ConnectionTrackerViewModel.TopLevelFilter.BLOCKED -> + repository.getBlockedLoggedAppsWithCount(childFilters) + } + } + appFilterOptionsLoading = false + } + + val listState = rememberLazyListState() + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + LogsControlsDeck { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + LogsPrimaryFilterRow( + options = filterOptions, + selectedValue = parentFilter, + onValueSelected = { filterType -> + parentFilter = filterType + childFilters = emptySet() + }, + modifier = Modifier.weight(1f) + ) + + LogsCompactIconAction( + icon = Icons.Rounded.FilterList, + contentDescription = stringResource(R.string.lbl_rules), + selected = childFilters.isNotEmpty(), + enabled = hasRulesFilter, + count = childFilters.size, + onClick = { showRulesDialog = true } + ) + + LogsCompactIconAction( + icon = Icons.Rounded.Apps, + contentDescription = stringResource(R.string.lbl_apps), + selected = selectedAppFilter != null, + enabled = true, + onClick = { openAppFilterAction() } + ) + } + } + + LogsPagedListContent( + items = items, + listState = listState, + modifier = Modifier.weight(1f) + ) { item, index, itemCount -> + ConnectionRow( + ct = item, + index = index, + itemCount = itemCount + ) + } + } + + if (showRulesDialog && ruleFilters.isNotEmpty()) { + LogsRulesDialog( + rules = ruleFilters, + selectedRules = childFilters, + onToggleRule = { ruleId -> + childFilters = + if (childFilters.contains(ruleId)) childFilters - ruleId + else childFilters + ruleId + }, + onClear = { childFilters = emptySet() }, + onDismiss = { showRulesDialog = false } + ) + } + + if (showAppFilterDialog) { + LogsAppFilterDialog( + options = appFilterOptions, + selectedApp = selectedAppFilter, + searchQuery = appPickerQuery, + isLoading = appFilterOptionsLoading, + onSearchQueryChange = { appPickerQuery = it }, + onSelectApp = { selectedApp -> + selectedAppFilter = selectedApp + showAppFilterDialog = false + }, + onClearSelection = { selectedAppFilter = null }, + onDismiss = { showAppFilterDialog = false } + ) + } + } + + LogsDeleteDialog( + show = showDeleteDialog, + onDismiss = { showDeleteDialog = false }, + onDelete = { repository.clearAllData() }, + onRefresh = { items.refresh() } + ) +} + +@OptIn(FlowPreview::class) +@Composable +private fun DnsLogsContent( + viewModel: DnsLogViewModel, + repository: DnsLogRepository, + persistentState: PersistentState, + onTopBarActionsChange: (refreshAction: (() -> Unit)?, clearAction: (() -> Unit)?) -> Unit, + onShowDnsLog: (DnsLog) -> Unit +) { + val logsFlow = remember(viewModel) { viewModel.dnsLogsList.asFlow() } + val items = logsFlow.collectAsLazyPagingItems() + + var showDeleteDialog by remember { mutableStateOf(false) } + var showAppFilterDialog by remember { mutableStateOf(false) } + var selectedAppFilter by remember { mutableStateOf(null) } + var appPickerQuery by remember { mutableStateOf("") } + var appFilterOptions by remember { mutableStateOf>(emptyList()) } + var appFilterOptionsLoading by remember { mutableStateOf(false) } + var filterType by remember { mutableStateOf(DnsLogViewModel.DnsLogFilter.ALL) } + + val filterOptions = listOf( + LogsFilterOption( + value = DnsLogViewModel.DnsLogFilter.ALL, + label = stringResource(R.string.lbl_all), + selectedIcon = Icons.Rounded.Public, + unselectedIcon = Icons.Outlined.Public + ), + LogsFilterOption( + value = DnsLogViewModel.DnsLogFilter.ALLOWED, + label = stringResource(R.string.lbl_allowed), + selectedIcon = Icons.Rounded.CheckCircle, + unselectedIcon = Icons.Outlined.CheckCircle + ), + LogsFilterOption( + value = DnsLogViewModel.DnsLogFilter.BLOCKED, + label = stringResource(R.string.lbl_blocked), + selectedIcon = Icons.Rounded.Block, + unselectedIcon = Icons.Outlined.Block + ) + ) + val refreshAction = remember(items) { { items.refresh() } } + val clearAction = remember { { showDeleteDialog = true } } + + BackHandler(enabled = selectedAppFilter != null && !showDeleteDialog && !showAppFilterDialog) { + selectedAppFilter = null + } + + LaunchedEffect(refreshAction, clearAction, persistentState.logsEnabled, items.itemCount) { + onTopBarActionsChange( + refreshAction.takeIf { persistentState.logsEnabled }, + clearAction.takeIf { persistentState.logsEnabled && items.itemCount > 0 } + ) + } + + LaunchedEffect(Unit) { + // Reset stale filters when re-entering the screen to avoid empty/hidden results. + viewModel.setFilter("", DnsLogViewModel.DnsLogFilter.ALL) + } + + LaunchedEffect(Unit) { + snapshotFlow { Pair(selectedAppFilter.orEmpty(), filterType) } + .debounce(300) + .distinctUntilChanged() + .collect { (q, type) -> + viewModel.setFilter(q, type) + } + } + + if (!persistentState.logsEnabled) { + LogsDisabledState() + return + } + + LaunchedEffect(showAppFilterDialog, filterType, persistentState.logsEnabled) { + if (!showAppFilterDialog || !persistentState.logsEnabled) return@LaunchedEffect + appFilterOptionsLoading = true + appFilterOptions = + withContext(Dispatchers.IO) { + when (filterType) { + DnsLogViewModel.DnsLogFilter.ALL -> repository.getAllLoggedAppsWithCount() + DnsLogViewModel.DnsLogFilter.ALLOWED -> repository.getAllowedLoggedAppsWithCount() + DnsLogViewModel.DnsLogFilter.BLOCKED -> repository.getBlockedLoggedAppsWithCount() + else -> repository.getAllLoggedAppsWithCount() + } + } + appFilterOptionsLoading = false + } + + val listState = rememberLazyListState() + + Column(modifier = Modifier.fillMaxSize()) { + LogsControlsDeck { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + LogsPrimaryFilterRow( + options = filterOptions, + selectedValue = filterType, + onValueSelected = { selectedFilter -> + filterType = selectedFilter + }, + modifier = Modifier.weight(1f) + ) + + LogsCompactIconAction( + icon = Icons.Rounded.Apps, + contentDescription = stringResource(R.string.lbl_apps), + selected = selectedAppFilter != null, + enabled = true, + onClick = { showAppFilterDialog = true } + ) + } + } + + LogsPagedListContent( + items = items, + listState = listState, + modifier = Modifier.weight(1f) + ) { item, index, itemCount -> + DnsLogRow( + log = item, + loadFavIcon = persistentState.fetchFavIcon, + isRethinkDns = false, + onShowBlocklist = onShowDnsLog, + index = index, + itemCount = itemCount, + ) + } + } + + if (showAppFilterDialog) { + LogsAppFilterDialog( + options = appFilterOptions, + selectedApp = selectedAppFilter, + searchQuery = appPickerQuery, + isLoading = appFilterOptionsLoading, + onSearchQueryChange = { appPickerQuery = it }, + onSelectApp = { selectedApp -> + selectedAppFilter = selectedApp + showAppFilterDialog = false + }, + onClearSelection = { selectedAppFilter = null }, + onDismiss = { showAppFilterDialog = false } + ) + } + + LogsDeleteDialog( + show = showDeleteDialog, + onDismiss = { showDeleteDialog = false }, + onDelete = { repository.clearAllData() }, + onRefresh = { items.refresh() } + ) +} + +@Composable +private fun LogsControlsDeck( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal) + .padding(top = Dimensions.spacingXs), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + content() + } +} + +@Composable +private fun LogsDisabledState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier + .padding(horizontal = Dimensions.screenPaddingHorizontal) + .padding(vertical = Dimensions.spacingXl) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingLg, vertical = Dimensions.spacingMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_logs_accent), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.logs_disabled_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun LogsTopBarOverflowActions( + onRefresh: (() -> Unit)?, + onDelete: (() -> Unit)? +) { + val isRefreshEnabled = onRefresh != null + val isDeleteEnabled = onDelete != null + var expanded by remember { mutableStateOf(false) } + + IconButton( + onClick = { expanded = true } + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.wireguard_fab_more_actions) + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.cd_refresh)) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null + ) + }, + enabled = isRefreshEnabled, + onClick = { + expanded = false + onRefresh?.invoke() + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.lbl_delete)) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + enabled = isDeleteEnabled, + onClick = { + expanded = false + onDelete?.invoke() + } + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun LogsInlineTabSwitch( + selectedTabIndex: Int, + onTabSelected: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val options = + listOf( + 0 to R.string.firewall_act_network_monitor_tab, + 1 to R.string.dns_mode_info_title + ) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) + ) { + options.forEachIndexed { index, (value, labelRes) -> + val selected = selectedTabIndex == value + ToggleButton( + checked = selected, + onCheckedChange = { checked -> + if (checked && !selected) onTabSelected(value) + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.94f), + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier + .heightIn(min = 34.dp) + .semantics { role = Role.RadioButton } + ) { + val tabIcon = + when (value) { + 0 -> if (selected) Icons.Rounded.NetworkPing else Icons.Outlined.NetworkPing + else -> if (selected) Icons.Rounded.Shield else Icons.Outlined.Shield + } + Icon( + imageVector = tabIcon, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } + } + } +} + +@Composable +private fun LogsCompactIconAction( + icon: ImageVector, + contentDescription: String, + selected: Boolean = false, + enabled: Boolean, + count: Int = 0, + onClick: () -> Unit +) { + Box(modifier = Modifier.size(36.dp)) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.matchParentSize() + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(20.dp), + tint = + if (!enabled) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.42f) + else if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (selected) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 5.dp, end = 5.dp) + .size(6.dp) + ) {} + } + if (count > 0) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 1.dp, end = 1.dp) + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun LogsAppFilterDialog( + options: List, + selectedApp: String?, + searchQuery: String, + isLoading: Boolean, + onSearchQueryChange: (String) -> Unit, + onSelectApp: (String?) -> Unit, + onClearSelection: () -> Unit, + onDismiss: () -> Unit +) { + val filteredOptions = + remember(options, searchQuery) { + if (searchQuery.isBlank()) { + options + } else { + options.filter { it.appName.contains(searchQuery.trim(), ignoreCase = true) } + } + } + val totalCount = remember(options) { options.sumOf { it.count } } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + .padding(horizontal = Dimensions.spacingMd) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingSm, vertical = Dimensions.spacingSm), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + RethinkSearchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = stringResource(R.string.search_apps_count_placeholder, options.size), + onClearQuery = { onSearchQueryChange("") }, + clearQueryContentDescription = stringResource(R.string.cd_clear_search), + closeWhenEmptyContentDescription = stringResource(R.string.lbl_dismiss), + onCloseWhenEmpty = onDismiss, + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + iconSize = 18.dp, + trailingIconSize = 16.dp, + trailingIconButtonSize = 30.dp + ) + if (selectedApp != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onClearSelection) { + Text( + text = stringResource(R.string.fapps_filter_clear_btn), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + if (isLoading) { + LogsLoadingState() + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + item("all_apps") { + LogsAppFilterListItem( + app = null, + selected = selectedApp == null, + count = totalCount, + onClick = { onSelectApp(null) } + ) + } + + items(filteredOptions, key = { "${it.packageName}|${it.appName}" }) { app -> + LogsAppFilterListItem( + app = app, + selected = selectedApp == app.appName, + count = app.count, + onClick = { onSelectApp(app.appName) } + ) + } + } + } + } + } + } +} + +@Composable +private fun LogsAppFilterListItem( + app: LogAppCount?, + selected: Boolean, + count: Int, + onClick: () -> Unit +) { + val context = LocalContext.current + var appIcon by remember(app?.packageName, app?.appName) { mutableStateOf(null) } + + LaunchedEffect(app?.packageName, app?.appName) { + if (app == null) return@LaunchedEffect + appIcon = + withContext(Dispatchers.IO) { + if (app.packageName.isBlank()) { + Utilities.getDefaultIcon(context) + } else { + Utilities.getIcon(context, app.packageName, app.appName) + } + } + } + + Surface( + onClick = onClick, + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + color = + if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.34f) + else MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingMd, vertical = Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + if (app == null) { + Icon( + imageVector = Icons.Rounded.Public, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (selected) MaterialTheme.colorScheme.secondary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val iconPainter = rememberDrawablePainter(appIcon ?: Utilities.getDefaultIcon(context)) + iconPainter?.let { painter -> + Image( + painter = painter, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(7.dp)) + ) + } + } + + Spacer(modifier = Modifier.size(Dimensions.spacingMd)) + + Text( + text = app?.appName ?: stringResource(R.string.lbl_all), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = + if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.84f) + else MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = + if (selected) MaterialTheme.colorScheme.onSecondaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + } + } +} + +private data class LogsFilterOption( + val value: T, + val label: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector = selectedIcon +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun LogsPrimaryFilterRow( + options: List>, + selectedValue: T, + onValueSelected: (T) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) + ) { + options.forEachIndexed { index, option -> + val selected = option.value == selectedValue + ToggleButton( + checked = selected, + onCheckedChange = { checked -> + if (checked && !selected) onValueSelected(option.value) + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.94f), + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier.semantics { role = Role.RadioButton } + ) { + val showLabel = selected + Icon( + imageVector = if (selected) option.selectedIcon else option.unselectedIcon, + contentDescription = if (showLabel) null else option.label, + modifier = Modifier.size(16.dp) + ) + if (showLabel) { + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + Text( + text = option.label, + maxLines = 1, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } +} + +@Composable +private fun LogsRulesDialog( + rules: List, + selectedRules: Set, + onToggleRule: (String) -> Unit, + onClear: () -> Unit, + onDismiss: () -> Unit +) { + val selectedCount = selectedRules.size + + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(0.96f), + shape = RoundedCornerShape(Dimensions.cornerRadiusLg), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.spacingXs, + top = Dimensions.spacingMd, + bottom = Dimensions.spacingSm + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.FilterList, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.size(Dimensions.spacingSm)) + Text( + text = stringResource(R.string.lbl_rules), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + if (selectedCount > 0) { + Spacer(modifier = Modifier.size(Dimensions.spacingSm)) + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusPill), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = selectedCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp) + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + if (selectedCount > 0) { + TextButton(onClick = onClear) { + Text( + text = stringResource(R.string.fapps_filter_clear_btn), + fontWeight = FontWeight.SemiBold + ) + } + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.lbl_dismiss), + modifier = Modifier.size(18.dp) + ) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false), + contentPadding = PaddingValues( + start = Dimensions.spacingSm, + end = Dimensions.spacingSm, + bottom = Dimensions.spacingSm + ) + ) { + itemsIndexed(rules, key = { _, rule -> rule.id }) { index, rule -> + val selected = selectedRules.contains(rule.id) + RethinkListItem( + headline = stringResource(rule.title), + supportingAnnotated = htmlToAnnotatedString(stringResource(rule.desc)), + leadingIconPainter = painterResource(id = FirewallRuleset.getRulesIcon(rule.id)), + leadingIconTint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + leadingIconContainerColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.surfaceContainerHighest, + position = cardPositionFor(index = index, lastIndex = rules.lastIndex), + highlighted = selected, + highlightContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.28f), + trailing = if (selected) { + { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { onToggleRule(rule.id) } + ) + } + } + } + } + } +} + +private fun htmlToAnnotatedString(input: String): AnnotatedString { + val sanitized = input.replace(Regex("(?i)"), "") + val tokenRegex = Regex("(?i)|") + val builder = AnnotatedString.Builder() + var cursor = 0 + var italicDepth = 0 + + tokenRegex.findAll(sanitized).forEach { match -> + if (match.range.first > cursor) { + builder.append(sanitized.substring(cursor, match.range.first)) + } + + when { + match.value.matches(Regex("(?i)")) -> builder.append("\n") + match.value.equals("", ignoreCase = true) -> { + builder.pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + italicDepth++ + } + match.value.equals("", ignoreCase = true) && italicDepth > 0 -> { + builder.pop() + italicDepth-- + } + } + cursor = match.range.last + 1 + } + + if (cursor < sanitized.length) { + builder.append(sanitized.substring(cursor)) + } + while (italicDepth > 0) { + builder.pop() + italicDepth-- + } + return builder.toAnnotatedString() +} + +@Composable +private fun LogsPagedListContent( + items: LazyPagingItems, + listState: LazyListState, + modifier: Modifier = Modifier, + rowContent: @Composable (item: T, index: Int, itemCount: Int) -> Unit +) { + val refreshState = items.loadState.refresh + val itemCount = items.itemCount + val isLoading = refreshState is LoadState.Loading && itemCount == 0 + val isEmpty = refreshState is LoadState.NotLoading && itemCount == 0 + val hasLoadError = refreshState is LoadState.Error && itemCount == 0 + val density = LocalDensity.current + val navBarBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + + LazyColumn( + state = listState, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingXs, + bottom = Dimensions.screenPaddingHorizontal + navBarBottomInset + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (isLoading) { + item(key = "logs_loading") { + LogsLoadingState() + } + } else if (isEmpty) { + item(key = "logs_empty") { + LogsEmptyState() + } + } else if (hasLoadError) { + item(key = "logs_load_error") { + LogsLoadErrorState(onRetry = { items.retry() }) + } + } else { + items( + count = itemCount + ) { index -> + val item = items[index] ?: return@items + rowContent(item, index, itemCount) + } + } + } +} + +@Composable +private fun LogsLoadingState() { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier + .fillMaxWidth() + .padding(top = Dimensions.spacingSm) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingLg, vertical = Dimensions.spacingLg), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text( + text = stringResource(id = R.string.lbl_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun LogsLoadErrorState(onRetry: () -> Unit) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier + .fillMaxWidth() + .padding(top = Dimensions.spacingSm) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingLg, vertical = Dimensions.spacingMd), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.error_loading_log_file), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onRetry) { + Text(text = stringResource(id = R.string.cd_refresh)) + } + } + } +} + +@Composable +private fun LogsDeleteDialog( + show: Boolean, + onDismiss: () -> Unit, + onDelete: suspend () -> Unit, + onRefresh: () -> Unit +) { + if (!show) return + val context = LocalContext.current + val scope = rememberCoroutineScope() + val refreshCompleteText = stringResource(R.string.refresh_complete) + ConfirmClearLogsDialog( + onDismiss = onDismiss, + onConfirm = { + onDismiss() + scope.launch(Dispatchers.IO) { onDelete() } + Utilities.showToastUiCentered( + context, + refreshCompleteText, + Toast.LENGTH_SHORT + ) + onRefresh() + } + ) +} + +@Composable +private fun LogsEmptyState() { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier + .fillMaxWidth() + .padding(top = Dimensions.spacingSm) + ) { + Text( + text = stringResource(id = R.string.lbl_no_logs), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = Dimensions.spacingXl, vertical = Dimensions.spacingLg) + ) + } +} + +@Composable +private fun ConfirmClearLogsDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.conn_track_clear_logs_title), + message = stringResource(R.string.conn_track_clear_logs_message), + confirmText = stringResource(R.string.lbl_delete), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = onConfirm, + onDismiss = onDismiss, + isConfirmDestructive = true + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConnTrackerDetailsSheet( + connection: ConnectionTracker, + onDismiss: () -> Unit +) { + val status = if (connection.isBlocked) stringResource(R.string.lbl_blocked) else stringResource(R.string.lbl_allowed) + LogDetailsSheet( + title = connection.appName, + appPackageName = connection.packageName, + appDisplayName = connection.appName, + details = listOf( + LogDetailEntry(stringResource(R.string.log_detail_ip_address), connection.ipAddress), + LogDetailEntry(stringResource(R.string.log_detail_port), connection.port.toString()), + LogDetailEntry(stringResource(R.string.log_detail_protocol), connection.protocol.toString()), + LogDetailEntry(stringResource(R.string.lbl_status), status, isError = connection.isBlocked) + ), + onDismiss = onDismiss + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DnsLogDetailsSheet( + log: DnsLog, + onDismiss: () -> Unit +) { + val status = if (log.isBlocked) stringResource(R.string.lbl_blocked) else stringResource(R.string.lbl_allowed) + val response = log.responseIps.ifEmpty { stringResource(R.string.settings_app_list_default_app) } + LogDetailsSheet( + title = log.queryStr, + appPackageName = log.packageName, + appDisplayName = log.appName, + details = listOf( + LogDetailEntry(stringResource(R.string.log_detail_app_name), log.appName), + LogDetailEntry(stringResource(R.string.log_detail_response), response), + LogDetailEntry(stringResource(R.string.dns_detail_latency), "${log.latency}ms"), + LogDetailEntry(stringResource(R.string.lbl_status), status, isError = log.isBlocked) + ), + onDismiss = onDismiss + ) +} + +private data class LogDetailEntry( + val label: String, + val value: String, + val isError: Boolean = false +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogDetailsSheet( + title: String, + appPackageName: String, + appDisplayName: String, + details: List, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var appIcon by remember(appPackageName, appDisplayName) { mutableStateOf(null) } + + LaunchedEffect(appPackageName, appDisplayName) { + appIcon = kotlinx.coroutines.withContext(Dispatchers.IO) { + if (appPackageName.isBlank()) { + Utilities.getDefaultIcon(context) + } else { + Utilities.getIcon(context, appPackageName, appDisplayName) + } + } + } + + RethinkModalBottomSheet(onDismissRequest = onDismiss, includeBottomSpacer = true) { + RethinkBottomSheetCard( + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + contentPadding = PaddingValues(Dimensions.spacingLg) + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + val iconPainter = + rememberDrawablePainter(appIcon ?: Utilities.getDefaultIcon(context)) + iconPainter?.let { painter -> + Image( + painter = painter, + contentDescription = null, + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(9.dp)) + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + } + details.forEach { entry -> + DetailRow( + label = entry.label, + value = entry.value, + isError = entry.isError + ) + } + + Spacer(modifier = Modifier.height(Dimensions.spacingXl)) + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { + Text(text = stringResource(R.string.lbl_dismiss)) + } + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String, isError: Boolean = false) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.spacingXs), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeNavigation.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeNavigation.kt new file mode 100644 index 000000000..a2a1e63c8 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeNavigation.kt @@ -0,0 +1,1274 @@ +/* + * Copyright 2024 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.compose.navigation + +import android.graphics.drawable.Drawable +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.asFlow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.toRoute +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.data.SummaryStatisticsType +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.ui.compose.alerts.AlertsScreen +import com.celzero.bravedns.ui.compose.about.AboutScreen +import com.celzero.bravedns.ui.compose.about.AboutUiState +import com.celzero.bravedns.ui.compose.app.AppInfoScreen +import com.celzero.bravedns.ui.compose.configure.ConfigureScreen +import com.celzero.bravedns.ui.compose.configure.SettingsSearchDestination +import com.celzero.bravedns.ui.compose.events.EventsScreen +import com.celzero.bravedns.ui.compose.firewall.FirewallSettingsScreen +import com.celzero.bravedns.ui.compose.home.HomeScreen +import com.celzero.bravedns.ui.compose.settings.AdvancedSettingsScreen +import com.celzero.bravedns.ui.compose.settings.AntiCensorshipScreen +import com.celzero.bravedns.ui.compose.settings.AppLockScreen +import com.celzero.bravedns.ui.compose.settings.AppLockResult +import com.celzero.bravedns.ui.compose.settings.MiscSettingsScreen +import com.celzero.bravedns.ui.compose.settings.TunnelSettingsScreen +import com.celzero.bravedns.ui.compose.settings.ConsoleLogScreen +import com.celzero.bravedns.ui.compose.settings.ProxySettingsScreen +import com.celzero.bravedns.ui.compose.proxy.TcpProxyMainScreen +import com.celzero.bravedns.ui.compose.logs.NetworkLogsScreen +import com.celzero.bravedns.ui.compose.settings.PingTestScreen +import com.celzero.bravedns.ui.dialog.WgIncludeAppsScreen +import com.celzero.bravedns.ui.compose.logs.AppWiseIpLogsScreen +import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel +import com.celzero.bravedns.ui.compose.apps.AppListScreen +import com.celzero.bravedns.ui.compose.firewall.CustomRulesScreen +import com.celzero.bravedns.ui.compose.home.WelcomeScreen +import com.celzero.bravedns.ui.compose.home.HomeScreenUiState +import com.celzero.bravedns.ui.compose.firewall.RulesMode +import com.celzero.bravedns.ui.compose.firewall.RulesTab +import com.celzero.bravedns.ui.compose.wireguard.WgConfigDetailScreen +import com.celzero.bravedns.ui.compose.wireguard.WgConfigEditorScreen +import com.celzero.bravedns.ui.compose.wireguard.WgType +import com.celzero.bravedns.ui.compose.rpn.RpnAvailabilityScreen +import com.celzero.bravedns.ui.compose.rpn.RpnCountriesScreen +import com.celzero.bravedns.ui.compose.rpn.RpnWinProxyDetailsScreen +import com.celzero.bravedns.ui.compose.logs.DomainConnectionsInputType +import com.celzero.bravedns.ui.compose.logs.DomainConnectionsScreen +import com.celzero.bravedns.ui.compose.statistics.DetailedStatisticsScreen +import com.celzero.bravedns.ui.compose.statistics.SummaryStatisticsScreen +import com.celzero.bravedns.ui.compose.database.DatabaseScreen +import com.celzero.bravedns.database.EventDao +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.viewmodel.AppConnectionsViewModel +import com.celzero.bravedns.viewmodel.CustomDomainViewModel +import com.celzero.bravedns.viewmodel.CustomIpViewModel +import com.celzero.bravedns.viewmodel.DomainConnectionsViewModel +import com.celzero.bravedns.viewmodel.DetailedStatisticsViewModel +import com.celzero.bravedns.viewmodel.EventsViewModel +import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel +import com.celzero.bravedns.viewmodel.ConsoleLogViewModel +import com.celzero.bravedns.database.ConsoleLogRepository +import com.celzero.bravedns.download.AppDownloadManager +import com.celzero.bravedns.ui.compose.dns.ConfigureRethinkBasicScreen +import com.celzero.bravedns.ui.compose.dns.ConfigureRethinkScreenType +import com.celzero.bravedns.ui.compose.dns.DnsDetailScreen +import com.celzero.bravedns.ui.compose.dns.DnsListScreen +import com.celzero.bravedns.ui.compose.dns.DnsSettingsViewModel +import com.celzero.bravedns.viewmodel.LocalBlocklistPacksMapViewModel +import com.celzero.bravedns.viewmodel.RemoteBlocklistPacksMapViewModel +import com.celzero.bravedns.viewmodel.RethinkEndpointViewModel +import com.celzero.bravedns.viewmodel.RethinkLocalFileTagViewModel +import com.celzero.bravedns.viewmodel.RethinkRemoteFileTagViewModel +import com.celzero.bravedns.viewmodel.AppInfoViewModel +import com.celzero.bravedns.database.RefreshDatabase +import com.celzero.bravedns.viewmodel.ConnectionTrackerViewModel +import com.celzero.bravedns.viewmodel.DnsLogViewModel +import com.celzero.bravedns.viewmodel.RethinkLogViewModel +import com.celzero.bravedns.database.ConnectionTrackerRepository +import com.celzero.bravedns.database.DnsLogRepository +import com.celzero.bravedns.database.RethinkLogRepository +import com.celzero.bravedns.ui.compose.dns.ConfigureOtherDnsScreen +import com.celzero.bravedns.ui.compose.dns.DnsScreenType +import com.celzero.bravedns.ui.compose.firewall.UniversalFirewallSettingsScreen +import com.celzero.bravedns.ui.compose.settings.CheckoutScreen +import com.celzero.bravedns.database.AppDatabase +import com.celzero.bravedns.viewmodel.DoHEndpointViewModel +import com.celzero.bravedns.viewmodel.DoTEndpointViewModel +import com.celzero.bravedns.viewmodel.DnsProxyEndpointViewModel +import com.celzero.bravedns.viewmodel.DnsCryptEndpointViewModel +import com.celzero.bravedns.viewmodel.DnsCryptRelayEndpointViewModel +import com.celzero.bravedns.viewmodel.ODoHEndpointViewModel +import com.celzero.bravedns.viewmodel.CheckoutViewModel +import com.celzero.bravedns.ui.compose.logs.AppWiseDomainLogsScreen +import com.celzero.bravedns.viewmodel.WgConfigViewModel +import com.celzero.bravedns.ui.compose.wireguard.WgMainScreen +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import com.celzero.bravedns.util.Utilities +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val BOTTOM_BAR_ENTER_DURATION = 220 +private const val BOTTOM_BAR_EXIT_DURATION = 180 +private const val NAV_ENTER_DURATION = 240 +private const val NAV_EXIT_DURATION = 200 + +enum class HomeDestination( + val route: HomeRoute, + val labelRes: Int, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector +) { + HOME(HomeRoute.Home, R.string.txt_home, Icons.Filled.Home, Icons.Filled.Home), + STATS(HomeRoute.Stats, R.string.title_statistics, Icons.Filled.Star, Icons.Filled.Star), + CONFIGURE(HomeRoute.Configure, R.string.lbl_configure, Icons.Filled.Settings, Icons.Filled.Settings), + ABOUT(HomeRoute.About, R.string.title_about, Icons.Filled.Info, Icons.Filled.Info) +} + +sealed interface HomeNavRequest { + data class DetailedStats( + val type: SummaryStatisticsType, + val timeCategory: SummaryStatisticsViewModel.TimeCategory + ) : HomeNavRequest + + data object Alerts : HomeNavRequest + data object RpnCountries : HomeNavRequest + data object RpnAvailability : HomeNavRequest + data object Events : HomeNavRequest + data object FirewallSettings : HomeNavRequest + data object AdvancedSettings : HomeNavRequest + data object AntiCensorship : HomeNavRequest + data object TunnelSettings : HomeNavRequest + data object MiscSettings : HomeNavRequest + data object ConsoleLogs : HomeNavRequest + data object NetworkLogs : HomeNavRequest + data object AppList : HomeNavRequest + data class CustomRules( + val uid: Int = UID_EVERYBODY, + val tab: CustomRulesTab = CustomRulesTab.IP, + val mode: CustomRulesMode = CustomRulesMode.APP_SPECIFIC + ) : HomeNavRequest + data object ProxySettings : HomeNavRequest + data object TcpProxyMain : HomeNavRequest + data object Welcome : HomeNavRequest + data object AppLock : HomeNavRequest + data object PingTest : HomeNavRequest + data object DnsDetail : HomeNavRequest + data class WgConfigDetail(val configId: Int, val wgType: WgType) : HomeNavRequest + data class WgConfigEditor(val configId: Int, val wgType: WgType) : HomeNavRequest + data class RpnWinProxyDetails(val countryCode: String) : HomeNavRequest + data class AppInfo(val uid: Int) : HomeNavRequest + data class DomainConnections( + val type: DomainConnectionsInputType, + val flag: String, + val domain: String, + val asn: String, + val ip: String, + val isBlocked: Boolean, + val timeCategory: DomainConnectionsViewModel.TimeCategory + ) : HomeNavRequest + + data object DnsList : HomeNavRequest + data class AppWiseIpLogs(val uid: Int, val isAsn: Boolean) : HomeNavRequest + data class ConfigureRethinkBasic( + val screenType: ConfigureRethinkScreenType, + val remoteName: String = "", + val remoteUrl: String = "", + val uid: Int = -1 + ) : HomeNavRequest + + data class ConfigureOtherDns(val dnsType: Int) : HomeNavRequest + data object UniversalFirewallSettings : HomeNavRequest + data class AppWiseDomainLogs(val uid: Int) : HomeNavRequest + data object Checkout : HomeNavRequest + data object WgMain : HomeNavRequest + data object Database : HomeNavRequest +} + +@Composable +fun HomeScreenRoot( + homeUiState: HomeScreenUiState, + onHomeStartStopClick: () -> Unit, + onHomeDnsClick: () -> Unit, + onHomeFirewallClick: () -> Unit, + onHomeProxyClick: () -> Unit, + onHomeLogsClick: () -> Unit, + onHomeAppsClick: () -> Unit, + onHomeSponsorClick: () -> Unit, + summaryViewModel: SummaryStatisticsViewModel, + onOpenDetailedStats: (SummaryStatisticsType) -> Unit, + startDestination: HomeRoute, + isDebug: Boolean, + onConfigureAppsClick: () -> Unit, + onConfigureDnsClick: () -> Unit, + onConfigureFirewallClick: () -> Unit, + onFirewallUniversalClick: () -> Unit, + onFirewallCustomIpClick: () -> Unit, + onFirewallAppWiseIpClick: () -> Unit, + onConfigureProxyClick: () -> Unit, + onConfigureNetworkClick: () -> Unit, + onConfigureOthersClick: () -> Unit, + onConfigureLogsClick: () -> Unit, + onConfigureAntiCensorshipClick: () -> Unit, + onConfigureAdvancedClick: () -> Unit, + aboutUiState: AboutUiState, + onSponsorClick: () -> Unit, + onTelegramClick: () -> Unit, + onBugReportClick: () -> Unit, + onWhatsNewClick: () -> Unit, + onAppUpdateClick: () -> Unit, + onContributorsClick: () -> Unit, + onTranslateClick: () -> Unit, + onWebsiteClick: () -> Unit, + onGithubClick: () -> Unit, + onFaqClick: () -> Unit, + onDocsClick: () -> Unit, + onPrivacyPolicyClick: () -> Unit, + onTermsOfServiceClick: () -> Unit, + onLicenseClick: () -> Unit, + onTwitterClick: () -> Unit, + onEmailClick: () -> Unit, + onRedditClick: () -> Unit, + onElementClick: () -> Unit, + onMastodonClick: () -> Unit, + onGeneralSettingsClick: () -> Unit, + onAppInfoClick: () -> Unit, + onVpnProfileClick: () -> Unit, + onNotificationClick: () -> Unit, + onStatsClick: () -> Unit, + onDbStatsClick: () -> Unit, + onFlightRecordClick: () -> Unit, + onEventLogsClick: () -> Unit, + onTokenClick: () -> Unit, + onTokenDoubleTap: () -> Unit, + onFossClick: () -> Unit, + onFlossFundsClick: () -> Unit, + snackbarHostState: SnackbarHostState, + detailedStatsViewModel: DetailedStatisticsViewModel, + domainConnectionsViewModel: DomainConnectionsViewModel, + eventsViewModel: EventsViewModel, + eventDao: EventDao, + appInfoEventLogger: EventLogger, + appInfoIpRulesViewModel: CustomIpViewModel, + appInfoDomainRulesViewModel: CustomDomainViewModel, + appInfoNetworkLogsViewModel: AppConnectionsViewModel, + persistentState: PersistentState, + appConfig: AppConfig, + onOpenVpnProfile: () -> Unit, + onRefreshDatabase: (() -> Unit)? = null, + onThemeModeChanged: ((Int) -> Unit)? = null, + onThemeColorChanged: ((Int) -> Unit)? = null, + consoleLogViewModel: ConsoleLogViewModel, + consoleLogRepository: ConsoleLogRepository, + onShareConsoleLogs: () -> Unit, + onConsoleLogsDeleteComplete: () -> Unit, + proxyAppsMappingViewModel: ProxyAppsMappingViewModel, + dnsSettingsViewModel: DnsSettingsViewModel, + appDownloadManager: AppDownloadManager, + onDnsCustomDnsClick: () -> Unit, + onDnsRethinkPlusDnsClick: () -> Unit, + onDnsLocalBlocklistConfigureClick: () -> Unit, + homeNavRequest: HomeNavRequest?, + onHomeNavConsumed: () -> Unit, + onAppLockResult: (AppLockResult) -> Unit = {}, + // ConfigureRethinkBasic dependencies + rethinkEndpointViewModel: RethinkEndpointViewModel, + remoteFileTagViewModel: RethinkRemoteFileTagViewModel, + localFileTagViewModel: RethinkLocalFileTagViewModel, + remoteBlocklistPacksMapViewModel: RemoteBlocklistPacksMapViewModel, + localBlocklistPacksMapViewModel: LocalBlocklistPacksMapViewModel, + appInfoViewModel: AppInfoViewModel, + refreshDatabase: RefreshDatabase, + connectionTrackerViewModel: ConnectionTrackerViewModel, + dnsLogViewModel: DnsLogViewModel, + rethinkLogViewModel: RethinkLogViewModel, + connectionTrackerRepository: ConnectionTrackerRepository, + dnsLogRepository: DnsLogRepository, + rethinkLogRepository: RethinkLogRepository, + onConfigureOtherDns: (Int) -> Unit, + // ConfigureOtherDns dependencies + dohViewModel: DoHEndpointViewModel, + dotViewModel: DoTEndpointViewModel, + dnsProxyViewModel: DnsProxyEndpointViewModel, + dnsCryptViewModel: DnsCryptEndpointViewModel, + dnsCryptRelayViewModel: DnsCryptRelayEndpointViewModel, + oDohViewModel: ODoHEndpointViewModel, + // UniversalFirewallSettings callbacks + onNavigateToLogs: (String) -> Unit, + onOpenAccessibilitySettings: () -> Unit, + // WireGuard dependencies + wgConfigViewModel: WgConfigViewModel, + // Checkout dependencies + checkoutViewModel: CheckoutViewModel?, + onNavigateToProxy: () -> Unit, + // WgMain callbacks + onWgCreateClick: () -> Unit, + onWgImportClick: () -> Unit, + onWgQrScanClick: () -> Unit, + appDatabase: AppDatabase +) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val currentHierarchy = currentDestination?.hierarchy.orEmpty() + val currentRoute = currentDestination?.route + val topLevelRoutes = remember { + HomeDestination.entries.mapNotNull { it.route::class.qualifiedName }.toSet() + } + val isHomeRoute = currentRoute == HomeRoute.Home::class.qualifiedName + val isWelcomeRoute = currentRoute == HomeRoute.Welcome::class.qualifiedName + val showBottomBar = + !isWelcomeRoute && + currentHierarchy.any { it.route != null && topLevelRoutes.contains(it.route) } + val isWideScreen = LocalConfiguration.current.screenWidthDp >= 840 + val showNavigationRail = showBottomBar && isWideScreen + val showNavigationBar = showBottomBar && !isWideScreen + + LaunchedEffect(homeNavRequest) { + val request = homeNavRequest ?: return@LaunchedEffect + when (request) { + is HomeNavRequest.DetailedStats -> { + navController.navigate(HomeRoute.DetailedStats(request.type.tid, request.timeCategory.value)) + } + + HomeNavRequest.Alerts -> { + navController.navigate(HomeRoute.Alerts) + } + + HomeNavRequest.RpnCountries -> { + navController.navigate(HomeRoute.RpnCountries) + } + + HomeNavRequest.RpnAvailability -> { + navController.navigate(HomeRoute.RpnAvailability) + } + + HomeNavRequest.Events -> { + navController.navigate(HomeRoute.Events) + } + + HomeNavRequest.FirewallSettings -> { + navController.navigate(HomeRoute.FirewallSettings()) + } + + HomeNavRequest.AdvancedSettings -> { + navController.navigate(HomeRoute.AdvancedSettings) + } + + HomeNavRequest.AntiCensorship -> { + navController.navigate(HomeRoute.AntiCensorship) + } + + HomeNavRequest.TunnelSettings -> { + navController.navigate(HomeRoute.TunnelSettings()) + } + + HomeNavRequest.MiscSettings -> { + navController.navigate(HomeRoute.MiscSettings()) + } + + HomeNavRequest.ConsoleLogs -> { + navController.navigate(HomeRoute.ConsoleLogs) + } + + HomeNavRequest.NetworkLogs -> { + navController.navigate(HomeRoute.NetworkLogs) + } + + HomeNavRequest.AppList -> { + navController.navigate(HomeRoute.AppList) + } + + is HomeNavRequest.CustomRules -> { + navController.navigate( + HomeRoute.CustomRules( + uid = request.uid, + tab = request.tab.value, + mode = request.mode.value + ) + ) + } + + HomeNavRequest.ProxySettings -> { + navController.navigate(HomeRoute.ProxySettings()) + } + + HomeNavRequest.TcpProxyMain -> { + navController.navigate(HomeRoute.TcpProxyMain) + } + + HomeNavRequest.Welcome -> { + navController.navigate(HomeRoute.Welcome) + } + + HomeNavRequest.PingTest -> { + navController.navigate(HomeRoute.PingTest) + } + + HomeNavRequest.AppLock -> { + navController.navigate(HomeRoute.AppLock) + } + + HomeNavRequest.DnsDetail -> { + navController.navigate(HomeRoute.DnsDetail()) + } + + is HomeNavRequest.RpnWinProxyDetails -> { + navController.navigate(HomeRoute.RpnWinProxyDetails(request.countryCode)) + } + + is HomeNavRequest.DomainConnections -> { + navController.navigate( + HomeRoute.DomainConnections( + typeId = request.type.type, + flag = request.flag, + domain = request.domain, + asn = request.asn, + ip = request.ip, + isBlocked = request.isBlocked, + timeCategory = request.timeCategory.value + ) + ) + } + + is HomeNavRequest.AppInfo -> { + navController.navigate(HomeRoute.AppInfo(request.uid)) + } + + is HomeNavRequest.WgConfigDetail -> { + navController.navigate(HomeRoute.WgConfigDetail(request.configId, request.wgType)) + } + + is HomeNavRequest.WgConfigEditor -> { + navController.navigate(HomeRoute.WgConfigEditor(request.configId, request.wgType)) + } + + is HomeNavRequest.ConfigureRethinkBasic -> { + navController.navigate( + HomeRoute.ConfigureRethinkBasic( + screenTypeOrdinal = request.screenType.ordinal, + remoteName = request.remoteName, + remoteUrl = request.remoteUrl, + uid = request.uid + ) + ) + } + + HomeNavRequest.DnsList -> { + navController.navigate(HomeRoute.DnsList) + } + + is HomeNavRequest.AppWiseIpLogs -> { + navController.navigate(HomeRoute.AppWiseIpLogs(request.uid, request.isAsn)) + } + + is HomeNavRequest.ConfigureOtherDns -> { + navController.navigate(HomeRoute.ConfigureOtherDns(request.dnsType)) + } + + HomeNavRequest.UniversalFirewallSettings -> { + navController.navigate(HomeRoute.UniversalFirewallSettings) + } + + is HomeNavRequest.AppWiseDomainLogs -> { + navController.navigate(HomeRoute.AppWiseDomainLogs(request.uid)) + } + + HomeNavRequest.Checkout -> { + navController.navigate(HomeRoute.Checkout) + } + + HomeNavRequest.WgMain -> { + navController.navigate(HomeRoute.WgMain) + } + + HomeNavRequest.Database -> { + navController.navigate(HomeRoute.Database) + } + } + onHomeNavConsumed() + } + + BackHandler(enabled = !isHomeRoute) { + if (isWelcomeRoute) { + persistentState.firstTimeLaunch = false + navController.navigate(HomeRoute.Home) { + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } + launchSingleTop = true + } + } else { + navController.navigate(HomeRoute.Home) { + popUpTo(navController.graph.findStartDestination().id) { inclusive = false } + launchSingleTop = true + } + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + AnimatedVisibility( + visible = showNavigationBar, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween( + durationMillis = BOTTOM_BAR_ENTER_DURATION, + easing = FastOutSlowInEasing + ) + ) + fadeIn(animationSpec = tween(durationMillis = BOTTOM_BAR_ENTER_DURATION)), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween( + durationMillis = BOTTOM_BAR_EXIT_DURATION, + easing = FastOutSlowInEasing + ) + ) + fadeOut(animationSpec = tween(durationMillis = BOTTOM_BAR_EXIT_DURATION)) + ) { + NavigationBar( + modifier = Modifier.fillMaxWidth(), + windowInsets = + WindowInsets.safeDrawing.only( + WindowInsetsSides.Start + WindowInsetsSides.End + WindowInsetsSides.Bottom + ) + ) { + HomeDestination.entries.forEach { destination -> + val routeName = destination.route::class.qualifiedName + val isSelected = currentHierarchy.any { it.route == routeName } + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(destination.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + imageVector = + if (isSelected) destination.selectedIcon else destination.unselectedIcon, + contentDescription = stringResource(id = destination.labelRes) + ) + }, + label = { + Text( + text = stringResource(id = destination.labelRes), + style = MaterialTheme.typography.labelMedium + ) + }, + alwaysShowLabel = true, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + } + ) { paddingValues -> + val navHostModifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + + val navHostContent: @Composable (Modifier) -> Unit = { modifier -> + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + enterTransition = { + val topLevelTransition = + topLevelRoutes.contains(initialState.destination.route) && + topLevelRoutes.contains(targetState.destination.route) + if (topLevelTransition) { + fadeIn(animationSpec = tween(durationMillis = NAV_EXIT_DURATION)) + } else { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween( + durationMillis = NAV_ENTER_DURATION, + easing = FastOutSlowInEasing + ) + ) + fadeIn(animationSpec = tween(durationMillis = NAV_ENTER_DURATION)) + } + }, + exitTransition = { + val topLevelTransition = + topLevelRoutes.contains(initialState.destination.route) && + topLevelRoutes.contains(targetState.destination.route) + if (topLevelTransition) { + fadeOut(animationSpec = tween(durationMillis = NAV_EXIT_DURATION)) + } else { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween( + durationMillis = NAV_EXIT_DURATION, + easing = FastOutSlowInEasing + ) + ) + fadeOut(animationSpec = tween(durationMillis = NAV_EXIT_DURATION)) + } + }, + popEnterTransition = { + val topLevelTransition = + topLevelRoutes.contains(initialState.destination.route) && + topLevelRoutes.contains(targetState.destination.route) + if (topLevelTransition) { + fadeIn(animationSpec = tween(durationMillis = NAV_EXIT_DURATION)) + } else { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween( + durationMillis = NAV_ENTER_DURATION, + easing = FastOutSlowInEasing + ) + ) + fadeIn(animationSpec = tween(durationMillis = NAV_ENTER_DURATION)) + } + }, + popExitTransition = { + val topLevelTransition = + topLevelRoutes.contains(initialState.destination.route) && + topLevelRoutes.contains(targetState.destination.route) + if (topLevelTransition) { + fadeOut(animationSpec = tween(durationMillis = NAV_EXIT_DURATION)) + } else { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween( + durationMillis = NAV_EXIT_DURATION, + easing = FastOutSlowInEasing + ) + ) + fadeOut(animationSpec = tween(durationMillis = NAV_EXIT_DURATION)) + } + } + ) { + composable { + HomeScreen( + uiState = homeUiState, + onStartStopClick = onHomeStartStopClick, + onDnsClick = onHomeDnsClick, + onFirewallClick = onHomeFirewallClick, + onProxyClick = onHomeProxyClick, + onLogsClick = onHomeLogsClick, + onAppsClick = onHomeAppsClick, + onSponsorClick = onHomeSponsorClick + ) + } + composable { + SummaryStatisticsScreen( + viewModel = summaryViewModel, + persistentState = persistentState, + onSeeMoreClick = onOpenDetailedStats + ) + } + composable { + AlertsScreen(onBackClick = { navController.popBackStack() }) + } + composable { + RpnCountriesScreen(onBackClick = { navController.popBackStack() }) + } + composable { + RpnAvailabilityScreen(onBackClick = { navController.popBackStack() }) + } + composable { + EventsScreen( + viewModel = eventsViewModel, + eventDao = eventDao, + onBackClick = { navController.popBackStack() } + ) + } + composable { entry -> + val args = entry.toRoute() + FirewallSettingsScreen( + onUniversalFirewallClick = onFirewallUniversalClick, + onCustomIpDomainClick = onFirewallCustomIpClick, + onAppWiseIpDomainClick = onFirewallAppWiseIpClick, + initialFocusKey = args.focusKey.takeIf { it.isNotBlank() }, + onBackClick = { navController.popBackStack() } + ) + } + composable { + AdvancedSettingsScreen( + persistentState = persistentState, + onBackClick = { navController.popBackStack() } + ) + } + composable { + AntiCensorshipScreen( + persistentState = persistentState, + eventLogger = appInfoEventLogger, + onBackClick = { navController.popBackStack() } + ) + } + composable { entry -> + val args = entry.toRoute() + TunnelSettingsScreen( + persistentState = persistentState, + appConfig = appConfig, + eventLogger = appInfoEventLogger, + onOpenVpnProfile = onOpenVpnProfile, + initialFocusKey = args.focusKey.takeIf { it.isNotBlank() }, + onBackClick = { navController.popBackStack() } + ) + } + composable { entry -> + val args = entry.toRoute() + MiscSettingsScreen( + persistentState = persistentState, + eventLogger = appInfoEventLogger, + initialFocusKey = args.focusKey.takeIf { it.isNotBlank() }, + onBackClick = { navController.popBackStack() }, + onRefreshDatabase = onRefreshDatabase, + onThemeModeChanged = onThemeModeChanged, + onThemeColorChanged = onThemeColorChanged + ) + } + composable { + PingTestScreen( + onBackClick = { navController.popBackStack() } + ) + } + composable { + ConsoleLogScreen( + viewModel = consoleLogViewModel, + consoleLogRepository = consoleLogRepository, + persistentState = persistentState, + onShareClick = onShareConsoleLogs, + onDeleteComplete = onConsoleLogsDeleteComplete, + onBackClick = { navController.popBackStack() } + ) + } + composable { + NetworkLogsScreen( + connectionTrackerViewModel = connectionTrackerViewModel, + dnsLogViewModel = dnsLogViewModel, + rethinkLogViewModel = rethinkLogViewModel, + connectionTrackerRepository = connectionTrackerRepository, + dnsLogRepository = dnsLogRepository, + rethinkLogRepository = rethinkLogRepository, + persistentState = persistentState, + eventLogger = appInfoEventLogger, + onBackClick = { navController.popBackStack() } + ) + } + + composable { + AppListScreen( + viewModel = appInfoViewModel, + eventLogger = appInfoEventLogger, + refreshDatabase = refreshDatabase, + onAppClick = { uid -> navController.navigate(HomeRoute.AppInfo(uid)) }, + onBackClick = { navController.popBackStack() } + ) + } + + composable { entry -> + val args = entry.toRoute() + CustomRulesScreen( + uid = args.uid, + initialTab = RulesTab.fromValue(args.tab), + initialMode = RulesMode.fromValue(args.mode), + domainViewModel = appInfoDomainRulesViewModel, + ipViewModel = appInfoIpRulesViewModel, + eventLogger = appInfoEventLogger, + onBackClick = { navController.popBackStack() } + ) + } + + composable { entry -> + val args = entry.toRoute() + ProxySettingsScreen( + appConfig = appConfig, + persistentState = persistentState, + eventLogger = appInfoEventLogger, + mappingViewModel = proxyAppsMappingViewModel, + initialFocusKey = args.focusKey.takeIf { it.isNotBlank() }, + onWireguardClick = { navController.navigate(HomeRoute.WgMain) }, + onOpenOrbotApps = { + navController.navigate(HomeRoute.OrbotAppSelect) { + launchSingleTop = true + } + }, + onNavigateToDns = { navController.navigate(HomeRoute.DnsDetail()) }, + onBackClick = { navController.popBackStack() } + ) + } + + composable { + WgIncludeAppsScreen( + viewModel = proxyAppsMappingViewModel, + proxyId = ProxyManager.ID_ORBOT_BASE, + proxyName = ProxyManager.ORBOT_PROXY_NAME, + onDismiss = { navController.popBackStack() } + ) + } + + composable { + TcpProxyMainScreen( + appConfig = appConfig, + mappingViewModel = proxyAppsMappingViewModel, + onBackClick = { navController.popBackStack() } + ) + } + composable { + WelcomeScreen( + onFinish = { + persistentState.firstTimeLaunch = false + navController.navigate(HomeRoute.Home) { + popUpTo(navController.graph.findStartDestination().id) { inclusive = true } + launchSingleTop = true + } + } + ) + } + composable { + AppLockScreen( + persistentState = persistentState, + onAuthResult = { result -> + onAppLockResult(result) + navController.popBackStack() + } + ) + } + composable { entry -> + val args = entry.toRoute() + DnsDetailScreen( + viewModel = dnsSettingsViewModel, + persistentState = persistentState, + appDownloadManager = appDownloadManager, + initialFocusKey = args.focusKey.takeIf { it.isNotBlank() }, + onCustomDnsClick = onDnsCustomDnsClick, + onRethinkPlusDnsClick = onDnsRethinkPlusDnsClick, + onLocalBlocklistConfigureClick = onDnsLocalBlocklistConfigureClick, + onBackClick = { navController.popBackStack() } + ) + } + composable { entry -> + val args = entry.toRoute() + AppInfoScreen( + uid = args.uid, + eventLogger = appInfoEventLogger, + ipRulesViewModel = appInfoIpRulesViewModel, + domainRulesViewModel = appInfoDomainRulesViewModel, + networkLogsViewModel = appInfoNetworkLogsViewModel, + onBackClick = { navController.popBackStack() }, + onAppWiseIpLogsClick = { u, isAsn -> + navController.navigate(HomeRoute.AppWiseIpLogs(u, isAsn)) + }, + onCustomIpRulesClick = { u -> + navController.navigate( + HomeRoute.CustomRules( + uid = u, + tab = CustomRulesTab.IP.value, + mode = CustomRulesMode.APP_SPECIFIC.value + ) + ) + }, + onCustomDomainRulesClick = { u -> + navController.navigate( + HomeRoute.CustomRules( + uid = u, + tab = CustomRulesTab.DOMAIN.value, + mode = CustomRulesMode.APP_SPECIFIC.value + ) + ) + } + ) + } + composable { + DnsListScreen( + appConfig = appConfig, + onConfigureOtherDns = onConfigureOtherDns, + onConfigureRethinkBasic = { type -> + navController.navigate( + HomeRoute.ConfigureRethinkBasic( + screenTypeOrdinal = ConfigureRethinkScreenType.entries[type].ordinal, + remoteName = "", + remoteUrl = "", + uid = -1 + ) + ) + }, + onBackClick = { navController.popBackStack() } + ) + } + composable { entry -> + val args = entry.toRoute() + AppWiseIpLogsScreen( + uid = args.uid, + isAsn = args.isAsn, + viewModel = appInfoNetworkLogsViewModel, + eventLogger = appInfoEventLogger, + onBackClick = { navController.popBackStack() } + ) + } + + composable { entry -> + val args = entry.toRoute() + RpnWinProxyDetailsScreen( + countryCode = args.countryCode, + onBackClick = { navController.popBackStack() } + ) + } + + composable { entry -> + val args = entry.toRoute() + val timeCategory = DomainConnectionsViewModel.TimeCategory.fromValue(args.timeCategory) + ?: DomainConnectionsViewModel.TimeCategory.ONE_HOUR + val type = DomainConnectionsInputType.fromValue(args.typeId) + + DomainConnectionsScreen( + viewModel = domainConnectionsViewModel, + type = type, + flag = args.flag, + domain = args.domain, + asn = args.asn, + ip = args.ip, + isBlocked = args.isBlocked, + timeCategory = timeCategory, + onBackClick = { navController.popBackStack() } + ) + } + + composable { entry -> + val args = entry.toRoute() + val type = SummaryStatisticsType.getType(args.typeId) + val timeCategory = SummaryStatisticsViewModel.TimeCategory.fromValue(args.timeCategory) + + DetailedStatisticsScreen( + type = type, + timeCategory = timeCategory ?: SummaryStatisticsViewModel.TimeCategory.TWENTY_FOUR_HOUR, + viewModel = detailedStatsViewModel, + onBackClick = { navController.popBackStack() } + ) + } + + composable { entry -> + val args = entry.toRoute() + WgConfigDetailScreen( + configId = args.configId, + wgType = args.wgType, + persistentState = persistentState, + eventLogger = appInfoEventLogger, + mappingViewModel = proxyAppsMappingViewModel, + onEditConfig = { id, type -> + navController.navigate(HomeRoute.WgConfigEditor(id, type)) + }, + onBackClick = { navController.popBackStack() } + ) + } + + composable { entry -> + val args = entry.toRoute() + WgConfigEditorScreen( + configId = args.configId, + wgType = args.wgType, + persistentState = persistentState, + onBackClick = { navController.popBackStack() }, + onSaveSuccess = { navController.popBackStack() } + ) + } + composable { + ConfigureScreen( + isDebug = isDebug, + onAppsClick = onConfigureAppsClick, + onDnsClick = onConfigureDnsClick, + onFirewallClick = onConfigureFirewallClick, + onProxyClick = onConfigureProxyClick, + onNetworkClick = onConfigureNetworkClick, + onOthersClick = onConfigureOthersClick, + onLogsClick = onConfigureLogsClick, + onAntiCensorshipClick = onConfigureAntiCensorshipClick, + onAdvancedClick = onConfigureAdvancedClick, + onSearchDestinationClick = { destination -> + when (destination) { + SettingsSearchDestination.Apps -> navController.navigate(HomeRoute.AppList) + is SettingsSearchDestination.Dns -> navController.navigate( + HomeRoute.DnsDetail(destination.focusKey) + ) + is SettingsSearchDestination.Firewall -> navController.navigate( + HomeRoute.FirewallSettings(destination.focusKey) + ) + is SettingsSearchDestination.Proxy -> navController.navigate( + HomeRoute.ProxySettings(destination.focusKey) + ) + is SettingsSearchDestination.Network -> navController.navigate( + HomeRoute.TunnelSettings(destination.focusKey) + ) + is SettingsSearchDestination.General -> navController.navigate( + HomeRoute.MiscSettings(destination.focusKey) + ) + SettingsSearchDestination.Logs -> navController.navigate(HomeRoute.NetworkLogs) + SettingsSearchDestination.AntiCensorship -> navController.navigate(HomeRoute.AntiCensorship) + SettingsSearchDestination.Advanced -> navController.navigate(HomeRoute.AdvancedSettings) + } + } + ) + } + composable { + AboutScreen( + uiState = aboutUiState, + onSponsorClick = onSponsorClick, + onTelegramClick = onTelegramClick, + onBugReportClick = onBugReportClick, + onWhatsNewClick = onWhatsNewClick, + onAppUpdateClick = onAppUpdateClick, + onContributorsClick = onContributorsClick, + onTranslateClick = onTranslateClick, + onWebsiteClick = onWebsiteClick, + onGithubClick = onGithubClick, + onFaqClick = onFaqClick, + onDocsClick = onDocsClick, + onPrivacyPolicyClick = onPrivacyPolicyClick, + onTermsOfServiceClick = onTermsOfServiceClick, + onLicenseClick = onLicenseClick, + onTwitterClick = onTwitterClick, + onEmailClick = onEmailClick, + onRedditClick = onRedditClick, + onElementClick = onElementClick, + onMastodonClick = onMastodonClick, + onGeneralSettingsClick = onGeneralSettingsClick, + onAppInfoClick = onAppInfoClick, + onVpnProfileClick = onVpnProfileClick, + onNotificationClick = onNotificationClick, + onStatsClick = onStatsClick, + onDbStatsClick = onDbStatsClick, + onFlightRecordClick = onFlightRecordClick, + onEventLogsClick = onEventLogsClick, + onTokenClick = onTokenClick, + onTokenDoubleTap = onTokenDoubleTap, + onFossClick = onFossClick, + onFlossFundsClick = onFlossFundsClick, + persistentState = persistentState, + onThemeModeChanged = onThemeModeChanged, + onThemeColorChanged = onThemeColorChanged + ) + } + composable { entry -> + val args = entry.toRoute() + val screenType = ConfigureRethinkScreenType.entries.getOrElse(args.screenTypeOrdinal) { + ConfigureRethinkScreenType.REMOTE + } + ConfigureRethinkBasicScreen( + screenType = screenType, + uid = args.uid, + persistentState = persistentState, + appConfig = appConfig, + appDownloadManager = appDownloadManager, + rethinkEndpointViewModel = rethinkEndpointViewModel, + remoteFileTagViewModel = remoteFileTagViewModel, + localFileTagViewModel = localFileTagViewModel, + remoteBlocklistPacksMapViewModel = remoteBlocklistPacksMapViewModel, + localBlocklistPacksMapViewModel = localBlocklistPacksMapViewModel, + onBackClick = { navController.popBackStack() } + ) + } + composable { entry -> + val args = entry.toRoute() + ConfigureOtherDnsScreen( + dnsType = DnsScreenType.fromIndex(args.dnsType), + appConfig = appConfig, + persistentState = persistentState, + dohViewModel = dohViewModel, + dotViewModel = dotViewModel, + dnsProxyViewModel = dnsProxyViewModel, + dnsCryptViewModel = dnsCryptViewModel, + dnsCryptRelayViewModel = dnsCryptRelayViewModel, + oDohViewModel = oDohViewModel, + onBackClick = { navController.popBackStack() } + ) + } + composable { + UniversalFirewallSettingsScreen( + persistentState = persistentState, + eventLogger = appInfoEventLogger, + connTrackerRepository = connectionTrackerRepository, + onNavigateToLogs = onNavigateToLogs, + onOpenAccessibilitySettings = onOpenAccessibilitySettings, + onBackClick = { navController.popBackStack() } + ) + } + composable { + val currentCheckoutViewModel = checkoutViewModel + if (currentCheckoutViewModel == null) { + Text(text = stringResource(id = R.string.checkout_unavailable_desc)) + } else { + val paymentStatus by currentCheckoutViewModel.paymentStatus.collectAsStateWithLifecycle() + val workInfoList by currentCheckoutViewModel.paymentWorkInfo + .asFlow() + .collectAsStateWithLifecycle(initialValue = emptyList()) + + LaunchedEffect(workInfoList) { + currentCheckoutViewModel.updatePaymentStatusFromWorkInfo(workInfoList) + } + + CheckoutScreen( + paymentStatus = paymentStatus, + onStartPayment = { currentCheckoutViewModel.startPayment() }, + onNavigateToProxy = onNavigateToProxy, + onBackClick = { navController.popBackStack() } + ) + } + } + composable { entry -> + val args = entry.toRoute() + AppWiseDomainLogsScreen( + uid = args.uid, + viewModel = appInfoNetworkLogsViewModel, + eventLogger = appInfoEventLogger, + onBackClick = { navController.popBackStack() } + ) + } + composable { + WgMainScreen( + wgConfigViewModel = wgConfigViewModel, + persistentState = persistentState, + appConfig = appConfig, + eventLogger = appInfoEventLogger, + onBackClick = { navController.popBackStack() }, + onCreateClick = onWgCreateClick, + onImportClick = onWgImportClick, + onQrScanClick = onWgQrScanClick, + onConfigDetailClick = { configId, wgType -> + navController.navigate(HomeRoute.WgConfigDetail(configId, wgType)) + } + ) + } + composable { + DatabaseScreen( + onBackClick = { navController.popBackStack() }, + appDatabase = appDatabase + ) + } + } + } + + if (showNavigationRail) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + NavigationRail( + modifier = Modifier + .fillMaxHeight() + .width(88.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Start) + ) { + HomeDestination.entries.forEach { destination -> + val routeName = destination.route::class.qualifiedName + val isSelected = currentHierarchy.any { it.route == routeName } + NavigationRailItem( + selected = isSelected, + onClick = { + navController.navigate(destination.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + imageVector = if (isSelected) destination.selectedIcon else destination.unselectedIcon, + contentDescription = stringResource(id = destination.labelRes) + ) + }, + label = { + Text( + text = stringResource(id = destination.labelRes), + style = MaterialTheme.typography.labelMedium + ) + }, + alwaysShowLabel = true, + colors = NavigationRailItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + + navHostContent(Modifier.weight(1f)) + } + } else { + navHostContent(navHostModifier) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeRoute.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeRoute.kt new file mode 100644 index 000000000..015aa91b0 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/navigation/HomeRoute.kt @@ -0,0 +1,167 @@ +package com.celzero.bravedns.ui.compose.navigation + +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import kotlinx.serialization.Serializable +import com.celzero.bravedns.ui.compose.wireguard.WgType + +sealed interface HomeRoute { + + @Serializable + data object Home : HomeRoute + + @Serializable + data object Stats : HomeRoute + + @Serializable + data object Configure : HomeRoute + + @Serializable + data object About : HomeRoute + + @Serializable + data object Alerts : HomeRoute + + @Serializable + data object RpnCountries : HomeRoute + + @Serializable + data object RpnAvailability : HomeRoute + + @Serializable + data object Events : HomeRoute + + @Serializable + data class FirewallSettings(val focusKey: String = "") : HomeRoute + + @Serializable + data object AdvancedSettings : HomeRoute + + @Serializable + data object AntiCensorship : HomeRoute + + @Serializable + data class TunnelSettings(val focusKey: String = "") : HomeRoute + + @Serializable + data class MiscSettings(val focusKey: String = "") : HomeRoute + + @Serializable + data object ConsoleLogs : HomeRoute + + @Serializable + data object NetworkLogs : HomeRoute + + @Serializable + data object AppList : HomeRoute + + @Serializable + data class CustomRules( + val uid: Int = UID_EVERYBODY, + val tab: Int = CustomRulesTab.IP.value, + val mode: Int = CustomRulesMode.APP_SPECIFIC.value + ) : HomeRoute + + @Serializable + data class ProxySettings(val focusKey: String = "") : HomeRoute + + @Serializable + data object OrbotAppSelect : HomeRoute + + @Serializable + data object TcpProxyMain : HomeRoute + + @Serializable + data object Welcome : HomeRoute + + @Serializable + data object PingTest : HomeRoute + + @Serializable + data object AppLock : HomeRoute + + @Serializable + data class DnsDetail(val focusKey: String = "") : HomeRoute + + @Serializable + data class DetailedStats(val typeId: Int, val timeCategory: Int) : HomeRoute + + @Serializable + data class RpnWinProxyDetails(val countryCode: String) : HomeRoute + + @Serializable + data class DomainConnections( + val typeId: Int, + val flag: String, + val domain: String, + val asn: String, + val ip: String, + val isBlocked: Boolean, + val timeCategory: Int + ) : HomeRoute + + @Serializable + data class AppInfo(val uid: Int) : HomeRoute + + @Serializable + data class WgConfigDetail(val configId: Int, val wgType: WgType) : HomeRoute + + @Serializable + data class WgConfigEditor(val configId: Int, val wgType: WgType) : HomeRoute + + // We handle ConfigureRethinkBasic carefully as it uses an enum index + @Serializable + data class ConfigureRethinkBasic( + val screenTypeOrdinal: Int, + val remoteName: String = "", + val remoteUrl: String = "", + val uid: Int = -1 + ) : HomeRoute + + @Serializable + data object DnsList : HomeRoute + + @Serializable + data class AppWiseIpLogs(val uid: Int, val isAsn: Boolean) : HomeRoute + + @Serializable + data class ConfigureOtherDns(val dnsType: Int) : HomeRoute + + @Serializable + data object UniversalFirewallSettings : HomeRoute + + @Serializable + data class AppWiseDomainLogs(val uid: Int) : HomeRoute + + @Serializable + data object Checkout : HomeRoute + + @Serializable + data object WgMain : HomeRoute + + @Serializable + data object Database : HomeRoute +} + +@Serializable +enum class CustomRulesTab(val value: Int) { + IP(0), + DOMAIN(1); + + companion object { + fun fromValue(value: Int): CustomRulesTab { + return entries.firstOrNull { it.value == value } ?: IP + } + } +} + +@Serializable +enum class CustomRulesMode(val value: Int) { + ALL_RULES(0), + APP_SPECIFIC(1); + + companion object { + fun fromValue(value: Int): CustomRulesMode { + return entries.firstOrNull { it.value == value } ?: APP_SPECIFIC + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/proxy/TcpProxyMainScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/proxy/TcpProxyMainScreen.kt new file mode 100644 index 000000000..80d53c905 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/proxy/TcpProxyMainScreen.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2024 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.compose.proxy + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.VpnKey +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.asFlow +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.TcpProxyHelper +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkAnimatedSection +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkTopBarLazyColumnScreen +import com.celzero.bravedns.ui.compose.theme.SectionHeaderWithSubtitle +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.ui.dialog.WgIncludeAppsDialog +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "TcpProxyMainScreen" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TcpProxyMainScreen( + appConfig: AppConfig, + mappingViewModel: ProxyAppsMappingViewModel, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var tcpProxySwitchChecked by remember { mutableStateOf(false) } + var tcpProxyStatus by remember { mutableStateOf("") } + var tcpProxyDesc by remember { mutableStateOf("") } + var tcpErrorVisible by remember { mutableStateOf(false) } + var tcpErrorText by remember { mutableStateOf("") } + var enableUdpRelayChecked by remember { mutableStateOf(false) } + var warpSwitchChecked by remember { mutableStateOf(false) } + var showIncludeAppsDialog by remember { mutableStateOf(false) } + var includeAppsProxyId by remember { mutableStateOf("") } + var includeAppsProxyName by remember { mutableStateOf("") } + val tcpProxyDefaultDesc = stringResource(R.string.settings_https_desc) + val tcpProxyWarpActiveError = stringResource(R.string.tcp_proxy_warp_active_error) + val tcpProxyNoAppsError = stringResource(R.string.tcp_proxy_no_apps_error) + val tcpProxyDisabledDescription = stringResource(R.string.settings_https_desc) + val tcpProxyDisabledErrorTemplate = stringResource(R.string.settings_https_disabled_error) + val activeText = stringResource(R.string.lbl_active) + val inactiveText = stringResource(R.string.lbl_inactive) + val udpExperimentalDesc = stringResource(R.string.adv_set_experimental_desc) + val appsText = stringResource(R.string.lbl_apps) + + val appCount by mappingViewModel.getAppCountById(ProxyManager.ID_TCP_BASE) + .asFlow() + .collectAsState(initial = 0) + val appListSubtitleText = stringResource(R.string.add_remove_apps, appCount.toString()) + + LaunchedEffect(Unit) { + tcpProxyDesc = tcpProxyDefaultDesc + } + + LaunchedEffect(Unit) { + displayTcpProxyStatus( + onStatusUpdate = { status, switchChecked, errorVisible, errorText -> + tcpProxyStatus = status + tcpProxySwitchChecked = switchChecked + tcpErrorVisible = errorVisible + tcpErrorText = errorText + } + ) + } + + fun onTcpProxySwitchChanged(checked: Boolean) { + tcpProxySwitchChecked = checked + scope.launch(Dispatchers.IO) { + val isWarpActive = warpSwitchChecked + withContext(Dispatchers.Main) { + if (checked && isWarpActive) { + tcpProxySwitchChecked = false + Utilities.showToastUiCentered( + context, + tcpProxyWarpActiveError, + Toast.LENGTH_SHORT + ) + return@withContext + } + + val apps = ProxyManager.isAnyAppSelected(ProxyManager.ID_TCP_BASE) + + if (!apps) { + Utilities.showToastUiCentered( + context, + tcpProxyNoAppsError, + Toast.LENGTH_SHORT + ) + warpSwitchChecked = false + tcpProxySwitchChecked = false + return@withContext + } + + if (!checked) { + scope.launch(Dispatchers.IO) { TcpProxyHelper.disable() } + tcpProxyDesc = tcpProxyDisabledDescription + return@withContext + } + + if (appConfig.getBraveMode().isDnsMode()) { + tcpProxySwitchChecked = false + return@withContext + } + + if (!appConfig.canEnableTcpProxy()) { + val provider = appConfig.getProxyProvider().lowercase().replaceFirstChar(Char::titlecase) + Utilities.showToastUiCentered( + context, + String.format(tcpProxyDisabledErrorTemplate, provider), + Toast.LENGTH_SHORT + ) + tcpProxySwitchChecked = false + return@withContext + } + + scope.launch(Dispatchers.IO) { TcpProxyHelper.enable() } + } + } + } + + fun openAppsDialog() { + includeAppsProxyId = ProxyManager.ID_TCP_BASE + includeAppsProxyName = ProxyManager.TCP_PROXY_NAME + showIncludeAppsDialog = true + } + + if (showIncludeAppsDialog) { + WgIncludeAppsDialog( + viewModel = mappingViewModel, + proxyId = includeAppsProxyId, + proxyName = includeAppsProxyName, + onDismiss = { showIncludeAppsDialog = false } + ) + } + + val listState = rememberLazyListState() + val subtitle = if (tcpProxySwitchChecked) activeText else inactiveText + + RethinkTopBarLazyColumnScreen( + title = stringResource(id = R.string.settings_https_heading), + subtitle = subtitle, + onBackClick = onBackClick, + containerColor = MaterialTheme.colorScheme.surface, + listState = listState, + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacing3xl + ) + ) { + item { + RethinkAnimatedSection(index = 0) { + SectionHeaderWithSubtitle( + title = stringResource(id = R.string.tcp_proxy_rethink_proxy_title), + subtitle = stringResource(id = R.string.settings_https_desc) + ) + Column { + val entries = 3 + RethinkListItem( + headline = stringResource(id = R.string.tcp_proxy_rethink_proxy_title), + supporting = if (tcpErrorVisible) tcpErrorText else tcpProxyStatus.ifEmpty { tcpProxyDesc }, + leadingIcon = Icons.Rounded.VpnKey, + position = cardPositionFor(index = 0, lastIndex = entries - 1), + onClick = { onTcpProxySwitchChanged(!tcpProxySwitchChecked) }, + trailing = { + Switch( + checked = tcpProxySwitchChecked, + onCheckedChange = { onTcpProxySwitchChanged(it) } + ) + } + ) + + RethinkListItem( + headline = stringResource(id = R.string.tcp_proxy_enable_udp_relay), + supporting = udpExperimentalDesc, + leadingIcon = Icons.Rounded.Settings, + position = cardPositionFor(index = 1, lastIndex = entries - 1), + onClick = { enableUdpRelayChecked = !enableUdpRelayChecked }, + trailing = { + Switch( + checked = enableUdpRelayChecked, + onCheckedChange = { enableUdpRelayChecked = it } + ) + } + ) + + RethinkListItem( + headline = appsText, + supporting = appListSubtitleText, + leadingIcon = Icons.Rounded.Apps, + position = cardPositionFor(index = 2, lastIndex = entries - 1), + onClick = { openAppsDialog() } + ) + } + } + } + + item { + RethinkAnimatedSection(index = 1) { + SectionHeaderWithSubtitle( + title = stringResource(id = R.string.tcp_proxy_cloudflare_warp_title), + subtitle = stringResource(id = R.string.tcp_proxy_cloudflare_warp_desc) + ) + RethinkListItem( + headline = stringResource(id = R.string.tcp_proxy_cloudflare_warp_title), + supporting = stringResource(id = R.string.tcp_proxy_cloudflare_warp_desc), + leadingIcon = Icons.Rounded.VpnKey, + position = cardPositionFor(index = 0, lastIndex = 0), + onClick = { warpSwitchChecked = !warpSwitchChecked }, + trailing = { + Switch( + checked = warpSwitchChecked, + onCheckedChange = { warpSwitchChecked = it } + ) + } + ) + } + } + + item { + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + } + } +} + +private suspend fun displayTcpProxyStatus( + onStatusUpdate: (status: String, switchChecked: Boolean, errorVisible: Boolean, errorText: String) -> Unit +) { + withContext(Dispatchers.IO) { + val tcpProxies = TcpProxyHelper.getActiveTcpProxy() + withContext(Dispatchers.Main) { + if (tcpProxies == null || !tcpProxies.isActive) { + onStatusUpdate("Not active", false, true, "Something went wrong") + return@withContext + } + + Napier.i("$TAG displayTcpProxyUi: ${tcpProxies.name}, ${tcpProxies.url}") + onStatusUpdate("Active", true, false, "") + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnAvailabilityScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnAvailabilityScreen.kt new file mode 100644 index 000000000..7cf0dcf98 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnAvailabilityScreen.kt @@ -0,0 +1,233 @@ +/* + * 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.compose.rpn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RpnAvailabilityScreen(onBackClick: () -> Unit) { + var options by remember { mutableStateOf>(emptyList()) } + var items by remember { mutableStateOf>(emptyList()) } + var strength by remember { mutableStateOf(0) } + var maxStrength by remember { mutableStateOf(0) } + + LaunchedEffect(Unit) { + options = listOf("WIN-US", "WIN-UK", "WIN-IN", "WIN-DE", "WIN-CA") + maxStrength = options.size + items = options.map { RpnAvailabilityItem(it, RpnAvailabilityStatus.Loading) } + + options.forEach { option -> + items = + items.map { item -> + if (item.name == option) item.copy(status = RpnAvailabilityStatus.Loading) + else item + } + val res = withContext(Dispatchers.IO) { + false + } + if (res) { + strength += 1 + items = + items.map { item -> + if (item.name == option) item.copy(status = RpnAvailabilityStatus.Active) + else item + } + } else { + items = + items.map { item -> + if (item.name == option) item.copy(status = RpnAvailabilityStatus.Inactive) + else item + } + } + Napier.i("RpnAvailabilityScreen strength: $strength ($res)") + } + } + + val progress = if (maxStrength > 0) strength.toFloat() / maxStrength else 0f + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(id = R.string.rpn_availability_title), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally + ) { + androidx.compose.material3.Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal, vertical = Dimensions.spacingSm), + shape = androidx.compose.foundation.shape.RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 1.dp + ) { + Column(modifier = Modifier.padding(Dimensions.spacingLg)) { + Text( + text = stringResource(id = R.string.rpn_availability_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(id = R.string.rpn_availability_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal, vertical = Dimensions.spacingLg) + ) { + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.size(120.dp), + strokeWidth = 8.dp + ) + Text( + text = "$strength/$maxStrength", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + items.forEachIndexed { index, item -> + AvailabilityRow(item) + if (index != items.lastIndex) { + HorizontalDivider() + } + } + } + } + } + } + } +} + +@Composable +private fun AvailabilityRow(item: RpnAvailabilityItem) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyMedium + ) + when (item.status) { + RpnAvailabilityStatus.Loading -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + RpnAvailabilityStatus.Active -> { + Text( + text = stringResource(id = R.string.lbl_active), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium + ) + } + + RpnAvailabilityStatus.Inactive -> { + Text( + text = stringResource(id = R.string.lbl_inactive), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +private data class RpnAvailabilityItem( + val name: String, + val status: RpnAvailabilityStatus +) + +private enum class RpnAvailabilityStatus { + Loading, + Active, + Inactive +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnCountriesScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnCountriesScreen.kt new file mode 100644 index 000000000..c879628a4 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/rpn/RpnCountriesScreen.kt @@ -0,0 +1,148 @@ +/* + * 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.compose.rpn + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.CountryRow +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RpnCountriesScreen(onBackClick: () -> Unit) { + var countries by remember { mutableStateOf>(emptyList()) } + var selectedCountries by remember { mutableStateOf>(emptySet()) } + var showNoCountriesDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val result = + withContext(Dispatchers.IO) { + val servers = runCatching { RpnProxyManager.getWinServers() }.getOrDefault(emptyList()) + val selected = runCatching { RpnProxyManager.getSelectedCCs() }.getOrDefault(emptySet()) + val serverCountries = + servers + .map { it.countryCode.uppercase() } + .filter { it.isNotBlank() } + .distinct() + .sorted() + Pair(serverCountries, selected.map { it.uppercase() }.toSet()) + } + + countries = result.first + selectedCountries = result.second + if (countries.isEmpty()) { + showNoCountriesDialog = true + } + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(id = R.string.lbl_countries), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (showNoCountriesDialog) { + RethinkConfirmDialog( + onDismissRequest = {}, + title = stringResource(id = R.string.rpn_no_countries_title), + message = stringResource(id = R.string.rpn_no_countries_desc), + confirmText = stringResource(id = R.string.dns_info_positive), + onConfirm = onBackClick + ) + } + androidx.compose.material3.Surface( + modifier = Modifier.padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ), + shape = androidx.compose.foundation.shape.RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 1.dp + ) { + Column(modifier = Modifier.padding(Dimensions.spacingLg)) { + Text( + text = stringResource(id = R.string.lbl_countries), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(id = R.string.rpn_availability_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + CountriesList( + countries = countries, + selectedCountries = selectedCountries, + modifier = Modifier.padding(horizontal = Dimensions.screenPaddingHorizontal) + ) + } + } +} + +@Composable +private fun CountriesList( + countries: List, + selectedCountries: Set, + modifier: Modifier = Modifier +) { + LazyColumn(modifier = modifier.fillMaxSize()) { + items(countries.size) { index -> + val country = countries[index] + CountryRow(country, selectedCountries.contains(country)) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AdvancedSettingsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AdvancedSettingsScreen.kt new file mode 100644 index 000000000..6bb2677cc --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AdvancedSettingsScreen.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2024 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.compose.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R +import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkAnimatedSection +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.cardPositionFor + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdvancedSettingsScreen( + persistentState: PersistentState, + onBackClick: (() -> Unit)? = null +) { + if (!DEBUG) { + return + } + + var experimentalEnabled by remember { mutableStateOf(persistentState.nwEngExperimentalFeatures) } + var autoDialEnabled by remember { mutableStateOf(persistentState.autoDialsParallel) } + var panicEnabled by remember { mutableStateOf(persistentState.panicRandom) } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + RethinkLargeTopBar( + title = stringResource(id = R.string.lbl_advanced), + subtitle = stringResource(id = R.string.adv_set_experimental_desc), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { padding -> + LazyColumn( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = + PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + item { + RethinkAnimatedSection(index = 0) { + Column { + val entries = 3 + RethinkListItem( + headline = stringResource(id = R.string.adv_set_experimental_title), + leadingIcon = Icons.Filled.Build, + position = cardPositionFor(index = 0, lastIndex = entries - 1), + onClick = { + experimentalEnabled = !experimentalEnabled + persistentState.nwEngExperimentalFeatures = experimentalEnabled + }, + trailing = { + Switch( + checked = experimentalEnabled, + onCheckedChange = { + experimentalEnabled = it + persistentState.nwEngExperimentalFeatures = it + } + ) + } + ) + + RethinkListItem( + headline = stringResource(id = R.string.set_auto_dial_title), + supporting = stringResource(id = R.string.set_auto_dial_desc), + leadingIcon = Icons.Filled.Tune, + position = cardPositionFor(index = 1, lastIndex = entries - 1), + onClick = { + autoDialEnabled = !autoDialEnabled + persistentState.autoDialsParallel = autoDialEnabled + }, + trailing = { + Switch( + checked = autoDialEnabled, + onCheckedChange = { + autoDialEnabled = it + persistentState.autoDialsParallel = it + } + ) + } + ) + + RethinkListItem( + headline = "Random panic", + supporting = "Debug-only chaos mode for tunnel reliability testing.", + leadingIcon = Icons.Filled.Warning, + position = cardPositionFor(index = 2, lastIndex = entries - 1), + onClick = { + panicEnabled = !panicEnabled + persistentState.panicRandom = panicEnabled + }, + trailing = { + Switch( + checked = panicEnabled, + onCheckedChange = { + panicEnabled = it + persistentState.panicRandom = it + } + ) + } + ) + } + } + } + + item { + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AntiCensorshipScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AntiCensorshipScreen.kt new file mode 100644 index 000000000..e20ecc8b7 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AntiCensorshipScreen.kt @@ -0,0 +1,287 @@ +/* + * Copyright 2024 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.compose.settings + +import android.widget.Toast +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.celzero.bravedns.R +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkTopBarLazyColumnScreen +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isOsVersionAbove412 +import com.celzero.firestack.settings.Settings + +private const val DESYNC_SUPPORTED_VERSION = "4.12" + +enum class DialStrategies(val mode: Int) { + SPLIT_AUTO(Settings.SplitAuto), + SPLIT_TCP(Settings.SplitTCP), + SPLIT_TCP_TLS(Settings.SplitTCPOrTLS), + DESYNC(Settings.SplitDesync), + NEVER_SPLIT(Settings.SplitNever), + TCP_PROXY(Settings.SplitAuto); + + companion object { + fun fromInt(value: Int): DialStrategies? = entries.firstOrNull { it.mode == value } + } +} + +enum class RetryStrategies(val mode: Int) { + RETRY_WITH_SPLIT(Settings.RetryWithSplit), + RETRY_NEVER(Settings.RetryNever), + RETRY_AFTER_SPLIT(Settings.RetryAfterSplit); + + companion object { + fun fromInt(value: Int): RetryStrategies? = entries.firstOrNull { it.mode == value } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AntiCensorshipScreen( + persistentState: PersistentState, + eventLogger: EventLogger, + onBackClick: (() -> Unit)? = null +) { + val desyncSupported = remember { isOsVersionAbove412(DESYNC_SUPPORTED_VERSION) } + + val initialDial = remember { + val base = DialStrategies.fromInt(persistentState.dialStrategy) ?: DialStrategies.SPLIT_AUTO + val resolved = if (base == DialStrategies.SPLIT_AUTO && persistentState.autoProxyEnabled) { + DialStrategies.TCP_PROXY + } else { + base + } + if (!desyncSupported && resolved == DialStrategies.DESYNC) { + persistentState.dialStrategy = DialStrategies.SPLIT_AUTO.mode + DialStrategies.SPLIT_AUTO + } else { + resolved + } + } + + val initialRetry = remember { + RetryStrategies.fromInt(persistentState.retryStrategy) ?: RetryStrategies.RETRY_WITH_SPLIT + } + + var dialSelection by remember { mutableStateOf(initialDial) } + var retrySelection by remember { mutableStateOf(initialRetry) } + val context = LocalContext.current + val retryDisabledToast = stringResource(id = R.string.ac_toast_retry_disabled) + + val selectedDialLabel = + stringResource( + when (dialSelection) { + DialStrategies.NEVER_SPLIT -> R.string.settings_app_list_default_app + DialStrategies.SPLIT_AUTO -> R.string.settings_ip_text_ipv46 + DialStrategies.SPLIT_TCP -> R.string.ac_split_tcp + DialStrategies.SPLIT_TCP_TLS -> R.string.ac_split_tls + DialStrategies.DESYNC -> R.string.ac_desync + DialStrategies.TCP_PROXY -> R.string.ac_tcp_proxy + } + ) + val selectedRetryLabel = + stringResource( + when (retrySelection) { + RetryStrategies.RETRY_NEVER -> R.string.settings_app_list_default_app + RetryStrategies.RETRY_WITH_SPLIT -> R.string.settings_ip_text_ipv46 + RetryStrategies.RETRY_AFTER_SPLIT -> R.string.lbl_always + } + ) + val topBarSubtitle = + "${stringResource(R.string.lbl_split)}: $selectedDialLabel · " + + "${stringResource(R.string.ac_retry_options_title)}: $selectedRetryLabel" + + RethinkTopBarLazyColumnScreen( + title = stringResource(R.string.anti_censorship_title), + subtitle = topBarSubtitle, + onBackClick = onBackClick, + containerColor = MaterialTheme.colorScheme.background, + topBarContainerColor = Color.Transparent, + topBarScrolledContainerColor = Color.Transparent + ) { + item { + RethinkListGroup { + val validStrategies = + DialStrategies.entries.filter { desyncSupported || it != DialStrategies.DESYNC } + validStrategies.forEachIndexed { index, strategy -> + val titleRes = when (strategy) { + DialStrategies.NEVER_SPLIT -> R.string.settings_app_list_default_app + DialStrategies.SPLIT_AUTO -> R.string.settings_ip_text_ipv46 + DialStrategies.SPLIT_TCP -> R.string.ac_split_tcp + DialStrategies.SPLIT_TCP_TLS -> R.string.ac_split_tls + DialStrategies.DESYNC -> R.string.ac_desync + DialStrategies.TCP_PROXY -> R.string.ac_tcp_proxy + } + val descRes = when (strategy) { + DialStrategies.NEVER_SPLIT -> R.string.ac_never_split_desc + DialStrategies.SPLIT_AUTO -> R.string.ac_split_auto_desc + DialStrategies.SPLIT_TCP -> R.string.ac_split_tcp_desc + DialStrategies.SPLIT_TCP_TLS -> R.string.ac_split_tls_desc + DialStrategies.DESYNC -> R.string.ac_desync_desc + DialStrategies.TCP_PROXY -> R.string.ac_tcp_proxy_desc + } + val position = when { + validStrategies.size == 1 -> CardPosition.Single + index == 0 -> CardPosition.First + index == validStrategies.size - 1 -> CardPosition.Last + else -> CardPosition.Middle + } + + RethinkListItem( + headline = stringResource(titleRes), + supporting = stringResource(descRes), + position = position, + contentOffset = Modifier.offset(x = (-Dimensions.spacingXs)), + onClick = { + if (dialSelection != strategy) { + dialSelection = strategy + persistentState.dialStrategy = strategy.mode + persistentState.autoProxyEnabled = strategy == DialStrategies.TCP_PROXY + val nextRetry = + when (strategy) { + DialStrategies.NEVER_SPLIT -> RetryStrategies.RETRY_NEVER + DialStrategies.SPLIT_AUTO, DialStrategies.TCP_PROXY -> RetryStrategies.RETRY_WITH_SPLIT + else -> RetryStrategies.fromInt(persistentState.retryStrategy) + ?: RetryStrategies.RETRY_WITH_SPLIT + } + persistentState.retryStrategy = nextRetry.mode + retrySelection = nextRetry + eventLogger.log( + EventType.UI_TOGGLE, + Severity.LOW, + "Anti-censorship UI", + EventSource.UI, + false, + "Anti-censorship dial strategy changed to ${strategy.mode}" + ) + } + }, + trailing = { + RadioButton( + selected = dialSelection == strategy, + onClick = null + ) + } + ) + } + } + } + + item { + SectionHeader(title = stringResource(R.string.ac_retry_options_title)) + Text( + text = stringResource(R.string.ac_retry_options_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Dimensions.spacingMd, vertical = Dimensions.spacingXs) + ) + + RethinkListGroup { + val strategies = RetryStrategies.entries + strategies.forEachIndexed { index, strategy -> + val titleRes = when (strategy) { + RetryStrategies.RETRY_NEVER -> R.string.settings_app_list_default_app + RetryStrategies.RETRY_WITH_SPLIT -> R.string.settings_ip_text_ipv46 + RetryStrategies.RETRY_AFTER_SPLIT -> R.string.lbl_always + } + val descRes = when (strategy) { + RetryStrategies.RETRY_NEVER -> R.string.ac_retry_options_never_desc + RetryStrategies.RETRY_WITH_SPLIT -> R.string.ac_retry_options_with_split_desc + RetryStrategies.RETRY_AFTER_SPLIT -> R.string.ac_retry_options_after_split_desc + } + val enabled = + dialSelection != DialStrategies.NEVER_SPLIT || + strategy == RetryStrategies.RETRY_NEVER + + val position = when { + strategies.size == 1 -> CardPosition.Single + index == 0 -> CardPosition.First + index == strategies.size - 1 -> CardPosition.Last + else -> CardPosition.Middle + } + + RethinkListItem( + headline = stringResource(titleRes), + supporting = stringResource(descRes), + position = position, + enabled = enabled, + contentOffset = Modifier.offset(x = (-Dimensions.spacingXs)), + onClick = { + if (!enabled) { + Utilities.showToastUiCentered( + context, + retryDisabledToast, + Toast.LENGTH_LONG + ) + return@RethinkListItem + } + if (retrySelection != strategy) { + var mode = strategy.mode + if ( + DialStrategies.NEVER_SPLIT.mode == persistentState.dialStrategy && + strategy != RetryStrategies.RETRY_NEVER + ) { + mode = RetryStrategies.RETRY_NEVER.mode + } + persistentState.retryStrategy = mode + retrySelection = strategy + eventLogger.log( + EventType.UI_TOGGLE, + Severity.LOW, + "Anti-censorship UI", + EventSource.UI, + false, + "Anti-censorship retry strategy changed to $mode" + ) + } + }, + trailing = { + RadioButton( + selected = retrySelection == strategy, + onClick = null, + enabled = enabled + ) + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppLockScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppLockScreen.kt new file mode 100644 index 000000000..c47c9eada --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppLockScreen.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.ui.compose.settings + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import android.os.SystemClock +import android.widget.Toast +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.celzero.bravedns.R +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.util.BioMetricType +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import io.github.aakira.napier.Napier +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +private const val TAG = "AppLockScreen" + +/** + * Result of the app lock authentication check. + */ +sealed interface AppLockResult { + /** Authentication succeeded - proceed to home */ + data object Success : AppLockResult + /** Authentication failed or was cancelled - finish the app */ + data object Failure : AppLockResult + /** Biometric authentication is not needed (disabled, TV, or within timeout) */ + data object NotRequired : AppLockResult + /** Waiting for user to complete biometric prompt */ + data object Pending : AppLockResult +} + +/** + * App lock screen that handles biometric/PIN authentication before allowing access to the app. + * + * This screen displays the app logo while the biometric prompt is shown. It handles: + * - Checking if biometric authentication is enabled + * - Checking if the app is running on TV (biometric not supported) + * - Checking if the authentication timeout has not expired + * - Showing the biometric prompt and handling callbacks + * + * @param persistentState The persistent state containing biometric settings + * @param onAuthResult Callback invoked when authentication completes with the result + */ +@Composable +fun AppLockScreen( + persistentState: PersistentState, + onAuthResult: (AppLockResult) -> Unit +) { + val context = LocalContext.current + val appName = stringResource(R.string.app_name) + val biometricTitle = stringResource(R.string.hs_biometeric_title) + val biometricDesc = stringResource(R.string.hs_biometeric_desc) + + var authState by remember { mutableStateOf(AppLockResult.Pending) } + var biometricPrompt by remember { mutableStateOf(null) } + + // Check if authentication is required + LaunchedEffect(Unit) { + val isRequired = checkBiometricRequired(context, persistentState) + if (!isRequired) { + Napier.v("$TAG biometric authentication not required") + authState = AppLockResult.NotRequired + onAuthResult(AppLockResult.NotRequired) + } + } + + // Set up biometric prompt when the composable is first composed + DisposableEffect(context) { + val activity = context as? FragmentActivity + if (activity != null && authState == AppLockResult.Pending) { + val shouldShow = checkBiometricRequired(context, persistentState) + if (shouldShow) { + val executor = ContextCompat.getMainExecutor(context) + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + Napier.i("$TAG auth error(code: $errorCode): $errString") + showToastUiCentered(context, errString.toString(), Toast.LENGTH_SHORT) + authState = AppLockResult.Failure + onAuthResult(AppLockResult.Failure) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + Napier.v("$TAG biometric authentication succeeded") + persistentState.biometricAuthTime = SystemClock.elapsedRealtime() + authState = AppLockResult.Success + onAuthResult(AppLockResult.Success) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + Napier.i("$TAG biometric authentication failed") + // Don't change state here - user can retry + } + } + + biometricPrompt = BiometricPrompt(activity, executor, callback) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(biometricTitle) + .setSubtitle(biometricDesc) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .setConfirmationRequired(false) + .build() + + Napier.v("$TAG showing biometric prompt") + biometricPrompt?.authenticate(promptInfo) + } + } + + onDispose { + biometricPrompt?.cancelAuthentication() + } + } + + // UI: Simple screen with app logo + AppLockContent( + appName = appName, + title = biometricTitle, + subtitle = biometricDesc + ) +} + +/** + * The visual content of the app lock screen - displays the app logo centered on a background. + */ +@Composable +private fun AppLockContent( + appName: String, + title: String, + subtitle: String +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingMd + ) + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = Dimensions.screenPaddingHorizontal) + ) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + contentAlignment = Alignment.Center + ) { + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(Dimensions.cornerRadius5xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier.padding(horizontal = Dimensions.spacing2xl, vertical = Dimensions.spacing2xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_launcher), + contentDescription = appName, + modifier = Modifier.size(120.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = appName, + style = MaterialTheme.typography.titleLarge + ) + } + } + } + } + } +} + +/** + * Checks if biometric authentication is required. + * + * Authentication is NOT required if: + * - Biometric is disabled in settings + * - App is running on TV + * - The configured timeout has not expired since last authentication + * + * @return true if biometric prompt should be shown, false otherwise + */ +private fun checkBiometricRequired( + context: Context, + persistentState: PersistentState +): Boolean { + // Check if biometric is enabled + val bioMetricType = BioMetricType.fromValue(persistentState.biometricAuthType) + if (!bioMetricType.enabled()) { + Napier.v("$TAG biometric authentication disabled") + return false + } + + // Check if running on TV + if (isAppRunningOnTv(context)) { + Napier.v("$TAG running on TV, biometric not supported") + return false + } + + // Check timeout + val lastAuthTime = persistentState.biometricAuthTime + var delay = bioMetricType.mins + + // Default to 15 minutes if delay is invalid + delay = if (delay == -1L) { + BioMetricType.FIFTEEN_MIN.mins + } else { + delay + } + + Napier.d("$TAG timeout: $delay, last auth: $lastAuthTime") + val timeSinceLastAuth = abs(SystemClock.elapsedRealtime() - lastAuthTime) + if (timeSinceLastAuth < TimeUnit.MINUTES.toMillis(delay)) { + Napier.i("$TAG biometric auth skipped, time since last auth: $timeSinceLastAuth") + return false + } + + return true +} + +/** + * Checks if the app is running on a TV device. + */ +private fun isAppRunningOnTv(context: Context): Boolean { + return try { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } catch (_: Exception) { + false + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppearanceSettingsCard.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppearanceSettingsCard.kt new file mode 100644 index 000000000..133b67b8b --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/AppearanceSettingsCard.kt @@ -0,0 +1,437 @@ +/* + * 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.compose.settings + +import android.os.Build +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.rounded.BrightnessAuto +import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.LightMode +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.RethinkColorPreset +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.ui.compose.theme.rememberReducedMotion +import com.celzero.bravedns.util.Themes + +enum class AppearanceMode { + AUTO, + LIGHT, + DARK; + + fun toThemePreference(): Int { + return when (this) { + AUTO -> Themes.SYSTEM_DEFAULT.id + LIGHT -> Themes.LIGHT_PLUS.id + DARK -> Themes.DARK_PLUS.id + } + } + + fun icon(): ImageVector { + return when (this) { + AUTO -> Icons.Rounded.BrightnessAuto + LIGHT -> Icons.Rounded.LightMode + DARK -> Icons.Rounded.DarkMode + } + } + + companion object { + fun fromThemePreference(preference: Int): AppearanceMode { + return when (preference) { + Themes.SYSTEM_DEFAULT.id -> AUTO + Themes.LIGHT.id, Themes.LIGHT_PLUS.id -> LIGHT + else -> DARK + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AppearanceSettingsCard( + themePreference: Int, + colorPresetId: Int, + onAppearanceModeSelected: (AppearanceMode) -> Unit, + onColorPresetSelected: (RethinkColorPreset) -> Unit, + sectionHeaderColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + showSectionHeader: Boolean = true +) { + var appearanceMode by remember(themePreference) { + mutableStateOf(AppearanceMode.fromThemePreference(themePreference)) + } + var colorPreset by remember(colorPresetId) { + mutableStateOf( + RethinkColorPreset.fromId(colorPresetId).let { + if (it == RethinkColorPreset.AUTO) RethinkColorPreset.DYNAMIC else it + } + ) + } + + val dynamicSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val context = LocalContext.current + val systemDarkMode = isSystemInDarkTheme() + val dynamicSwatchColor = remember(dynamicSupported, systemDarkMode, context) { + if (!dynamicSupported) { + Color(0xff7C8BFF) + } else { + if (systemDarkMode) androidx.compose.material3.dynamicDarkColorScheme(context).primary + else androidx.compose.material3.dynamicLightColorScheme(context).primary + } + } + val dynamicPreviewColor = dynamicSwatchColor + val selectableColorPresets = remember { + listOf( + RethinkColorPreset.DYNAMIC, + RethinkColorPreset.CORAL, + RethinkColorPreset.ROSE, + RethinkColorPreset.ORANGE, + RethinkColorPreset.AMBER, + RethinkColorPreset.GREEN, + RethinkColorPreset.TEAL, + RethinkColorPreset.CYAN, + RethinkColorPreset.BLUE, + RethinkColorPreset.INDIGO, + RethinkColorPreset.PURPLE + ) + } + + Column { + if (showSectionHeader) { + SectionHeader( + title = stringResource(R.string.settings_theme_heading), + color = sectionHeaderColor + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 4.dp) + ) { + val modeOptions = listOf( + Triple(AppearanceMode.AUTO, AppearanceMode.AUTO.toDisplayName(), AppearanceMode.AUTO.icon()), + Triple(AppearanceMode.LIGHT, AppearanceMode.LIGHT.toDisplayName(), AppearanceMode.LIGHT.icon()), + Triple(AppearanceMode.DARK, AppearanceMode.DARK.toDisplayName(), AppearanceMode.DARK.icon()) + ) + val selectedIndex = modeOptions.indexOfFirst { it.first == appearanceMode } + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 0.dp, bottom = 2.dp), + horizontalArrangement = + Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + modeOptions.forEachIndexed { index, (mode, label, icon) -> + val selected = index == selectedIndex + ToggleButton( + checked = selected, + onCheckedChange = { isChecked -> + if (isChecked && appearanceMode != mode) { + appearanceMode = mode + onAppearanceModeSelected(mode) + } + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + modeOptions.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.semantics { role = Role.RadioButton } + ) { + Icon( + imageVector = if (selected) Icons.Filled.Check else icon, + contentDescription = null, + modifier = Modifier.size(ToggleButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, top = 6.dp, bottom = 0.dp) + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 0.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + selectableColorPresets.forEach { preset -> + ThemeColorSwatch( + preset = preset, + isSelected = preset == colorPreset, + isEnabled = preset != RethinkColorPreset.DYNAMIC || dynamicSupported, + dynamicColor = dynamicPreviewColor, + onClick = { + if (preset == colorPreset) return@ThemeColorSwatch + colorPreset = preset + onColorPresetSelected(preset) + } + ) + } + } + + if (!dynamicSupported) { + Text( + text = stringResource(id = R.string.settings_theme_color_dynamic_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + } + } +} + +@Composable +private fun ThemeColorSwatch( + preset: RethinkColorPreset, + isSelected: Boolean, + isEnabled: Boolean, + dynamicColor: Color, + onClick: () -> Unit +) { + val reducedMotion = rememberReducedMotion() + val pickerPreset = preset.forPicker() + val baseColor = when (pickerPreset) { + RethinkColorPreset.DYNAMIC -> dynamicColor + else -> pickerPreset.seedColor ?: dynamicColor + } + val displayColor = if (isEnabled) baseColor else baseColor.copy(alpha = 0.42f) + val tokenSize = 50.dp + val glowSize = 56.dp + val orbSize = 40.dp + val overallAlpha = if (isEnabled) 1f else 0.52f + val interactionSource = remember { MutableInteractionSource() } + val swatchDescription = pickerPreset.toDisplayName() + + val cornerFraction by animateFloatAsState( + targetValue = if (isSelected) 0.5f else 0.26f, + animationSpec = if (reducedMotion) tween(durationMillis = 0) else spring(dampingRatio = 0.7f, stiffness = 520f), + label = "swatch_corner_${preset.id}" + ) + val orbScale by animateFloatAsState( + targetValue = if (isSelected) 1.02f else 0.86f, + animationSpec = if (reducedMotion) tween(durationMillis = 0) else spring(dampingRatio = 0.56f, stiffness = 600f), + label = "swatch_scale_${preset.id}" + ) + val orbRotation by animateFloatAsState( + targetValue = if (isSelected && !reducedMotion) 8f else 0f, + animationSpec = if (reducedMotion) tween(durationMillis = 0) else spring(dampingRatio = 0.66f, stiffness = 420f), + label = "swatch_rotation_${preset.id}" + ) + val glowAlpha by animateFloatAsState( + targetValue = if (isSelected) 1f else 0f, + animationSpec = if (reducedMotion) tween(durationMillis = 0) else tween(durationMillis = 240, easing = FastOutSlowInEasing), + label = "swatch_glow_${preset.id}" + ) + val iconAlpha by animateFloatAsState( + targetValue = if (isSelected) 1f else 0f, + animationSpec = if (reducedMotion) tween(durationMillis = 0) else tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "swatch_icon_${preset.id}" + ) + val orbShape = RoundedCornerShape(percent = (cornerFraction * 100).toInt()) + + Box( + modifier = Modifier + .size(tokenSize) + .graphicsLayer { alpha = overallAlpha } + .clickable( + enabled = isEnabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .semantics { + role = Role.RadioButton + selected = isSelected + contentDescription = swatchDescription + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(glowSize) + .graphicsLayer { alpha = glowAlpha } + .drawBehind { + drawCircle( + color = displayColor.copy(alpha = 0.44f), + radius = size.minDimension * 0.5f + ) + } + ) + + Box( + modifier = Modifier + .size(orbSize) + .graphicsLayer { + scaleX = orbScale + scaleY = orbScale + rotationZ = orbRotation + } + .clip(orbShape) + .indication( + interactionSource = interactionSource, + indication = ripple( + bounded = true, + radius = 18.dp, + color = Color.White.copy(alpha = 0.32f) + ) + ) + .background(displayColor), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf(Color.White.copy(alpha = 0.28f), Color.Transparent), + start = Offset.Zero, + end = Offset(60f, 60f) + ) + ) + ) + + when { + isSelected -> { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .graphicsLayer { + alpha = iconAlpha + rotationZ = -orbRotation + }, + tint = Color.White + ) + } + pickerPreset == RethinkColorPreset.DYNAMIC -> { + Icon( + imageVector = Icons.Rounded.Palette, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.White.copy(alpha = 0.88f) + ) + } + } + } + } +} + +@Composable +private fun AppearanceMode.toDisplayName(): String { + return when (this) { + AppearanceMode.AUTO -> stringResource(id = R.string.settings_theme_dialog_themes_1) + AppearanceMode.LIGHT -> stringResource(id = R.string.settings_theme_dialog_themes_2) + AppearanceMode.DARK -> stringResource(id = R.string.settings_theme_dialog_themes_3) + } +} + +private fun RethinkColorPreset.forPicker(): RethinkColorPreset { + return if (this == RethinkColorPreset.AUTO) RethinkColorPreset.DYNAMIC else this +} + +@Composable +private fun RethinkColorPreset.toDisplayName(): String { + return when (forPicker()) { + RethinkColorPreset.DYNAMIC -> stringResource(id = R.string.settings_theme_color_dynamic) + RethinkColorPreset.CORAL -> stringResource(id = R.string.settings_theme_color_coral) + RethinkColorPreset.ROSE -> stringResource(id = R.string.settings_theme_color_rose) + RethinkColorPreset.TEAL -> stringResource(id = R.string.settings_theme_color_teal) + RethinkColorPreset.BLUE -> stringResource(id = R.string.settings_theme_color_blue) + RethinkColorPreset.PURPLE -> stringResource(id = R.string.settings_theme_color_purple) + RethinkColorPreset.ORANGE -> stringResource(id = R.string.settings_theme_color_orange) + RethinkColorPreset.GREEN -> stringResource(id = R.string.settings_theme_color_green) + RethinkColorPreset.AMBER -> stringResource(id = R.string.settings_theme_color_amber) + RethinkColorPreset.CYAN -> stringResource(id = R.string.settings_theme_color_cyan) + RethinkColorPreset.INDIGO -> stringResource(id = R.string.settings_theme_color_indigo) + else -> stringResource(id = R.string.settings_theme_color_dynamic) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/CheckoutScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/CheckoutScreen.kt new file mode 100644 index 000000000..88d9b394d --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/CheckoutScreen.kt @@ -0,0 +1,368 @@ +/* + * Copyright 2024 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.compose.settings + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.service.TcpProxyHelper +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.RadioButtonChecked +import androidx.compose.material.icons.rounded.RadioButtonUnchecked +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.SectionHeader + +enum class CheckoutPlan(val titleRes: Int, val subtitleRes: Int) { + ONE_MONTH(R.string.checkout_plan_1m_title, R.string.checkout_plan_1m_subtitle), + THREE_MONTH(R.string.checkout_plan_3m_title, R.string.checkout_plan_3m_subtitle), + SIX_MONTH(R.string.checkout_plan_6m_title, R.string.checkout_plan_6m_subtitle) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CheckoutScreen( + paymentStatus: TcpProxyHelper.PaymentStatus, + onStartPayment: () -> Unit, + onNavigateToProxy: () -> Unit, + onBackClick: (() -> Unit)? = null +) { + var selectedPlan by remember { mutableStateOf(CheckoutPlan.SIX_MONTH) } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.checkout_app_name), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.background) + ) { + when (paymentStatus) { + TcpProxyHelper.PaymentStatus.NOT_PAID -> PaymentContent( + selectedPlan = selectedPlan, + onPlanSelected = { selectedPlan = it }, + onStartPayment = onStartPayment, + onNavigateToProxy = onNavigateToProxy + ) + + TcpProxyHelper.PaymentStatus.INITIATED -> PaymentAwaiting() + TcpProxyHelper.PaymentStatus.PAID -> PaymentSuccess(onNavigateToProxy) + TcpProxyHelper.PaymentStatus.FAILED -> PaymentFailed(onNavigateToProxy) + else -> PaymentContent( + selectedPlan = selectedPlan, + onPlanSelected = { selectedPlan = it }, + onStartPayment = onStartPayment, + onNavigateToProxy = onNavigateToProxy + ) + } + } + } +} + +@Composable +private fun PaymentContent( + selectedPlan: CheckoutPlan, + onPlanSelected: (CheckoutPlan) -> Unit, + onStartPayment: () -> Unit, + onNavigateToProxy: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Dimensions.spacingLg), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + shape = RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f) + ), + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier.padding(Dimensions.spacingLg), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + Text( + text = stringResource(R.string.checkout_app_name), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.checkout_choose_plan), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + SectionHeader( + title = stringResource(R.string.checkout_choose_plan), + modifier = Modifier.padding(horizontal = Dimensions.spacingXs) + ) + + RethinkListGroup { + val plans = CheckoutPlan.entries + plans.forEachIndexed { index, plan -> + val isSelected = selectedPlan == plan + val position = when { + plans.size == 1 -> CardPosition.Single + index == 0 -> CardPosition.First + index == plans.size - 1 -> CardPosition.Last + else -> CardPosition.Middle + } + RethinkListItem( + headline = stringResource(plan.titleRes), + supporting = stringResource(plan.subtitleRes), + position = position, + leadingIcon = if (isSelected) Icons.Rounded.RadioButtonChecked else Icons.Rounded.RadioButtonUnchecked, + leadingIconTint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { onPlanSelected(plan) }, + trailing = { + RadioButton( + selected = isSelected, + onClick = null + ) + } + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd)) { + Button( + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + onClick = onStartPayment, + shape = RoundedCornerShape(Dimensions.buttonCornerRadius) + ) { + Text( + text = stringResource(R.string.checkout_purchase), + style = MaterialTheme.typography.labelLarge + ) + } + + androidx.compose.material3.OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + onClick = onNavigateToProxy, + shape = RoundedCornerShape(Dimensions.buttonCornerRadius), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + ) { + Text(text = stringResource(R.string.checkout_restore)) + } + } + + Spacer(modifier = Modifier.height(Dimensions.spacingMd)) + + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs) + ) { + Text( + text = stringResource(R.string.checkout_terms_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.checkout_terms_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(modifier = Modifier.height(Dimensions.spacingLg)) + } + } +} + +@Composable +private fun PaymentSuccess(onNavigateToProxy: () -> Unit) { + StatusScreen( + title = stringResource(R.string.checkout_payment_success_title), + message = stringResource(R.string.checkout_payment_success_message), + buttonLabel = stringResource(R.string.checkout_payment_success_button), + onButtonClick = onNavigateToProxy + ) +} + +@Composable +private fun PaymentFailed(onNavigateToProxy: () -> Unit) { + StatusScreen( + title = stringResource(R.string.checkout_payment_failed_title), + message = stringResource(R.string.checkout_payment_failed_message), + buttonLabel = stringResource(R.string.checkout_payment_failed_button), + onButtonClick = onNavigateToProxy + ) +} + +@Composable +private fun PaymentAwaiting() { + CheckoutStatusCard( + title = stringResource(R.string.checkout_payment_awaiting_title), + message = stringResource(R.string.checkout_payment_awaiting_message) + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} + +@Composable +private fun StatusScreen( + title: String, + message: String, + buttonLabel: String, + onButtonClick: () -> Unit +) { + CheckoutStatusCard( + title = title, + message = message + ) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onButtonClick, + shape = RoundedCornerShape(Dimensions.buttonCornerRadius) + ) { + Text(text = buttonLabel) + } + } +} + +@Composable +private fun CheckoutStatusCard( + title: String, + message: String, + content: @Composable ColumnScope.() -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal, vertical = Dimensions.spacingXl) + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.26f) + ), + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 22.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(Dimensions.spacingXs)) + content() + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/ConsoleLogScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/ConsoleLogScreen.kt new file mode 100644 index 000000000..5d3730ba7 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/ConsoleLogScreen.kt @@ -0,0 +1,330 @@ +/* + * Copyright 2024 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.compose.settings + +import Logger +import Logger.LOG_TAG_BUG_REPORT +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.asFlow +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.ConsoleLogRow +import com.celzero.bravedns.database.ConsoleLogRepository +import com.celzero.bravedns.net.go.GoVpnAdapter +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.ConsoleLogViewModel +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Share +import kotlinx.coroutines.launch + +private const val QUERY_TEXT_DELAY: Long = 1000 + +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun ConsoleLogScreen( + viewModel: ConsoleLogViewModel, + consoleLogRepository: ConsoleLogRepository, + persistentState: PersistentState, + onShareClick: () -> Unit, + onDeleteComplete: () -> Unit, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val consoleLogDesc = stringResource(R.string.console_log_desc) + val logsCardDuration = stringResource(R.string.logs_card_duration) + val twoArgumentSpace = stringResource(R.string.two_argument_space) + + var query by remember { mutableStateOf("") } + var infoText by remember { mutableStateOf("") } + var progressVisible by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } + var selectedLogLevel by remember { mutableIntStateOf(Logger.uiLogLevel.toInt()) } + + val filterOptions = listOf( + stringResource(R.string.settings_gologger_dialog_option_0), + stringResource(R.string.settings_gologger_dialog_option_1), + stringResource(R.string.settings_gologger_dialog_option_2), + stringResource(R.string.settings_gologger_dialog_option_3), + stringResource(R.string.settings_gologger_dialog_option_4), + stringResource(R.string.settings_gologger_dialog_option_5), + stringResource(R.string.settings_gologger_dialog_option_6), + stringResource(R.string.settings_gologger_dialog_option_7) + ) + + // Initialize info text + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + val sinceTime = viewModel.sinceTime() + if (sinceTime != 0L) { + val since = Utilities.convertLongToTime(sinceTime, Constants.TIME_FORMAT_3) + val sinceTxt = String.format(logsCardDuration, since) + infoText = String.format(twoArgumentSpace, consoleLogDesc, sinceTxt) + } + } + } + + // Set up log level and query filtering + LaunchedEffect(Unit) { + viewModel.setLogLevel(Logger.uiLogLevel) + snapshotFlow { query } + .debounce(QUERY_TEXT_DELAY) + .distinctUntilChanged() + .collect { value -> + viewModel.setFilter(value) + } + } + + if (showFilterDialog) { + RethinkConfirmDialog( + onDismissRequest = { showFilterDialog = false }, + title = stringResource(R.string.console_log_title), + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + filterOptions.forEachIndexed { index, label -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedLogLevel == index, + onClick = { + selectedLogLevel = index + Logger.uiLogLevel = index.toLong() + GoVpnAdapter.setLogLevel( + persistentState.goLoggerLevel.toInt(), + Logger.uiLogLevel.toInt() + ) + viewModel.setLogLevel(index.toLong()) + if (index < Logger.LoggerLevel.ERROR.id) { + consoleLogRepository.setStartTimestamp(System.currentTimeMillis()) + } + Logger.i(LOG_TAG_BUG_REPORT, "Log level set to $label") + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = label, style = MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmText = stringResource(R.string.fapps_info_dialog_positive_btn), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { showFilterDialog = false }, + onDismiss = { showFilterDialog = false } + ) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + Scaffold( + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.console_log_title), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + text = { Text(text = stringResource(R.string.about_bug_report_desc)) }, + icon = { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = null + ) + }, + onClick = onShareClick + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + SearchRow( + query = query, + onQueryChange = { query = it }, + onFilterClick = { + selectedLogLevel = Logger.uiLogLevel.toInt() + showFilterDialog = true + }, + onShareClick = onShareClick, + onDeleteClick = { + scope.launch(Dispatchers.IO) { + Logger.i(LOG_TAG_BUG_REPORT, "deleting all console logs") + consoleLogRepository.deleteAllLogs() + onDeleteComplete() + } + } + ) + + if (progressVisible) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal, vertical = 4.dp) + ) + } + + Box(modifier = Modifier.weight(1f)) { + ConsoleLogList(viewModel = viewModel) + } + } + } +} + +@Composable +private fun SearchRow( + query: String, + onQueryChange: (String) -> Unit, + onFilterClick: () -> Unit, + onShareClick: () -> Unit, + onDeleteClick: () -> Unit +) { + androidx.compose.material3.Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.screenPaddingHorizontal, vertical = Dimensions.spacingMd), + shape = RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier + .padding(horizontal = Dimensions.spacingSm, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(start = Dimensions.spacingSm) + .size(Dimensions.iconSizeMd) + ) + + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { + Text( + text = stringResource(R.string.lbl_search), + style = MaterialTheme.typography.bodyMedium + ) + }, + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + focusedBorderColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedBorderColor = androidx.compose.ui.graphics.Color.Transparent, + focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent + ) + ) + + Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) { + IconButton(onClick = onFilterClick) { + Icon( + imageVector = Icons.Rounded.FilterList, + contentDescription = stringResource(R.string.cd_filter), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onShareClick) { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(R.string.lbl_delete), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} + +@Composable +private fun ConsoleLogList(viewModel: ConsoleLogViewModel) { + val items = viewModel.logs.asFlow().collectAsLazyPagingItems() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 2.dp, vertical = 2.dp) + ) { + items(count = items.itemCount) { index -> + val item = items[index] ?: return@items + ConsoleLogRow(item) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/MiscSettingsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/MiscSettingsScreen.kt new file mode 100644 index 000000000..a1cb4cebe --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/MiscSettingsScreen.kt @@ -0,0 +1,508 @@ +/* + * 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.compose.settings + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Backup +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.R +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.bottomsheet.BackupRestoreSheet +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.RethinkAnimatedSection +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkToggleListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.util.UIUtils.openUrl +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.isFdroidFlavour +import kotlinx.coroutines.delay +import kotlin.math.abs +import kotlin.math.roundToInt + +private fun miscFocusTarget( + focusKey: String, + showFirewallBubble: Boolean, + isFdroidFlavor: Boolean +): Pair? { + val rowHeight = 76 + val cardRowStart = 54 + + fun rowOffset(row: Int): Int { + return cardRowStart + (rowHeight * row) + } + + val togglesRow = + when (focusKey) { + "general_logs" -> 0 + "general_autostart" -> 1 + "general_tombstone" -> 2 + "general_firewall_bubble" -> if (showFirewallBubble) 3 else null + "general_ip_info" -> if (showFirewallBubble) 4 else 3 + "general_app_updates" -> + if (isFdroidFlavor) { + null + } else if (showFirewallBubble) { + 5 + } else { + 4 + } + "general_crash_reports" -> + if (isFdroidFlavor) { + null + } else if (showFirewallBubble) { + 6 + } else { + 5 + } + "general_custom_downloader" -> + if (isFdroidFlavor) { + if (showFirewallBubble) 5 else 4 + } else if (showFirewallBubble) { + 7 + } else { + 6 + } + else -> null + } + + return when (focusKey) { + "general_appearance", + "general_theme_mode" -> 0 to 48 + "general_theme_color" -> 0 to 148 + "general_backup", + "general_backup_restore" -> 1 to rowOffset(0) + "general_about", + "general_website" -> 3 to rowOffset(0) + else -> { + togglesRow?.let { 2 to rowOffset(it) } + } + } +} + +private fun miscFocusIndex(focusKey: String): Int? { + return when (focusKey) { + "general_appearance", + "general_theme_mode", + "general_theme_color" -> 0 + "general_backup_restore", + "general_backup" -> 1 + "general_toggles", + "general_logs", + "general_autostart", + "general_tombstone", + "general_firewall_bubble", + "general_ip_info", + "general_app_updates", + "general_crash_reports", + "general_custom_downloader" -> 2 + "general_about", + "general_website" -> 3 + else -> null + } +} + +private suspend fun smartScrollToItem( + listState: LazyListState, + density: Density, + index: Int, + offsetDp: Int +) { + val offsetPx = with(density) { offsetDp.dp.toPx().roundToInt() } + listState.animateScrollToItem(index, 0) + repeat(3) { + val info = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return + val desiredTop = -offsetPx + val delta = info.offset - desiredTop + if (abs(delta) <= 8) return + listState.animateScrollBy(delta.toFloat()) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MiscSettingsScreen( + persistentState: PersistentState, + eventLogger: EventLogger, + initialFocusKey: String? = null, + onBackClick: (() -> Unit)? = null, + onRefreshDatabase: (() -> Unit)? = null, + onThemeModeChanged: ((Int) -> Unit)? = null, + onThemeColorChanged: ((Int) -> Unit)? = null +) { + var logsEnabled by remember { mutableStateOf(persistentState.logsEnabled) } + var checkUpdatesEnabled by remember { mutableStateOf(persistentState.checkForAppUpdate) } + var firebaseEnabled by remember { mutableStateOf(persistentState.firebaseErrorReportingEnabled) } + var ipInfoEnabled by remember { mutableStateOf(persistentState.downloadIpInfo) } + var customDownloadEnabled by remember { mutableStateOf(persistentState.useCustomDownloadManager) } + var autoStartEnabled by remember { mutableStateOf(persistentState.prefAutoStartBootUp) } + var tombstoneEnabled by remember { mutableStateOf(persistentState.tombstoneApps) } + var firewallBubbleEnabled by remember { mutableStateOf(persistentState.firewallBubbleEnabled) } + var showBackupSheet by remember { mutableStateOf(false) } + val initialFocus = initialFocusKey?.trim().orEmpty() + var pendingFocusKey by rememberSaveable(initialFocus) { mutableStateOf(initialFocus) } + var activeFocusKey by rememberSaveable(initialFocus) { + mutableStateOf(initialFocus.ifBlank { null }) + } + + val context = LocalContext.current + val aboutWebsiteLink = stringResource(id = R.string.about_website_link) + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val listState = rememberLazyListState() + val density = LocalDensity.current + val showFirewallBubble = isAtleastQ() + val isFdroidFlavor = isFdroidFlavour() + val generalToggleKeys = remember(showFirewallBubble, isFdroidFlavor) { + buildList { + add("general_logs") + add("general_autostart") + add("general_tombstone") + if (showFirewallBubble) add("general_firewall_bubble") + add("general_ip_info") + if (!isFdroidFlavor) { + add("general_app_updates") + add("general_crash_reports") + } + add("general_custom_downloader") + } + } + + LaunchedEffect(pendingFocusKey) { + val key = pendingFocusKey.trim() + if (key.isBlank()) return@LaunchedEffect + activeFocusKey = key + val target = + miscFocusTarget( + focusKey = key, + showFirewallBubble = showFirewallBubble, + isFdroidFlavor = isFdroidFlavor + ) + + if (target != null) { + val (index, offsetDp) = target + smartScrollToItem(listState, density, index, offsetDp) + delay(850) + if (activeFocusKey == key) { + activeFocusKey = null + } + pendingFocusKey = "" + return@LaunchedEffect + } + + val index = miscFocusIndex(key) + if (index != null) { + smartScrollToItem(listState, density, index, 0) + delay(700) + if (activeFocusKey == key) { + activeFocusKey = null + } + } + pendingFocusKey = "" + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.settings_general_header).titlecaseFirst(), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + item { + RethinkAnimatedSection(index = 0) { + AppearanceSettingsCard( + themePreference = persistentState.theme, + colorPresetId = persistentState.themeColorPreset, + onAppearanceModeSelected = { mode -> + val themeId = mode.toThemePreference() + persistentState.theme = themeId + onThemeModeChanged?.invoke(themeId) + logEvent( + eventLogger = eventLogger, + msg = "Appearance", + details = "Theme set to ${mode.name.lowercase()}" + ) + }, + onColorPresetSelected = { preset -> + persistentState.themeColorPreset = preset.id + onThemeColorChanged?.invoke(preset.id) + logEvent( + eventLogger = eventLogger, + msg = "Appearance color", + details = "Color preset set to ${preset.name.lowercase()}" + ) + }, + showSectionHeader = true + ) + } + } + + item { + RethinkAnimatedSection(index = 1) { + SectionHeader( + title = stringResource(id = R.string.brbs_title) + ) + Column { + RethinkActionListItem( + title = stringResource(id = R.string.brbs_backup_title), + description = stringResource(id = R.string.brbs_backup_desc), + icon = Icons.Rounded.Backup, + accentColor = MaterialTheme.colorScheme.secondary, + highlighted = activeFocusKey == "general_backup", + position = CardPosition.Single, + onClick = { showBackupSheet = true } + ) + } + } + } + + item { + RethinkAnimatedSection(index = 2) { + SectionHeader( + title = stringResource(id = R.string.settings_general_header).titlecaseFirst() + ) + Column { + RethinkToggleListItem( + title = stringResource(id = R.string.settings_enable_logs), + description = stringResource(id = R.string.settings_enable_logs_desc), + iconRes = R.drawable.ic_logs_accent, + checked = logsEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_logs", + position = generalToggleKeys.positionFor("general_logs"), + onCheckedChange = { enabled -> + logsEnabled = enabled + persistentState.logsEnabled = enabled + logEvent(eventLogger, "Logs", "User ${if (enabled) "enabled" else "disabled"} logs") + } + ) + RethinkToggleListItem( + title = stringResource(id = R.string.settings_autostart_bootup_heading), + description = stringResource(id = R.string.settings_autostart_bootup_desc), + iconRes = R.drawable.ic_auto_start, + checked = autoStartEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_autostart", + position = generalToggleKeys.positionFor("general_autostart"), + onCheckedChange = { enabled -> + autoStartEnabled = enabled + persistentState.prefAutoStartBootUp = enabled + logEvent(eventLogger, "Auto-start", "Auto-start on power-up set to $enabled") + } + ) + RethinkToggleListItem( + title = stringResource(id = R.string.tombstone_app_title), + description = stringResource(id = R.string.tombstone_app_desc), + iconRes = R.drawable.ic_tombstone, + checked = tombstoneEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_tombstone", + position = generalToggleKeys.positionFor("general_tombstone"), + onCheckedChange = { enabled -> + tombstoneEnabled = enabled + persistentState.tombstoneApps = enabled + logEvent(eventLogger, "Tombstone apps", "Remember uninstalled apps set to $enabled") + } + ) + + if (showFirewallBubble) { + RethinkToggleListItem( + title = stringResource(id = R.string.firewall_bubble_title), + description = stringResource(id = R.string.firewall_bubble_desc), + iconRes = R.drawable.ic_firewall_bubble, + checked = firewallBubbleEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_firewall_bubble", + position = generalToggleKeys.positionFor("general_firewall_bubble"), + onCheckedChange = { enabled -> + firewallBubbleEnabled = enabled + persistentState.firewallBubbleEnabled = enabled + logEvent(eventLogger, "Firewall bubble", "Firewall bubble set to $enabled") + } + ) + } + + RethinkToggleListItem( + title = stringResource(id = R.string.download_ip_info_title), + description = stringResource( + id = R.string.download_ip_info_desc, + stringResource(id = R.string.lbl_ipinfo_inc) + ), + iconRes = R.drawable.ic_ip_info, + checked = ipInfoEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_ip_info", + position = generalToggleKeys.positionFor("general_ip_info"), + onCheckedChange = { enabled -> + ipInfoEnabled = enabled + persistentState.downloadIpInfo = enabled + } + ) + + if (!isFdroidFlavor) { + RethinkToggleListItem( + title = stringResource(id = R.string.settings_check_update_heading), + description = stringResource(id = R.string.settings_check_update_desc), + iconRes = R.drawable.ic_update, + checked = checkUpdatesEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_app_updates", + position = generalToggleKeys.positionFor("general_app_updates"), + onCheckedChange = { enabled -> + checkUpdatesEnabled = enabled + persistentState.checkForAppUpdate = enabled + } + ) + + RethinkToggleListItem( + title = stringResource(id = R.string.settings_firebase_error_reporting_heading), + description = stringResource(id = R.string.settings_firebase_error_reporting_desc), + icon = Icons.Rounded.BugReport, + checked = firebaseEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_crash_reports", + position = generalToggleKeys.positionFor("general_crash_reports"), + onCheckedChange = { enabled -> + firebaseEnabled = enabled + persistentState.firebaseErrorReportingEnabled = enabled + } + ) + } + + RethinkToggleListItem( + title = stringResource(id = R.string.settings_custom_downloader_heading), + description = stringResource(id = R.string.settings_custom_downloader_desc), + icon = Icons.Rounded.Settings, + checked = customDownloadEnabled, + accentColor = MaterialTheme.colorScheme.primary, + highlighted = activeFocusKey == "general_custom_downloader", + position = generalToggleKeys.positionFor("general_custom_downloader"), + onCheckedChange = { enabled -> + customDownloadEnabled = enabled + persistentState.useCustomDownloadManager = enabled + } + ) + } + } + } + + item { + RethinkAnimatedSection(index = 3) { + SectionHeader( + title = stringResource(id = R.string.title_about) + ) + Column { + RethinkListItem( + headline = stringResource(id = R.string.about_website), + supporting = stringResource(id = R.string.about_website_link), + leadingIcon = Icons.Rounded.Public, + leadingIconTint = MaterialTheme.colorScheme.tertiary, + leadingIconContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.14f), + position = CardPosition.Single, + highlighted = activeFocusKey == "general_website", + highlightContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.24f), + onClick = { openUrl(context, aboutWebsiteLink) } + ) + } + } + } + } + } + + if (showBackupSheet) { + BackupRestoreSheet(onDismiss = { showBackupSheet = false }) + } +} + +private fun logEvent(eventLogger: EventLogger, msg: String, details: String) { + eventLogger.log( + type = EventType.UI_SETTING_CHANGED, + severity = Severity.LOW, + message = msg, + source = EventSource.UI, + userAction = true, + details = details + ) +} + +private fun List.positionFor(key: String): CardPosition { + val index = indexOf(key) + if (index < 0) return CardPosition.Middle + return when { + size == 1 -> CardPosition.Single + index == 0 -> CardPosition.First + index == lastIndex -> CardPosition.Last + else -> CardPosition.Middle + } +} + +private fun String.titlecaseFirst(): String { + return replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/PingTestScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/PingTestScreen.kt new file mode 100644 index 000000000..3e10f06b1 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/PingTestScreen.kt @@ -0,0 +1,385 @@ +/* + * Copyright 2024 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.compose.settings + +import androidx.compose.foundation.layout.Arrangement +import com.celzero.bravedns.ui.compose.theme.Dimensions +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.service.VpnController +import com.celzero.firestack.backend.Backend +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val PING_IP1 = "1.1.1.1:53" +private const val PING_IP2 = "8.8.8.8:53" +private const val PING_IP3 = "216.239.32.27:443" +private const val PING_HOST1 = "cloudflare.com:443" +private const val PING_HOST2 = "google.com:443" +private const val PING_HOST3 = "brave.com:443" +private const val STRENGTH_MAX = 5 +private const val TAG = "PingUi" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingTestScreen( + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var ip1 by remember { mutableStateOf(PING_IP1) } + var ip2 by remember { mutableStateOf(PING_IP2) } + var ip3 by remember { mutableStateOf(PING_IP3) } + var host1 by remember { mutableStateOf(PING_HOST1) } + var host2 by remember { mutableStateOf(PING_HOST2) } + var host3 by remember { mutableStateOf(PING_HOST3) } + + var ip1Status by remember { mutableStateOf(PingStatus.Idle) } + var ip2Status by remember { mutableStateOf(PingStatus.Idle) } + var ip3Status by remember { mutableStateOf(PingStatus.Idle) } + var host1Status by remember { mutableStateOf(PingStatus.Idle) } + var host2Status by remember { mutableStateOf(PingStatus.Idle) } + var host3Status by remember { mutableStateOf(PingStatus.Idle) } + + var strength by remember { mutableStateOf(null) } + val showStartVpnDialog = remember { !VpnController.hasTunnel() } + + // Cache for proxy status + val proxiesStatus = remember { mutableListOf() } + + suspend fun getProxiesStatus(csv: String): List { + if (proxiesStatus.isNotEmpty()) return proxiesStatus + + return withContext(Dispatchers.IO) { + val warp = VpnController.isProxyReachable(Backend.RpnWin, csv) + val amz = VpnController.isProxyReachable(Backend.RpnWin, csv) + val win = VpnController.isProxyReachable(Backend.RpnWin, csv) + val se = VpnController.isProxyReachable(Backend.RpnSE, csv) + val w64 = VpnController.isProxyReachable(Backend.Rpn64, csv) + Napier.d("$TAG proxies reachable: $warp, $amz $win, $se, $w64") + + val status = listOf(warp, amz, win, se, w64) + proxiesStatus.clear() + proxiesStatus.addAll(status) + status + } + } + + suspend fun isReachable(csv: String): Boolean { + val status = getProxiesStatus(csv) + // Check if any proxy is reachable + val reachable = status.any { it } + Napier.d("$TAG ip $csv reachable: $reachable") + return reachable + } + + suspend fun calculateStrength(csv: String): Int { + val status = getProxiesStatus(csv) + // Count how many are true + val strengthVal = status.count { it } + Napier.i("$TAG strength: $strengthVal ($status)") + return strengthVal + } + + fun performPing() { + scope.launch { + try { + proxiesStatus.clear() // Clear cache for new test + Napier.v("$TAG initiating ping test") + ip1Status = PingStatus.Loading + ip2Status = PingStatus.Loading + ip3Status = PingStatus.Loading + host1Status = PingStatus.Loading + host2Status = PingStatus.Loading + host3Status = PingStatus.Loading + strength = null + + val ip1Local = ip1 + val ip2Local = ip2 + val ip3Local = ip3 + val host1Local = host1 + val host2Local = host2 + val host3Local = host3 + + // Run reachable checks sequentially in IO as per original + val validI1 = isReachable(ip1Local) + val validI2 = isReachable(ip2Local) + val validI3 = isReachable(ip3Local) + val validH1 = isReachable(host1Local) + val validH2 = isReachable(host2Local) + val validH3 = isReachable(host3Local) + + ip1Status = PingStatus.Result(validI1) + ip2Status = PingStatus.Result(validI2) + ip3Status = PingStatus.Result(validI3) + host1Status = PingStatus.Result(validH1) + host2Status = PingStatus.Result(validH2) + host3Status = PingStatus.Result(validH3) + + val strengthValue = calculateStrength(ip3Local) + strength = strengthValue.coerceIn(1, STRENGTH_MAX) + } catch (e: Exception) { + Napier.e("$TAG err isReachable: ${e.message}", e) + ip1Status = PingStatus.Result(false) + ip2Status = PingStatus.Result(false) + ip3Status = PingStatus.Result(false) + host1Status = PingStatus.Result(false) + host2Status = PingStatus.Result(false) + host3Status = PingStatus.Result(false) + } + } + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.settings_connectivity_checks), + subtitle = stringResource(R.string.settings_connectivity_checks_desc), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + if (showStartVpnDialog) { + RethinkConfirmDialog( + onDismissRequest = {}, + title = stringResource(R.string.vpn_not_active_dialog_title), + message = stringResource(R.string.vpn_not_active_dialog_desc), + confirmText = stringResource(R.string.lbl_dismiss), + onConfirm = { onBackClick?.invoke() } + ) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + item { + SectionHeader(title = stringResource(R.string.ping_ip_port_title)) + RethinkListGroup { + Column( + modifier = Modifier.padding( + horizontal = Dimensions.cardPadding, + vertical = Dimensions.spacingSm + ) + ) { + PingField( + value = ip1, + readOnly = true, + status = ip1Status + ) + PingField( + value = ip2, + readOnly = true, + status = ip2Status + ) + PingField( + value = ip3, + readOnly = false, + status = ip3Status, + onValueChange = { ip3 = it } + ) + } + } + } + + item { + SectionHeader(title = stringResource(R.string.ping_host_port_title)) + RethinkListGroup { + Column( + modifier = Modifier.padding( + horizontal = Dimensions.cardPadding, + vertical = Dimensions.spacingSm + ) + ) { + PingField( + value = host1, + readOnly = true, + status = host1Status + ) + PingField( + value = host2, + readOnly = true, + status = host2Status + ) + PingField( + value = host3, + readOnly = false, + status = host3Status, + onValueChange = { host3 = it } + ) + } + } + } + + item { + RethinkListGroup { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.cardPadding, vertical = Dimensions.spacingSm), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { onBackClick?.invoke() } + ) { + Text(text = stringResource(R.string.lbl_cancel)) + } + Button( + modifier = Modifier.weight(1f), + onClick = { performPing() } + ) { + Text(text = stringResource(R.string.lbl_test)) + } + } + } + } + + strength?.let { value -> + val progress = value.toFloat() / STRENGTH_MAX.toFloat() + item { + SectionHeader(title = stringResource(R.string.ping_strength_title)) + RethinkListGroup { + Column( + modifier = Modifier.padding( + horizontal = Dimensions.cardPadding, + vertical = Dimensions.spacingLg + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Text( + text = stringResource( + R.string.two_argument, + value.toString(), + STRENGTH_MAX.toString() + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + } +} + +@Composable +private fun PingField( + value: String, + readOnly: Boolean, + status: PingStatus, + onValueChange: (String) -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + readOnly = readOnly, + modifier = Modifier.weight(1f), + singleLine = true + ) + Spacer(modifier = Modifier.width(8.dp)) + when (status) { + PingStatus.Loading -> { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + + is PingStatus.Result -> { + val icon = + if (status.success) R.drawable.ic_tick else R.drawable.ic_cross_accent + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + + PingStatus.Idle -> Unit + } + } +} + +private sealed class PingStatus { + data object Idle : PingStatus() + data object Loading : PingStatus() + data class Result(val success: Boolean) : PingStatus() +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/ProxySettingsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/ProxySettingsScreen.kt new file mode 100644 index 000000000..74859f3ab --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/ProxySettingsScreen.kt @@ -0,0 +1,2333 @@ +/* + * Copyright 2024 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.compose.settings + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.graphics.drawable.Drawable +import android.text.format.DateUtils +import android.util.LruCache +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.core.net.toUri +import androidx.lifecycle.asFlow +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.database.EventSource +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.service.EventLogger +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkMultiActionDialog +import com.celzero.bravedns.ui.compose.theme.RethinkToggleListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import androidx.compose.ui.res.painterResource +import com.celzero.bravedns.util.OrbotHelper +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getIcon +import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel +import com.celzero.firestack.backend.Backend +import com.celzero.firestack.backend.RouterStats +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale +import kotlin.math.roundToInt + +private const val REFRESH_TIMEOUT_MS = 4000L + +private data class ProxyScreenState( + val canEnableProxy: Boolean, + val socks5Enabled: Boolean, + val httpEnabled: Boolean, + val orbotEnabled: Boolean, + val wireguardDescription: String, + val socks5Description: String, + val httpDescription: String, + val orbotDescription: String +) + +private data class Socks5DialogState( + val host: String, + val port: String, + val username: String, + val password: String, + val selectedAppPackage: String, + val appOptions: List, + val udpBlocked: Boolean, + val includeProxyApps: Boolean, + val lockdown: Boolean, + val error: String? = null +) + +private data class HttpDialogState( + val host: String, + val selectedAppPackage: String, + val appOptions: List, + val includeProxyApps: Boolean, + val lockdown: Boolean, + val error: String? = null +) + +private data class ProxyDialogAppOption( + val packageName: String, + val label: String, + val iconLookupName: String = label +) + +private object ProxyDialogAppIconCache { + private const val CACHE_SIZE = 192 + private val cache = LruCache(CACHE_SIZE) + + fun get(key: String): Drawable? = cache.get(key) + + fun put(key: String, icon: Drawable?) { + if (key.isBlank() || icon == null) return + cache.put(key, icon) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProxySettingsScreen( + appConfig: AppConfig, + persistentState: PersistentState, + eventLogger: EventLogger, + mappingViewModel: ProxyAppsMappingViewModel? = null, + initialFocusKey: String? = null, + onWireguardClick: (() -> Unit)? = null, + onOpenOrbotApps: (() -> Unit)? = null, + onNavigateToDns: (() -> Unit)? = null, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val providerName = + persistentState.proxyProvider.lowercase().replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + val lockDownProxyDesc = stringResource(R.string.settings_lock_down_proxy_desc) + val refreshToast = stringResource(R.string.dc_refresh_toast) + val socks5VpnDisabledError = stringResource(R.string.settings_socks5_vpn_disabled_error) + val socks5DisabledError = stringResource(R.string.settings_socks5_disabled_error, providerName) + val httpDisabledError = stringResource(R.string.settings_https_disabled_error, providerName) + val orbotDisabledError = stringResource(R.string.settings_orbot_disabled_error) + val orbotInstallError = stringResource(R.string.orbot_install_activity_error) + val orbotWebsiteLink = stringResource(R.string.orbot_website_link) + val orbotNoAppToast = stringResource(R.string.orbot_no_app_toast) + val httpProxyHostEmptyError = stringResource(R.string.settings_http_proxy_error_text3) + val httpProxyInvalidPortError = stringResource(R.string.settings_http_proxy_error_text2) + val httpProxyRangePortError = stringResource(R.string.settings_http_proxy_error_text1) + val httpProxyToastSuccess = stringResource(R.string.settings_http_proxy_toast_success) + val defaultAppLabel = stringResource(R.string.settings_app_list_default_app) + val orbotStopTitle = stringResource(R.string.orbot_stop_dialog_title) + val orbotStopMessage = stringResource(R.string.orbot_stop_dialog_message) + val orbotStopDnsMessage = stringResource(R.string.orbot_stop_dialog_dns_message) + val orbotStopMessageCombo = + stringResource( + R.string.orbot_stop_dialog_message_combo, + orbotStopMessage, + orbotStopDnsMessage + ) + val defaultWireguardDescription = stringResource(R.string.wireguard_description) + val defaultSocks5Description = stringResource(R.string.settings_socks_forwarding_default_desc) + val defaultHttpDescription = stringResource(R.string.settings_https_desc) + val defaultOrbotDescription = stringResource(R.string.orbot_bs_status_4) + val defaultSocks5NoAppTemplate = stringResource(R.string.settings_socks_forwarding_desc_no_app) + val defaultSocks5WithAppTemplate = stringResource(R.string.settings_socks_forwarding_desc) + val httpProxyDescriptionTemplate = stringResource(R.string.settings_http_proxy_desc) + val orbotStatus2Description = stringResource(R.string.orbot_bs_status_2) + val orbotStatus1Template = stringResource(R.string.orbot_bs_status_1) + val orbotStatus3Template = stringResource(R.string.orbot_bs_status_3) + val orbotStatusArgDns = stringResource(R.string.orbot_status_arg_3) + val orbotStatusArgProxy = stringResource(R.string.orbot_status_arg_2) + val wireguardStatusFailingText = stringResource(R.string.status_failing).replaceFirstChar(Char::titlecase) + val wireguardStatusWaitingText = stringResource(R.string.status_waiting) + val wireguardVersionTemplate = stringResource(R.string.about_version_install_source) + val wireguardIpLabelTemplate = stringResource(R.string.ci_ip_label) + val proxyStatusLabelById = mutableMapOf().apply { + for (status in UIUtils.ProxyStatus.entries) { + put( + status.id, + stringResource(UIUtils.getProxyStatusStringRes(status.id)).replaceFirstChar(Char::titlecase) + ) + } + } + + val orbotHelper = remember(context, persistentState, appConfig) { + OrbotHelper(context, persistentState, appConfig) + } + val listState = rememberLazyListState() + val density = LocalDensity.current + val initialFocus = initialFocusKey?.trim().orEmpty() + var pendingFocusKey by rememberSaveable(initialFocus) { mutableStateOf(initialFocus) } + var activeFocusKey by rememberSaveable(initialFocus) { + mutableStateOf(initialFocus.ifBlank { null }) + } + + var refreshTick by remember { mutableIntStateOf(0) } + var isRefreshing by remember { mutableStateOf(false) } + + var canEnableProxy by remember { mutableStateOf(appConfig.canEnableProxy()) } + var socks5Enabled by remember { mutableStateOf(appConfig.isCustomSocks5Enabled()) } + var httpEnabled by remember { mutableStateOf(appConfig.isCustomHttpProxyEnabled()) } + var orbotEnabled by remember { mutableStateOf(appConfig.isOrbotProxyEnabled()) } + val orbotConnecting = + remember { persistentState.orbotConnectionStatus.asFlow() }.collectAsState(initial = false).value + + var wireguardDescription by remember(defaultWireguardDescription) { mutableStateOf(defaultWireguardDescription) } + var socks5Description by remember(defaultSocks5Description) { mutableStateOf(defaultSocks5Description) } + var httpDescription by remember(defaultHttpDescription) { mutableStateOf(defaultHttpDescription) } + var orbotDescription by remember(defaultOrbotDescription) { mutableStateOf(defaultOrbotDescription) } + + var showOrbotInstallDialog by remember { mutableStateOf(false) } + var showOrbotModeDialog by remember { mutableStateOf(false) } + var showOrbotStopDialog by remember { mutableStateOf(false) } + var showOrbotInfoDialog by remember { mutableStateOf(false) } + var orbotStopHasDnsHint by remember { mutableStateOf(false) } + var selectedOrbotMode by remember { mutableStateOf(AppConfig.ProxyType.SOCKS5.name) } + + var socks5DialogState by remember { mutableStateOf(null) } + var httpDialogState by remember { mutableStateOf(null) } + val orbotAppCount = + if (mappingViewModel != null) { + mappingViewModel.getAppCountById(ProxyManager.ID_ORBOT_BASE) + .asFlow() + .collectAsState(initial = 0) + .value + } else { + 0 + } + + fun logEvent(details: String) { + eventLogger.log( + type = EventType.UI_SETTING_CHANGED, + severity = Severity.LOW, + message = "Proxy settings", + source = EventSource.UI, + userAction = true, + details = details + ) + } + + fun reloadUi() { + refreshTick++ + } + + fun showProxyDisabledToast() { + Utilities.showToastUiCentered( + context, + lockDownProxyDesc, + Toast.LENGTH_SHORT + ) + } + + fun refreshWireguard() { + if (isRefreshing) return + scope.launch { + isRefreshing = true + withContext(Dispatchers.IO) { + VpnController.refreshOrPauseOrResumeOrReAddProxies() + } + delay(REFRESH_TIMEOUT_MS) + Utilities.showToastUiCentered( + context, + refreshToast, + Toast.LENGTH_SHORT + ) + isRefreshing = false + reloadUi() + } + } + + fun openSocksDialog() { + scope.launch { + socks5DialogState = + withContext(Dispatchers.IO) { + buildSocks5DialogState( + context = context, + appConfig = appConfig, + persistentState = persistentState, + defaultApp = defaultAppLabel + ) + } + } + } + + fun openHttpDialog() { + scope.launch { + httpDialogState = + withContext(Dispatchers.IO) { + buildHttpDialogState( + context = context, + appConfig = appConfig, + persistentState = persistentState, + defaultApp = defaultAppLabel + ) + } + } + } + + fun tryOpenCustomProxyDialog( + canEnableSpecificProxy: () -> Boolean, + disabledError: String, + openDialog: () -> Unit + ) { + if (!canEnableProxy) { + showProxyDisabledToast() + reloadUi() + return + } + + if (appConfig.getBraveMode().isDnsMode()) { + Utilities.showToastUiCentered(context, socks5VpnDisabledError, Toast.LENGTH_SHORT) + reloadUi() + return + } + + if (!canEnableSpecificProxy()) { + Utilities.showToastUiCentered(context, disabledError, Toast.LENGTH_SHORT) + reloadUi() + return + } + + openDialog() + } + + fun disableCustomProxy(type: AppConfig.ProxyType, logMessage: String) { + scope.launch { + withContext(Dispatchers.IO) { + appConfig.removeProxy(type, AppConfig.ProxyProvider.CUSTOM) + } + logEvent(logMessage) + reloadUi() + } + } + + fun enableOrbotFlow() { + scope.launch { + if (!canEnableProxy) { + showProxyDisabledToast() + reloadUi() + return@launch + } + + val isInstalled = withContext(Dispatchers.IO) { FirewallManager.isOrbotInstalled() } + if (!isInstalled) { + showOrbotInstallDialog = true + return@launch + } + + if (!appConfig.canEnableOrbotProxy()) { + val msg = + if (providerName.equals(AppConfig.ProxyProvider.CUSTOM.name, ignoreCase = true)) { + orbotDisabledError + } else { + socks5DisabledError + } + Utilities.showToastUiCentered(context, msg, Toast.LENGTH_SHORT) + reloadUi() + return@launch + } + + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + socks5VpnDisabledError, + Toast.LENGTH_SHORT + ) + reloadUi() + return@launch + } + + selectedOrbotMode = + if (orbotEnabled) appConfig.getProxyType() else AppConfig.ProxyType.SOCKS5.name + showOrbotModeDialog = true + } + } + + fun stopOrbotForwarding(showDialog: Boolean) { + scope.launch { + val hasDnsHint = + withContext(Dispatchers.IO) { + val isOrbotDns = appConfig.isOrbotDns() + appConfig.removeAllProxies() + orbotHelper.stopOrbot(isInteractive = true) + isOrbotDns + } + if (showDialog) { + orbotStopHasDnsHint = hasDnsHint + showOrbotStopDialog = true + } + logEvent("Orbot proxy disabled") + reloadUi() + } + } + + LaunchedEffect(refreshTick) { + val state = + withContext(Dispatchers.IO) { + buildProxyScreenState( + context = context, + appConfig = appConfig, + defaultSocks5Description = defaultSocks5Description, + defaultHttpDescription = defaultHttpDescription, + httpProxyDescriptionTemplate = httpProxyDescriptionTemplate, + defaultSocks5DescriptionNoApp = defaultSocks5NoAppTemplate, + defaultSocks5DescriptionWithApp = defaultSocks5WithAppTemplate, + orbotDisabledDescription = defaultOrbotDescription, + orbotStatus2Description = orbotStatus2Description, + orbotStatus1Template = orbotStatus1Template, + orbotStatus3Template = orbotStatus3Template, + orbotStatusArgDns = orbotStatusArgDns, + orbotStatusArgProxy = orbotStatusArgProxy, + defaultWireguardDescription = defaultWireguardDescription, + statusTextById = proxyStatusLabelById, + statusFailingText = wireguardStatusFailingText, + statusWaitingText = wireguardStatusWaitingText, + wireguardVersionTemplate = wireguardVersionTemplate, + ciIpLabelTemplate = wireguardIpLabelTemplate + ) + } + + canEnableProxy = state.canEnableProxy + socks5Enabled = state.socks5Enabled + httpEnabled = state.httpEnabled + orbotEnabled = state.orbotEnabled + wireguardDescription = state.wireguardDescription + socks5Description = state.socks5Description + httpDescription = state.httpDescription + orbotDescription = state.orbotDescription + } + + LaunchedEffect(pendingFocusKey, canEnableProxy, onWireguardClick != null) { + val key = pendingFocusKey.trim() + if (key.isBlank()) return@LaunchedEffect + activeFocusKey = key + + val showWarning = !canEnableProxy + var index = 0 + if (showWarning && key == "proxy_warning") { + listState.animateScrollToItem(0) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + pendingFocusKey = "" + return@LaunchedEffect + } + if (showWarning) index++ + + val hasWireguard = onWireguardClick != null + if (hasWireguard && key == "proxy_wireguard") { + listState.animateScrollToItem(index) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + pendingFocusKey = "" + return@LaunchedEffect + } + if (hasWireguard) index++ + + if (key == "proxy_socks") { + listState.animateScrollToItem(index) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + pendingFocusKey = "" + return@LaunchedEffect + } + index++ + + if (key == "proxy_http") { + listState.animateScrollToItem(index) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + pendingFocusKey = "" + return@LaunchedEffect + } + index++ + + val orbotOffsetDp = + when (key) { + "proxy_orbot" -> 0 + "proxy_orbot_apps" -> 70 + "proxy_orbot_open_app", + "proxy_orbot_notification" -> 70 + "proxy_orbot_info" -> if (mappingViewModel != null) 132 else 70 + else -> null + } + + if (orbotOffsetDp != null) { + val offsetPx = with(density) { orbotOffsetDp.dp.toPx().roundToInt() } + listState.animateScrollToItem(index, offsetPx) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + } + pendingFocusKey = "" + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.settings_proxy_header), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior, + titleStartPadding = Dimensions.spacingSm, + actions = { + if (canEnableProxy) { + IconButton( + onClick = { refreshWireguard() }, + enabled = !isRefreshing + ) { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.padding(Dimensions.spacingSm), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.dc_refresh_toast) + ) + } + } + } + } + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { padding -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = + PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + if (!canEnableProxy) { + item { + val warningFocused = activeFocusKey == "proxy_warning" + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + color = + if (warningFocused) { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f) + } else { + MaterialTheme.colorScheme.errorContainer + } + ) { + Text( + text = stringResource(R.string.settings_lock_down_proxy_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = + Modifier.padding( + horizontal = Dimensions.spacingMd, + vertical = Dimensions.spacingSmMd + ) + ) + } + } + } + + // Network Proxy (WireGuard) + if (onWireguardClick != null) { + item { + SectionHeader(title = stringResource(R.string.setup_wireguard)) + RethinkListGroup { + RethinkListItem( + headline = stringResource(R.string.setup_wireguard), + supporting = wireguardDescription, + leadingIconPainter = painterResource(id = R.drawable.ic_wireguard_icon), + position = CardPosition.Single, + highlighted = activeFocusKey == "proxy_wireguard", + onClick = { + if (!canEnableProxy) { + showProxyDisabledToast() + } else { + onWireguardClick() + } + } + ) + } + } + } + + // SOCKS5 + item { + SectionHeader(title = stringResource(R.string.settings_socks5_heading)) + RethinkListGroup { + RethinkToggleListItem( + title = stringResource(R.string.settings_socks5_heading), + description = socks5Description, + iconRes = R.drawable.ic_socks5, + checked = socks5Enabled, + position = CardPosition.Single, + highlighted = activeFocusKey == "proxy_socks", + onRowClick = { + if (canEnableProxy && socks5Enabled) { + openSocksDialog() + } else if (!socks5Enabled) { + tryOpenCustomProxyDialog( + canEnableSpecificProxy = { appConfig.canEnableSocks5Proxy() }, + disabledError = socks5DisabledError, + openDialog = ::openSocksDialog + ) + } + }, + onCheckedChange = { enabled -> + if (enabled) { + tryOpenCustomProxyDialog( + canEnableSpecificProxy = { appConfig.canEnableSocks5Proxy() }, + disabledError = socks5DisabledError, + openDialog = ::openSocksDialog + ) + } else { + disableCustomProxy( + type = AppConfig.ProxyType.SOCKS5, + logMessage = "Custom SOCKS5 disabled" + ) + } + } + ) + } + } + + // HTTP + item { + SectionHeader(title = stringResource(R.string.settings_https_heading)) + RethinkListGroup { + RethinkToggleListItem( + title = stringResource(R.string.settings_https_heading), + description = httpDescription, + iconRes = R.drawable.ic_http, + checked = httpEnabled, + position = CardPosition.Single, + highlighted = activeFocusKey == "proxy_http", + onRowClick = { + if (canEnableProxy && httpEnabled) { + openHttpDialog() + } else if (!httpEnabled) { + tryOpenCustomProxyDialog( + canEnableSpecificProxy = { appConfig.canEnableHttpProxy() }, + disabledError = httpDisabledError, + openDialog = ::openHttpDialog + ) + } + }, + onCheckedChange = { enabled -> + if (enabled) { + tryOpenCustomProxyDialog( + canEnableSpecificProxy = { appConfig.canEnableHttpProxy() }, + disabledError = httpDisabledError, + openDialog = ::openHttpDialog + ) + } else { + disableCustomProxy( + type = AppConfig.ProxyType.HTTP, + logMessage = "Custom HTTP disabled" + ) + } + } + ) + } + } + + // Orbot + item { + SectionHeader(title = stringResource(R.string.orbot)) + val isOrbotInteractive = canEnableProxy && !orbotConnecting + OrbotSettingsPanel( + title = stringResource(R.string.orbot), + description = + if (orbotConnecting) { + stringResource(R.string.orbot_bs_status_trying_connect) + } else { + orbotDescription + }, + checked = orbotEnabled, + connecting = orbotConnecting, + enabled = isOrbotInteractive, + appCount = if (mappingViewModel != null) orbotAppCount else null, + highlightedKey = activeFocusKey, + onMainClick = { + if (isOrbotInteractive) { + enableOrbotFlow() + } + }, + onCheckedChange = { checked -> + if (checked) { + enableOrbotFlow() + } else { + stopOrbotForwarding(showDialog = true) + } + }, + onAppsClick = + if (mappingViewModel != null && onOpenOrbotApps != null) { + { onOpenOrbotApps() } + } else { + null + }, + onOpenAppClick = { orbotHelper.openOrbotApp() }, + onInfoClick = { showOrbotInfoDialog = true } + ) + } + } + } + + if (showOrbotInfoDialog) { + RethinkConfirmDialog( + onDismissRequest = { showOrbotInfoDialog = false }, + title = stringResource(R.string.orbot_title), + message = stringResource(R.string.orbot_explanation), + confirmText = stringResource(R.string.lbl_dismiss), + onConfirm = { showOrbotInfoDialog = false } + ) + } + + if (showOrbotInstallDialog) { + RethinkMultiActionDialog( + onDismissRequest = { showOrbotInstallDialog = false }, + title = stringResource(R.string.orbot_install_dialog_title), + message = stringResource(R.string.orbot_install_dialog_message), + primaryText = stringResource(R.string.orbot_install_dialog_positive), + onPrimary = { + showOrbotInstallDialog = false + val installIntent = orbotHelper.getIntentForDownload() + if (installIntent == null) { + Utilities.showToastUiCentered( + context, + orbotInstallError, + Toast.LENGTH_SHORT + ) + return@RethinkMultiActionDialog + } + + try { + context.startActivity(installIntent) + } catch (_: ActivityNotFoundException) { + Utilities.showToastUiCentered( + context, + orbotInstallError, + Toast.LENGTH_SHORT + ) + } + }, + secondaryText = stringResource(R.string.orbot_install_dialog_neutral), + onSecondary = { + showOrbotInstallDialog = false + try { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + orbotWebsiteLink.toUri() + ) + ) + } catch (_: ActivityNotFoundException) { + Utilities.showToastUiCentered( + context, + orbotInstallError, + Toast.LENGTH_SHORT + ) + } + }, + tertiaryText = stringResource(R.string.lbl_dismiss), + onTertiary = { showOrbotInstallDialog = false } + ) + } + + if (showOrbotModeDialog) { + OrbotModeDialog( + supportsHttp = Utilities.isAtleastQ(), + selectedMode = selectedOrbotMode, + onSelectedMode = { selectedOrbotMode = it }, + onDismiss = { showOrbotModeDialog = false }, + onConfirm = { + scope.launch { + showOrbotModeDialog = false + + if (selectedOrbotMode == AppConfig.ProxyType.NONE.name) { + stopOrbotForwarding(showDialog = true) + return@launch + } + + if (!ProxyManager.isAnyAppSelected(ProxyManager.ID_ORBOT_BASE)) { + Utilities.showToastUiCentered( + context, + orbotNoAppToast, + Toast.LENGTH_SHORT + ) + reloadUi() + return@launch + } + + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + socks5VpnDisabledError, + Toast.LENGTH_SHORT + ) + reloadUi() + return@launch + } + + withContext(Dispatchers.IO) { + persistentState.orbotConnectionStatus.postValue(true) + orbotHelper.startOrbot(selectedOrbotMode) + } + logEvent("Orbot mode updated: $selectedOrbotMode") + reloadUi() + } + } + ) + } + + if (showOrbotStopDialog) { + RethinkMultiActionDialog( + onDismissRequest = { showOrbotStopDialog = false }, + title = orbotStopTitle, + text = { + Text( + text = if (orbotStopHasDnsHint) orbotStopMessageCombo else orbotStopMessage + ) + }, + primaryText = stringResource(R.string.lbl_dismiss), + onPrimary = { showOrbotStopDialog = false }, + secondaryText = if (orbotStopHasDnsHint && onNavigateToDns != null) stringResource(R.string.orbot_stop_dialog_neutral) else null, + onSecondary = if (orbotStopHasDnsHint && onNavigateToDns != null) { + { + showOrbotStopDialog = false + onNavigateToDns() + } + } else { + null + }, + tertiaryText = stringResource(R.string.orbot_stop_dialog_negative), + onTertiary = { + showOrbotStopDialog = false + orbotHelper.openOrbotApp() + } + ) + } + + socks5DialogState?.let { state -> + Socks5Dialog( + state = state, + onStateChange = { socks5DialogState = it }, + onCancel = { + scope.launch { + withContext(Dispatchers.IO) { + appConfig.removeProxy( + AppConfig.ProxyType.SOCKS5, + AppConfig.ProxyProvider.CUSTOM + ) + } + socks5DialogState = null + reloadUi() + } + }, + onConfirm = { + val current = socks5DialogState ?: return@Socks5Dialog + scope.launch { + val validationError = + validateSocks5Input( + current.host, + current.port, + httpProxyHostEmptyError, + httpProxyInvalidPortError, + httpProxyRangePortError + ) + if (validationError != null) { + socks5DialogState = current.copy(error = validationError) + return@launch + } + + withContext(Dispatchers.IO) { + var endpoint = appConfig.getSocks5ProxyDetails() + if (endpoint == null) { + appConfig.addProxy( + AppConfig.ProxyType.SOCKS5, + AppConfig.ProxyProvider.CUSTOM + ) + endpoint = appConfig.getSocks5ProxyDetails() + } + + endpoint?.let { + it.proxyIP = current.host.trim() + it.proxyPort = current.port.toInt() + it.userName = current.username.ifBlank { null } + it.password = current.password.ifBlank { null } + it.proxyAppName = current.selectedAppPackage + it.isUDP = current.udpBlocked + appConfig.updateCustomSocks5Proxy(it) + } + + persistentState.setUdpBlocked(current.udpBlocked) + persistentState.excludeAppsInProxy = !current.includeProxyApps + } + + logEvent( + "Custom SOCKS5 updated: ${current.host}:${current.port}, app=${current.selectedAppLabel()}, udp=${current.udpBlocked}" + ) + socks5DialogState = null + reloadUi() + } + } + ) + } + + httpDialogState?.let { state -> + HttpDialog( + state = state, + onStateChange = { httpDialogState = it }, + onCancel = { + scope.launch { + withContext(Dispatchers.IO) { + appConfig.removeProxy( + AppConfig.ProxyType.HTTP, + AppConfig.ProxyProvider.CUSTOM + ) + } + httpDialogState = null + reloadUi() + } + }, + onConfirm = { + val current = httpDialogState ?: return@HttpDialog + scope.launch { + if (current.host.isBlank()) { + httpDialogState = + current.copy( + error = httpProxyHostEmptyError + ) + return@launch + } + + withContext(Dispatchers.IO) { + var endpoint = appConfig.getHttpProxyDetails() + if (endpoint == null) { + appConfig.addProxy( + AppConfig.ProxyType.HTTP, + AppConfig.ProxyProvider.CUSTOM + ) + endpoint = appConfig.getHttpProxyDetails() + } + + endpoint?.let { + it.proxyIP = current.host.trim() + it.proxyPort = 0 + it.userName = null + it.password = null + it.proxyAppName = current.selectedAppPackage + appConfig.updateCustomHttpProxy(it) + } + + persistentState.excludeAppsInProxy = !current.includeProxyApps + } + + Utilities.showToastUiCentered( + context, + httpProxyToastSuccess, + Toast.LENGTH_SHORT + ) + logEvent( + "Custom HTTP updated: ${current.host}, app=${current.selectedAppLabel()}" + ) + httpDialogState = null + reloadUi() + } + } + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun OrbotSettingsPanel( + title: String, + description: String, + checked: Boolean, + connecting: Boolean, + enabled: Boolean, + appCount: Int?, + highlightedKey: String?, + onMainClick: () -> Unit, + onCheckedChange: (Boolean) -> Unit, + onAppsClick: (() -> Unit)?, + onOpenAppClick: () -> Unit, + onInfoClick: () -> Unit +) { + val mainHighlighted = highlightedKey == "proxy_orbot" + val cardColor = + if (mainHighlighted) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.32f) + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + + val statusText: String + val statusColor: Color + val statusBackground: Color + if (connecting) { + statusText = stringResource(R.string.status_waiting) + statusColor = MaterialTheme.colorScheme.primary + statusBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else if (checked) { + statusText = stringResource(R.string.lbl_active) + statusColor = MaterialTheme.colorScheme.tertiary + statusBackground = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.14f) + } else { + statusText = stringResource(R.string.lbl_inactive) + statusColor = MaterialTheme.colorScheme.onSurfaceVariant + statusBackground = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.14f) + } + + val iconTint = if (connecting || checked) statusColor else MaterialTheme.colorScheme.onSurfaceVariant + val showActions = enabled + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius3xl), + color = cardColor, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f)) + ) { + Column( + modifier = + Modifier.padding( + horizontal = Dimensions.spacingMd, + vertical = Dimensions.spacingSmMd + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onMainClick), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSmMd), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = statusBackground, + modifier = Modifier.size(34.dp) + ) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_orbot), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(18.dp) + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Surface( + shape = RoundedCornerShape(Dimensions.buttonCornerRadius), + color = statusBackground + ) { + Text( + text = statusText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = statusColor, + modifier = + Modifier.padding( + horizontal = 8.dp, + vertical = 2.dp + ) + ) + } + } + + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange + ) + } + + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + if (showActions) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.34f)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + if (appCount != null && onAppsClick != null) { + OrbotAppsActionChip( + appCount = appCount, + highlighted = highlightedKey == "proxy_orbot_apps", + onClick = onAppsClick + ) + } + OrbotActionChip( + label = stringResource(R.string.settings_orbot_notification_action), + icon = Icons.AutoMirrored.Filled.ArrowForward, + highlighted = + highlightedKey == "proxy_orbot_open_app" || + highlightedKey == "proxy_orbot_notification", + onClick = onOpenAppClick + ) + OrbotActionChip( + label = stringResource(R.string.lbl_info), + icon = Icons.Rounded.Info, + highlighted = highlightedKey == "proxy_orbot_info", + onClick = onInfoClick + ) + } + } + } + } +} + +@Composable +private fun OrbotAppsActionChip( + appCount: Int, + highlighted: Boolean, + onClick: () -> Unit +) { + val containerColor = + if (highlighted) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + val contentColor = + if (highlighted) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + val badgeColor = + if (highlighted) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } + + Surface( + modifier = + Modifier + .heightIn(min = Dimensions.touchTargetSm) + .clickable(onClick = onClick), + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + color = containerColor, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)) + ) { + Row( + modifier = + Modifier.padding( + horizontal = Dimensions.spacingMd, + vertical = Dimensions.spacingXs + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Apps, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource(R.string.lbl_apps), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = contentColor + ) + Surface( + shape = RoundedCornerShape(Dimensions.buttonCornerRadius), + color = badgeColor + ) { + Text( + text = appCount.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = contentColor, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 1.dp) + ) + } + } + } +} + +@Composable +private fun OrbotActionChip( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + highlighted: Boolean, + onClick: () -> Unit +) { + val containerColor = + if (highlighted) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + val contentColor = + if (highlighted) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + + Surface( + modifier = + Modifier + .heightIn(min = Dimensions.touchTargetSm) + .clickable(onClick = onClick), + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + color = containerColor, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)) + ) { + Row( + modifier = + Modifier.padding( + horizontal = Dimensions.spacingMd, + vertical = Dimensions.spacingXs + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(16.dp) + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ProxyOverviewCard( + canEnableProxy: Boolean, + socks5Enabled: Boolean, + httpEnabled: Boolean, + orbotEnabled: Boolean +) { + val activeCount = listOf(socks5Enabled, httpEnabled, orbotEnabled).count { it } + + Surface( + shape = RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = Dimensions.Elevation.low, + border = + androidx.compose.foundation.BorderStroke( + Dimensions.dividerThicknessBold, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f) + ) + ) { + Column( + modifier = Modifier.padding(Dimensions.spacingXl), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + Text( + text = stringResource(R.string.settings_proxy_header), + style = MaterialTheme.typography.titleMedium, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.settings_exclude_proxy_apps_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Active: $activeCount / 3", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + if (!canEnableProxy) { + Text( + text = stringResource(R.string.settings_lock_down_proxy_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@Composable +private fun WireguardEntryCard( + title: String, + description: String, + enabled: Boolean, + onClick: () -> Unit +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimensions.spacingLg), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.75f else 0.45f) + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null + ) + } + } +} + +@Composable +private fun ProxyCard( + title: String, + description: String, + enabled: Boolean, + cardEnabled: Boolean, + onEnabledChange: (Boolean) -> Unit, + onConfigureClick: (() -> Unit)? +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimensions.spacingLg) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + Switch( + checked = enabled, + enabled = cardEnabled, + onCheckedChange = onEnabledChange + ) + } + + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (cardEnabled) 0.75f else 0.45f) + ) + + if (onConfigureClick != null && cardEnabled) { + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + TextButton(onClick = onConfigureClick) { + Text(text = stringResource(R.string.lbl_configure)) + } + } + } + } +} + +@Composable +private fun OrbotModeDialog( + supportsHttp: Boolean, + selectedMode: String, + onSelectedMode: (String) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + val options = + buildList { + add(AppConfig.ProxyType.SOCKS5.name to R.string.orbot_socks5) + if (supportsHttp) { + add(AppConfig.ProxyType.HTTP.name to R.string.orbot_http) + add(AppConfig.ProxyType.HTTP_SOCKS5.name to R.string.orbot_both) + } + add(AppConfig.ProxyType.NONE.name to R.string.orbot_none) + } + + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.orbot_title), + text = { + Column(verticalArrangement = Arrangement.spacedBy(Dimensions.spacingXs)) { + options.forEach { (value, labelRes) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onSelectedMode(value) } + .padding(vertical = Dimensions.spacingXs), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedMode == value, + onClick = { onSelectedMode(value) } + ) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + }, + confirmText = stringResource(R.string.lbl_save), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = onConfirm, + onDismiss = onDismiss + ) +} + +@Composable +private fun Socks5Dialog( + state: Socks5DialogState, + onStateChange: (Socks5DialogState) -> Unit, + onCancel: () -> Unit, + onConfirm: () -> Unit +) { + val context = LocalContext.current + + RethinkConfirmDialog( + onDismissRequest = {}, + title = stringResource(R.string.settings_dns_proxy_dialog_header), + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + OutlinedTextField( + value = state.host, + onValueChange = { onStateChange(state.copy(host = it, error = null)) }, + label = { Text(text = stringResource(R.string.proxy_host_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = state.port, + onValueChange = { onStateChange(state.copy(port = it, error = null)) }, + label = { Text(text = stringResource(R.string.proxy_port_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = state.username, + onValueChange = { onStateChange(state.copy(username = it, error = null)) }, + label = { Text(text = stringResource(R.string.proxy_username_optional_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = state.password, + onValueChange = { onStateChange(state.copy(password = it, error = null)) }, + label = { Text(text = stringResource(R.string.proxy_password_optional_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = stringResource(R.string.settings_dns_proxy_dialog_app_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + ProxyAppSelectorField( + selectedPackageName = state.selectedAppPackage, + options = state.appOptions, + onOptionSelected = { packageName -> + onStateChange(state.copy(selectedAppPackage = packageName, error = null)) + } + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onStateChange( + state.copy(udpBlocked = !state.udpBlocked, error = null) + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + checked = state.udpBlocked, + onCheckedChange = { + onStateChange(state.copy(udpBlocked = it, error = null)) + } + ) + Text( + text = stringResource(R.string.settings_udp_block), + modifier = Modifier.padding(start = Dimensions.spacingSm) + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = !state.lockdown) { + onStateChange( + state.copy( + includeProxyApps = !state.includeProxyApps, + error = null + ) + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + checked = state.includeProxyApps, + enabled = !state.lockdown, + onCheckedChange = { + onStateChange(state.copy(includeProxyApps = it, error = null)) + } + ) + Text( + text = stringResource(R.string.settings_exclude_proxy_apps_heading), + modifier = Modifier.padding(start = Dimensions.spacingSm) + ) + } + + if (state.lockdown) { + Text( + text = stringResource(R.string.settings_lock_down_mode_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.clickable { UIUtils.openVpnProfile(context) } + ) + } + + if (!state.error.isNullOrBlank()) { + Text( + text = state.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmText = stringResource(R.string.lbl_save), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = onConfirm, + onDismiss = onCancel + ) +} + +@Composable +private fun HttpDialog( + state: HttpDialogState, + onStateChange: (HttpDialogState) -> Unit, + onCancel: () -> Unit, + onConfirm: () -> Unit +) { + val context = LocalContext.current + + RethinkConfirmDialog( + onDismissRequest = {}, + title = stringResource(R.string.http_proxy_dialog_heading), + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + Text( + text = stringResource(R.string.http_proxy_dialog_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedTextField( + value = state.host, + onValueChange = { onStateChange(state.copy(host = it, error = null)) }, + label = { Text(text = stringResource(R.string.proxy_host_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = stringResource(R.string.settings_dns_proxy_dialog_app_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ProxyAppSelectorField( + selectedPackageName = state.selectedAppPackage, + options = state.appOptions, + onOptionSelected = { packageName -> + onStateChange(state.copy(selectedAppPackage = packageName, error = null)) + } + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = !state.lockdown) { + onStateChange( + state.copy( + includeProxyApps = !state.includeProxyApps, + error = null + ) + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + checked = state.includeProxyApps, + enabled = !state.lockdown, + onCheckedChange = { + onStateChange(state.copy(includeProxyApps = it, error = null)) + } + ) + Text( + text = stringResource(R.string.settings_exclude_proxy_apps_heading), + modifier = Modifier.padding(start = Dimensions.spacingSm) + ) + } + + if (state.lockdown) { + Text( + text = stringResource(R.string.settings_lock_down_mode_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.clickable { UIUtils.openVpnProfile(context) } + ) + } + + if (!state.error.isNullOrBlank()) { + Text( + text = state.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmText = stringResource(R.string.lbl_save), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = onConfirm, + onDismiss = onCancel + ) +} + +private fun Socks5DialogState.selectedAppLabel(): String { + return selectedProxyAppLabel(selectedAppPackage, appOptions) +} + +private fun HttpDialogState.selectedAppLabel(): String { + return selectedProxyAppLabel(selectedAppPackage, appOptions) +} + +private fun selectedProxyAppLabel(selectedPackageName: String, options: List): String { + return options.firstOrNull { it.packageName == selectedPackageName }?.label + ?: options.firstOrNull()?.label + ?: "" +} + +@Composable +private fun ProxyAppSelectorField( + selectedPackageName: String, + options: List, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + var expanded by remember { mutableStateOf(false) } + var anchorSize by remember { mutableStateOf(IntSize.Zero) } + var anchorOffset by remember { mutableStateOf(IntOffset.Zero) } + val density = LocalDensity.current + val selectorShape = RoundedCornerShape(Dimensions.cornerRadiusMdLg) + val selectedOption = + options.firstOrNull { it.packageName == selectedPackageName } ?: options.firstOrNull() + val containerColor = + if (enabled) { + MaterialTheme.colorScheme.surfaceContainerLow + } else { + MaterialTheme.colorScheme.surfaceVariant + } + + Box(modifier = modifier.fillMaxWidth()) { + Surface( + shape = selectorShape, + color = containerColor, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)), + modifier = + Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + anchorSize = coordinates.size + val windowPos = coordinates.positionInWindow() + anchorOffset = IntOffset(windowPos.x.roundToInt(), windowPos.y.roundToInt()) + } + .clip(selectorShape) + .clickable(enabled = enabled) { expanded = true } + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Dimensions.spacingMd, vertical = Dimensions.spacingSm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + ProxyDialogAppIcon(option = selectedOption, size = 24.dp) + Text( + text = selectedOption?.label.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (expanded && enabled && anchorSize.width > 0) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(anchorOffset.x, anchorOffset.y + anchorSize.height), + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = true, clippingEnabled = true) + ) { + Surface( + shape = selectorShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 8.dp, + tonalElevation = 6.dp, + modifier = + Modifier + .width(with(density) { anchorSize.width.toDp() }) + .heightIn(max = 360.dp) + .clip(selectorShape) + ) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = options) { option -> + DropdownMenuItem( + leadingIcon = { ProxyDialogAppIcon(option = option, size = 20.dp) }, + text = { + Text( + text = option.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onClick = { + expanded = false + onOptionSelected(option.packageName) + } + ) + } + } + } + } + } + } +} + +@Composable +private fun ProxyDialogAppIcon( + option: ProxyDialogAppOption?, + size: androidx.compose.ui.unit.Dp +) { + if (option == null || option.packageName.isBlank()) { + ProxyDialogIconPlaceholder(size = size) + return + } + + val context = LocalContext.current + val cacheKey = option.packageName.ifBlank { option.iconLookupName } + var iconDrawable by + remember(cacheKey) { + mutableStateOf( + if (cacheKey.isBlank()) { + null + } else { + ProxyDialogAppIconCache.get(cacheKey) + } + ) + } + + LaunchedEffect(cacheKey, option.packageName, option.iconLookupName) { + if (iconDrawable != null || option.packageName.isBlank()) return@LaunchedEffect + val icon = + withContext(Dispatchers.IO) { + getIcon(context, option.packageName, option.iconLookupName) + } + iconDrawable = icon + ProxyDialogAppIconCache.put(cacheKey, icon) + } + + val painter = rememberDrawablePainter(iconDrawable) + if (painter != null) { + Image( + painter = painter, + contentDescription = null, + modifier = + Modifier + .size(size) + .clip(RoundedCornerShape(8.dp)) + ) + } else { + ProxyDialogIconPlaceholder(size = size) + } +} + +@Composable +private fun ProxyDialogIconPlaceholder(size: androidx.compose.ui.unit.Dp) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)), + modifier = Modifier.size(size) + ) {} +} + +private suspend fun buildProxyScreenState( + context: android.content.Context, + appConfig: AppConfig, + defaultSocks5Description: String, + defaultHttpDescription: String, + httpProxyDescriptionTemplate: String, + defaultSocks5DescriptionNoApp: String, + defaultSocks5DescriptionWithApp: String, + orbotDisabledDescription: String, + orbotStatus2Description: String, + orbotStatus1Template: String, + orbotStatus3Template: String, + orbotStatusArgDns: String, + orbotStatusArgProxy: String, + defaultWireguardDescription: String, + statusTextById: Map, + statusFailingText: String, + statusWaitingText: String, + wireguardVersionTemplate: String, + ciIpLabelTemplate: String +): ProxyScreenState { + val canEnableProxy = appConfig.canEnableProxy() + val socks5Enabled = appConfig.isCustomSocks5Enabled() + val httpEnabled = appConfig.isCustomHttpProxyEnabled() + val orbotEnabled = appConfig.isOrbotProxyEnabled() + + val socks5Description = + if (!socks5Enabled) { + defaultSocks5Description + } else { + formatSocks5Description( + context, + runCatching { appConfig.getSocks5ProxyDetails() }.getOrNull(), + defaultSocks5Description, + defaultSocks5DescriptionNoApp, + defaultSocks5DescriptionWithApp + ) + } + + val httpDescription = + if (!httpEnabled) { + defaultHttpDescription + } else { + val endpoint = runCatching { appConfig.getHttpProxyDetails() }.getOrNull() + val host = endpoint?.proxyIP ?: "" + if (host.isBlank()) { + defaultHttpDescription + } else { + String.format(httpProxyDescriptionTemplate, host) + } + } + + val orbotDescription = + formatOrbotDescription( + context, + appConfig, + orbotDisabledDescription, + orbotStatus2Description, + orbotStatus1Template, + orbotStatus3Template, + orbotStatusArgDns, + orbotStatusArgProxy + ) + val wireguardDescription = + formatWireguardDescription( + defaultWireguardDescription, + statusTextById, + statusWaitingText, + statusFailingText, + wireguardVersionTemplate, + ciIpLabelTemplate + ) + + return ProxyScreenState( + canEnableProxy = canEnableProxy, + socks5Enabled = socks5Enabled, + httpEnabled = httpEnabled, + orbotEnabled = orbotEnabled, + wireguardDescription = wireguardDescription, + socks5Description = socks5Description, + httpDescription = httpDescription, + orbotDescription = orbotDescription + ) +} + +private suspend fun formatSocks5Description( + context: android.content.Context, + endpoint: ProxyEndpoint?, + defaultDescription: String, + defaultDescriptionNoAppTemplate: String, + defaultDescriptionWithAppTemplate: String +): String { + val endpointValue = endpoint ?: return defaultDescription + if (endpointValue.proxyIP.isNullOrBlank()) { + return defaultDescription + } + + val ip = endpointValue.proxyIP ?: return defaultDescription + val port = endpointValue.proxyPort.toString() + val packageName = endpointValue.proxyAppName + if (packageName.isNullOrBlank()) { + return String.format(defaultDescriptionNoAppTemplate, ip, port) + } + + val appName = FirewallManager.getAppInfoByPackage(packageName)?.appName + return if (appName.isNullOrBlank()) { + String.format(defaultDescriptionNoAppTemplate, ip, port) + } else { + String.format(defaultDescriptionWithAppTemplate, ip, port, appName) + } +} + +private suspend fun formatOrbotDescription( + context: android.content.Context, + appConfig: AppConfig, + orbotDisabledDescription: String, + orbotStatus2Description: String, + orbotStatus1Template: String, + orbotStatus3Template: String, + orbotStatusArgDns: String, + orbotStatusArgProxy: String +): String { + val isInstalled = FirewallManager.isOrbotInstalled() + if (!isInstalled) { + return orbotDisabledDescription + } + + if (!appConfig.isOrbotProxyEnabled()) { + return orbotDisabledDescription + } + + val isOrbotDns = appConfig.isOrbotDns() + return when (appConfig.getProxyType()) { + AppConfig.ProxyType.HTTP.name -> { + orbotStatus2Description + } + + AppConfig.ProxyType.SOCKS5.name -> { + if (isOrbotDns) { + String.format(orbotStatus1Template, orbotStatusArgDns) + } else { + String.format(orbotStatus1Template, orbotStatusArgProxy) + } + } + + AppConfig.ProxyType.HTTP_SOCKS5.name -> { + if (isOrbotDns) { + String.format(orbotStatus3Template, orbotStatusArgDns) + } else { + String.format(orbotStatus3Template, orbotStatusArgProxy) + } + } + + else -> orbotDisabledDescription + } +} + +private suspend fun formatWireguardDescription( + defaultDescription: String, + statusTextById: Map, + statusWaitingText: String, + statusFailingText: String, + wireguardVersionTemplate: String, + ciIpLabelTemplate: String +): String { + val activeWgs = WireguardManager.getActiveConfigs() + if (activeWgs.isEmpty()) { + return defaultDescription + } + + val details = StringBuilder() + activeWgs.forEach { + val id = ProxyManager.ID_WG_BASE + it.getId() + val statusPair = VpnController.getProxyStatusById(id) + val stats = VpnController.getProxyStats(id) + val dnsStatusId = VpnController.getDnsStatus(id) + + val statusText = + if (statusPair.first == Backend.TPU) { + statusTextById[UIUtils.ProxyStatus.TPU.id] ?: statusFailingText + } else if (isDnsError(dnsStatusId)) { + statusFailingText + } else { + getProxyStatusText( + statusPair = statusPair, + stats = stats, + statusTextById = statusTextById, + statusWaitingText = statusWaitingText, + statusFailingText = statusFailingText, + wireguardVersionTemplate = wireguardVersionTemplate + ) + } + + details.append( + String.format( + ciIpLabelTemplate, + it.getName(), + statusText.padStart(1, ' ') + ) + ) + details.append('\n') + } + + return details.toString().trimEnd() +} + +private fun isDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || + s == Transaction.Status.BAD_RESPONSE || + s == Transaction.Status.NO_RESPONSE || + s == Transaction.Status.SEND_FAIL || + s == Transaction.Status.CLIENT_ERROR || + s == Transaction.Status.INTERNAL_ERROR || + s == Transaction.Status.TRANSPORT_ERROR +} + +private fun getProxyStatusText( + statusPair: Pair, + stats: RouterStats?, + statusTextById: Map, + statusWaitingText: String, + statusFailingText: String, + wireguardVersionTemplate: String +): String { + val status = UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } + if (status == null) { + val txt = + if (!statusPair.second.isNullOrBlank()) { + "$statusWaitingText (${statusPair.second})" + } else { + statusWaitingText + } + return txt.replaceFirstChar(Char::titlecase) + } + + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + if (now - since > WireguardManager.WG_UPTIME_THRESHOLD && lastOk == 0L) { + return statusFailingText + } + + val baseText = + statusTextById.getValue(status.id) + val handshakeTime = + if (stats != null && stats.lastOK > 0L) { + DateUtils.getRelativeTimeSpanString( + stats.lastOK, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + .toString() + } else { + null + } + + return if (stats?.lastOK != 0L && handshakeTime != null) { + String.format(wireguardVersionTemplate, baseText, handshakeTime) + } else { + baseText + } +} + +private suspend fun buildSocks5DialogState( + context: android.content.Context, + appConfig: AppConfig, + persistentState: PersistentState, + defaultApp: String +): Socks5DialogState { + val endpoint = runCatching { appConfig.getSocks5ProxyDetails() }.getOrNull() + val appOptions = buildProxyDialogAppOptions(context, defaultApp) + val selectedPackage = endpoint?.proxyAppName.orEmpty() + val normalizedSelectedPackage = + if (appOptions.any { it.packageName == selectedPackage }) { + selectedPackage + } else { + "" + } + + return Socks5DialogState( + host = endpoint?.proxyIP ?: com.celzero.bravedns.util.Constants.SOCKS_DEFAULT_IP, + port = + if ((endpoint?.proxyPort ?: 0) > 0) { + endpoint?.proxyPort?.toString() ?: com.celzero.bravedns.util.Constants.SOCKS_DEFAULT_PORT.toString() + } else { + com.celzero.bravedns.util.Constants.SOCKS_DEFAULT_PORT.toString() + }, + username = endpoint?.userName.orEmpty(), + password = endpoint?.password.orEmpty(), + selectedAppPackage = normalizedSelectedPackage, + appOptions = appOptions, + udpBlocked = persistentState.getUdpBlocked(), + includeProxyApps = !persistentState.excludeAppsInProxy, + lockdown = VpnController.isVpnLockdown() + ) +} + +private suspend fun buildHttpDialogState( + context: android.content.Context, + appConfig: AppConfig, + persistentState: PersistentState, + defaultApp: String +): HttpDialogState { + val endpoint = runCatching { appConfig.getHttpProxyDetails() }.getOrNull() + val appOptions = buildProxyDialogAppOptions(context, defaultApp) + + val selectedPackage = endpoint?.proxyAppName.orEmpty() + val normalizedSelectedPackage = + if (appOptions.any { it.packageName == selectedPackage }) { + selectedPackage + } else { + "" + } + + return HttpDialogState( + host = endpoint?.proxyIP ?: "http://127.0.0.1:8118", + selectedAppPackage = normalizedSelectedPackage, + appOptions = appOptions, + includeProxyApps = !persistentState.excludeAppsInProxy, + lockdown = VpnController.isVpnLockdown() + ) +} + +private suspend fun buildProxyDialogAppOptions( + context: android.content.Context, + defaultApp: String +): List { + val sortedApps = FirewallManager.getAllAppsSortedByVpnPermission(context) + val duplicateCountByName = sortedApps.groupingBy { it.appName }.eachCount() + + return buildList { + add(ProxyDialogAppOption(packageName = "", label = defaultApp)) + sortedApps.forEach { appInfo -> + val appName = appInfo.appName + val packageName = appInfo.packageName.orEmpty() + val label = + if (duplicateCountByName[appName] ?: 0 > 1 && packageName.isNotBlank()) { + "$appName ($packageName)" + } else { + appName + } + add( + ProxyDialogAppOption( + packageName = packageName, + label = label, + iconLookupName = appName + ) + ) + } + } +} + +private fun validateSocks5Input( + host: String, + portText: String, + emptyHostError: String, + invalidPortError: String, + portRangeError: String +): String? { + if (host.isBlank()) { + return emptyHostError + } + + val port = + try { + portText.toInt() + } catch (_: NumberFormatException) { + return invalidPortError + } + + val valid = + if (Utilities.isLanIpv4(host)) { + Utilities.isValidLocalPort(port) + } else { + Utilities.isValidPort(port) + } + if (!valid) { + return portRangeError + } + + return null +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/settings/TunnelSettingsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/TunnelSettingsScreen.kt new file mode 100644 index 000000000..e33e5ee52 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/settings/TunnelSettingsScreen.kt @@ -0,0 +1,1168 @@ +/* + * Copyright 2023 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.compose.settings + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkToggleListItem +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.ui.dialog.CustomLanIpSheet +import com.celzero.bravedns.ui.dialog.NetworkReachabilitySheet +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.InternetProtocol +import com.celzero.bravedns.util.NewSettingsManager +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private const val SECONDS_PER_MINUTE = 60 +private const val SECONDS_PER_HOUR = 3600 +private const val POLICY_AUTO = 0 +private const val POLICY_SENSITIVE = 1 +private const val POLICY_RELAXED = 2 +private const val POLICY_FIXED = 3 +private const val IP_DIALOG_POS_IPV4 = 0 +private const val IP_DIALOG_POS_IPV6 = 1 +private const val IP_DIALOG_POS_ALWAYS_V46 = 2 +private const val IP_DIALOG_POS_V46 = 3 + +private data class NetworkPolicyOption(val title: String, val description: String) + +private fun tunnelFocusTarget( + focusKey: String, + isLockdown: Boolean, + showConnectivityChecksOption: Boolean, + showPingIps: Boolean, + showAllowIncoming: Boolean, + showVpnMetered: Boolean +): Pair? { + val networkIndex = if (isLockdown) 2 else 1 + val advancedIndex = if (isLockdown) 3 else 2 + val timeoutIndex = if (isLockdown) 4 else 3 + val rowHeight = 82 + val groupStart = 62 + + fun groupOffset(row: Int): Int = groupStart + (rowHeight * row) + + val networkRow = + when (focusKey) { + "network_allow_bypass" -> 0 + "network_fail_open" -> 1 + "network_allow_lan" -> 2 + "network_all_networks" -> 3 + "network_exclude_apps_proxy" -> 4 + "network_protocol_translation" -> 5 + else -> null + } + + val advancedRows = mutableMapOf() + var row = 0 + fun addAdvancedRow(key: String, visible: Boolean = true) { + if (!visible) return + advancedRows[key] = row + row++ + } + + addAdvancedRow("network_default_dns") + addAdvancedRow("network_vpn_policy") + addAdvancedRow("network_ip_protocol") + addAdvancedRow("network_connectivity_checks", showConnectivityChecksOption) + addAdvancedRow("network_ping_ips", showPingIps) + addAdvancedRow("network_mobile_metered") + addAdvancedRow("network_wg_listen_port") + addAdvancedRow("network_wg_lockdown") + addAdvancedRow("network_endpoint_independence") + addAdvancedRow("network_allow_incoming_wg", showAllowIncoming) + addAdvancedRow("network_tcp_keep_alive") + addAdvancedRow("network_jumbo_packets") + addAdvancedRow("network_vpn_metered", showVpnMetered) + addAdvancedRow("network_custom_lan_ip") + val advancedRow = advancedRows[focusKey] + + return when (focusKey) { + "network_core" -> networkIndex to 0 + "network_advanced" -> advancedIndex to 0 + "network_dial_timeout" -> timeoutIndex to 0 + else -> { + when { + networkRow != null -> networkIndex to groupOffset(networkRow) + advancedRow != null -> advancedIndex to groupOffset(advancedRow) + else -> null + } + } + } +} + +private fun tunnelFocusIndex(focusKey: String, isLockdown: Boolean): Int? { + val networkIndex = if (isLockdown) 2 else 1 + val advancedIndex = if (isLockdown) 3 else 2 + val timeoutIndex = if (isLockdown) 4 else 3 + return when (focusKey) { + "network_core", + "network_allow_bypass", + "network_fail_open", + "network_allow_lan", + "network_all_networks", + "network_exclude_apps_proxy", + "network_protocol_translation" -> networkIndex + "network_advanced", + "network_default_dns", + "network_vpn_policy", + "network_ip_protocol", + "network_connectivity_checks", + "network_ping_ips", + "network_mobile_metered", + "network_wg_listen_port", + "network_wg_lockdown", + "network_endpoint_independence", + "network_allow_incoming_wg", + "network_tcp_keep_alive", + "network_jumbo_packets", + "network_vpn_metered", + "network_custom_lan_ip" -> advancedIndex + "network_dial_timeout" -> timeoutIndex + else -> null + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TunnelSettingsScreen( + persistentState: PersistentState, + appConfig: AppConfig, + eventLogger: EventLogger, + onOpenVpnProfile: () -> Unit, + initialFocusKey: String? = null, + onBackClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val disabledText = stringResource(R.string.lbl_disabled) + val protocolTranslationInactiveText = stringResource(R.string.settings_protocol_translation_dns_inactive) + val socks5VpnDisabledErrorText = stringResource(R.string.settings_socks5_vpn_disabled_error) + + var isLockdown by remember { mutableStateOf(VpnController.isVpnLockdown()) } + var allowBypass by remember { mutableStateOf(persistentState.allowBypass) } + var allowBypassLoading by remember { mutableStateOf(false) } + var useMultipleNetworks by remember { mutableStateOf(persistentState.useMultipleNetworks) } + var routeLan by remember { mutableStateOf(persistentState.privateIps) } + var excludeApps by remember { mutableStateOf(!persistentState.excludeAppsInProxy) } + var stallNoNetwork by remember { mutableStateOf(persistentState.stallOnNoNetwork) } + var protocolTranslation by remember { mutableStateOf(persistentState.protocolTranslationType) } + var meteredOnlyMobile by remember { mutableStateOf(persistentState.treatOnlyMobileNetworkAsMetered) } + var listenPortFixed by remember { mutableStateOf(!persistentState.randomizeListenPort) } + var wgLockdown by remember { mutableStateOf(persistentState.wgGlobalLockdown) } + var endpointIndependence by remember { mutableStateOf(persistentState.endpointIndependence) } + var allowIncoming by remember { mutableStateOf(persistentState.nwEngExperimentalFeatures) } + var tcpKeepAlive by remember { mutableStateOf(persistentState.tcpKeepAlive) } + var useMaxMtu by remember { mutableStateOf(persistentState.useMaxMtu) } + var tunnelMetered by remember { mutableStateOf(persistentState.setVpnBuilderToMetered) } + var dialTimeoutMin by remember { mutableIntStateOf(persistentState.dialTimeoutSec / SECONDS_PER_MINUTE) } + var internetProtocol by remember { mutableIntStateOf(persistentState.internetProtocolType) } + var vpnPolicy by remember { mutableIntStateOf(persistentState.vpnBuilderPolicy) } + var connectivityChecks by remember { mutableStateOf(persistentState.connectivityChecks) } + var showCustomLanIpSheet by remember { mutableStateOf(false) } + var showReachabilitySheet by remember { mutableStateOf(false) } + var showDefaultDnsDialog by remember { mutableStateOf(false) } + var showVpnPolicyDialog by remember { mutableStateOf(false) } + var showIpDialog by remember { mutableStateOf(false) } + var showConnectivityChecksDialog by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val density = LocalDensity.current + val initialFocus = initialFocusKey?.trim().orEmpty() + var pendingFocusKey by rememberSaveable(initialFocus) { mutableStateOf(initialFocus) } + var activeFocusKey by rememberSaveable(initialFocus) { + mutableStateOf(initialFocus.ifBlank { null }) + } + + val canModify = !isLockdown + val showPtrans = internetProtocol == InternetProtocol.IPv6.id + val showConnectivityChecksOption = internetProtocol == InternetProtocol.IPv46.id + val showPingIps = showConnectivityChecksOption && connectivityChecks + + fun logEvent(msg: String, details: String) { + eventLogger.log(EventType.TUN_ESTABLISHED, Severity.LOW, msg, EventSource.UI, false, details) + } + + fun formatTimeShort(totalSeconds: Int, disabledText: String): String { + val hours = totalSeconds / SECONDS_PER_HOUR + val minutes = (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE + val seconds = totalSeconds % SECONDS_PER_MINUTE + val parts = mutableListOf() + if (hours > 0) parts.add("${hours}h") + if (minutes > 0) parts.add("${minutes}m") + if (seconds > 0) parts.add("${seconds}s") + return if (parts.isEmpty()) disabledText else parts.joinToString(" ") + } + + val ipDesc = when (internetProtocol) { + InternetProtocol.IPv4.id -> stringResource(R.string.settings_ip_text_ipv4) + InternetProtocol.IPv6.id -> stringResource(R.string.settings_ip_text_ipv6) + InternetProtocol.IPv46.id -> stringResource(R.string.settings_ip_text_ipv46) + InternetProtocol.ALWAYSv46.id -> stringResource(R.string.settings_ip_text_ipv4) + " & " + stringResource(R.string.settings_ip_text_ipv6) + else -> stringResource(R.string.settings_ip_text_ipv4) + } + + val vpnPolicyDesc = when (vpnPolicy) { + POLICY_AUTO -> stringResource(R.string.settings_ip_text_ipv46) + POLICY_SENSITIVE -> stringResource(R.string.vpn_policy_sensitive) + POLICY_RELAXED -> stringResource(R.string.vpn_policy_relaxed) + POLICY_FIXED -> stringResource(R.string.vpn_policy_fixed) + else -> stringResource(R.string.settings_ip_text_ipv46) + } + + val dialTimeoutDesc = formatTimeShort(dialTimeoutMin * SECONDS_PER_MINUTE, disabledText) + val topBarSubtitle = + stringResource( + R.string.two_argument_colon, + stringResource(R.string.vpn_policy_title), + vpnPolicyDesc + ) + + // Default DNS Dialog + if (showDefaultDnsDialog) { + DefaultDnsDialog( + persistentState = persistentState, + onDismiss = { showDefaultDnsDialog = false }, + onConfirm = { logEvent("default dns changed", "Default DNS changed") } + ) + } + + // VPN Policy Dialog + if (showVpnPolicyDialog) { + VpnPolicyDialog( + persistentState = persistentState, + onDismiss = { showVpnPolicyDialog = false }, + onConfirm = { selectedIndex -> + if (selectedIndex == POLICY_FIXED) { + persistentState.enableStabilityDependentSettings(context) + persistentState.useMaxMtu = true + useMaxMtu = true + persistentState.internetProtocolType = InternetProtocol.ALWAYSv46.id + internetProtocol = InternetProtocol.ALWAYSv46.id + } + persistentState.vpnBuilderPolicy = selectedIndex + vpnPolicy = selectedIndex + logEvent("vpn policy changed", "VPN builder network policy changed to: $selectedIndex") + } + ) + } + + // IP Dialog + if (showIpDialog) { + IpProtocolDialog( + persistentState = persistentState, + context = context, + onDismiss = { showIpDialog = false }, + onConfirm = { selectedProtocol -> + internetProtocol = selectedProtocol + logEvent("ip protocol changed", "Internet protocol changed to: $selectedProtocol") + } + ) + } + + // Connectivity Checks Dialog + if (showConnectivityChecksDialog) { + ConnectivityChecksDialog( + persistentState = persistentState, + onDismiss = { showConnectivityChecksDialog = false }, + onConfirm = { enabled -> + connectivityChecks = enabled + logEvent("connectivity checks", "Connectivity checks changed") + } + ) + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + LaunchedEffect( + pendingFocusKey, + isLockdown, + showConnectivityChecksOption, + showPingIps, + endpointIndependence + ) { + val key = pendingFocusKey.trim() + if (key.isBlank()) return@LaunchedEffect + activeFocusKey = key + val target = + tunnelFocusTarget( + focusKey = key, + isLockdown = isLockdown, + showConnectivityChecksOption = showConnectivityChecksOption, + showPingIps = showPingIps, + showAllowIncoming = endpointIndependence, + showVpnMetered = isAtleastQ() + ) + if (target != null) { + val (index, offsetDp) = target + val offsetPx = with(density) { offsetDp.dp.toPx().roundToInt() } + listState.animateScrollToItem(index, offsetPx) + delay(900) + if (activeFocusKey == key) { + activeFocusKey = null + } + pendingFocusKey = "" + return@LaunchedEffect + } + + val index = tunnelFocusIndex(key, isLockdown) + if (index != null) { + listState.animateScrollToItem(index, 0) + delay(750) + if (activeFocusKey == key) { + activeFocusKey = null + } + } + pendingFocusKey = "" + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.lbl_network), + subtitle = topBarSubtitle, + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { padding -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = + PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingLg) + ) { + if (isLockdown) { + item { + RethinkListGroup { + RethinkActionListItem( + title = stringResource(R.string.settings_lock_down_mode_desc), + iconPainter = painterResource(id = R.drawable.ic_firewall_lockdown_on), + position = CardPosition.Single, + onClick = { onOpenVpnProfile() } + ) + } + } + } + + item { + RethinkListGroup { + RethinkToggleListItem( + title = stringResource(R.string.settings_allow_bypass_heading), + description = stringResource(R.string.settings_allow_bypass_desc), + icon = Icons.Filled.Settings, + position = CardPosition.First, + highlighted = activeFocusKey == "network_allow_bypass", + checked = allowBypass, + enabled = canModify && !Utilities.isPlayStoreFlavour(), + onRowClick = { + if (Utilities.isPlayStoreFlavour()) return@RethinkToggleListItem + val checked = !allowBypass + allowBypass = checked + persistentState.allowBypass = checked + allowBypassLoading = true + scope.launch { + delay(1000L) + allowBypassLoading = false + } + logEvent("allow bypass", "Allow bypass VPN: $checked") + }, + onCheckedChange = { checked -> + if (Utilities.isPlayStoreFlavour()) return@RethinkToggleListItem + allowBypass = checked + persistentState.allowBypass = checked + allowBypassLoading = true + scope.launch { + delay(1000L) + allowBypassLoading = false + } + logEvent("allow bypass", "Allow bypass VPN: $checked") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.fail_open_network_title), + description = stringResource(R.string.fail_open_network_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_fail_open", + checked = stallNoNetwork, + onRowClick = { + val checked = !stallNoNetwork + stallNoNetwork = checked + persistentState.stallOnNoNetwork = checked + logEvent("stall on no network", "Stall on no network: $checked") + }, + onCheckedChange = { checked -> + stallNoNetwork = checked + persistentState.stallOnNoNetwork = checked + logEvent("stall on no network", "Stall on no network: $checked") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_allow_lan_heading), + description = stringResource(R.string.settings_allow_lan_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_allow_lan", + checked = routeLan, + enabled = canModify, + onRowClick = { + if (canModify) { + val checked = !routeLan + routeLan = checked + persistentState.privateIps = checked + if (checked) persistentState.enableStabilityDependentSettings(context) + logEvent("route lan traffic", "Route LAN traffic: $checked") + } + }, + onCheckedChange = { checked -> + routeLan = checked + persistentState.privateIps = checked + if (checked) persistentState.enableStabilityDependentSettings(context) + logEvent("route lan traffic", "Route LAN traffic: $checked") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_network_all_networks), + description = stringResource(R.string.settings_network_all_networks_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_all_networks", + checked = useMultipleNetworks, + enabled = canModify, + onRowClick = { + if (canModify) { + val checked = !useMultipleNetworks + useMultipleNetworks = checked + persistentState.useMultipleNetworks = checked + if (checked) persistentState.enableStabilityDependentSettings(context) + if (!checked && persistentState.routeRethinkInRethink) { + persistentState.routeRethinkInRethink = false + } + logEvent("use all networks", "Use all networks for VPN: $checked") + } + }, + onCheckedChange = { checked -> + useMultipleNetworks = checked + persistentState.useMultipleNetworks = checked + if (checked) persistentState.enableStabilityDependentSettings(context) + if (!checked && persistentState.routeRethinkInRethink) { + persistentState.routeRethinkInRethink = false + } + logEvent("use all networks", "Use all networks for VPN: $checked") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_exclude_apps_in_proxy), + description = stringResource(R.string.settings_exclude_apps_in_proxy_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_exclude_apps_proxy", + checked = excludeApps, + enabled = canModify, + onRowClick = { + if (canModify) { + val checked = !excludeApps + excludeApps = checked + persistentState.excludeAppsInProxy = !checked + logEvent("exclude apps in proxy", "Exclude apps in proxy: ${!checked}") + } + }, + onCheckedChange = { checked -> + excludeApps = checked + persistentState.excludeAppsInProxy = !checked + logEvent("exclude apps in proxy", "Exclude apps in proxy: ${!checked}") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_protocol_translation), + description = stringResource(R.string.settings_protocol_translation_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Last, + highlighted = activeFocusKey == "network_protocol_translation", + checked = protocolTranslation, + enabled = showPtrans, + onRowClick = { + if (showPtrans) { + val checked = !protocolTranslation + if (appConfig.getBraveMode().isDnsActive()) { + protocolTranslation = checked + persistentState.protocolTranslationType = checked + } else { + protocolTranslation = false + showToastUiCentered( + context, + protocolTranslationInactiveText, + Toast.LENGTH_SHORT + ) + } + logEvent("protocol translation", "Protocol translation: $checked") + } + }, + onCheckedChange = { checked -> + if (appConfig.getBraveMode().isDnsActive()) { + protocolTranslation = checked + persistentState.protocolTranslationType = checked + } else { + protocolTranslation = false + showToastUiCentered( + context, + protocolTranslationInactiveText, + Toast.LENGTH_SHORT + ) + } + logEvent("protocol translation", "Protocol translation: $checked") + } + ) + } + } + + item { + SectionHeader(title = stringResource(R.string.lbl_advanced)) + RethinkListGroup { + RethinkActionListItem( + title = stringResource(R.string.settings_default_dns_heading), + description = stringResource(R.string.settings_default_dns_desc), + icon = Icons.Filled.Settings, + position = CardPosition.First, + highlighted = activeFocusKey == "network_default_dns", + onClick = { showDefaultDnsDialog = true } + ) + + RethinkActionListItem( + title = stringResource(R.string.vpn_policy_title), + description = vpnPolicyDesc, + icon = Icons.Filled.Settings, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_vpn_policy", + onClick = { showVpnPolicyDialog = true } + ) + + RethinkActionListItem( + title = stringResource(R.string.settings_ip_dialog_title), + description = stringResource(R.string.settings_selected_ip_desc, ipDesc), + icon = Icons.Filled.Settings, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_ip_protocol", + onClick = { if (vpnPolicy != POLICY_FIXED) showIpDialog = true } + ) + + if (showConnectivityChecksOption) { + RethinkActionListItem( + title = stringResource(R.string.settings_connectivity_checks), + description = stringResource(R.string.settings_connectivity_checks_desc), + icon = Icons.Filled.Settings, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_connectivity_checks", + onClick = { showConnectivityChecksDialog = true } + ) + } + + if (showPingIps) { + RethinkActionListItem( + title = stringResource(R.string.settings_ping_ips), + icon = Icons.Filled.NetworkCheck, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_ping_ips", + onClick = { + if (!VpnController.hasTunnel()) { + showToastUiCentered( + context, + socks5VpnDisabledErrorText, + Toast.LENGTH_SHORT + ) + } else { + showReachabilitySheet = true + } + } + ) + } + + RethinkToggleListItem( + title = stringResource(R.string.settings_treat_mobile_metered), + description = stringResource(R.string.settings_treat_mobile_metered_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_mobile_metered", + checked = meteredOnlyMobile, + onRowClick = { + val checked = !meteredOnlyMobile + meteredOnlyMobile = checked + persistentState.treatOnlyMobileNetworkAsMetered = checked + logEvent("mobile metered", "Treat mobile as metered: $checked") + }, + onCheckedChange = { checked -> + meteredOnlyMobile = checked + persistentState.treatOnlyMobileNetworkAsMetered = checked + logEvent("mobile metered", "Treat mobile as metered: $checked") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_wg_listen_port), + description = stringResource(R.string.settings_wg_listen_port_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_wg_listen_port", + checked = listenPortFixed, + onRowClick = { + val checked = !listenPortFixed + listenPortFixed = checked + persistentState.randomizeListenPort = !checked + logEvent("listen port", "Randomize listen port: ${!checked}") + }, + onCheckedChange = { checked -> + listenPortFixed = checked + persistentState.randomizeListenPort = !checked + logEvent("listen port", "Randomize listen port: ${!checked}") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_wg_lockdown), + description = stringResource(R.string.settings_wg_lockdown_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_wg_lockdown", + checked = wgLockdown, + onRowClick = { + val checked = !wgLockdown + wgLockdown = checked + persistentState.wgGlobalLockdown = checked + NewSettingsManager.markSettingSeen(NewSettingsManager.WG_GLOBAL_LOCKDOWN_MODE_SETTING) + logEvent("wg lockdown", "WG global lockdown: $checked") + }, + onCheckedChange = { checked -> + wgLockdown = checked + persistentState.wgGlobalLockdown = checked + NewSettingsManager.markSettingSeen(NewSettingsManager.WG_GLOBAL_LOCKDOWN_MODE_SETTING) + logEvent("wg lockdown", "WG global lockdown: $checked") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_endpoint_independence), + description = stringResource(R.string.settings_endpoint_independence_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_endpoint_independence", + checked = endpointIndependence, + onRowClick = { + val checked = !endpointIndependence + endpointIndependence = checked + persistentState.endpointIndependence = checked + if (!checked) { + allowIncoming = false + persistentState.nwEngExperimentalFeatures = false + } else { + allowIncoming = persistentState.nwEngExperimentalFeatures + } + logEvent("endpoint independence", "Endpoint independence: $checked") + }, + onCheckedChange = { checked -> + endpointIndependence = checked + persistentState.endpointIndependence = checked + if (!checked) { + allowIncoming = false + persistentState.nwEngExperimentalFeatures = false + } else { + allowIncoming = persistentState.nwEngExperimentalFeatures + } + logEvent("endpoint independence", "Endpoint independence: $checked") + } + ) + + if (endpointIndependence) { + RethinkToggleListItem( + title = stringResource(R.string.settings_allow_incoming_wg_packets), + description = stringResource(R.string.settings_allow_incoming_wg_packets_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_allow_incoming_wg", + checked = allowIncoming, + onRowClick = { + val checked = !allowIncoming + allowIncoming = checked + persistentState.nwEngExperimentalFeatures = checked + logEvent("allow incoming", "Allow incoming WG packets: $checked") + }, + onCheckedChange = { checked -> + allowIncoming = checked + persistentState.nwEngExperimentalFeatures = checked + logEvent("allow incoming", "Allow incoming WG packets: $checked") + } + ) + } + + RethinkToggleListItem( + title = stringResource(R.string.settings_tcp_keep_alive), + description = stringResource(R.string.settings_tcp_keep_alive_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_tcp_keep_alive", + checked = tcpKeepAlive, + onRowClick = { + val checked = !tcpKeepAlive + tcpKeepAlive = checked + persistentState.tcpKeepAlive = checked + logEvent("tcp keep alive", "TCP keep alive: $checked") + }, + onCheckedChange = { checked -> + tcpKeepAlive = checked + persistentState.tcpKeepAlive = checked + logEvent("tcp keep alive", "TCP keep alive: $checked") + } + ) + + RethinkToggleListItem( + title = stringResource(R.string.settings_jumbo_packets), + description = stringResource(R.string.settings_jumbo_packets_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_jumbo_packets", + checked = useMaxMtu, + enabled = vpnPolicy != POLICY_FIXED && !persistentState.routeRethinkInRethink, + onRowClick = { + if (vpnPolicy != POLICY_FIXED && !persistentState.routeRethinkInRethink) { + val checked = !useMaxMtu + useMaxMtu = checked + persistentState.useMaxMtu = checked + logEvent("jumbo packets", "Use jumbo packets: $checked") + } + }, + onCheckedChange = { checked -> + useMaxMtu = checked + persistentState.useMaxMtu = checked + logEvent("jumbo packets", "Use jumbo packets: $checked") + } + ) + + if (isAtleastQ()) { + RethinkToggleListItem( + title = stringResource(R.string.settings_vpn_builder_metered), + description = stringResource(R.string.settings_vpn_builder_metered_desc), + icon = Icons.Filled.Tune, + position = CardPosition.Middle, + highlighted = activeFocusKey == "network_vpn_metered", + checked = tunnelMetered, + onRowClick = { + val checked = !tunnelMetered + tunnelMetered = checked + persistentState.setVpnBuilderToMetered = checked + logEvent("vpn metered", "VPN builder metered: $checked") + }, + onCheckedChange = { checked -> + tunnelMetered = checked + persistentState.setVpnBuilderToMetered = checked + logEvent("vpn metered", "VPN builder metered: $checked") + } + ) + } + + RethinkActionListItem( + title = stringResource(R.string.custom_lan_ip_title), + description = stringResource(R.string.custom_lan_ip_desc), + icon = Icons.Filled.Settings, + position = CardPosition.Last, + highlighted = activeFocusKey == "network_custom_lan_ip", + onClick = { showCustomLanIpSheet = true } + ) + } + } + + // Dial Timeout Slider + item { + RethinkListGroup { + Surface( + modifier = Modifier.fillMaxWidth(), + color = if (activeFocusKey == "network_dial_timeout") { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + shape = RoundedCornerShape(Dimensions.cornerRadiusLg) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(Dimensions.cardPadding)) { + Text( + text = stringResource(R.string.settings_dial_timeout), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = dialTimeoutDesc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(Dimensions.spacingSm)) + Slider( + value = dialTimeoutMin.toFloat(), + onValueChange = { value -> + dialTimeoutMin = value.toInt() + persistentState.dialTimeoutSec = dialTimeoutMin * SECONDS_PER_MINUTE + }, + valueRange = 0f..60f, + colors = androidx.compose.material3.SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + ) + } + } + } + } + } + } + + if (showCustomLanIpSheet) { + CustomLanIpSheet( + persistentState = persistentState, + onDismiss = { showCustomLanIpSheet = false } + ) + } + if (showReachabilitySheet) { + NetworkReachabilitySheet( + persistentState = persistentState, + onDismiss = { showReachabilitySheet = false } + ) + } +} + +@Composable +private fun DefaultDnsDialog( + persistentState: PersistentState, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + val options = Constants.DEFAULT_DNS_LIST + val checkedItem = options.firstOrNull { it.url == persistentState.defaultDnsUrl }?.let { options.indexOf(it) } ?: 0 + var selectedIndex by remember { mutableIntStateOf(checkedItem) } + + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.settings_default_dns_heading), + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + options.forEachIndexed { index, item -> + DialogRadioOptionRow( + title = item.name, + selected = selectedIndex == index, + onClick = { selectedIndex = index } + ) + } + } + }, + confirmText = stringResource(R.string.fapps_info_dialog_positive_btn), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + persistentState.defaultDnsUrl = options[selectedIndex].url + onConfirm() + onDismiss() + }, + onDismiss = onDismiss + ) +} + +@Composable +private fun VpnPolicyDialog( + persistentState: PersistentState, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit +) { + val conservativeTxt = stringResource(R.string.vpn_policy_fixed) + " " + stringResource(R.string.lbl_experimental) + val options = listOf( + NetworkPolicyOption( + stringResource(R.string.settings_ip_text_ipv46), + stringResource(R.string.vpn_policy_auto_desc) + ), + NetworkPolicyOption( + stringResource(R.string.vpn_policy_sensitive), + stringResource(R.string.vpn_policy_sensitive_desc) + ), + NetworkPolicyOption( + stringResource(R.string.vpn_policy_relaxed), + stringResource(R.string.vpn_policy_relaxed_desc) + ), + NetworkPolicyOption(conservativeTxt, stringResource(R.string.vpn_policy_fixed_desc)) + ) + var selectedIndex by remember { mutableIntStateOf(persistentState.vpnBuilderPolicy) } + + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.vpn_policy_title), + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + options.forEachIndexed { index, option -> + DialogRadioOptionRow( + title = option.title, + description = option.description, + selected = selectedIndex == index, + onClick = { selectedIndex = index } + ) + } + } + }, + confirmText = stringResource(R.string.fapps_info_dialog_positive_btn), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + onConfirm(selectedIndex) + onDismiss() + }, + onDismiss = onDismiss + ) +} + +@Composable +private fun IpProtocolDialog( + persistentState: PersistentState, + context: Context, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit +) { + val alwaysv46Txt = + stringResource(R.string.settings_ip_text_ipv4) + " & " + stringResource(R.string.settings_ip_text_ipv6) + " " + stringResource( + R.string.lbl_experimental + ) + val items = listOf( + stringResource(R.string.settings_ip_dialog_ipv4), + stringResource(R.string.settings_ip_dialog_ipv6), + alwaysv46Txt, + stringResource(R.string.settings_ip_dialog_ipv46) + ) + val chosenProtocol = persistentState.internetProtocolType + val checkedItem = when (chosenProtocol) { + InternetProtocol.ALWAYSv46.id -> IP_DIALOG_POS_ALWAYS_V46 + InternetProtocol.IPv46.id -> IP_DIALOG_POS_V46 + InternetProtocol.IPv4.id -> IP_DIALOG_POS_IPV4 + InternetProtocol.IPv6.id -> IP_DIALOG_POS_IPV6 + else -> IP_DIALOG_POS_IPV4 + } + var selectedIndex by remember { mutableIntStateOf(checkedItem) } + + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.settings_ip_dialog_title), + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items.forEachIndexed { index, label -> + DialogRadioOptionRow( + title = label, + selected = selectedIndex == index, + onClick = { selectedIndex = index } + ) + } + } + }, + confirmText = stringResource(R.string.fapps_info_dialog_positive_btn), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + val selectedItem = when (selectedIndex) { + IP_DIALOG_POS_V46 -> InternetProtocol.IPv46.id + IP_DIALOG_POS_ALWAYS_V46 -> InternetProtocol.ALWAYSv46.id + else -> selectedIndex + } + if (persistentState.internetProtocolType != selectedItem) { + val protocolType = InternetProtocol.getInternetProtocol(selectedItem) + persistentState.internetProtocolType = protocolType.id + if (protocolType.id == InternetProtocol.IPv6.id || + protocolType.id == InternetProtocol.IPv46.id || + protocolType.id == InternetProtocol.ALWAYSv46.id + ) { + persistentState.enableStabilityDependentSettings(context) + } + onConfirm(protocolType.id) + } + onDismiss() + }, + onDismiss = onDismiss + ) +} + +@Composable +private fun ConnectivityChecksDialog( + persistentState: PersistentState, + onDismiss: () -> Unit, + onConfirm: (Boolean) -> Unit +) { + val items = listOf( + stringResource(R.string.settings_app_list_default_app), + stringResource(R.string.settings_ip_text_ipv46), + stringResource(R.string.lbl_manual) + ) + val type = persistentState.performAutoNetworkConnectivityChecks + val enabled = persistentState.connectivityChecks + val checkedItem = if (!enabled) 0 else if (type) 1 else 2 + var selectedIndex by remember { mutableIntStateOf(checkedItem) } + + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.settings_connectivity_checks), + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items.forEachIndexed { index, label -> + DialogRadioOptionRow( + title = label, + selected = selectedIndex == index, + onClick = { selectedIndex = index } + ) + } + } + }, + confirmText = stringResource(R.string.fapps_info_dialog_positive_btn), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + when (selectedIndex) { + 0 -> { + persistentState.performAutoNetworkConnectivityChecks = true + persistentState.connectivityChecks = false + onConfirm(false) + } + + 1 -> { + persistentState.performAutoNetworkConnectivityChecks = true + persistentState.connectivityChecks = true + onConfirm(true) + } + + 2 -> { + persistentState.performAutoNetworkConnectivityChecks = false + persistentState.connectivityChecks = true + onConfirm(true) + } + } + onDismiss() + }, + onDismiss = onDismiss + ) +} + +@Composable +private fun DialogRadioOptionRow( + title: String, + selected: Boolean, + onClick: () -> Unit, + description: String? = null +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) { + RadioButton( + selected = selected, + onClick = onClick + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text(title, style = MaterialTheme.typography.bodyMedium) + if (!description.isNullOrBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/DetailedStatisticsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/DetailedStatisticsScreen.kt new file mode 100644 index 000000000..b9ab4e5ed --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/DetailedStatisticsScreen.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2024 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.compose.statistics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.data.SummaryStatisticsType +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.CompactEmptyState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.util.UIUtils.formatBytes +import com.celzero.bravedns.viewmodel.DetailedStatisticsViewModel +import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel.TimeCategory + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailedStatisticsScreen( + viewModel: DetailedStatisticsViewModel, + type: SummaryStatisticsType, + timeCategory: TimeCategory, + onBackClick: () -> Unit +) { + val pagingItems = when (type) { + SummaryStatisticsType.TOP_ACTIVE_CONNS -> viewModel.getAllActiveConns + SummaryStatisticsType.MOST_CONNECTED_APPS -> viewModel.getAllAllowedAppNetworkActivity + SummaryStatisticsType.MOST_BLOCKED_APPS -> viewModel.getAllBlockedAppNetworkActivity + SummaryStatisticsType.MOST_CONNECTED_ASN -> viewModel.getAllAllowedAsn + SummaryStatisticsType.MOST_BLOCKED_ASN -> viewModel.getAllBlockedAsn + SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> viewModel.getAllContactedDomains + SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> viewModel.getAllBlockedDomains + SummaryStatisticsType.MOST_CONTACTED_IPS -> viewModel.getAllContactedIps + SummaryStatisticsType.MOST_BLOCKED_IPS -> viewModel.getAllBlockedIps + SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> viewModel.getAllContactedCountries + }.collectAsLazyPagingItems() + + LaunchedEffect(type, timeCategory) { + viewModel.setData(type) + viewModel.timeCategoryChanged(timeCategory) + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val timeSubtitle = if (type == SummaryStatisticsType.TOP_ACTIVE_CONNS) null else getTimeCategoryText(timeCategory) + val density = LocalDensity.current + val bottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + DetailedStatisticsTopBar( + type = type, + subtitle = timeSubtitle, + itemCount = pagingItems.itemCount, + scrollBehavior = scrollBehavior, + onBackClick = onBackClick + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + when { + pagingItems.loadState.refresh is LoadState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + pagingItems.itemCount == 0 -> { + CompactEmptyState( + message = stringResource(R.string.blocklist_update_check_failure), + icon = Icons.Default.Info, + modifier = Modifier.align(Alignment.Center) + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacingLg + bottomInset + ), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + items(pagingItems.itemCount) { index -> + pagingItems[index]?.let { item -> + DetailedStatListItem( + item = item, + type = type, + position = cardPositionFor(index, pagingItems.itemCount - 1) + ) + } + } + + if (pagingItems.loadState.append is LoadState.Loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(Dimensions.spacingLg), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(Dimensions.iconSizeMd) + ) + } + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DetailedStatisticsTopBar( + type: SummaryStatisticsType, + subtitle: String?, + itemCount: Int, + scrollBehavior: TopAppBarScrollBehavior, + onBackClick: () -> Unit +) { + RethinkLargeTopBar( + title = stringResource(id = getTitleResId(type)), + subtitle = subtitle, + onBackClick = onBackClick, + scrollBehavior = scrollBehavior, + titleTextStyle = MaterialTheme.typography.headlineMedium, + actions = { + if (itemCount > 0) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusFull), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.padding(end = Dimensions.spacingSm) + ) { + Text( + text = itemCount.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + } + } + ) +} + +@Composable +private fun DetailedStatListItem( + item: AppConnection, + type: SummaryStatisticsType, + position: CardPosition +) { + val isCountryType = type == SummaryStatisticsType.MOST_CONTACTED_COUNTRIES + val appIconPainter = if (type.supportsAppIcon()) rememberStatisticsAppIconPainter(item.uid) else null + val hasTrueAppIcon = appIconPainter != null + val countryName = if (isCountryType) countryNameFromFlag(item.flag) else null + + val titleText = when { + item.appOrDnsName?.isNotBlank() == true -> item.appOrDnsName + item.ipAddress.isNotBlank() -> item.ipAddress + else -> stringResource(id = R.string.network_log_app_name_unknown) + } + + val metricText = buildString { + append(stringResource(id = R.string.summary_connections_count, item.count)) + item.totalBytes?.takeIf { it > 0L }?.let { + append(" \u00b7 ") + append(formatBytes(it)) + } + } + + val leadingContent: (@Composable () -> Unit)? = when { + isCountryType && item.flag.isNotBlank() -> { + { + Text( + text = item.flag, + style = MaterialTheme.typography.headlineSmall + ) + } + } + hasTrueAppIcon && appIconPainter != null -> { + { + androidx.compose.material3.Icon( + painter = appIconPainter, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(24.dp) + ) + } + } + else -> null + } + + RethinkListItem( + headline = if (isCountryType) metricText else titleText, + supporting = if (isCountryType) (countryName ?: titleText) else metricText, + leadingContent = leadingContent, + leadingIconPainter = if (leadingContent == null) { + if (isCountryType) { + painterResource(id = R.drawable.ic_flag_placeholder) + } else { + painterResource(id = R.drawable.ic_app_info) + } + } else { + null + }, + leadingIconTint = + when { + hasTrueAppIcon -> Color.Unspecified + isCountryType -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + leadingIconContainerColor = + when { + leadingContent != null -> Color.Transparent + isCountryType -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.32f) + else -> MaterialTheme.colorScheme.surfaceContainerHighest + }, + position = position, + showTrailingChevron = false, + onClick = null + ) +} + +private fun getTitleResId(type: SummaryStatisticsType): Int { + return when (type) { + SummaryStatisticsType.TOP_ACTIVE_CONNS -> R.string.top_active_conns + SummaryStatisticsType.MOST_CONNECTED_APPS -> R.string.ssv_app_network_activity_heading + SummaryStatisticsType.MOST_BLOCKED_APPS -> R.string.ssv_app_blocked_heading + SummaryStatisticsType.MOST_CONNECTED_ASN -> R.string.most_contacted_asn + SummaryStatisticsType.MOST_BLOCKED_ASN -> R.string.most_blocked_asn + SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> R.string.ssv_most_contacted_domain_heading + SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> R.string.ssv_most_blocked_domain_heading + SummaryStatisticsType.MOST_CONTACTED_IPS -> R.string.ssv_most_contacted_ips_heading + SummaryStatisticsType.MOST_BLOCKED_IPS -> R.string.ssv_most_blocked_ips_heading + SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> R.string.ssv_most_contacted_countries_heading + } +} + +@Composable +private fun getTimeCategoryText(timeCategory: TimeCategory): String { + val window = when (timeCategory) { + TimeCategory.ONE_HOUR -> stringResource(id = R.string.time_window_one_hour_short) + TimeCategory.TWENTY_FOUR_HOUR -> stringResource(id = R.string.time_window_twenty_four_hours_short) + TimeCategory.SEVEN_DAYS -> stringResource(id = R.string.time_window_seven_days_short) + } + return "${stringResource(id = R.string.lbl_last)} $window" +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsCountryUtils.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsCountryUtils.kt new file mode 100644 index 000000000..91aeeeac8 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsCountryUtils.kt @@ -0,0 +1,41 @@ +/* + * 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.compose.statistics + +import java.util.Locale + +private const val REGIONAL_INDICATOR_A = 0x1F1E6 +private const val LATIN_CAPITAL_A = 0x41 +private const val LATIN_CAPITAL_Z = 0x5A + +internal fun countryNameFromFlag(flag: String?): String? { + if (flag.isNullOrBlank()) return null + + val codepoints = flag.codePoints().toArray() + if (codepoints.size != 2) return null + + val first = codepoints[0] - REGIONAL_INDICATOR_A + LATIN_CAPITAL_A + val second = codepoints[1] - REGIONAL_INDICATOR_A + LATIN_CAPITAL_A + if (first !in LATIN_CAPITAL_A..LATIN_CAPITAL_Z || second !in LATIN_CAPITAL_A..LATIN_CAPITAL_Z) { + return null + } + + val countryCode = "${first.toChar()}${second.toChar()}" + val countryLocale = + runCatching { Locale.Builder().setRegion(countryCode).build() }.getOrNull() ?: return null + val displayName = countryLocale.getDisplayCountry(Locale.getDefault()) + return displayName.takeIf { it.isNotBlank() && !it.equals(countryCode, ignoreCase = true) } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsIconUtils.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsIconUtils.kt new file mode 100644 index 000000000..3bf22b1a6 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsIconUtils.kt @@ -0,0 +1,68 @@ +/* + * 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.compose.statistics + +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import com.celzero.bravedns.data.SummaryStatisticsType +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.ui.compose.rememberDrawablePainter +import com.celzero.bravedns.util.Utilities +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal fun SummaryStatisticsType.supportsAppIcon(): Boolean { + return this == SummaryStatisticsType.MOST_CONNECTED_APPS || + this == SummaryStatisticsType.MOST_BLOCKED_APPS +} + +@Composable +internal fun rememberStatisticsAppIconPainter(uid: Int): Painter? { + val context = LocalContext.current + val drawable by produceState(initialValue = null, uid) { + value = + withContext(Dispatchers.IO) { + val normalizedUid = FirewallManager.appId(uid, mainUserOnly = true) + val candidateUids = listOf(uid, normalizedUid).distinct() + + val packageName = + candidateUids.firstNotNullOfOrNull { candidate -> + FirewallManager.getPackageNameByUid(candidate) + } ?: candidateUids.firstNotNullOfOrNull { candidate -> + Utilities.getPackageInfoForUid(context, candidate)?.firstOrNull() + } + + if (packageName.isNullOrBlank()) { + null + } else { + try { + context.packageManager.getApplicationIcon(packageName) + } catch (_: PackageManager.NameNotFoundException) { + null + } catch (_: SecurityException) { + null + } + } + } + } + + return rememberDrawablePainter(drawable) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsSummaryItem.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsSummaryItem.kt new file mode 100644 index 000000000..0f659b1b8 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/StatisticsSummaryItem.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2024 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.compose.statistics + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.rememberDrawablePainter + +@Composable +fun StatisticsSummaryItem( + title: String, + subtitle: String?, + countText: String, + iconDrawable: Drawable?, + flagText: String?, + showProgress: Boolean, + progress: Float, + progressColor: Color, + showIndicator: Boolean, + onClick: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = onClick != null) { onClick?.invoke() } + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(32.dp), + contentAlignment = Alignment.Center + ) { + if (!flagText.isNullOrEmpty()) { + Text(text = flagText, fontSize = 22.sp) + } else { + val painter = rememberDrawablePainter(iconDrawable) + painter?.let { + Image( + painter = it, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (showProgress) { + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + color = progressColor, + trackColor = MaterialTheme.colorScheme.surface + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = countText, + style = MaterialTheme.typography.titleMedium + ) + + if (showIndicator) { + Spacer(modifier = Modifier.width(8.dp)) + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_right_arrow_white), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(top = 8.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f) + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/SummaryStatisticsScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/SummaryStatisticsScreen.kt new file mode 100644 index 000000000..a912ea48c --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/statistics/SummaryStatisticsScreen.kt @@ -0,0 +1,769 @@ +/* + * Copyright 2024 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.compose.statistics + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.produceState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Unspecified +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.data.DataUsageSummary +import com.celzero.bravedns.data.SummaryStatisticsType +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.compose.theme.CompactEmptyState +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkAnimatedSection +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkListItem +import com.celzero.bravedns.ui.compose.theme.RethinkTopBarLazyColumnScreen +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.ui.compose.theme.cardPositionFor +import com.celzero.bravedns.util.UIUtils.formatBytes +import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel +import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel.TimeCategory + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SummaryStatisticsScreen( + viewModel: SummaryStatisticsViewModel, + persistentState: PersistentState, + onSeeMoreClick: (SummaryStatisticsType) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + + val topActiveConns = viewModel.getTopActiveConns.collectAsLazyPagingItems() + val mostConnectedApps = viewModel.getAllowedAppNetworkActivity.collectAsLazyPagingItems() + val mostBlockedApps = viewModel.getBlockedAppNetworkActivity.collectAsLazyPagingItems() + val mostConnectedAsn = viewModel.getMostConnectedASN.collectAsLazyPagingItems() + val mostBlockedAsn = viewModel.getMostBlockedASN.collectAsLazyPagingItems() + val mostContactedDomains = viewModel.mcd.collectAsLazyPagingItems() + val mostBlockedDomains = viewModel.mbd.collectAsLazyPagingItems() + val mostContactedCountries = viewModel.getMostContactedCountries.collectAsLazyPagingItems() + val mostContactedIps = viewModel.getMostContactedIps.collectAsLazyPagingItems() + val mostBlockedIps = viewModel.getMostBlockedIps.collectAsLazyPagingItems() + + RethinkTopBarLazyColumnScreen( + title = stringResource(id = R.string.title_statistics), + containerColor = MaterialTheme.colorScheme.surface, + topBarTitleTextStyle = MaterialTheme.typography.headlineMedium, + listState = listState, + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacing3xl + ), + topBarActions = { + TimeCategorySelector( + selectedCategory = uiState.timeCategory, + onCategorySelected = { viewModel.timeCategoryChanged(it) } + ) + } + ) { + item { + RethinkAnimatedSection(index = 0) { + UsageOverviewCard(dataUsage = uiState.dataUsage) + } + } + + item { + RethinkAnimatedSection(index = 1) { + SummaryStatSection( + title = stringResource(id = R.string.ssv_app_network_activity_heading), + type = SummaryStatisticsType.MOST_CONNECTED_APPS, + pagingItems = mostConnectedApps, + accentColor = MaterialTheme.colorScheme.primary, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + } + + item { + RethinkAnimatedSection(index = 2) { + SummaryStatSection( + title = stringResource(id = R.string.ssv_app_blocked_heading), + type = SummaryStatisticsType.MOST_BLOCKED_APPS, + pagingItems = mostBlockedApps, + accentColor = MaterialTheme.colorScheme.error, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + } + + item { + RethinkAnimatedSection(index = 3) { + SummaryStatSection( + title = stringResource(id = R.string.ssv_most_contacted_countries_heading), + type = SummaryStatisticsType.MOST_CONTACTED_COUNTRIES, + pagingItems = mostContactedCountries, + accentColor = MaterialTheme.colorScheme.tertiary, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + } + + if (persistentState.downloadIpInfo) { + if (shouldShowOptionalSection(mostConnectedAsn)) { + item { + SummaryStatSection( + title = stringResource(id = R.string.most_contacted_asn), + type = SummaryStatisticsType.MOST_CONNECTED_ASN, + pagingItems = mostConnectedAsn, + accentColor = MaterialTheme.colorScheme.secondary, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + } + + if (shouldShowOptionalSection(mostBlockedAsn)) { + item { + SummaryStatSection( + title = stringResource(id = R.string.most_blocked_asn), + type = SummaryStatisticsType.MOST_BLOCKED_ASN, + pagingItems = mostBlockedAsn, + accentColor = MaterialTheme.colorScheme.error, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + } + } + + item { + SummaryStatSection( + title = stringResource(id = R.string.ssv_most_contacted_domain_heading), + type = SummaryStatisticsType.MOST_CONTACTED_DOMAINS, + pagingItems = mostContactedDomains, + accentColor = MaterialTheme.colorScheme.secondary, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + + item { + SummaryStatSection( + title = stringResource(id = R.string.ssv_most_blocked_domain_heading), + type = SummaryStatisticsType.MOST_BLOCKED_DOMAINS, + pagingItems = mostBlockedDomains, + accentColor = MaterialTheme.colorScheme.error, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + + item { + SummaryStatSection( + title = stringResource(id = R.string.ssv_most_contacted_ips_heading), + type = SummaryStatisticsType.MOST_CONTACTED_IPS, + pagingItems = mostContactedIps, + accentColor = MaterialTheme.colorScheme.secondary, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + + item { + SummaryStatSection( + title = stringResource(id = R.string.ssv_most_blocked_ips_heading), + type = SummaryStatisticsType.MOST_BLOCKED_IPS, + pagingItems = mostBlockedIps, + accentColor = MaterialTheme.colorScheme.error, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + + if (shouldShowOptionalSection(topActiveConns)) { + item { + SummaryStatSection( + title = stringResource(id = R.string.top_active_conns), + type = SummaryStatisticsType.TOP_ACTIVE_CONNS, + pagingItems = topActiveConns, + accentColor = MaterialTheme.colorScheme.primary, + onSeeMoreClick = onSeeMoreClick, + viewModel = viewModel, + refreshToken = uiState.timeCategory + ) + } + } + } +} + +@Composable +private fun UsageOverviewCard(dataUsage: DataUsageSummary) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(id = R.string.lbl_overall), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + UsageStatPill( + label = stringResource(id = R.string.lbl_download), + value = formatBytes(dataUsage.totalDownload), + modifier = Modifier.weight(1f), + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.44f), + valueColor = MaterialTheme.colorScheme.primary + ) + UsageStatPill( + label = stringResource(id = R.string.lbl_upload), + value = formatBytes(dataUsage.totalUpload), + modifier = Modifier.weight(1f), + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.42f), + valueColor = MaterialTheme.colorScheme.tertiary + ) + UsageStatPill( + label = stringResource(id = R.string.lbl_connections), + value = dataUsage.connectionsCount.toString(), + modifier = Modifier.weight(1f), + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + valueColor = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun UsageStatPill( + label: String, + value: String, + modifier: Modifier = Modifier, + containerColor: Color, + valueColor: Color +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(Dimensions.cornerRadiusLg), + color = containerColor + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = value, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = valueColor + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun TimeCategorySelector( + selectedCategory: TimeCategory, + onCategorySelected: (TimeCategory) -> Unit, + modifier: Modifier = Modifier +) { + val options = listOf( + TimeCategory.ONE_HOUR to timeCategoryShortLabel(TimeCategory.ONE_HOUR), + TimeCategory.TWENTY_FOUR_HOUR to timeCategoryShortLabel(TimeCategory.TWENTY_FOUR_HOUR), + TimeCategory.SEVEN_DAYS to timeCategoryShortLabel(TimeCategory.SEVEN_DAYS) + ) + val selectedIndex = options.indexOfFirst { it.first == selectedCategory } + + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + options.forEachIndexed { index, (category, label) -> + val selected = index == selectedIndex + ToggleButton( + checked = selected, + onCheckedChange = { isChecked -> + if (isChecked && selectedCategory != category) { + onCategorySelected(category) + } + }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.semantics { role = Role.RadioButton } + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } + } + } +} + +@Composable +private fun timeCategoryShortLabel(category: TimeCategory): String { + return when (category) { + TimeCategory.ONE_HOUR -> stringResource(id = R.string.time_window_one_hour_short) + TimeCategory.TWENTY_FOUR_HOUR -> stringResource(id = R.string.time_window_twenty_four_hours_short) + TimeCategory.SEVEN_DAYS -> stringResource(id = R.string.time_window_seven_days_short) + } +} + +@Composable +private fun SummaryStatSection( + title: String, + type: SummaryStatisticsType, + pagingItems: LazyPagingItems, + accentColor: Color, + onSeeMoreClick: (SummaryStatisticsType) -> Unit, + viewModel: SummaryStatisticsViewModel, + refreshToken: TimeCategory +) { + val isCountrySection = type == SummaryStatisticsType.MOST_CONTACTED_COUNTRIES + val refreshState = pagingItems.loadState.refresh + val isLoading = refreshState is LoadState.Loading && pagingItems.itemCount == 0 + val hasData = pagingItems.itemCount > 0 + val isEmpty = !hasData && !isLoading + var showAllInlineCountries by remember(type) { mutableStateOf(false) } + val showAllVisibilityState = remember(type) { MutableTransitionState(false) } + val snapshotItems: List = pagingItems.itemSnapshotList.items.filterNotNull() + val visibleItems = if (isCountrySection) snapshotItems.take(5) else snapshotItems + var expandedCountryFlag by remember(type) { mutableStateOf(null) } + + LaunchedEffect(showAllInlineCountries) { + showAllVisibilityState.targetState = showAllInlineCountries + } + + Column { + SectionHeader( + title = title, + color = accentColor, + actionLabel = null, + onAction = null + ) + + if (isLoading) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimensions.spacingLg), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(Dimensions.iconSizeMd)) + } + } + } else if (isEmpty) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cornerRadius4xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) + ) { + CompactEmptyState( + message = stringResource(id = R.string.lbl_no_logs), + modifier = Modifier.padding(vertical = Dimensions.spacingSm) + ) + } + } else { + val sizeSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing) + RethinkListGroup { + val baseItems = if (isCountrySection) snapshotItems.take(5) else visibleItems + val extraCountryItems = if (isCountrySection) snapshotItems.drop(5) else emptyList() + val isExtraBlockPresent = + isCountrySection && extraCountryItems.isNotEmpty() && + (showAllVisibilityState.currentState || showAllVisibilityState.targetState) + val visibleLastIndex = + if (isExtraBlockPresent) snapshotItems.lastIndex else baseItems.lastIndex + + val renderRow: @Composable (index: Int, item: AppConnection) -> Unit = { index, item -> + val metricText = item.totalBytes?.takeIf { it > 0L }?.let { formatBytes(it) } ?: item.count.toString() + val countryName = if (isCountrySection) countryNameFromFlag(item.flag) else null + val countryHeadline = when { + item.appOrDnsName?.isNotBlank() == true && item.flag.isNotBlank() -> + "${item.flag} ${item.appOrDnsName}" + item.appOrDnsName?.isNotBlank() == true -> item.appOrDnsName + item.flag.isNotBlank() -> item.flag + else -> stringResource(id = R.string.network_log_app_name_unknown) + } + val headline = if (isCountrySection) { + countryName ?: countryHeadline + } else { + item.appOrDnsName?.takeIf { it.isNotBlank() } ?: item.ipAddress + } + val supporting = buildString { + append(stringResource(id = R.string.summary_connections_count, item.count)) + item.totalBytes?.takeIf { it > 0L }?.let { + append(" \u00b7 ") + append(formatBytes(it)) + } + } + val appIconPainter = + if (type.supportsAppIcon()) { + rememberStatisticsAppIconPainter(item.uid) + } else { + null + } + val hasTrueAppIcon = appIconPainter != null + val fallbackPainter = + if (isCountrySection && item.flag.isBlank()) { + painterResource(id = R.drawable.ic_flag_placeholder) + } else if (isCountrySection) { + null + } else { + painterResource(id = R.drawable.ic_app_info) + } + val customLeadingContent: (@Composable () -> Unit)? = + when { + isCountrySection && item.flag.isNotBlank() -> { + { + Text( + text = item.flag, + style = MaterialTheme.typography.headlineMedium + ) + } + } + hasTrueAppIcon && appIconPainter != null -> { + { + Icon( + painter = appIconPainter, + contentDescription = null, + tint = Unspecified, + modifier = Modifier.size(24.dp) + ) + } + } + else -> null + } + val isExpanded = isCountrySection && expandedCountryFlag == item.flag + + RethinkListItem( + headline = headline.ifBlank { "-" }, + supporting = if (isCountrySection) null else supporting, + leadingContent = customLeadingContent, + leadingIconPainter = if (customLeadingContent == null) appIconPainter ?: fallbackPainter else null, + leadingIconTint = when { + hasTrueAppIcon -> Unspecified + isCountrySection -> MaterialTheme.colorScheme.tertiary + else -> accentColor + }, + leadingIconContainerColor = if (isCountrySection) { + MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.32f) + } else if (hasTrueAppIcon) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + accentColor.copy(alpha = 0.14f) + }, + position = cardPositionFor(index = index, lastIndex = visibleLastIndex), + showTrailingChevron = false, + onClick = if (isCountrySection) { + { + expandedCountryFlag = if (isExpanded) null else item.flag + } + } else { + null + }, + trailing = { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + text = metricText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = accentColor + ) + if (isCountrySection) { + Spacer(modifier = Modifier.size(Dimensions.spacingXs)) + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier + .size(18.dp) + .rotate(if (isExpanded) 90f else 0f) + ) + } + } + } + ) + + AnimatedVisibility( + visible = isExpanded && item.flag.isNotBlank(), + enter = expandVertically( + animationSpec = sizeSpec + ), + exit = shrinkVertically( + animationSpec = sizeSpec + ) + ) { + CountryBreakdown( + flag = item.flag, + accentColor = accentColor, + viewModel = viewModel, + refreshToken = refreshToken + ) + } + } + + baseItems.forEachIndexed { index, item -> + renderRow(index, item) + } + + if (isCountrySection && extraCountryItems.isNotEmpty()) { + AnimatedVisibility( + visibleState = showAllVisibilityState, + enter = expandVertically(animationSpec = sizeSpec), + exit = shrinkVertically(animationSpec = sizeSpec) + ) { + Column { + extraCountryItems.forEachIndexed { extraIndex, item -> + renderRow(baseItems.size + extraIndex, item) + } + } + } + } + } + + val shouldShowInlineCountriesToggle = + isCountrySection && (showAllInlineCountries || pagingItems.itemCount > 5) + val shouldShowDefaultSeeMore = !isCountrySection && pagingItems.itemCount > 5 + + if (shouldShowInlineCountriesToggle || shouldShowDefaultSeeMore) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = Dimensions.spacingSm), + horizontalArrangement = Arrangement.End + ) { + FilledTonalButton( + onClick = { + if (isCountrySection) { + val nextShowAll = !showAllInlineCountries + if (!nextShowAll && expandedCountryFlag != null) { + val isExpandedRowVisibleInCollapsed = + snapshotItems.take(5).any { it.flag == expandedCountryFlag } + if (!isExpandedRowVisibleInCollapsed) { + expandedCountryFlag = null + } + } + showAllInlineCountries = nextShowAll + } else { + onSeeMoreClick(type) + } + }, + shape = RoundedCornerShape(Dimensions.cornerRadiusPill) + ) { + Text( + text = if (isCountrySection && showAllInlineCountries) { + stringResource(id = R.string.ssv_see_less) + } else { + stringResource(id = R.string.ssv_see_more) + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + } +} + +private fun shouldShowOptionalSection(pagingItems: LazyPagingItems): Boolean { + val isLoading = pagingItems.loadState.refresh is LoadState.Loading + return isLoading || pagingItems.itemCount > 0 +} + +@Composable +private fun CountryBreakdown( + flag: String, + accentColor: Color, + viewModel: SummaryStatisticsViewModel, + refreshToken: TimeCategory +) { + val apps by produceState>(initialValue = emptyList(), flag, refreshToken) { + value = viewModel.getTopAppsForCountry(flag, limit = 4) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = Dimensions.spacingXs, bottom = Dimensions.spacingXs), + shape = RoundedCornerShape(Dimensions.cornerRadiusLg), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Column( + modifier = Modifier.padding(horizontal = Dimensions.spacingMd, vertical = Dimensions.spacingSm), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm) + ) { + Text( + text = stringResource(id = R.string.ssv_app_network_activity_heading), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = accentColor + ) + if (apps.isEmpty()) { + Text( + text = stringResource(id = R.string.lbl_no_logs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + apps.forEach { app -> + val iconPainter = rememberStatisticsAppIconPainter(app.uid) + val appName = app.appOrDnsName?.takeIf { it.isNotBlank() } + ?: stringResource(id = R.string.network_log_app_name_unknown) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(20.dp) + ) { + if (iconPainter != null) { + Icon( + painter = iconPainter, + contentDescription = null, + tint = Unspecified, + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_app_info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + Text( + text = appName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + } + Text( + text = app.count.toString(), + style = MaterialTheme.typography.labelSmall, + color = accentColor, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Color.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Color.kt new file mode 100644 index 000000000..b970e801c --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Color.kt @@ -0,0 +1,118 @@ +package com.celzero.bravedns.ui.compose.theme + +import androidx.compose.ui.graphics.Color + +// Light palette +val PrimaryLight = Color(0xFF804136) +val OnPrimaryLight = Color(0xFFFFFFFF) +val PrimaryContainerLight = Color(0xFFFFDBD3) +val OnPrimaryContainerLight = Color(0xFF32120C) +val SecondaryLight = Color(0xFF755A54) +val OnSecondaryLight = Color(0xFFFFFFFF) +val SecondaryContainerLight = Color(0xFFFFDBD3) +val OnSecondaryContainerLight = Color(0xFF2B1612) +val TertiaryLight = Color(0xFF6F5D2E) +val OnTertiaryLight = Color(0xFFFFFFFF) +val TertiaryContainerLight = Color(0xFFF9E1A6) +val OnTertiaryContainerLight = Color(0xFF251A00) +val BackgroundLight = Color(0xFFFFF8F6) +val OnBackgroundLight = Color(0xFF231A18) +val SurfaceLight = Color(0xFFFFF8F6) +val OnSurfaceLight = Color(0xFF231A18) +val SurfaceVariantLight = Color(0xFFF4DED9) +val OnSurfaceVariantLight = Color(0xFF54433F) +val OutlineLight = Color(0xFF86736E) +val ErrorLight = Color(0xFFBA1A1A) +val OnErrorLight = Color(0xFFFFFFFF) +val ErrorContainerLight = Color(0xFFFFDAD6) +val OnErrorContainerLight = Color(0xFF410002) + +// Dark palette +val PrimaryDark = Color(0xFFFFB4A3) +val OnPrimaryDark = Color(0xFF4C1E15) +val PrimaryContainerDark = Color(0xFF653429) +val OnPrimaryContainerDark = Color(0xFFFFDBD3) +val SecondaryDark = Color(0xFFE6BDB4) +val OnSecondaryDark = Color(0xFF432A25) +val SecondaryContainerDark = Color(0xFF5C3F39) +val OnSecondaryContainerDark = Color(0xFFFFDBD3) +val TertiaryDark = Color(0xFFDBC58C) +val OnTertiaryDark = Color(0xFF3D2F05) +val TertiaryContainerDark = Color(0xFF554519) +val OnTertiaryContainerDark = Color(0xFFF9E1A6) +val BackgroundDark = Color(0xFF1A1110) +val OnBackgroundDark = Color(0xFFF0DEDA) +val SurfaceDark = Color(0xFF1A1110) +val OnSurfaceDark = Color(0xFFF0DEDA) +val SurfaceVariantDark = Color(0xFF53433F) +val OnSurfaceVariantDark = Color(0xFFD8C2BC) +val OutlineDark = Color(0xFFA08C87) +val ErrorDark = Color(0xFFFFB4AB) +val OnErrorDark = Color(0xFF690005) +val ErrorContainerDark = Color(0xFF93000A) +val OnErrorContainerDark = Color(0xFFFFDAD6) + +// True Black palette +val PrimaryBlack = Color(0xFFFFB7A7) +val OnPrimaryBlack = Color(0xFF45170F) +val PrimaryContainerBlack = Color(0xFF5F2E24) +val OnPrimaryContainerBlack = Color(0xFFFFDBD3) +val SecondaryBlack = Color(0xFFE8BDB2) +val OnSecondaryBlack = Color(0xFF3E2722) +val SecondaryContainerBlack = Color(0xFF583C36) +val OnSecondaryContainerBlack = Color(0xFFFFDBD3) +val TertiaryBlack = Color(0xFFDCC58B) +val OnTertiaryBlack = Color(0xFF3A2D03) +val TertiaryContainerBlack = Color(0xFF514214) +val OnTertiaryContainerBlack = Color(0xFFF9E1A6) +val BackgroundBlack = Color(0xFF090607) +val OnBackgroundBlack = Color(0xFFF0DEDA) +val SurfaceBlack = Color(0xFF090607) +val OnSurfaceBlack = Color(0xFFF0DEDA) +val SurfaceVariantBlack = Color(0xFF4F3F3B) +val OnSurfaceVariantBlack = Color(0xFFD6C1BC) +val OutlineBlack = Color(0xFF9E8B86) +val ErrorBlack = Color(0xFFFFB4AB) +val OnErrorBlack = Color(0xFF690005) +val ErrorContainerBlack = Color(0xFF93000A) +val OnErrorContainerBlack = Color(0xFFFFDAD6) + +// Light Plus palette +val PrimaryLightPlus = Color(0xFF255FA6) +val OnPrimaryLightPlus = Color(0xFFFFFFFF) +val PrimaryContainerLightPlus = Color(0xFFD6E3FF) +val OnPrimaryContainerLightPlus = Color(0xFF001B3E) +val SecondaryLightPlus = Color(0xFF505F79) +val OnSecondaryLightPlus = Color(0xFFFFFFFF) +val SecondaryContainerLightPlus = Color(0xFFD8E3F8) +val OnSecondaryContainerLightPlus = Color(0xFF0C1C33) +val BackgroundLightPlus = Color(0xFFF8F9FF) +val OnBackgroundLightPlus = Color(0xFF1A1C21) +val SurfaceLightPlus = Color(0xFFF8F9FF) +val OnSurfaceLightPlus = Color(0xFF1A1C21) +val SurfaceVariantLightPlus = Color(0xFFE0E2EC) +val OnSurfaceVariantLightPlus = Color(0xFF43474E) +val OutlineLightPlus = Color(0xFF73777F) +val AccentGoodLightPlus = Color(0xFF006E2A) +val AccentBadLightPlus = Color(0xFFBA1A1A) + +// True Black Plus palette +val PrimaryBlackPlus = Color(0xFFA8C8FF) +val OnPrimaryBlackPlus = Color(0xFF003061) +val PrimaryContainerBlackPlus = Color(0xFF004786) +val OnPrimaryContainerBlackPlus = Color(0xFFD6E3FF) +val SecondaryBlackPlus = Color(0xFFBBC7DC) +val OnSecondaryBlackPlus = Color(0xFF253248) +val SecondaryContainerBlackPlus = Color(0xFF3B4960) +val OnSecondaryContainerBlackPlus = Color(0xFFD8E3F8) +val BackgroundBlackPlus = Color(0xFF0A0D14) +val OnBackgroundBlackPlus = Color(0xFFE3E8F3) +val SurfaceBlackPlus = Color(0xFF0A0D14) +val OnSurfaceBlackPlus = Color(0xFFE3E8F3) +val SurfaceVariantBlackPlus = Color(0xFF43474E) +val OnSurfaceVariantBlackPlus = Color(0xFFC3C6D0) +val OutlineBlackPlus = Color(0xFF8D9199) +val AccentGoodBlackPlus = Color(0xFF7ADD8E) +val AccentBadBlackPlus = Color(0xFFFFB4AB) + +val SeedColor = Color(0xFF255FA6) diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/DesignComponents.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/DesignComponents.kt new file mode 100644 index 000000000..463b8bdf9 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/DesignComponents.kt @@ -0,0 +1,1434 @@ +/* + * Copyright 2024 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. + */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.celzero.bravedns.ui.compose.theme + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions.Elevation +import com.celzero.bravedns.ui.compose.theme.Dimensions.Opacity + +// ==================== ANIMATED SECTIONS ==================== + +// ==================== CARDS ==================== + +/** + * Standard app card with M3 Expressive corner radius. + */ +@Composable +fun AppCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + colors: CardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + content: @Composable ColumnScope.() -> Unit +) { + if (onClick != null) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cardCornerRadius), + colors = colors, + elevation = CardDefaults.cardElevation(defaultElevation = Elevation.medium), + onClick = onClick, + content = content + ) + } else { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimensions.cardCornerRadius), + colors = colors, + elevation = CardDefaults.cardElevation(defaultElevation = Elevation.low), + content = content + ) + } +} + +// ==================== EMPTY STATES ==================== + +/** + * Compact empty state for inline use in lists/sections. + */ +@Composable +fun CompactEmptyState( + modifier: Modifier = Modifier, + message: String, + icon: ImageVector? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(Dimensions.spacingLg), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .size(Dimensions.iconSizeSm) + .alpha(Opacity.HINT), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(Dimensions.spacingSm)) + } + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.alpha(Opacity.HINT) + ) + } +} + +// ==================== LIST ITEMS ==================== + +/** + * Shared grid tile used by configure/about quick actions. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RethinkGridTile( + title: String, + iconRes: Int, + accentColor: Color, + shape: RoundedCornerShape, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + titleColor: Color = MaterialTheme.colorScheme.onSurface, + iconTint: Color = accentColor, + iconContainerColor: Color = accentColor.copy(alpha = 0.16f), + trailing: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null +) { + val iconShape = MaterialShapes.Sunny.toShape() + + if (onClick != null) { + Surface( + onClick = onClick, + shape = shape, + color = containerColor, + modifier = modifier + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = iconShape, + color = iconContainerColor, + modifier = Modifier.size(34.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(18.dp) + ) + } + } + + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = titleColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + trailing?.invoke() + } + } + } else { + Surface( + shape = shape, + color = containerColor, + modifier = modifier + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = iconShape, + color = iconContainerColor, + modifier = Modifier.size(34.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(18.dp) + ) + } + } + + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = titleColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + trailing?.invoke() + } + } + } +} + +// ==================== SECTION HEADERS ==================== + +enum class RethinkSecondaryActionStyle { TONAL, OUTLINED, TEXT } + +@Composable +fun RethinkSegmentedChoiceRow( + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, + modifier: Modifier = Modifier, + fillEqually: Boolean = false, + minHeight: Dp = 0.dp, + icon: (@Composable (option: T, selected: Boolean) -> Unit)? = null, + label: @Composable (option: T, selected: Boolean) -> Unit +) { + SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + val isSelected = option == selectedOption + SegmentedButton( + modifier = + Modifier + .then(if (fillEqually) Modifier.weight(1f) else Modifier) + .heightIn(min = minHeight), + selected = isSelected, + onClick = { if (!isSelected) onOptionSelected(option) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + icon = { + icon?.invoke(option, isSelected) + }, + label = { + label(option, isSelected) + } + ) + } + } +} + +@Composable +fun RethinkConnectedChoiceButtonRow( + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, + modifier: Modifier = Modifier, + buttonMinHeight: Dp = 0.dp, + icon: (@Composable (option: T, selected: Boolean) -> Unit)? = null, + label: @Composable (option: T, selected: Boolean) -> Unit +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) + ) { + options.forEachIndexed { index, option -> + val isSelected = option == selectedOption + ToggleButton( + checked = isSelected, + onCheckedChange = { checked -> + if (checked && !isSelected) { + onOptionSelected(option) + } + }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = + ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.82f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = null, + modifier = Modifier + .weight(1f) + .heightIn(min = buttonMinHeight) + .semantics { role = Role.RadioButton } + ) { + icon?.let { + it(option, isSelected) + Spacer(modifier = Modifier.size(ToggleButtonDefaults.IconSpacing)) + } + label(option, isSelected) + } + } + } +} + +@Composable +fun RethinkTwoOptionSegmentedRow( + leftLabel: String, + rightLabel: String, + leftSelected: Boolean, + onLeftClick: () -> Unit, + onRightClick: () -> Unit, + modifier: Modifier = Modifier, + minHeight: Dp = 0.dp +) { + RethinkSegmentedChoiceRow( + options = listOf(true, false), + selectedOption = leftSelected, + onOptionSelected = { selected -> + if (selected) onLeftClick() else onRightClick() + }, + modifier = modifier, + fillEqually = true, + minHeight = minHeight, + label = { selected, _ -> + Text(text = if (selected) leftLabel else rightLabel) + } + ) +} + +@Composable +fun RethinkDropdownSelector( + selectedText: String, + options: List, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + var expanded by remember { mutableStateOf(false) } + val containerColor = + if (enabled) { + MaterialTheme.colorScheme.surfaceContainerLow + } else { + MaterialTheme.colorScheme.surfaceVariant + } + + Box(modifier = modifier.fillMaxWidth()) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + color = containerColor, + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f) + ), + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { expanded = true } + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.spacingLg, + vertical = Dimensions.spacingMd + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + DropdownMenu( + expanded = expanded && enabled, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { + Text( + text = option, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onClick = { + expanded = false + onOptionSelected(option) + } + ) + } + } + } +} + +@Composable +fun RethinkConfirmDialog( + onDismissRequest: () -> Unit, + confirmText: String, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + message: String? = null, + dismissText: String? = null, + onDismiss: (() -> Unit)? = onDismissRequest, + isConfirmDestructive: Boolean = false, + confirmEnabled: Boolean = true, + dismissEnabled: Boolean = true, + text: (@Composable (() -> Unit))? = null +) { + val confirmColors = + if (isConfirmDestructive) { + ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + } else { + ButtonDefaults.textButtonColors() + } + + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = title?.let { { Text(text = it) } }, + text = text ?: message?.let { { Text(text = it) } }, + confirmButton = { + TextButton(onClick = onConfirm, colors = confirmColors, enabled = confirmEnabled) { + Text(text = confirmText) + } + }, + dismissButton = + if (dismissText != null && onDismiss != null) { + { + TextButton(onClick = onDismiss, enabled = dismissEnabled) { + Text(text = dismissText) + } + } + } else { + null + } + ) +} + +@Composable +fun RethinkMultiActionDialog( + onDismissRequest: () -> Unit, + title: String, + primaryText: String, + onPrimary: () -> Unit, + modifier: Modifier = Modifier, + message: String? = null, + secondaryText: String? = null, + onSecondary: (() -> Unit)? = null, + tertiaryText: String? = null, + onTertiary: (() -> Unit)? = null, + isPrimaryDestructive: Boolean = false, + text: (@Composable (() -> Unit))? = null +) { + val primaryColors = + if (isPrimaryDestructive) { + ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + } else { + ButtonDefaults.textButtonColors() + } + + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = text ?: message?.let { { Text(text = it) } }, + confirmButton = { + TextButton(onClick = onPrimary, colors = primaryColors) { + Text(text = primaryText) + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingXs)) { + if (secondaryText != null && onSecondary != null) { + TextButton(onClick = onSecondary) { + Text(text = secondaryText) + } + } + if (tertiaryText != null && onTertiary != null) { + TextButton(onClick = onTertiary) { + Text(text = tertiaryText) + } + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RethinkBottomSheetDragHandle( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(top = Dimensions.spacingXs, bottom = Dimensions.spacingSm), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .width(44.dp) + .height(5.dp), + shape = RoundedCornerShape(100), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.42f) + ) {} + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RethinkModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + dragHandle: @Composable (() -> Unit)? = { RethinkBottomSheetDragHandle() }, + containerColor: Color = Color.Unspecified, + contentPadding: PaddingValues = PaddingValues( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingSm + ), + verticalSpacing: Dp = Dimensions.spacingLg, + includeBottomSpacer: Boolean = true, + content: @Composable ColumnScope.() -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + dragHandle = dragHandle, + containerColor = + if (containerColor == Color.Unspecified) { + MaterialTheme.colorScheme.surface + } else { + containerColor + } + ) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(verticalSpacing) + ) { + content() + if (includeBottomSpacer) { + Spacer(modifier = Modifier.height(Dimensions.spacing2xl)) + } + } + } +} + +@Composable +fun RethinkBottomSheetCard( + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(Dimensions.cornerRadius3xl), + contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 12.dp), + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f)), + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingSm), + content = content + ) + } +} + +@Composable +fun RethinkBottomSheetActionRow( + modifier: Modifier = Modifier, + primaryText: String, + onPrimaryClick: () -> Unit, + secondaryText: String? = null, + onSecondaryClick: (() -> Unit)? = null, + primaryEnabled: Boolean = true, + secondaryEnabled: Boolean = true, + secondaryStyle: RethinkSecondaryActionStyle = RethinkSecondaryActionStyle.TONAL, + useCardContainer: Boolean = false +) { + val content: @Composable () -> Unit = { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.spacingMd, + vertical = Dimensions.spacingSmMd + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingSmMd), + verticalAlignment = Alignment.CenterVertically + ) { + if (secondaryText != null && onSecondaryClick != null) { + when (secondaryStyle) { + RethinkSecondaryActionStyle.TONAL -> { + FilledTonalButton( + modifier = Modifier.weight(1f), + onClick = onSecondaryClick, + enabled = secondaryEnabled + ) { + Text(text = secondaryText) + } + } + + RethinkSecondaryActionStyle.OUTLINED -> { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onSecondaryClick, + enabled = secondaryEnabled + ) { + Text(text = secondaryText) + } + } + + RethinkSecondaryActionStyle.TEXT -> { + TextButton( + modifier = Modifier.weight(1f), + onClick = onSecondaryClick, + enabled = secondaryEnabled + ) { + Text(text = secondaryText) + } + } + } + Button( + modifier = Modifier.weight(1f), + onClick = onPrimaryClick, + enabled = primaryEnabled + ) { + Text(text = primaryText) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onPrimaryClick, + enabled = primaryEnabled + ) { + Text(text = primaryText) + } + } + } + } + + if (useCardContainer) { + RethinkBottomSheetCard( + modifier = modifier, + shape = RoundedCornerShape(Dimensions.cornerRadiusXl), + contentPadding = PaddingValues(0.dp) + ) { + content() + } + } else { + Box( + modifier = + modifier + .fillMaxWidth() + .padding( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.spacingXs + ) + ) { + content() + } + } +} + +/** + * Section header — M3 Expressive style with more prominent styling and typography. + */ +@Composable +fun SectionHeader( + title: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurfaceVariant, + actionLabel: String? = null, + onAction: (() -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacingSm + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.1.sp, + color = color + ) + if (actionLabel != null && onAction != null) { + TextButton( + onClick = onAction, + contentPadding = PaddingValues(horizontal = Dimensions.spacingSm, vertical = 0.dp) + ) { + Text( + text = actionLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = color, + letterSpacing = 0.1.sp + ) + } + } + } +} + +/** + * Section header with optional subtitle for more context. + */ +@Composable +fun SectionHeaderWithSubtitle( + modifier: Modifier = Modifier, + title: String, + subtitle: String? = null, + color: Color = MaterialTheme.colorScheme.onSurfaceVariant, + actionLabel: String? = null, + onAction: (() -> Unit)? = null +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingMd, + bottom = Dimensions.spacingSm + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.1.sp, + color = color + ) + if (actionLabel != null && onAction != null) { + TextButton( + onClick = onAction, + contentPadding = PaddingValues( + horizontal = Dimensions.spacingSm, + vertical = 0.dp + ) + ) { + Text( + text = actionLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = color, + letterSpacing = 0.1.sp + ) + } + } + } + if (subtitle != null) { + Spacer(modifier = Modifier.height(Dimensions.spacingXs)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ) + } + } +} + +// ==================== BUTTONS ==================== + +/** + * Primary action button — M3 Expressive full-pill shape. + */ +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: ImageVector? = null +) { + Button( + onClick = onClick, + modifier = modifier.height(Dimensions.buttonHeight), + enabled = enabled, + shape = RoundedCornerShape(Dimensions.buttonCornerRadius), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(Dimensions.iconSizeSm) + ) + Spacer(modifier = Modifier.width(Dimensions.spacingSm)) + } + Text(text = text, fontWeight = FontWeight.SemiBold) + } +} + +/** + * Secondary action button — pill shape. + */ +@Composable +fun SecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: ImageVector? = null +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.height(Dimensions.buttonHeight), + enabled = enabled, + shape = RoundedCornerShape(Dimensions.buttonCornerRadius) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(Dimensions.iconSizeSm) + ) + Spacer(modifier = Modifier.width(Dimensions.spacingSm)) + } + Text(text = text) + } +} + +// ==================== STAT ITEMS ==================== + +/** + * Stat display for dashboard cards. + */ +@Composable +fun StatItem( + label: String, + value: String, + modifier: Modifier = Modifier, + isHighlighted: Boolean = false +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = if (isHighlighted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + letterSpacing = (-0.5).sp + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = Opacity.MEDIUM), + letterSpacing = 0.2.sp + ) + } +} + +// ==================== EXPRESSIVE LAYOUT PRIMITIVES ==================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RethinkTopBar( + title: String, + onBackClick: (() -> Unit)? = null, + scrollBehavior: TopAppBarScrollBehavior? = null, + containerColor: Color = MaterialTheme.colorScheme.surface, + scrolledContainerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + actions: @Composable RowScope.() -> Unit = {} +) { + TopAppBar( + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.15).sp + ) + }, + navigationIcon = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_navigate_back) + ) + } + } + }, + actions = actions, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + scrolledContainerColor = scrolledContainerColor, + navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RethinkLargeTopBar( + title: String, + subtitle: String? = null, + onBackClick: (() -> Unit)? = null, + scrollBehavior: TopAppBarScrollBehavior? = null, + containerColor: Color = MaterialTheme.colorScheme.surface, + scrolledContainerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + titleTextStyle: TextStyle = MaterialTheme.typography.titleLarge, + titleStartPadding: Dp = 0.dp, + titleLeading: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + LargeTopAppBar( + title = { + Row( + modifier = Modifier.padding(start = titleStartPadding), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + titleLeading?.invoke() + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = titleTextStyle, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.2).sp + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + }, + navigationIcon = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_navigate_back) + ) + } + } + }, + actions = actions, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + scrolledContainerColor = scrolledContainerColor, + navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) +} + +enum class CardPosition { + First, Middle, Last, Single +} + +fun cardPositionFor(index: Int, lastIndex: Int): CardPosition { + return when { + lastIndex <= 0 -> CardPosition.Single + index == 0 -> CardPosition.First + index == lastIndex -> CardPosition.Last + else -> CardPosition.Middle + } +} + +/** + * Expressive grouped list container — M3 Expressive card shape. + */ +@Composable +fun RethinkListGroup( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + content = content + ) +} + +/** + * Expressive list item with spring press animation and tinted icon container. + */ +@Composable +fun RethinkListItem( + modifier: Modifier = Modifier, + headline: String, + headlineAnnotated: AnnotatedString? = null, + supporting: String? = null, + supportingAnnotated: AnnotatedString? = null, + contentOffset: Modifier = Modifier, + leadingIcon: ImageVector? = null, + leadingIconPainter: Painter? = null, + leadingIconTint: Color = MaterialTheme.colorScheme.primary, + leadingIconContainerColor: Color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f), + leadingIconContainerShape: Shape = RoundedCornerShape(Dimensions.iconContainerRadius), + leadingContent: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + position: CardPosition = CardPosition.Middle, + enabled: Boolean = true, + highlighted: Boolean = false, + defaultContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + highlightContainerColor: Color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.34f), + showTrailingChevron: Boolean = false, + onClick: (() -> Unit)? = null +) { + val hapticFeedback = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.985f else 1f, + animationSpec = spring(dampingRatio = 0.62f, stiffness = Spring.StiffnessMediumLow), + label = "listItemScale" + ) + + val itemShape = when (position) { + CardPosition.Single -> RoundedCornerShape(Dimensions.cornerRadius3xl) + CardPosition.First -> RoundedCornerShape( + topStart = 22.dp, + topEnd = 22.dp, + bottomStart = 6.dp, + bottomEnd = 6.dp + ) + + CardPosition.Last -> RoundedCornerShape( + topStart = 6.dp, + topEnd = 6.dp, + bottomStart = 22.dp, + bottomEnd = 22.dp + ) + + CardPosition.Middle -> RoundedCornerShape(Dimensions.cornerRadiusSm) + } + + val contentAlpha = if (enabled) 1f else 0.5f + val highlightAlpha by animateFloatAsState( + targetValue = if (highlighted) 1f else 0f, + animationSpec = tween(durationMillis = 120), + label = "listItemHighlight" + ) + val containerColor = lerp(defaultContainerColor, highlightContainerColor, highlightAlpha) + val wrappedOnClick = + if (onClick != null) { + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + } else { + null + } + + Column( + modifier = modifier + .fillMaxWidth() + .scale(scale) + ) { + Surface( + shape = itemShape, + color = containerColor, + modifier = Modifier + .fillMaxWidth() + .padding( + top = if (position == CardPosition.First || position == CardPosition.Single) 0.dp else 2.dp + ), + onClick = wrappedOnClick ?: {}, + enabled = wrappedOnClick != null && enabled, + interactionSource = interactionSource + ) { + ListItem( + headlineContent = { + Text( + text = headlineAnnotated ?: AnnotatedString(headline), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), + letterSpacing = 0.sp, + modifier = contentOffset + ) + }, + supportingContent = + if (supporting != null || supportingAnnotated != null) { + { + Text( + text = supportingAnnotated ?: AnnotatedString(supporting.orEmpty()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.82f * contentAlpha), + letterSpacing = 0.sp, + modifier = contentOffset.then(Modifier.padding(top = Dimensions.spacingXs)) + ) + } + } else { + null + }, + leadingContent = { + if (leadingContent != null) { + leadingContent() + } else if (leadingIcon != null || leadingIconPainter != null) { + Surface( + shape = leadingIconContainerShape, + color = leadingIconContainerColor, + modifier = Modifier.size(Dimensions.iconContainerSm) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(Dimensions.iconContainerSm) + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = leadingIconTint.copy(alpha = contentAlpha), + modifier = Modifier.size(Dimensions.iconSizeSm) + ) + } else if (leadingIconPainter != null) { + if (leadingIconTint == Color.Unspecified) { + Image( + painter = leadingIconPainter, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } else { + Icon( + painter = leadingIconPainter, + contentDescription = null, + tint = leadingIconTint.copy(alpha = contentAlpha), + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } + }, + trailingContent = + when { + trailing != null -> trailing + showTrailingChevron && onClick != null && enabled -> { + { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + + else -> null + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + modifier = Modifier + .clip(itemShape) + .padding(horizontal = Dimensions.spacingNone, vertical = 1.dp) + ) + } + } +} + +@Composable +fun RethinkActionListItem( + title: String, + description: String? = null, + iconRes: Int? = null, + icon: ImageVector? = null, + iconPainter: Painter? = null, + leadingContent: @Composable (() -> Unit)? = null, + accentColor: Color = MaterialTheme.colorScheme.primary, + position: CardPosition = CardPosition.Middle, + highlighted: Boolean = false, + enabled: Boolean = true, + trailing: @Composable (() -> Unit)? = null, + onClick: () -> Unit +) { + val resolvedPainter = iconPainter ?: iconRes?.let { painterResource(id = it) } + RethinkListItem( + headline = title, + supporting = description, + leadingIcon = icon, + leadingIconPainter = resolvedPainter, + leadingContent = leadingContent, + leadingIconTint = accentColor, + leadingIconContainerColor = accentColor.copy(alpha = 0.14f), + position = position, + highlighted = highlighted, + enabled = enabled, + showTrailingChevron = false, + trailing = trailing, + onClick = onClick + ) +} + +@Composable +fun RethinkToggleListItem( + title: String, + description: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconRes: Int? = null, + icon: ImageVector? = null, + iconPainter: Painter? = null, + accentColor: Color = MaterialTheme.colorScheme.primary, + position: CardPosition = CardPosition.Middle, + highlighted: Boolean = false, + enabled: Boolean = true, + onRowClick: (() -> Unit)? = null, + trailingPrefix: @Composable (() -> Unit)? = null +) { + val hapticFeedback = LocalHapticFeedback.current + val onSwitchChange: (Boolean) -> Unit = { value -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onCheckedChange(value) + } + RethinkActionListItem( + title = title, + description = description, + iconRes = iconRes, + icon = icon, + iconPainter = iconPainter, + accentColor = accentColor, + position = position, + highlighted = highlighted, + enabled = enabled, + trailing = { + if (trailingPrefix == null) { + Switch( + checked = checked, + onCheckedChange = onSwitchChange, + enabled = enabled + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + trailingPrefix() + Switch( + checked = checked, + onCheckedChange = onSwitchChange, + enabled = enabled + ) + } + } + }, + onClick = onRowClick ?: { onCheckedChange(!checked) } + ) +} + +@Composable +fun RethinkRadioListItem( + title: String, + description: String? = null, + selected: Boolean, + onSelect: () -> Unit, + iconRes: Int? = null, + icon: ImageVector? = null, + iconPainter: Painter? = null, + accentColor: Color = MaterialTheme.colorScheme.primary, + position: CardPosition = CardPosition.Middle, + highlighted: Boolean = false, + onInfoClick: (() -> Unit)? = null +) { + val hapticFeedback = LocalHapticFeedback.current + RethinkActionListItem( + title = title, + description = description, + iconRes = iconRes, + icon = icon, + iconPainter = iconPainter, + accentColor = accentColor, + position = position, + highlighted = highlighted, + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (onInfoClick != null) { + IconButton( + onClick = onInfoClick, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource(id = R.string.lbl_info), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + modifier = Modifier.size(20.dp) + ) + } + } + RadioButton( + selected = selected, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSelect() + } + ) + } + }, + onClick = onSelect + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Dimensions.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Dimensions.kt new file mode 100644 index 000000000..9bc9109dc --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Dimensions.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2024 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.compose.theme + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Design system dimensions following Material 3 Expressive guidelines. + */ +object Dimensions { + // Spacing scale (4dp base) + val spacingNone: Dp = 0.dp + val spacingXs: Dp = 4.dp + val spacingGridTile: Dp = 2.dp + val spacingSm: Dp = 8.dp + val spacingMd: Dp = 12.dp + val spacingSmMd: Dp = 10.dp + val spacingLg: Dp = 16.dp + val spacingXl: Dp = 24.dp + val spacing2xl: Dp = 32.dp + val spacing3xl: Dp = 48.dp + + // M3 Expressive shape scale — larger, more expressive corner radii + val cornerRadius2xs: Dp = 3.dp + val cornerRadiusXs: Dp = 4.dp + val cornerRadiusSm: Dp = 6.dp + val cornerRadiusSmMd: Dp = 10.dp + val cornerRadiusMd: Dp = 12.dp + val cornerRadiusMdLg: Dp = 14.dp + val cornerRadiusLg: Dp = 16.dp + // Normalized medium-large card radii to one standard (20dp) + val cornerRadiusXl: Dp = 20.dp + val cornerRadius2xl: Dp = 20.dp + val cornerRadius3xl: Dp = 20.dp + val cornerRadius4xl: Dp = 24.dp + val cornerRadius5xl: Dp = 28.dp + val cornerRadiusPill: Dp = 50.dp + val cornerRadiusFull: Dp = 999.dp + + val cardCornerRadius: Dp = 20.dp // Cards, list groups + val cardCornerRadiusLarge: Dp = 28.dp // Section cards + val heroCornerRadius: Dp = 32.dp // Hero/protection cards + val chipCornerRadius: Dp = 50.dp // Full pill chips + val iconContainerRadius: Dp = 12.dp // Icon surface containers + val buttonCornerRadius: Dp = 50.dp // Full pill buttons (M3 Expressive default) + val buttonCornerRadiusLarge: Dp = 50.dp + + // Card padding + val cardPadding: Dp = 16.dp + val cardPaddingSm: Dp = 12.dp + + // Screen padding + val screenPaddingHorizontal: Dp = 16.dp + val screenPaddingVertical: Dp = 12.dp + + // Icon sizes + val iconSizeXs: Dp = 16.dp + val iconSizeSm: Dp = 20.dp + val iconSizeMd: Dp = 24.dp + val iconSizeLg: Dp = 32.dp + val iconSizeXl: Dp = 48.dp + + // Icon container sizes (tinted squircle containers) + val iconContainerSm: Dp = 32.dp + val iconContainerMd: Dp = 40.dp + val iconContainerLg: Dp = 48.dp + + // Touch targets (minimum 48dp for accessibility) + val touchTargetMin: Dp = 48.dp + val touchTargetSm: Dp = 44.dp + + // Button dimensions — M3 Expressive prefers taller buttons + val buttonHeight: Dp = 52.dp + val buttonHeightSm: Dp = 44.dp + val buttonHeightLg: Dp = 60.dp + + // List item dimensions + val listItemHeight: Dp = 64.dp + val listItemHeightSm: Dp = 56.dp + val listItemHeightLg: Dp = 72.dp + + // Progress indicators + val progressBarHeight: Dp = 10.dp + + // Active tab indicator + val tabIndicatorHeight: Dp = 4.dp + + // Divider + val dividerThickness: Dp = 0.5.dp + val dividerThicknessBold: Dp = 1.dp + + // Opacity values + object Opacity { + const val FULL: Float = 1f + const val HIGH: Float = 0.87f + const val MEDIUM: Float = 0.7f + const val DISABLED: Float = 0.38f + const val LOW: Float = 0.5f + const val HINT: Float = 0.6f + } + + // Elevation values + object Elevation { + val none: Dp = 0.dp + val low: Dp = 1.dp + val medium: Dp = 4.dp + val high: Dp = 8.dp + } +} + +/** + * Standard padding values for common use cases. + */ +object Paddings { + val none = PaddingValues(0.dp) + val xs = PaddingValues(Dimensions.spacingXs) + val sm = PaddingValues(Dimensions.spacingSm) + val md = PaddingValues(Dimensions.spacingMd) + val lg = PaddingValues(Dimensions.spacingLg) + val xl = PaddingValues(Dimensions.spacingXl) + + val screen = PaddingValues( + horizontal = Dimensions.screenPaddingHorizontal, + vertical = Dimensions.screenPaddingVertical + ) + + val card = PaddingValues(Dimensions.cardPadding) + val cardSm = PaddingValues(Dimensions.cardPaddingSm) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/FilterComponents.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/FilterComponents.kt new file mode 100644 index 000000000..6f59efa56 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/FilterComponents.kt @@ -0,0 +1,162 @@ +/* + * 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.compose.theme + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun RethinkSearchField( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: RoundedCornerShape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + textStyle: TextStyle = MaterialTheme.typography.bodyMedium, + leadingIconTint: Color = MaterialTheme.colorScheme.primary, + iconSize: Dp = Dimensions.iconSizeSm, + trailingIconSize: Dp = iconSize, + trailingIconButtonSize: Dp? = null, + clearQueryContentDescription: String? = null, + closeWhenEmptyContentDescription: String? = null, + onClearQuery: (() -> Unit)? = null, + onCloseWhenEmpty: (() -> Unit)? = null +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier, + singleLine = true, + enabled = enabled, + textStyle = textStyle, + placeholder = { Text(placeholder) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = leadingIconTint, + modifier = Modifier.size(iconSize) + ) + }, + trailingIcon = { + val action = + when { + query.isNotEmpty() -> onClearQuery ?: { onQueryChange("") } + onCloseWhenEmpty != null -> onCloseWhenEmpty + else -> null + } + if (action != null) { + val description = + if (query.isNotEmpty()) { + clearQueryContentDescription + } else { + closeWhenEmptyContentDescription + } + val buttonModifier = + if (trailingIconButtonSize != null) { + Modifier.size(trailingIconButtonSize) + } else { + Modifier + } + IconButton( + onClick = action, + modifier = buttonModifier + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = description, + modifier = Modifier.size(trailingIconSize) + ) + } + } + }, + shape = shape, + colors = TextFieldDefaults.colors( + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) +} + +@Composable +fun RethinkFilterChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(Dimensions.cornerRadiusMdLg), + selectedContainerColor: Color = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + labelColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + textStyle: TextStyle = MaterialTheme.typography.labelMedium, + leadingIcon: (@Composable () -> Unit)? = null, + selectedLeadingIconColor: Color = selectedLabelColor, + leadingIconColor: Color = labelColor, + border: BorderStroke? = null, + minHeight: Dp = 0.dp, + selectedLabelWeight: FontWeight = FontWeight.SemiBold, + defaultLabelWeight: FontWeight = FontWeight.Normal +) { + FilterChip( + modifier = modifier.heightIn(min = minHeight), + selected = selected, + onClick = onClick, + label = { + Text( + text = label, + fontWeight = if (selected) selectedLabelWeight else defaultLabelWeight, + style = textStyle + ) + }, + leadingIcon = leadingIcon, + shape = shape, + border = border, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = selectedContainerColor, + selectedLabelColor = selectedLabelColor, + containerColor = containerColor, + labelColor = labelColor, + selectedLeadingIconColor = selectedLeadingIconColor, + iconColor = leadingIconColor + ) + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Motion.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Motion.kt new file mode 100644 index 000000000..8e5642f2c --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Motion.kt @@ -0,0 +1,36 @@ +/* + * 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.compose.theme + +import android.animation.ValueAnimator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +fun rememberReducedMotion(): Boolean { + return remember { + runCatching { !ValueAnimator.areAnimatorsEnabled() }.getOrDefault(false) + } +} + +@Composable +@Suppress("UNUSED_PARAMETER") +fun RethinkAnimatedSection( + index: Int, + content: @Composable () -> Unit +) { + content() +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/RethinkScreenScaffold.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/RethinkScreenScaffold.kt new file mode 100644 index 000000000..977cb0ce1 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/RethinkScreenScaffold.kt @@ -0,0 +1,85 @@ +/* + * 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.compose.theme + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.TextStyle + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RethinkTopBarLazyColumnScreen( + title: String, + subtitle: String? = null, + onBackClick: (() -> Unit)? = null, + containerColor: Color = MaterialTheme.colorScheme.background, + topBarContainerColor: Color = MaterialTheme.colorScheme.surface, + topBarScrolledContainerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + topBarTitleTextStyle: TextStyle = MaterialTheme.typography.titleLarge, + listState: LazyListState? = null, + contentPadding: PaddingValues = + PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacing3xl + ), + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(Dimensions.spacingLg), + topBarActions: @Composable RowScope.() -> Unit = {}, + content: LazyListScope.() -> Unit +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val effectiveListState = listState ?: rememberLazyListState() + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = containerColor, + topBar = { + RethinkLargeTopBar( + title = title, + subtitle = subtitle, + onBackClick = onBackClick, + scrollBehavior = scrollBehavior, + containerColor = topBarContainerColor, + scrolledContainerColor = topBarScrolledContainerColor, + titleTextStyle = topBarTitleTextStyle, + actions = topBarActions + ) + } + ) { paddingValues -> + LazyColumn( + state = effectiveListState, + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentPadding = contentPadding, + verticalArrangement = verticalArrangement, + content = content + ) + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Theme.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Theme.kt new file mode 100644 index 000000000..d9035d020 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Theme.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2024 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.celzero.bravedns.ui.compose.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import com.celzero.bravedns.util.Themes +import com.materialkolor.dynamiccolor.ColorSpec +import com.materialkolor.rememberDynamicMaterialThemeState + +private val RethinkCoralSeed = Color(0xffFF5D73) +private val RethinkRoseSeed = Color(0xffEC4899) +private val RethinkTealSeed = Color(0xff14B8A6) +private val RethinkBlueSeed = Color(0xff3B82F6) +private val RethinkPurpleSeed = Color(0xffA855F7) +private val RethinkOrangeSeed = Color(0xffF97316) +private val RethinkGreenSeed = Color(0xff22C55E) +private val RethinkAmberSeed = Color(0xffF2B705) +private val RethinkCyanSeed = Color(0xff06B6D4) +private val RethinkIndigoSeed = Color(0xff6366F1) + +// M3 Expressive shape scale — generous corner radii throughout +val RethinkShapes = Shapes( + extraSmall = RoundedCornerShape(Dimensions.cornerRadiusSm), + small = RoundedCornerShape(Dimensions.cornerRadiusSmMd), + medium = RoundedCornerShape(Dimensions.cornerRadiusLg), + large = RoundedCornerShape(Dimensions.cornerRadius2xl), + extraLarge = RoundedCornerShape(Dimensions.heroCornerRadius) +) + +enum class RethinkColorPreset(val id: Int, val seedColor: Color?) { + AUTO(0, null), + DYNAMIC(1, null), + CORAL(2, RethinkCoralSeed), + ROSE(11, RethinkRoseSeed), + TEAL(3, RethinkTealSeed), + BLUE(4, RethinkBlueSeed), + PURPLE(5, RethinkPurpleSeed), + ORANGE(6, RethinkOrangeSeed), + GREEN(7, RethinkGreenSeed), + AMBER(8, RethinkAmberSeed), + CYAN(9, RethinkCyanSeed), + INDIGO(10, RethinkIndigoSeed); + + companion object { + fun fromId(id: Int): RethinkColorPreset { + return entries.firstOrNull { it.id == id } ?: AUTO + } + } +} + +@Composable +fun RethinkTheme( + themePreference: Int = Themes.SYSTEM_DEFAULT.id, + colorPreset: RethinkColorPreset = RethinkColorPreset.AUTO, + content: @Composable () -> Unit +) { + val resolvedThemePreference = Themes.resolveThemePreference( + isDarkThemeOn = androidx.compose.foundation.isSystemInDarkTheme(), + preference = themePreference + ) + val darkTheme = resolvedThemePreference == Themes.DARK.id || resolvedThemePreference == Themes.DARK_PLUS.id + + val useDynamicColor = when (colorPreset) { + RethinkColorPreset.AUTO -> Themes.useDynamicColor(themePreference) + RethinkColorPreset.DYNAMIC -> Themes.useDynamicColor(themePreference) + else -> false + } && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + val seedColor = colorPreset.seedColor ?: RethinkCoralSeed + + val colorScheme = when { + useDynamicColor -> { + val context = LocalContext.current + if (darkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } + + else -> + rememberDynamicMaterialThemeState( + seedColor = seedColor, + isDark = darkTheme, + specVersion = ColorSpec.SpecVersion.SPEC_2025, + ).colorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val windowInsetsController = WindowCompat.getInsetsController(window, view) + windowInsetsController.isAppearanceLightStatusBars = !darkTheme + windowInsetsController.isAppearanceLightNavigationBars = !darkTheme + } + } + + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = Typography, + shapes = RethinkShapes, + content = content + ) +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Type.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Type.kt new file mode 100644 index 000000000..52e5e9011 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/theme/Type.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2024 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.compose.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * M3 Expressive-tuned typography. + * + * Uses the system font family (Roboto / system sans-serif) but applies + * bolder weights, tighter letterSpacing on display sizes, and slightly + * larger labelLarge and titleMedium for improved readability on small + * phone screens. + */ +val Typography = Typography( + // Display + displayLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + // Headline + headlineLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + // Title + titleLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + // Body + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + // Label + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgCardComponents.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgCardComponents.kt new file mode 100644 index 000000000..561d87ad2 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgCardComponents.kt @@ -0,0 +1,69 @@ +/* + * 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.compose.wireguard + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.ui.compose.theme.Dimensions + +@Composable +internal fun WgCardSurface( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(Dimensions.cardCornerRadiusLarge), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)), + tonalElevation = 1.dp + ) { + content() + } +} + +@Composable +internal fun WgIconBadge(modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(Dimensions.cornerRadiusSmMd), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) + ) { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_wireguard_icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(20.dp) + ) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigDetailScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigDetailScreen.kt new file mode 100644 index 000000000..0e751c132 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigDetailScreen.kt @@ -0,0 +1,842 @@ +/* + * Copyright 2023 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.compose.wireguard + +import android.app.Activity +import android.text.format.DateUtils +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.asFlow +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.WgPeerRow +import com.celzero.bravedns.data.SsidItem +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.database.WgConfigFilesImmutable +import com.celzero.bravedns.net.doh.Transaction +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_OTHER_WG_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_FULL +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_WG_INVALID +import com.celzero.bravedns.service.WireguardManager.INVALID_CONF_ID +import com.celzero.bravedns.service.WireguardManager.WG_UPTIME_THRESHOLD +import com.celzero.bravedns.ui.compose.theme.CardPosition +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkActionListItem +import com.celzero.bravedns.ui.compose.theme.RethinkListGroup +import com.celzero.bravedns.ui.compose.theme.RethinkToggleListItem +import com.celzero.bravedns.ui.dialog.WgAddPeerDialog +import com.celzero.bravedns.ui.dialog.WgHopDialog +import com.celzero.bravedns.ui.dialog.WgIncludeAppsDialog +import com.celzero.bravedns.ui.dialog.WgSsidDialog +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.SectionHeader +import com.celzero.bravedns.util.SsidPermissionManager +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel +import com.celzero.bravedns.wireguard.Config +import com.celzero.bravedns.wireguard.Peer +import com.celzero.bravedns.wireguard.WgHopManager +import com.celzero.firestack.backend.RouterStats +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WgConfigDetailScreen( + configId: Int, + wgType: WgType, + persistentState: PersistentState, + eventLogger: EventLogger, + mappingViewModel: ProxyAppsMappingViewModel, + onEditConfig: (Int, WgType) -> Unit, + onBackClick: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val socks5VpnDisabledErrorText = stringResource(R.string.settings_socks5_vpn_disabled_error) + val wireguardEnabledFailureText = stringResource(R.string.wireguard_enabled_failure) + val configAddSuccessToast = stringResource(R.string.config_add_success_toast) + val lblSsidsText = stringResource(R.string.lbl_ssids) + + // State variables + var configFiles by remember { mutableStateOf(null) } + var config by remember { mutableStateOf(null) } + var peers by remember { mutableStateOf>(emptyList()) } + var statusText by remember { mutableStateOf("") } + var statusColor by remember { mutableStateOf(null) } + var catchAllEnabled by remember { mutableStateOf(false) } + var useMobileEnabled by remember { mutableStateOf(false) } + var ssidEnabled by remember { mutableStateOf(false) } + var ssids by remember { mutableStateOf>(emptyList()) } + var showInvalidConfigDialog by remember { mutableStateOf(false) } + var showDeleteInterfaceDialog by remember { mutableStateOf(false) } + var showSsidPermissionDialog by remember { mutableStateOf(false) } + var showAddPeerDialog by remember { mutableStateOf(false) } + var showHopDialog by remember { mutableStateOf(false) } + var hopDialogConfigs by remember { mutableStateOf>(emptyList()) } + var hopDialogSelectedId by remember { mutableStateOf(INVALID_CONF_ID) } + var showSsidDialog by remember { mutableStateOf(false) } + var ssidDialogCurrent by remember { mutableStateOf("") } + var showIncludeAppsDialog by remember { mutableStateOf(false) } + var includeAppsProxyId by remember { mutableStateOf("") } + var includeAppsProxyName by remember { mutableStateOf("") } + val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb() + val errorColor = MaterialTheme.colorScheme.error.toArgb() + val tertiaryColor = MaterialTheme.colorScheme.tertiary.toArgb() + val statusFailingText = + stringResource(id = R.string.status_failing).replaceFirstChar(Char::titlecase) + val statusDisabledText = + stringResource(id = R.string.lbl_disabled).replaceFirstChar(Char::titlecase) + val statusWaitingText = stringResource(id = R.string.status_waiting) + val statusTextById = mutableMapOf().apply { + for (status in UIUtils.ProxyStatus.entries) { + put( + status.id, + stringResource(id = UIUtils.getProxyStatusStringRes(status.id)).replaceFirstChar(Char::titlecase) + ) + } + } + val wireguardVersionTemplate = stringResource(id = R.string.about_version_install_source) + + val appsCount by mappingViewModel + .getAppCountById(ID_WG_BASE + configId) + .asFlow() + .collectAsState(initial = 0) + + // Refresh config on launch + LaunchedEffect(configId) { + val cfg = WireguardManager.getConfigById(configId) + val mapping = WireguardManager.getConfigFilesById(configId) + if (cfg == null || mapping == null) { + showInvalidConfigDialog = true + return@LaunchedEffect + } + config = cfg + configFiles = mapping + peers = cfg.getPeers() ?: emptyList() + catchAllEnabled = mapping.isCatchAll + useMobileEnabled = mapping.useOnlyOnMetered + ssidEnabled = mapping.ssidEnabled + ssids = SsidItem.parseStorageList(mapping.ssids) + } + + // Update status UI when config changes + LaunchedEffect(configFiles?.isActive, configFiles?.id) { + updateStatusUi( + id = configId, + persistentState = persistentState, + onSurfaceVariantColor = onSurfaceVariantColor, + errorColor = errorColor, + tertiaryColor = tertiaryColor, + statusTextById = statusTextById, + statusFailingText = statusFailingText, + statusDisabledText = statusDisabledText, + statusWaitingText = statusWaitingText, + wireguardVersionTemplate = wireguardVersionTemplate, + onStatusUpdate = { text, color -> + statusText = text + statusColor = color + } + ) + } + + // Helper functions + fun logEvent(msg: String, details: String) { + eventLogger.log( + type = EventType.PROXY_SWITCH, + severity = Severity.LOW, + message = msg, + source = EventSource.MANAGER, + userAction = true, + details = details + ) + } + + suspend fun refreshConfig() { + val cfg = WireguardManager.getConfigById(configId) + val mapping = WireguardManager.getConfigFilesById(configId) + if (cfg == null || mapping == null) { + showInvalidConfigDialog = true + return + } + config = cfg + configFiles = mapping + peers = cfg.getPeers() ?: emptyList() + catchAllEnabled = mapping.isCatchAll + useMobileEnabled = mapping.useOnlyOnMetered + ssidEnabled = mapping.ssidEnabled + ssids = SsidItem.parseStorageList(mapping.ssids) + } + + fun updateUseOnMobileNetwork(enabled: Boolean) { + useMobileEnabled = enabled + scope.launch(Dispatchers.IO) { + WireguardManager.updateUseOnMobileNetworkConfig(configId, enabled) + } + logEvent( + "WireGuard Use on Mobile Networks", + "User ${if (enabled) "enabled" else "disabled"} use on mobile networks for WireGuard config with id $configId" + ) + } + + fun updateCatchAll(enabled: Boolean) { + catchAllEnabled = enabled + scope.launch(Dispatchers.IO) { + if (!VpnController.hasTunnel()) { + withContext(Dispatchers.Main) { + catchAllEnabled = !enabled + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + socks5VpnDisabledErrorText, + Toast.LENGTH_LONG + ) + } + return@launch + } + + if (!WireguardManager.canEnableProxy()) { + withContext(Dispatchers.Main) { + catchAllEnabled = false + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_FULL + wireguardEnabledFailureText, + Toast.LENGTH_LONG + ) + } + return@launch + } + + if (WireguardManager.oneWireGuardEnabled()) { + withContext(Dispatchers.Main) { + catchAllEnabled = false + Utilities.showToastUiCentered( + context, + ERR_CODE_OTHER_WG_ACTIVE + wireguardEnabledFailureText, + Toast.LENGTH_LONG + ) + } + return@launch + } + + val cfg = WireguardManager.getConfigFilesById(configId) + if (cfg == null) { + withContext(Dispatchers.Main) { + catchAllEnabled = false + Utilities.showToastUiCentered( + context, + ERR_CODE_WG_INVALID + wireguardEnabledFailureText, + Toast.LENGTH_LONG + ) + } + return@launch + } + + WireguardManager.updateCatchAllConfig(configId, enabled) + logEvent( + "WireGuard Catch All apps", + "User ${if (enabled) "enabled" else "disabled"} catch all apps for WireGuard config with id $configId" + ) + } + } + + fun toggleSsid(enabled: Boolean) { + ssidEnabled = enabled + val activity = context as? Activity + if (activity == null || !SsidPermissionManager.hasRequiredPermissions(activity) || !SsidPermissionManager.isLocationEnabled( + activity + ) + ) { + ssidEnabled = false + showSsidPermissionDialog = true + return + } + scope.launch(Dispatchers.IO) { + WireguardManager.updateSsidEnabled(configId, enabled) + } + } + + fun openAppsDialog(proxyName: String) { + val proxyId = ID_WG_BASE + configId + includeAppsProxyId = proxyId + includeAppsProxyName = proxyName + showIncludeAppsDialog = true + } + + fun openHopDialog() { + scope.launch(Dispatchers.IO) { + val hopables = WgHopManager.getHopableWgs(configId) + val selectedId = convertStringIdToId(WgHopManager.getHop(configId)) + withContext(Dispatchers.Main) { + hopDialogConfigs = hopables + hopDialogSelectedId = selectedId + showHopDialog = true + } + } + } + + fun openSsidDialog() { + ssidDialogCurrent = WireguardManager.getConfigFilesById(configId)?.ssids.orEmpty() + showSsidDialog = true + } + + // Dialogs + if (showInvalidConfigDialog) { + RethinkConfirmDialog( + onDismissRequest = {}, + title = stringResource(R.string.lbl_wireguard), + message = stringResource(R.string.config_invalid_desc), + confirmText = stringResource(R.string.lbl_delete), + dismissText = stringResource(R.string.fapps_info_dialog_positive_btn), + onConfirm = { + showInvalidConfigDialog = false + WireguardManager.deleteConfig(configId) + }, + onDismiss = { + showInvalidConfigDialog = false + onBackClick() + }, + isConfirmDestructive = true + ) + } + + if (showDeleteInterfaceDialog) { + val delText = stringResource( + R.string.two_argument_space, + stringResource(R.string.config_delete_dialog_title), + stringResource(R.string.lbl_wireguard) + ) + RethinkConfirmDialog( + onDismissRequest = { showDeleteInterfaceDialog = false }, + title = delText, + message = stringResource(R.string.config_delete_dialog_desc), + confirmText = delText, + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + showDeleteInterfaceDialog = false + scope.launch(Dispatchers.IO) { + WireguardManager.deleteConfig(configId) + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + configAddSuccessToast, + Toast.LENGTH_SHORT + ) + onBackClick() + } + logEvent( + "Delete WireGuard config", + "User deleted WireGuard config with id $configId" + ) + } + }, + onDismiss = { showDeleteInterfaceDialog = false }, + isConfirmDestructive = true + ) + } + + if (showSsidPermissionDialog) { + RethinkConfirmDialog( + onDismissRequest = { showSsidPermissionDialog = false }, + title = stringResource(R.string.lbl_ssid), + message = SsidPermissionManager.getPermissionExplanation(context), + confirmText = stringResource(R.string.fapps_info_dialog_positive_btn), + dismissText = stringResource(R.string.lbl_cancel), + onConfirm = { + showSsidPermissionDialog = false + val activity = context as? Activity + if (activity != null) { + SsidPermissionManager.requestSsidPermissions(activity) + } + }, + onDismiss = { + showSsidPermissionDialog = false + scope.launch(Dispatchers.IO) { + WireguardManager.updateSsidEnabled(configId, false) + } + } + ) + } + + if (showAddPeerDialog) { + WgAddPeerDialog( + configId = configId, + wgPeer = null, + onDismiss = { + showAddPeerDialog = false + scope.launch { refreshConfig() } + } + ) + } + + if (showHopDialog) { + WgHopDialog( + srcId = configId, + hopables = hopDialogConfigs, + selectedId = hopDialogSelectedId, + onDismiss = { showHopDialog = false } + ) + } + + if (showSsidDialog) { + WgSsidDialog( + currentSsids = ssidDialogCurrent, + onSave = { newSsids -> + scope.launch(Dispatchers.IO) { + WireguardManager.updateSsids(configId, newSsids) + val cfg = WireguardManager.getConfigFilesById(configId) + withContext(Dispatchers.Main) { + ssids = SsidItem.parseStorageList(cfg?.ssids ?: "") + } + } + }, + onDismiss = { showSsidDialog = false } + ) + } + + if (showIncludeAppsDialog) { + WgIncludeAppsDialog( + viewModel = mappingViewModel, + proxyId = includeAppsProxyId, + proxyName = includeAppsProxyName, + onDismiss = { showIncludeAppsDialog = false } + ) + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.lbl_wireguard), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + if (config == null || configFiles == null) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(Dimensions.screenPaddingHorizontal), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Text(text = stringResource(id = R.string.config_invalid_desc)) + Button(onClick = onBackClick) { + Text(text = stringResource(id = R.string.fapps_info_dialog_positive_btn)) + } + } + return@Scaffold + } + + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = + PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + bottom = Dimensions.spacing3xl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + item { + WgConfigOverviewCard( + name = config?.getName().orEmpty(), + status = statusText.ifEmpty { + stringResource(R.string.single_argument_parenthesis, configId.toString()) + }, + statusColor = statusColor + ) + } + + if (wgType.isOneWg()) { + item { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Text( + text = stringResource(id = R.string.one_wg_apps_added), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp) + ) + } + } + } + + item { + SectionHeader(title = stringResource(id = R.string.lbl_configure)) + RethinkListGroup { + RethinkActionListItem( + title = stringResource(id = R.string.lbl_add), + description = stringResource(id = R.string.lbl_peer), + icon = Icons.Rounded.Add, + position = CardPosition.First, + onClick = { showAddPeerDialog = true } + ) + RethinkActionListItem( + title = stringResource(id = R.string.rt_edit_dialog_positive), + description = stringResource(id = R.string.lbl_wireguard), + icon = Icons.Rounded.Edit, + position = CardPosition.Middle, + onClick = { onEditConfig(configId, wgType) } + ) + RethinkActionListItem( + title = stringResource(id = R.string.lbl_delete), + description = stringResource(id = R.string.config_delete_dialog_title), + icon = Icons.Rounded.Delete, + accentColor = MaterialTheme.colorScheme.error, + position = CardPosition.Last, + onClick = { showDeleteInterfaceDialog = true } + ) + } + } + + if (wgType.isDefault()) { + item { + SectionHeader(title = stringResource(id = R.string.lbl_apps)) + RethinkListGroup { + RethinkActionListItem( + title = stringResource(R.string.add_remove_apps, appsCount.toString()), + icon = Icons.Rounded.Apps, + position = CardPosition.First, + onClick = { openAppsDialog(config?.getName().orEmpty()) }, + enabled = !catchAllEnabled + ) + RethinkActionListItem( + title = stringResource(id = R.string.hop_add_remove_title), + icon = Icons.AutoMirrored.Rounded.ArrowForward, + position = CardPosition.Last, + onClick = { openHopDialog() } + ) + } + } + } + + item { + SectionHeader(title = stringResource(id = R.string.lbl_advanced)) + RethinkListGroup { + RethinkToggleListItem( + title = stringResource(id = R.string.catch_all_wg_dialog_title), + description = stringResource(id = R.string.catch_all_wg_dialog_desc), + iconRes = R.drawable.ic_firewall_shield, + checked = catchAllEnabled, + onCheckedChange = { enabled -> updateCatchAll(enabled) }, + position = CardPosition.First, + ) + RethinkToggleListItem( + title = stringResource(id = R.string.wg_setting_use_on_mobile), + description = stringResource(id = R.string.wg_setting_use_on_mobile_desc), + iconRes = R.drawable.ic_meter_mobile_only, + checked = useMobileEnabled, + onCheckedChange = { enabled -> updateUseOnMobileNetwork(enabled) }, + position = if (SsidPermissionManager.isDeviceSupported(context)) CardPosition.Middle else CardPosition.Last, + ) + if (SsidPermissionManager.isDeviceSupported(context)) { + val ssidSubtitle = + buildString { + append( + stringResource(R.string.wg_setting_ssid_desc, lblSsidsText) + ) + if (ssids.isNotEmpty()) { + append("\n") + append(ssids.joinToString { it.name }) + } + } + RethinkToggleListItem( + title = stringResource(id = R.string.wg_setting_ssid_title), + description = ssidSubtitle, + iconRes = R.drawable.ic_firewall_wifi_on, + checked = ssidEnabled, + onCheckedChange = { enabled -> toggleSsid(enabled) }, + position = CardPosition.Middle, + ) + RethinkActionListItem( + title = stringResource(id = R.string.rt_edit_dialog_positive), + description = stringResource(id = R.string.lbl_ssids), + icon = Icons.Rounded.Edit, + position = CardPosition.Last, + onClick = { openSsidDialog() } + ) + } + } + } + + item { + SectionHeader(title = stringResource(id = R.string.lbl_peer)) + } + + items(peers) { peer -> + WgPeerRow( + context = context, + configId = configId, + wgPeer = peer, + onPeerChanged = { scope.launch { refreshConfig() } } + ) + } + } + } +} + +@Composable +private fun WgConfigOverviewCard(name: String, status: String, statusColor: Int?) { + WgCardSurface { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + WgIconBadge() + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = status, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (statusColor != null) { + Surface( + shape = RoundedCornerShape(Dimensions.cornerRadiusFull), + color = Color(statusColor).copy(alpha = 0.14f) + ) { + Text( + text = status, + style = MaterialTheme.typography.labelMedium, + color = Color(statusColor), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + } + } + } +} + +private suspend fun updateStatusUi( + id: Int, + persistentState: PersistentState, + onSurfaceVariantColor: Int, + errorColor: Int, + tertiaryColor: Int, + statusTextById: Map, + statusFailingText: String, + statusDisabledText: String, + statusWaitingText: String, + wireguardVersionTemplate: String, + onStatusUpdate: (String, Int?) -> Unit +) { + val mapping = WireguardManager.getConfigFilesById(id) + val cid = ID_WG_BASE + id + if (mapping?.isActive == true) { + val statusPair = VpnController.getProxyStatusById(cid) + val stats = VpnController.getProxyStats(cid) + val ps = UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } + val dnsStatusId = if (persistentState.splitDns) { + VpnController.getDnsStatus(cid) + } else { + null + } + withContext(Dispatchers.Main) { + if (dnsStatusId != null && isDnsError(dnsStatusId)) { + val text = statusFailingText + val color = errorColor + onStatusUpdate(text, color) + return@withContext + } + val text = + getStatusText( + statusTextById = statusTextById, + statusFailingText = statusFailingText, + statusWaitingText = statusWaitingText, + wireguardVersionTemplate = wireguardVersionTemplate, + status = ps, + handshakeTime = getHandshakeTime(stats).toString(), + stats = stats, + errMsg = statusPair.second + ) + val color = getStrokeColorForStatus(ps, stats, onSurfaceVariantColor, errorColor, tertiaryColor) + onStatusUpdate(text, color) + } + } else { + withContext(Dispatchers.Main) { + val text = statusDisabledText + onStatusUpdate(text, null) + } + } +} + +private fun isDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || + s == Transaction.Status.BAD_RESPONSE || + s == Transaction.Status.NO_RESPONSE || + s == Transaction.Status.SEND_FAIL || + s == Transaction.Status.CLIENT_ERROR || + s == Transaction.Status.INTERNAL_ERROR || + s == Transaction.Status.TRANSPORT_ERROR +} + +private fun getStatusText( + statusTextById: Map, + statusFailingText: String, + statusWaitingText: String, + wireguardVersionTemplate: String, + status: UIUtils.ProxyStatus?, + handshakeTime: String? = null, + stats: RouterStats?, + errMsg: String? = null +): String { + if (status == null) { + val txt = + if (!errMsg.isNullOrEmpty()) { + "$statusWaitingText ($errMsg)" + } else { + statusWaitingText + } + return txt.replaceFirstChar(Char::titlecase) + } + + if (status == UIUtils.ProxyStatus.TPU) { + return statusTextById.getValue(status.id) + } + + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { + return statusFailingText + } + + val baseText = statusTextById.getValue(status.id) + + return if (stats?.lastOK != 0L && handshakeTime != null) { + String.format(wireguardVersionTemplate, baseText, handshakeTime) + } else { + baseText + } +} + +private fun getHandshakeTime(stats: RouterStats?): CharSequence { + if (stats == null) { + return "" + } + if (stats.lastOK == 0L) { + return "" + } + val now = System.currentTimeMillis() + return DateUtils.getRelativeTimeSpanString( + stats.lastOK, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) +} + +private fun getStrokeColorForStatus( + status: UIUtils.ProxyStatus?, + stats: RouterStats?, + onSurfaceVariantColor: Int, + errorColor: Int, + tertiaryColor: Int +): Int { + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + val isFailing = now - since > WG_UPTIME_THRESHOLD && lastOk == 0L + return when (status) { + UIUtils.ProxyStatus.TOK -> + if (isFailing) { + onSurfaceVariantColor + } else { + tertiaryColor + } + + UIUtils.ProxyStatus.TUP, + UIUtils.ProxyStatus.TZZ, + UIUtils.ProxyStatus.TNT -> onSurfaceVariantColor + + else -> errorColor + } +} + +private fun convertStringIdToId(id: String): Int { + return try { + id.removePrefix(ID_WG_BASE).toIntOrNull() ?: INVALID_CONF_ID + } catch (_: Exception) { + INVALID_CONF_ID + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigEditorScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigEditorScreen.kt new file mode 100644 index 000000000..5f37a51dd --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgConfigEditorScreen.kt @@ -0,0 +1,500 @@ +/* + * Copyright 2024 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.compose.wireguard + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.WindowManager +import android.widget.Toast +import androidx.annotation.Keep +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.celzero.bravedns.R +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.util.UIUtils.clipboardCopy +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.tos +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.PrimaryButton +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.SecondaryButton +import com.celzero.bravedns.wireguard.WgInterface +import com.celzero.bravedns.wireguard.util.ErrorMessages +import com.celzero.firestack.backend.Backend +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val CLIPBOARD_PUBLIC_KEY_LBL = "Public Key" +private const val DEFAULT_MTU = "-1" + +// when dns is set to auto, the default dns is set to 1.1.1.1. this differs from official +// wireguard for android, because rethink requires a dns to be set in "Simple" mode +private const val DEFAULT_DNS = "1.1.1.1" +private const val DEFAULT_LISTEN_PORT = "0" + +@Keep +enum class WgType(val value: Int) { + DEFAULT(0), + ONE_WG(1); + + fun isOneWg() = this == ONE_WG + + fun isDefault() = this == DEFAULT + + companion object { + fun fromInt(value: Int): WgType { + return entries.firstOrNull { it.value == value } ?: DEFAULT + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WgConfigEditorScreen( + configId: Int, + wgType: WgType, + persistentState: PersistentState, + onBackClick: () -> Unit, + onSaveSuccess: () -> Unit +) { + val context = LocalContext.current + val activity = context.findActivity() + val scope = rememberCoroutineScope() + val publicKeyCopyToast = stringResource(R.string.public_key_copy_toast_msg) + val configAddSuccessToast = stringResource(R.string.config_add_success_toast) + + var interfaceName by remember { mutableStateOf("") } + var privateKey by remember { mutableStateOf("") } + var publicKey by remember { mutableStateOf("") } + var addresses by remember { mutableStateOf("") } + var listenPort by remember { mutableStateOf("") } + var dnsServers by remember { mutableStateOf("") } + var mtu by remember { mutableStateOf("") } + var amzProps by remember { mutableStateOf("") } + var showListenPortState by remember { mutableStateOf(false) } + + fun showListenPort(wgIface: WgInterface?): Boolean { + val isPresent = + wgIface?.listenPort?.isPresent == true && wgIface.listenPort.get() != 1 + val byType = wgType.isOneWg() || (!persistentState.randomizeListenPort && wgType.isDefault()) + return isPresent && byType + } + + // Load config on first composition + LaunchedEffect(configId) { + withContext(Dispatchers.IO) { + val cfg = WireguardManager.getConfigById(configId) + val iface = cfg?.getInterface() + + withContext(Dispatchers.Main) { + interfaceName = cfg?.getName().orEmpty() + privateKey = iface?.getKeyPair()?.getPrivateKey()?.base64()?.tos().orEmpty() + publicKey = iface?.getKeyPair()?.getPublicKey()?.base64()?.tos().orEmpty() + + var dns = iface?.dnsServers?.joinToString { it.hostAddress ?: "" } + val searchDomains = iface?.dnsSearchDomains?.joinToString { it } + dns = if (!searchDomains.isNullOrEmpty()) { + "$dns,$searchDomains" + } else { + dns + } + dnsServers = dns.orEmpty() + + addresses = if (iface?.getAddresses()?.isEmpty() != true) { + iface?.getAddresses()?.joinToString { it.toString() }.orEmpty() + } else { + "" + } + + showListenPortState = showListenPort(iface) + listenPort = if (showListenPortState) { + iface?.listenPort?.get()?.toString().orEmpty() + } else { + "" + } + + mtu = if (iface?.mtu?.isPresent == true) { + iface.mtu.get().toString() + } else { + "" + } + + amzProps = if (iface?.isAmnezia() == true) { + iface.getAmzProps().orEmpty() + } else { + "" + } + } + } + } + + fun generateKeys() { + val key = Backend.newWgPrivateKey() + privateKey = key.base64().toString() + publicKey = key.mult().base64().toString() + } + + fun copyPublicKey() { + clipboardCopy(context, publicKey, CLIPBOARD_PUBLIC_KEY_LBL) + Utilities.showToastUiCentered( + context, + publicKeyCopyToast, + Toast.LENGTH_SHORT + ) + } + + fun saveConfig() { + val name = interfaceName + val addr = addresses + val mtuValue = mtu.ifEmpty { DEFAULT_MTU } + val listenPortValue = listenPort.ifEmpty { DEFAULT_LISTEN_PORT } + val dns = dnsServers.ifEmpty { DEFAULT_DNS } + val privateKeyValue = privateKey + + scope.launch(Dispatchers.IO) { + try { + val newWgInterface = WgInterface.Builder() + .parsePrivateKey(privateKeyValue) + .parseAddresses(addr) + .parseListenPort(listenPortValue) + .parseDnsServers(dns) + .parseMtu(mtuValue) + .build() + + val result = WireguardManager.addOrUpdateInterface(configId, name, newWgInterface) + if (result != null) { + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered( + context, + configAddSuccessToast, + Toast.LENGTH_LONG + ) + onSaveSuccess() + } + } + } catch (e: Throwable) { + val error = ErrorMessages[context, e] + Napier.e("err while parsing wg interface: $error", e) + withContext(Dispatchers.Main) { + Utilities.showToastUiCentered(context, error, Toast.LENGTH_LONG) + } + } + } + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val density = LocalDensity.current + val editorFieldShape = RoundedCornerShape(Dimensions.cornerRadiusLg) + val imeBottomInset = with(density) { WindowInsets.ime.getBottom(density).toDp() } + val navBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + val actionBarBottomInset = when { + imeBottomInset > 0.dp -> imeBottomInset + navBottomInset > 0.dp -> navBottomInset + else -> 48.dp + } + DisposableEffect(activity) { + val window = activity?.window + val previousSoftInputMode = window?.attributes?.softInputMode + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + onDispose { + if (previousSoftInputMode != null) { + window.setSoftInputMode(previousSoftInputMode) + } + } + } + + Scaffold( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(R.string.lbl_configure), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + }, + bottomBar = { + EditorActionsBar( + bottomInset = actionBarBottomInset, + onCancelClick = onBackClick, + onSaveClick = { saveConfig() } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = Dimensions.spacingXl + ), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + item { + EditorSection( + title = stringResource(R.string.lbl_configure) + ) { + OutlinedTextField( + value = interfaceName, + onValueChange = { interfaceName = it }, + label = { Text(stringResource(R.string.cd_dns_crypt_dialog_name)) }, + modifier = Modifier.fillMaxWidth(), + shape = editorFieldShape, + singleLine = true + ) + + OutlinedTextField( + value = addresses, + onValueChange = { addresses = it }, + label = { Text(stringResource(R.string.lbl_addresses)) }, + modifier = Modifier.fillMaxWidth(), + shape = editorFieldShape, + minLines = 2 + ) + + OutlinedTextField( + value = dnsServers, + onValueChange = { dnsServers = it }, + label = { Text(stringResource(R.string.lbl_dns_servers)) }, + modifier = Modifier.fillMaxWidth(), + shape = editorFieldShape, + minLines = 2 + ) + } + } + item { EditorSectionDivider() } + + item { + EditorSection( + title = stringResource(R.string.setup_wireguard) + ) { + OutlinedTextField( + value = privateKey, + onValueChange = { privateKey = it }, + label = { Text(stringResource(R.string.lbl_private_key)) }, + modifier = Modifier.fillMaxWidth(), + shape = editorFieldShape, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + trailingIcon = { + IconButton(onClick = { generateKeys() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(id = R.string.cd_generate_keys) + ) + } + } + ) + + OutlinedTextField( + value = publicKey, + onValueChange = { }, + label = { Text(stringResource(R.string.lbl_public_key)) }, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = publicKey.isNotEmpty()) { + copyPublicKey() + }, + shape = editorFieldShape, + readOnly = true, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + trailingIcon = { + IconButton(onClick = { copyPublicKey() }, enabled = publicKey.isNotEmpty()) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = stringResource(id = R.string.cd_copy_public_key) + ) + } + } + ) + } + } + item { EditorSectionDivider() } + + item { + EditorSection( + title = stringResource(R.string.lbl_network) + ) { + if (showListenPortState) { + OutlinedTextField( + value = listenPort, + onValueChange = { listenPort = it }, + label = { Text(stringResource(R.string.lbl_listen_port)) }, + modifier = Modifier.fillMaxWidth(), + shape = editorFieldShape, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } + + OutlinedTextField( + value = mtu, + onValueChange = { mtu = it }, + label = { Text(stringResource(R.string.lbl_mtu)) }, + modifier = Modifier.fillMaxWidth(), + shape = editorFieldShape, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } + } + + if (amzProps.isNotEmpty()) { + item { EditorSectionDivider() } + item { + EditorSection( + title = stringResource(R.string.lbl_advanced) + ) { + Text( + text = amzProps, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +private tailrec fun Context.findActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } +} + +@Composable +private fun EditorSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + content() + } +} + +@Composable +private fun EditorSectionDivider() { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) +} + +@Composable +private fun EditorActionsBar( + bottomInset: Dp, + onCancelClick: () -> Unit, + onSaveClick: () -> Unit +) { + val actionBottomPadding = Dimensions.spacingSm + bottomInset + + Surface( + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 2.dp, + shadowElevation = 8.dp + ) { + Column { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = Dimensions.screenPaddingHorizontal, + end = Dimensions.screenPaddingHorizontal, + top = Dimensions.spacingSm, + bottom = actionBottomPadding + ), + horizontalArrangement = Arrangement.spacedBy(Dimensions.spacingMd) + ) { + SecondaryButton( + text = stringResource(R.string.lbl_cancel), + onClick = onCancelClick, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = stringResource(R.string.lbl_save), + onClick = onSaveClick, + modifier = Modifier.weight(1f) + ) + } + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgMainScreen.kt b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgMainScreen.kt new file mode 100644 index 000000000..3751f6e8e --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/ui/compose/wireguard/WgMainScreen.kt @@ -0,0 +1,558 @@ +/* + * 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.compose.wireguard + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SplitButtonDefaults +import androidx.compose.material3.SplitButtonLayout +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.asFlow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.OneWgConfigRow +import com.celzero.bravedns.adapter.WgConfigRow +import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.database.EventSource +import com.celzero.bravedns.database.EventType +import com.celzero.bravedns.database.Severity +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.ui.compose.theme.Dimensions +import com.celzero.bravedns.ui.compose.theme.RethinkConfirmDialog +import com.celzero.bravedns.ui.compose.theme.RethinkLargeTopBar +import com.celzero.bravedns.ui.compose.theme.RethinkTwoOptionSegmentedRow +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.WgConfigViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val EMPTY_ALPHA = 0.7f + +enum class WgTab { + ONE, + GENERAL +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun WgMainScreen( + wgConfigViewModel: WgConfigViewModel, + persistentState: PersistentState, + appConfig: AppConfig, + eventLogger: EventLogger, + onBackClick: () -> Unit, + onCreateClick: () -> Unit, + onImportClick: () -> Unit, + onQrScanClick: () -> Unit, + onConfigDetailClick: (Int, WgType) -> Unit +) { + val context = LocalContext.current + val density = LocalDensity.current + val navBarBottomInset = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + val splitFabBottomPadding = navBarBottomInset + 12.dp + val scope = rememberCoroutineScope() + val wireguardDisclaimerText = stringResource(R.string.wireguard_disclaimer) + val fallbackDnsLabel = stringResource(R.string.lbl_fallback) + val wireguardDisableFailure = stringResource(R.string.wireguard_disable_failure) + val wireguardDisableFailureRelay = stringResource(R.string.wireguard_disable_failure_relay) + + var selectedTab by remember { + mutableStateOf( + if (WireguardManager.isAnyWgActive() && !WireguardManager.oneWireGuardEnabled()) { + WgTab.GENERAL + } else { + WgTab.ONE + } + ) + } + var isFabMenuExpanded by remember { mutableStateOf(false) } + var showDisableDialog by remember { mutableStateOf(false) } + var disableDialogIsOneWgToggle by remember { mutableStateOf(false) } + var disclaimerText by remember { mutableStateOf("") } + + val configCount by wgConfigViewModel.configCount().asFlow() + .collectAsStateWithLifecycle(initialValue = 0) + val showEmpty = configCount == 0 + + // Observe connected DNS for non-OneWG mode + val connectedDns by appConfig.getConnectedDnsObservable().asFlow() + .collectAsStateWithLifecycle(initialValue = "") + + // DNS status listener callback - updates disclaimer text + fun updateDisclaimerText() { + val activeConfigs = WireguardManager.getActiveConfigs() + disclaimerText = if (WireguardManager.oneWireGuardEnabled()) { + val dnsName = activeConfigs.firstOrNull()?.getName() ?: "" + String.format(wireguardDisclaimerText, dnsName) + } else { + var dnsNames = connectedDns + if (persistentState.splitDns && activeConfigs.isNotEmpty()) { + if (dnsNames.isNotEmpty()) { + dnsNames += ", " + } + dnsNames += activeConfigs.joinToString(",") { it.getName() } + } + if (persistentState.useFallbackDnsToBypass) { + dnsNames += ", $fallbackDnsLabel" + } + String.format(wireguardDisclaimerText, dnsNames) + } + } + + // A counter to trigger disclaimer text refresh + var dnsRefreshTrigger by remember { mutableStateOf(0) } + + // Initialize and update disclaimer text when tab, DNS, or refresh trigger changes + LaunchedEffect(selectedTab, connectedDns, dnsRefreshTrigger) { + updateDisclaimerText() + } + + + + BackHandler(enabled = isFabMenuExpanded) { + isFabMenuExpanded = false + } + + if (showDisableDialog) { + DisableConfigsDialog( + onDismiss = { showDisableDialog = false }, + onConfirm = { + showDisableDialog = false + val isOneWgToggle = disableDialogIsOneWgToggle + scope.launch(Dispatchers.IO) { + if (WireguardManager.canDisableAllActiveConfigs()) { + WireguardManager.disableAllActiveConfigs() + logEvent( + eventLogger, + "Wireguard disable", + "all configs from toggle switch; isOneWgToggle: $isOneWgToggle" + ) + withContext(Dispatchers.Main) { + dnsRefreshTrigger++ + selectedTab = if (isOneWgToggle) WgTab.ONE else WgTab.GENERAL + } + } else { + val configs = WireguardManager.getActiveCatchAllConfig() + withContext(Dispatchers.Main) { + val msgText = if (configs.isNotEmpty()) { + wireguardDisableFailure + } else { + wireguardDisableFailureRelay + } + Utilities.showToastUiCentered( + context, + msgText, + Toast.LENGTH_LONG + ) + } + } + } + } + ) + } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RethinkLargeTopBar( + title = stringResource(id = R.string.lbl_wireguard), + onBackClick = onBackClick, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (showEmpty) { + EmptyState(bottomInset = navBarBottomInset) + } else { + Column( + modifier = Modifier + .fillMaxSize() + ) { + WireguardOverviewCard(disclaimerText = disclaimerText) + WgConfigContent( + selectedTab = selectedTab, + wgConfigViewModel = wgConfigViewModel, + eventLogger = eventLogger, + onDnsStatusChanged = { dnsRefreshTrigger++ }, + onConfigDetailClick = onConfigDetailClick, + bottomInset = navBarBottomInset, + modifier = Modifier.weight(1f), + onOneWgToggleClick = { + val activeConfigs = WireguardManager.getActiveConfigs() + val isAnyConfigActive = activeConfigs.isNotEmpty() + val isOneWgEnabled = WireguardManager.oneWireGuardEnabled() + if (isAnyConfigActive && !isOneWgEnabled) { + disableDialogIsOneWgToggle = true + showDisableDialog = true + } else { + selectedTab = WgTab.ONE + } + }, + onGeneralToggleClick = { + if (WireguardManager.oneWireGuardEnabled()) { + disableDialogIsOneWgToggle = false + showDisableDialog = true + } else { + selectedTab = WgTab.GENERAL + } + } + ) + } + } + + WgSplitFab( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(start = 16.dp, end = 16.dp, bottom = splitFabBottomPadding), + expanded = isFabMenuExpanded, + onExpandedChange = { isFabMenuExpanded = it }, + onCreateClick = { + isFabMenuExpanded = false + onCreateClick() + }, + onImportClick = { + isFabMenuExpanded = false + onImportClick() + }, + onQrClick = { + isFabMenuExpanded = false + onQrScanClick() + } + ) + } + } +} + +@Composable +private fun WgConfigContent( + selectedTab: WgTab, + wgConfigViewModel: WgConfigViewModel, + eventLogger: EventLogger, + onDnsStatusChanged: () -> Unit, + onConfigDetailClick: (Int, WgType) -> Unit, + bottomInset: Dp, + modifier: Modifier = Modifier, + onOneWgToggleClick: () -> Unit, + onGeneralToggleClick: () -> Unit +) { + Column(modifier = modifier.fillMaxSize()) { + ToggleRow( + selectedTab = selectedTab, + onOneWgClick = onOneWgToggleClick, + onGeneralClick = onGeneralToggleClick + ) + + Box(modifier = Modifier.fillMaxSize()) { + val items = wgConfigViewModel.interfaces.asFlow().collectAsLazyPagingItems() + val padding = PaddingValues(bottom = 84.dp + bottomInset) + + if (selectedTab == WgTab.GENERAL) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = padding + ) { + items(count = items.itemCount) { index -> + val item = items[index] ?: return@items + WgConfigRow( + config = item, + eventLogger = eventLogger, + onDnsStatusChanged = onDnsStatusChanged, + onConfigDetailClick = onConfigDetailClick + ) + } + } + } + + if (selectedTab == WgTab.ONE) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = padding + ) { + items(count = items.itemCount) { index -> + val item = items[index] ?: return@items + OneWgConfigRow( + config = item, + eventLogger = eventLogger, + onDnsStatusChanged = onDnsStatusChanged, + onConfigDetailClick = onConfigDetailClick + ) + } + } + } + } + } +} + +@Composable +private fun ToggleRow( + selectedTab: WgTab, + onOneWgClick: () -> Unit, + onGeneralClick: () -> Unit +) { + RethinkTwoOptionSegmentedRow( + leftLabel = stringResource(id = R.string.rt_list_simple_btn_txt), + rightLabel = stringResource(id = R.string.lbl_advanced), + leftSelected = selectedTab == WgTab.ONE, + onLeftClick = onOneWgClick, + onRightClick = onGeneralClick, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) +} + +@Composable +private fun DisableConfigsDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + RethinkConfirmDialog( + onDismissRequest = onDismiss, + title = stringResource(id = R.string.wireguard_disable_title), + message = stringResource(id = R.string.wireguard_disable_message), + confirmText = stringResource(id = R.string.always_on_dialog_positive), + dismissText = stringResource(id = R.string.lbl_cancel), + onConfirm = onConfirm, + onDismiss = onDismiss + ) +} + +@Composable +private fun EmptyState(bottomInset: Dp) { + Surface( + modifier = Modifier + .fillMaxSize() + .padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 24.dp + bottomInset), + shape = RoundedCornerShape(Dimensions.cornerRadius2xl), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.illustrations_no_record), + contentDescription = null, + modifier = Modifier.size(200.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.wireguard_no_config_msg), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun WireguardOverviewCard(disclaimerText: String) { + WgCardSurface(modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + WgIconBadge() + Text( + text = disclaimerText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun WgSplitFab( + modifier: Modifier, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onCreateClick: () -> Unit, + onImportClick: () -> Unit, + onQrClick: () -> Unit +) { + val createLabel = stringResource(R.string.lbl_create) + val moreDescription = stringResource(R.string.wireguard_fab_more_actions) + val expandedStateLabel = stringResource(R.string.wireguard_fab_expanded) + val collapsedStateLabel = stringResource(R.string.wireguard_fab_collapsed) + Box( + modifier = modifier.wrapContentSize() + ) { + SplitButtonLayout( + leadingButton = { + SplitButtonDefaults.LeadingButton( + onClick = onCreateClick, + modifier = Modifier + .height(56.dp) + .semantics { contentDescription = createLabel } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add), + modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize), + contentDescription = null + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(id = R.string.lbl_create)) + } + }, + trailingButton = { + Box { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above + ), + tooltip = { PlainTooltip { Text(text = moreDescription) } }, + state = rememberTooltipState() + ) { + SplitButtonDefaults.TrailingButton( + checked = expanded, + onCheckedChange = onExpandedChange, + modifier = Modifier + .height(56.dp) + .semantics { + stateDescription = if (expanded) { + expandedStateLabel + } else { + collapsedStateLabel + } + contentDescription = moreDescription + } + ) { + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "wgSplitArrowRotation" + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + modifier = Modifier + .size(SplitButtonDefaults.TrailingIconSize) + .graphicsLayer { rotationZ = rotation }, + contentDescription = null + ) + } + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(false) } + ) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.lbl_import)) }, + onClick = onImportClick, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_import_conf), + contentDescription = null + ) + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.lbl_qr_code)) }, + onClick = onQrClick, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_qr_code_scanner), + contentDescription = null + ) + } + ) + } + } + } + ) + } +} + +private fun logEvent(eventLogger: EventLogger, msg: String, details: String) { + eventLogger.log(EventType.PROXY_SWITCH, Severity.LOW, msg, EventSource.UI, false, details) +} diff --git a/app/src/main/java/com/celzero/bravedns/util/BioMetricType.kt b/app/src/main/java/com/celzero/bravedns/util/BioMetricType.kt new file mode 100644 index 000000000..ab1147d16 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/util/BioMetricType.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 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.util + +/** + * Enum representing biometric authentication types and their respective timeout durations. + */ +enum class BioMetricType(val action: Int, val mins: Long) { + OFF(0, 0L), + EVERY_TIME(1, 0L), + FIVE_MIN(2, 5L), + FIFTEEN_MIN(3, 15L); + + fun enabled(): Boolean { + return this != OFF + } + + companion object { + fun fromAction(value: Int): BioMetricType { + return entries.find { it.action == value } ?: OFF + } + + fun fromValue(value: Int): BioMetricType { + return fromAction(value) + } + } +} diff --git a/app/src/main/java/com/celzero/bravedns/util/OrbotHelper.kt b/app/src/main/java/com/celzero/bravedns/util/OrbotHelper.kt index aa179806a..6fd8a1652 100644 --- a/app/src/main/java/com/celzero/bravedns/util/OrbotHelper.kt +++ b/app/src/main/java/com/celzero/bravedns/util/OrbotHelper.kt @@ -40,7 +40,7 @@ import com.celzero.bravedns.database.ProxyEndpoint.Companion.DEFAULT_PROXY_TYPE import com.celzero.bravedns.receiver.NotificationActionReceiver import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.ProxyManager -import com.celzero.bravedns.ui.activity.AppLockActivity +import com.celzero.bravedns.ui.HomeScreenActivity import com.celzero.bravedns.util.Constants.Companion.HTTP_PROXY_PORT import com.celzero.bravedns.util.Constants.Companion.SOCKS_DEFAULT_PORT import com.celzero.bravedns.util.Utilities.getActivityPendingIntent @@ -174,12 +174,12 @@ class OrbotHelper( // link. return Intent( Intent.ACTION_VIEW, - context.resources.getString(R.string.orbot_download_link_website).toUri() + context.getString(R.string.orbot_download_link_website).toUri() ) } else { return Intent( Intent.ACTION_VIEW, - context.resources.getString(R.string.orbot_download_link_website).toUri() + context.getString(R.string.orbot_download_link_website).toUri() ) } } @@ -279,7 +279,7 @@ class OrbotHelper( val pendingIntent = getActivityPendingIntent( context, - Intent(context, AppLockActivity::class.java), + Intent(context, HomeScreenActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, mutable = false ) @@ -287,7 +287,7 @@ class OrbotHelper( var builder: NotificationCompat.Builder if (isAtleastO()) { val name: CharSequence = context.getString(R.string.notif_channel_proxy_failure) - val description = context.resources.getString(R.string.notif_channel_desc_proxy_failure) + val description = context.getString(R.string.notif_channel_desc_proxy_failure) val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(NOTIF_CHANNEL_ID_PROXY_ALERTS, name, importance) channel.description = description @@ -298,8 +298,8 @@ class OrbotHelper( builder = NotificationCompat.Builder(context, NOTIF_CHANNEL_ID_PROXY_ALERTS) } - val contentTitle = context.resources.getString(R.string.lbl_action_required) - val contentText = context.resources.getString(R.string.settings_orbot_notification_content) + val contentTitle = context.getString(R.string.lbl_action_required) + val contentText = context.getString(R.string.settings_orbot_notification_content) builder .setSmallIcon(R.drawable.ic_notification_icon) .setContentTitle(contentTitle) @@ -312,7 +312,7 @@ class OrbotHelper( val notificationAction: NotificationCompat.Action = NotificationCompat.Action( 0, - context.resources.getString(R.string.settings_orbot_notification_action), + context.getString(R.string.settings_orbot_notification_action), openIntent ) builder.addAction(notificationAction) diff --git a/app/src/main/java/com/celzero/bravedns/util/Themes.kt b/app/src/main/java/com/celzero/bravedns/util/Themes.kt index fa2d90bbe..ba1514b60 100644 --- a/app/src/main/java/com/celzero/bravedns/util/Themes.kt +++ b/app/src/main/java/com/celzero/bravedns/util/Themes.kt @@ -15,152 +15,58 @@ */ package com.celzero.bravedns.util -import android.view.Window -import androidx.core.view.WindowInsetsControllerCompat -import com.celzero.bravedns.R -import com.celzero.bravedns.util.Utilities.isAtleastQ -import com.celzero.bravedns.util.Utilities.isAtleastS - // Application themes enum enum class Themes(val id: Int) { SYSTEM_DEFAULT(0), LIGHT(1), DARK(2), - TRUE_BLACK(3), LIGHT_PLUS(4), - DARK_PLUS(5), - DARK_FROST(6); + DARK_PLUS(5); companion object { + private const val LEGACY_TRUE_BLACK_ID = 3 + private const val LEGACY_DARK_FROST_ID = 6 + fun getThemeCount(): Int { return entries.count() } fun getAvailableThemeCount(): Int { - return if (isAtleastS()) { - entries.count() - } else { - // Exclude LIGHT_FROST and DARK_FROST for pre-Android S devices - entries.count() - 2 - } + return entries.count() } fun isFrostTheme(id: Int): Boolean { - return id == DARK_FROST.id + return false } fun isThemeAvailable(id: Int): Boolean { - if (isFrostTheme(id)) { - return isAtleastS() - } - return true - } - - fun getTheme(id: Int): Int { return when (id) { - SYSTEM_DEFAULT.id -> 0 // system default - LIGHT.id -> R.style.AppThemeWhite - DARK.id -> R.style.AppTheme - TRUE_BLACK.id -> R.style.AppThemeTrueBlack - LIGHT_PLUS.id -> R.style.AppThemeWhitePlus - DARK_PLUS.id -> R.style.AppThemeTrueBlackPlus - DARK_FROST.id -> R.style.AppThemeTrueBlackFrost - else -> 0 + SYSTEM_DEFAULT.id, + LIGHT.id, + DARK.id, + LIGHT_PLUS.id, + DARK_PLUS.id, + LEGACY_TRUE_BLACK_ID, + LEGACY_DARK_FROST_ID -> true + else -> false } } - private fun getBottomSheetTheme(id: Int): Int { - return when (id) { - SYSTEM_DEFAULT.id -> 0 // system default - LIGHT.id -> R.style.BottomSheetDialogThemeWhite - DARK.id -> R.style.BottomSheetDialogTheme - TRUE_BLACK.id -> R.style.BottomSheetDialogThemeTrueBlack - LIGHT_PLUS.id -> R.style.BottomSheetDialogThemeWhitePlus - DARK_PLUS.id -> R.style.BottomSheetDialogThemeTrueBlackPlus - // for now use same as dark, can be changed later - DARK_FROST.id -> R.style.BottomSheetDialogThemeTrueBlack - else -> 0 + fun resolveThemePreference(isDarkThemeOn: Boolean, preference: Int): Int { + return when (preference) { + SYSTEM_DEFAULT.id -> if (isDarkThemeOn) DARK_PLUS.id else LIGHT_PLUS.id + LIGHT.id, + DARK.id, + LIGHT_PLUS.id, + DARK_PLUS.id -> preference + LEGACY_TRUE_BLACK_ID, + LEGACY_DARK_FROST_ID -> DARK_PLUS.id + else -> if (isDarkThemeOn) DARK_PLUS.id else LIGHT_PLUS.id } } - fun getCurrentTheme(isDarkThemeOn: Boolean, theme: Int): Int { - // If Frost themes are requested on pre-Android S, fallback to appropriate theme - if (isFrostTheme(theme) && !isAtleastS()) { - return getTheme(DARK_FROST.id) - } - - return if (theme == SYSTEM_DEFAULT.id) { - if (isDarkThemeOn) { - getTheme(TRUE_BLACK.id) - } else { - getTheme(LIGHT.id) - } - } else if (theme == LIGHT.id) { - getTheme(theme) - } else if (theme == DARK.id) { - getTheme(theme) - } else if (theme == LIGHT_PLUS.id) { - getTheme(theme) - } else if (theme == DARK_PLUS.id) { - getTheme(theme) - } else if (theme == DARK_FROST.id) { - getTheme(theme) - } else { - getTheme(TRUE_BLACK.id) - } - } - - fun getBottomSheetCurrentTheme(isDarkThemeOn: Boolean, theme: Int): Int { - // If Frost themes are requested on pre-Android S, fallback to appropriate theme - if (isFrostTheme(theme) && !isAtleastS()) { - return getBottomSheetTheme(TRUE_BLACK.id) - } - - return if (theme == SYSTEM_DEFAULT.id) { - if (isDarkThemeOn) { - getBottomSheetTheme(TRUE_BLACK.id) - } else { - getBottomSheetTheme(LIGHT.id) - } - } else if (theme == LIGHT.id) { - getBottomSheetTheme(theme) - } else if (theme == DARK.id) { - getBottomSheetTheme(theme) - } else if (theme == LIGHT_PLUS.id) { - getBottomSheetTheme(theme) - } else if (theme == DARK_PLUS.id) { - getBottomSheetTheme(theme) - } else if (theme == DARK_FROST.id) { - getBottomSheetTheme(theme) - } else { - getBottomSheetTheme(TRUE_BLACK.id) - } - } - - fun isBottomSheetLightTheme(isDarkThemeOn: Boolean, theme: Int): Boolean { - val resolved = getBottomSheetCurrentTheme(isDarkThemeOn, theme) - return resolved == R.style.BottomSheetDialogThemeWhite || - resolved == R.style.BottomSheetDialogThemeWhitePlus - } - - fun isActivityLightTheme(isDarkThemeOn: Boolean, theme: Int): Boolean { - val resolved = getCurrentTheme(isDarkThemeOn, theme) - return resolved == R.style.AppThemeWhite || - resolved == R.style.AppThemeWhitePlus - } - - fun applyBottomSheetSystemBarAppearance( - window: Window, - isDarkThemeOn: Boolean, - theme: Int - ) { - if (!isAtleastQ()) return - val isLight = isBottomSheetLightTheme(isDarkThemeOn, theme) - WindowInsetsControllerCompat(window, window.decorView).apply { - isAppearanceLightStatusBars = isLight - isAppearanceLightNavigationBars = isLight - } - window.isNavigationBarContrastEnforced = false + fun useDynamicColor(preference: Int): Boolean { + return preference == SYSTEM_DEFAULT.id } } } diff --git a/app/src/main/java/com/celzero/bravedns/viewmodel/CheckoutViewModel.kt b/app/src/main/java/com/celzero/bravedns/viewmodel/CheckoutViewModel.kt new file mode 100644 index 000000000..9007e61c7 --- /dev/null +++ b/app/src/main/java/com/celzero/bravedns/viewmodel/CheckoutViewModel.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2024 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 android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.celzero.bravedns.service.EncryptedFileManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.TcpProxyHelper +import com.celzero.bravedns.util.Utilities.togb +import com.celzero.bravedns.util.Utilities.togs +import com.celzero.bravedns.util.Utilities.tos +import com.celzero.firestack.backend.Backend +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.math.BigInteger +import java.security.SecureRandom +import java.util.UUID + +class CheckoutViewModel(val app: Application, private val persistentState: PersistentState) : AndroidViewModel(app) { + + private val _paymentStatus = MutableStateFlow(TcpProxyHelper.getTcpProxyPaymentStatus()) + val paymentStatus = _paymentStatus.asStateFlow() + + init { + // create and handle keys if not paid + if (TcpProxyHelper.getTcpProxyPaymentStatus().isNotPaid()) { + handleKeys() + } + + // Generate random tokens/uuids for logs/tracking as done in Activity + // UUID.randomUUID().toString().let { uuid -> Napier.d("UUID: $uuid") } + // generateRandomHexToken(TOKEN_LENGTH).let { token -> Napier.d("Token: $token") } + // These were just logged in Activity, maybe not needed? Keeping them for parity if they had side effects. + // They didn't seem to store anything? + // "generateRandomHexToken(TOKEN_LENGTH).let { token -> Napier.d("Token: $token") }" + } + + private fun handleKeys() { + viewModelScope.launch(Dispatchers.IO) { + try { + val key = TcpProxyHelper.getPublicKey() + Napier.d("Public Key: $key") + // if there is a key state, the msgOrExistingState (keyState.msg/keyState.v()) should not be empty + val keyGenerator = Backend.newPipKeyProvider(key.togb(), "".togs()) + val keyState = keyGenerator.blind() + // id: use 64 chars as account id + val id = keyState.msg.opaque()?.s ?: "" + val accountId = id.substring(0, 64) + // rest of the keyState values will never be used in kotlin + + // keyState.v() should be retrieved from the file system + Backend.newPipKeyStateFrom(keyState.v()) // retrieve the key state alone + + Napier.d("Blind: $keyState") + val path = + File( + app.filesDir.canonicalPath + + File.separator + + TcpProxyHelper.TCP_FOLDER_NAME + + File.separator + + TcpProxyHelper.PIP_KEY_FILE_NAME + ) + EncryptedFileManager.writeTcpConfig(app, keyState.v().tos() ?: "", TcpProxyHelper.PIP_KEY_FILE_NAME) + val content = EncryptedFileManager.read(app, path) + Napier.d("Content: $content") + } catch (e: Exception) { + Napier.e("err in handleKeys: ${e.message}", e) + } + } + } + + val paymentWorkInfo = WorkManager.getInstance(app).getWorkInfosByTagLiveData(TcpProxyHelper.PAYMENT_WORKER_TAG) + + fun updatePaymentStatusFromWorkInfo(workInfoList: List) { + val workInfo = workInfoList.firstOrNull() ?: return + if (workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING) { + _paymentStatus.value = TcpProxyHelper.getTcpProxyPaymentStatus() + } else if (workInfo.state == WorkInfo.State.SUCCEEDED) { + _paymentStatus.value = TcpProxyHelper.getTcpProxyPaymentStatus() + WorkManager.getInstance(app).pruneWork() + } else if (workInfo.state == WorkInfo.State.CANCELLED || workInfo.state == WorkInfo.State.FAILED) { + _paymentStatus.value = TcpProxyHelper.getTcpProxyPaymentStatus() + WorkManager.getInstance(app).pruneWork() + WorkManager.getInstance(app).cancelAllWorkByTag(TcpProxyHelper.PAYMENT_WORKER_TAG) + } + } + + fun startPayment() { + TcpProxyHelper.initiatePaymentVerification(app) + _paymentStatus.value = TcpProxyHelper.getTcpProxyPaymentStatus() + } +} diff --git a/app/src/main/java/com/celzero/bravedns/viewmodel/EventsViewModel.kt b/app/src/main/java/com/celzero/bravedns/viewmodel/EventsViewModel.kt index ef9905122..4c56338f5 100644 --- a/app/src/main/java/com/celzero/bravedns/viewmodel/EventsViewModel.kt +++ b/app/src/main/java/com/celzero/bravedns/viewmodel/EventsViewModel.kt @@ -15,24 +15,28 @@ */ package com.celzero.bravedns.viewmodel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import androidx.paging.liveData +import androidx.paging.cachedIn import com.celzero.bravedns.database.Event import com.celzero.bravedns.database.EventDao import com.celzero.bravedns.database.EventSource import com.celzero.bravedns.database.Severity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class EventsViewModel(private val eventDao: EventDao) : ViewModel() { - private val filteredQuery = MutableLiveData() - private val filteredSeverity = MutableLiveData() - private val filteredSources = MutableLiveData>() + private val _filterQuery = MutableStateFlow("") + private val _filterSeverity = MutableStateFlow(null) + private val _filterSources = MutableStateFlow>(emptySet()) companion object { private const val PAGE_SIZE = 50 @@ -46,26 +50,22 @@ class EventsViewModel(private val eventDao: EventDao) : ViewModel() { private var filterType: TopLevelFilter = TopLevelFilter.ALL - init { - filteredQuery.value = "" - filteredSeverity.value = null - filteredSources.value = emptySet() - } - - val eventsList: LiveData> = - filteredQuery.switchMap { query -> - filteredSeverity.switchMap { severity -> - filteredSources.switchMap { sources -> - getEventsLiveData(query, severity, sources) - } - } - } - - private fun getEventsLiveData( + val eventsFlow: kotlinx.coroutines.flow.Flow> = + kotlinx.coroutines.flow.combine( + _filterQuery, + _filterSeverity, + _filterSources + ) { query, severity, sources -> + Triple(query, severity, sources) + }.flatMapLatest { (query, severity, sources) -> + getEventsPagingData(query, severity, sources) + }.cachedIn(viewModelScope) + + private fun getEventsPagingData( query: String, severity: Severity?, sources: Set - ): LiveData> { + ): kotlinx.coroutines.flow.Flow> { return Pager( config = PagingConfig( pageSize = PAGE_SIZE, @@ -99,13 +99,13 @@ class EventsViewModel(private val eventDao: EventDao) : ViewModel() { } } } - ).liveData + ).flow } fun setFilter(query: String, sources: Set, severity: Severity?) { - filteredSources.value = sources - filteredSeverity.value = severity - filteredQuery.value = "%$query%" + _filterSources.value = sources + _filterSeverity.value = severity + _filterQuery.value = query } fun setFilterType(type: TopLevelFilter) { @@ -117,15 +117,15 @@ class EventsViewModel(private val eventDao: EventDao) : ViewModel() { } fun getCurrentSeverity(): Severity? { - return filteredSeverity.value + return _filterSeverity.value } fun getCurrentSources(): Set { - return filteredSources.value ?: emptySet() + return _filterSources.value } fun getCurrentQuery(): String { - return filteredQuery.value ?: "" + return _filterQuery.value } } diff --git a/app/src/main/java/com/celzero/bravedns/wireguard/Peer.kt b/app/src/main/java/com/celzero/bravedns/wireguard/Peer.kt index 33cdce4f3..04e338053 100644 --- a/app/src/main/java/com/celzero/bravedns/wireguard/Peer.kt +++ b/app/src/main/java/com/celzero/bravedns/wireguard/Peer.kt @@ -61,7 +61,7 @@ class Peer private constructor(builder: Builder) { unresolvedEndpoint = builder.unresolvedEndpoint persistentKeepalive = builder.persistentKeepalive preSharedKey = builder.preSharedKey - publicKey = Objects.requireNonNull(builder.publicKey, "Peers must have a public key")!! + publicKey = requireNotNull(builder.publicKey) { "Peers must have a public key" } } @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") @@ -232,7 +232,7 @@ class Peer private constructor(builder: Builder) { } fun addAllowedIps(allowedIps: Collection?): Builder { - this.allowedIps.addAll(allowedIps!!) + allowedIps?.let { this.allowedIps.addAll(it) } return this } diff --git a/app/src/main/res/drawable/ic_arrow_back_24.xml b/app/src/main/res/drawable/ic_arrow_back_24.xml index 7739802b5..a8a2da5c2 100644 --- a/app/src/main/res/drawable/ic_arrow_back_24.xml +++ b/app/src/main/res/drawable/ic_arrow_back_24.xml @@ -4,9 +4,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="?attr/colorOnPrimary"> + android:tint="?attr/primaryTextColor"> - diff --git a/app/src/main/res/drawable/ic_expand_more_24.xml b/app/src/main/res/drawable/ic_expand_more_24.xml index dcaf5a95d..b48d2b358 100644 --- a/app/src/main/res/drawable/ic_expand_more_24.xml +++ b/app/src/main/res/drawable/ic_expand_more_24.xml @@ -4,9 +4,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="?attr/colorOnSurface"> + android:tint="?attr/primaryTextColor"> - diff --git a/app/src/main/res/drawable/ic_firewall_welcome.xml b/app/src/main/res/drawable/ic_firewall_welcome.xml index 776d9a96d..1908afffa 100644 --- a/app/src/main/res/drawable/ic_firewall_welcome.xml +++ b/app/src/main/res/drawable/ic_firewall_welcome.xml @@ -12,7 +12,7 @@ diff --git a/app/src/main/res/drawable/ic_location_on_24.xml b/app/src/main/res/drawable/ic_location_on_24.xml index 1ba7ba311..c42e84d14 100644 --- a/app/src/main/res/drawable/ic_location_on_24.xml +++ b/app/src/main/res/drawable/ic_location_on_24.xml @@ -4,9 +4,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="?attr/colorOnPrimaryContainer"> + android:tint="?attr/primaryTextColor"> - diff --git a/app/src/main/res/drawable/ic_twitter.xml b/app/src/main/res/drawable/ic_twitter.xml index 97bb67c52..ad3b49135 100644 --- a/app/src/main/res/drawable/ic_twitter.xml +++ b/app/src/main/res/drawable/ic_twitter.xml @@ -4,11 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:fillColor="#FF000000" + android:pathData="M3,2h5.6l4.2,6L18,2h3l-6.8,7.9L22,22h-5.6l-4.6,-6.6L6.1,22H3.1l7.4,-8.5zM7.3,4.3H5.4l11.3,15.4h1.9z" /> diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 000000000..467b3efec --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/app/src/main/res/values-ab/strings.xml b/app/src/main/res/values-ab/strings.xml index df41f498c..6457cb1a6 100644 --- a/app/src/main/res/values-ab/strings.xml +++ b/app/src/main/res/values-ab/strings.xml @@ -340,7 +340,7 @@ Ашәахә: %1$s Cannot pause: Rethink not active Ашәақәа - ашҟа + Ашҟа Аҧсуа DNS Риthink Ҟьшьшәааԥш %1$s аҧызар » @@ -369,9 +369,9 @@ Апсихуақьтәи аполитика аԥхьа Аҧしшәлашь Абжьыра - апп + Апп Е-маил ашәашәалоит - sistema + Sistema Апликациазын адылхъва VPN Ашьапынеибамра А kebijakan ашәахәаҧшқәа @@ -1012,7 +1012,7 @@ Уры уиэит «Rethink» %1$s амш, урҭыр аусура аԥазалара $%2$s. Аразвилаз ашәшьары змоу аспонсорра узышьҭалаша ушьҭыршьымшәа? Ашәаҧшра ашәарҭадара аҿы дахьыжьымкәа адоменқәа рахь System DNS ахыҵхәаҧш Асистем DNS аиахъазлар ауеит адоменқәа рзы, еицҧаауамызқәа, мамзаргьы .lan, .internal, еиԥш. - фильтрациа + Фильтрациа Split DNS (ашәахcharт ашқәа) Аԥсшәа:\nАԥсоуп DNS ашәҟәы иахәаԥкны анхацәа рҟны ианызсыжьуа апрокси арҿы апрокси а DNS хылаԥшҩцәа рҟны. Уабыжьым иҟны аҧҵәаны аҿацәқәрақә зегьы рыцхраамла diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d562c8e6c..4280eaa76 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -101,7 +101,7 @@ تنزيل التنبيهات بدون اسم (%1$s) المنفذ غير صالح - vpn + VPN معلومات التطبيق عزل تم تمكين نظام dns الخصوصي @@ -600,7 +600,7 @@ الإعدادات يفشل خادم نظام أسماء النطاقات متوقف - إعادة التفكير + Re-Rethink يأخذ غفوة… التالي أهلاً وسهلاً @@ -645,7 +645,7 @@ احذف محلل DNSCrypt من التكوين المحفوظ. هناك خطأ ما ! إما أن خوادمنا معطلة أو لا يمكن إنشاء الاتصال. من فضلك حاول مرة أخرى بعد بعض من الوقت. - متصل بـ RethinkDNS + متصل بـ Re-RethinkDNS يجب أن يتراوح نطاق المنفذ بين 1024 و 65535 قوائم الحظر على الجهاز موجودة بالفعل في أحدث إصدار %1$s. هناك خطأ ما. حاول مرة أخرى في وقت لاحق! diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 32023d2da..62e264b5a 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1,6 +1,6 @@ - RethinkDNS + Re-Rethink DNS и Firewall с много възможности за персонализиране Rethink използва услуга за достъпност, за да открива и защитава фонови приложения. Rethink не събира и не продава никаква информация. Rethink е най-лесният начин да наблюдавате мрежовата активност, да заобикаляте интернет цензурата и приложенията за защитна стена на вашето устройство с Android. @@ -12,7 +12,7 @@ няма интернет защитен с http proxy и частен dns защитен със socks5 и частен dns - Свързан с RethinkDNS + Свързан с Re-RethinkDNS защитен с прокси не е защитен Свързан към защитната стена @@ -255,7 +255,7 @@ Новата версия на приложението е достъпна за изтегляне от уебсайта. Искате ли да продължите? защитен с wireguard и частен dns Копиран публичният ключ. - vpn + VPN TCP + HTTP прокси Изтриване на DNS прокси. Изтегляне на блокови списъци diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index bc5c7b012..6a308d7eb 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -116,7 +116,7 @@ অনুমতি - অ্যাক্সেসযোগ্যতা নেটওয়ার্ক লগ এখনই আপডেট করা যাচ্ছে না - vpn + VPN ইউআরএল কপি করা হয়েছে উপরের সবগুলো. ইনস্টল করুন @@ -290,7 +290,7 @@ সত্যিকারের কালো DNS / DNS + ফায়ারওয়াল অ্যাপের তথ্য পাওয়া যায়নি - RethinkDNS + Re-RethinkDNS GitHub-এ অবদান রাখুন [RethinkDNS]: কাস্টম সার্ভার URL @@ -354,7 +354,7 @@ বৈশিষ্ট্য প্রস্তাব করুন ডিভাইস লক হয়ে গেলে সমস্ত অ্যাপ ব্লক করুন IPv6 তে IPv4 ব্লক করুন (পরীক্ষামূলক) - RethinkDNS + Re-Rethink ঘুমানো… rethink পছন্দসমূহ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3b84b9985..bb3db7f9d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -3,7 +3,7 @@ O aplikaci Nastavení Statistiky - pravidla + Pravidla Konfigurovat záznamy síť @@ -130,7 +130,7 @@ Selhalo: používá se záložní DNS Připojeno k RethinkDNS a Firewallu Monitorujte síťovou aktivitu a firewallujte jakoukoli aplikaci nebo IP adresu. - Připojeno k RethinkDNS + Připojeno k Re-RethinkDNS Vyberte mód. Aplikace DNS + Firewall @@ -185,7 +185,7 @@ Žádné spustit Nepodařilo se získat crash logy. Chcete chybu nahlásit ručně? - odkazy + Odkazy Navštívit rethinkdns.com %1$sms Obcházeno @@ -277,7 +277,7 @@ Firewall DNS a Firewall (výchozí) VPN - vpn + VPN IP adresa zkopírována. URL zkopírována Veřejný klíč zkopírován. @@ -426,8 +426,8 @@ Optimalizace baterie Vypnout %1$s %1$s může vést ke ztrátě konektivity. Změňte nastavení, abyste Rethinku povolili používat data a další prostředky na pozadí. - pravidla - typ + Pravidla + Typ Jiné DNS Rethink DNS Blokovací seznamy v zařízení @@ -453,7 +453,7 @@ Zobrazit FAQ Přečíst dokumentaci Přečíst zásady ochrany soukromí - kontakt + Kontakt VPN profil Nastavení oznámení Sledujte nás na Twitteru @@ -552,7 +552,7 @@ Izolovat vybrané aplikace? Zobrazuji všechny aplikace Nebyla nalezena žádná firewallová pravidla - znovu načíst + Znovu načíst Neznámé Nepojmenováno (%1$s) Chyba při připojování k DNSCrypt serveru. @@ -699,14 +699,14 @@ Obnovení se nezdařilo Nelze provést obnovu. Zkusit znovu? Záloha dokončena - nejčastěji povolované aplikace - nejčastěji blokované aplikace - nejčastěji navštěvované domény - nejčastěji kontaktované země - nejčastěji blokované domény - nejčastěji kontaktované IP - nejčastěji blokované IP - nejčastěji blokované země + Nejčastěji povolované aplikace + Nejčastěji blokované aplikace + Nejčastěji navštěvované domény + Nejčastěji kontaktované země + Nejčastěji blokované domény + Nejčastěji kontaktované IP + Nejčastěji blokované IP + Nejčastěji blokované země Zobrazit vše Neplatný klíč Neznámý atribut @@ -785,7 +785,7 @@ Povolit RDNS+ Sponzorovat Napište nám e-mail - systém + Systém Info o aplikaci Interní Externí @@ -853,7 +853,7 @@ Záloha Orbot %1$sms - aplikace + Aplikace Poznámka Obcházet aplikaci pro všechny proxy Tato aplikace již obchází proxy diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d049deea1..e55d25eda 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,6 +1,6 @@ - RethinkDNS + Re-Rethink rethink Einstellungen Über @@ -28,7 +28,7 @@ geschützt geschützt mit Tor Anwendungsfehler - DNS-Fehler + DNS Fehler DNS-Server ausgefallen Kein Internet geschützt durch privaten DNS @@ -40,7 +40,7 @@ geschützt mit Proxy private DNS ist eingeschaltet nicht geschützt - Verbunden mit RethinkDNS + Verbunden mit Re-RethinkDNS Verbunden mit der Firewall Verbunden mit RethinkDNS und Firewall FORTSETZEN @@ -94,10 +94,10 @@ Du nutzt bereits die neueste Version. Firewall DNS - vpn + VPN IP-Adresse kopiert. DNS- und Firewall-Logs lokal auf dem Gerät speichern. - Geräteinterne Protokollierung aktivieren + Geräteinteres Logging aktivieren Alle Netzwerk-Logs löschen? blockiert %1$s auf dem Gerät #1: Blockiert eine App daran, eine Verbindung zum Internet herzustellen. \n #2: Blockiert alle Apps, sich mit einer bestimmten IP-Adresse zu verbinden. \n #3: Blockiert alle Verbindungen, während das Gerät gesperrt ist (Universelle Firewall). \n #4: Blockiert alle nicht verwendeten Apps (Universelle Firewall). \n #5: Blockiert Verbindungen, wenn die Quell-App unbekannt ist (Universelle Firewall). \n #6: Blockiert den gesamten UDP-Datenverkehr außer DNS (Universelle Firewall). \n #7: Blockiert Verbindungen, wenn DNS umgangen wird (Universelle Firewall). @@ -121,7 +121,7 @@ Anfragenden Apps erlauben, alle verfügbaren Netzwerke zu benutzen. Diese Apps könnten die RethinkDNS-Firewall bei Bedarf umgehen. Einige Audio-/Videokonferenz-Apps wie Zoom und Meet können dies erfordern, um richtig zu funktionieren. Baut einen HTTP-CONNECT-Tunnel zu Orbot auf. Rethink hat aufgehört Verbindungen zu Orbot weiterzuleiten, aber Orbot läuft vielleicht noch im Hintergrund. - Orbot ist eine kostenlose Proxy-App, die es anderen Apps ermöglicht, das Internet sicherer zu nutzen.\nOrbot verwendet Tor, um deinen Internetverkehr zu verschlüsseln, was dir dabei hilft, Zensur zu umgehen und dich gegen Formen der Netzwerküberwachung zu verteidigen, die die persönliche Freiheit und Privatsphäre bedrohen.\nTCP Proxy: Rethink leitet nur TCP-Verbindungen von eingeschlossenen Apps an Orbot weiter.\nHTTP Proxy: Rethink stellt einen HTTP CONNECT-Tunnel zu Orbot her und leitet nur TCP-Verbindungen von eingeschlossenen Apps weiter. + Orbot ist eine kostenlose Proxy-App, die es anderen Apps ermöglicht, das Internet sicherer zu nutzen.\nOrbot verwendet Tor, um Ihren Internetverkehr zu verschlüsseln, was Ihnen hilft, Zensur zu umgehen und sich gegen Formen der Netzwerküberwachung zu verteidigen, die die persönliche Freiheit und Privatsphäre bedrohen.\nTCP Proxy: Rethink leitet nur TCP-Verbindungen von eingeschlossenen Apps an Orbot weiter.\nHTTP Proxy: Rethink stellt einen HTTP CONNECT-Tunnel zu Orbot her und leitet nur TCP-Verbindungen von eingeschlossenen Apps weiter. Orbot installieren\? Orbot im nicht-VPN-Modus mit HTTPS/SOCKS5. Einstellungen öffnen @@ -132,7 +132,7 @@ DNSCrypt DNS-Proxy Verbunden mit %1$s - DNSCrypt: %1$s Resolver + DNSCrypt: %1$s Auflöser HTTP(S)-CONNECT-Proxy festlegen Etwas ist schiefgelaufen! Entweder sind unsere Server ausgefallen oder die Verbindung konnte nicht hergestellt werden. Bitte versuche es nach einiger Zeit erneut. @@ -194,7 +194,7 @@ Orbot ❯ erlaubt bei %1$s %2$s DNS (energiesparend) - HTTP-Proxy ist eingestellt. + HTTP-Proxy ist festgelegt. Manueller Eingriff erforderlich Orbot stoppen https://guardianproject.info/apps/org.torproject.android/ @@ -238,13 +238,13 @@ pausiert Hinweis: %1$s blockierte Anwendungen werden weiterhin mit einer Firewall versehen. DoH - DNS-Proxy - DNSCrypt + Dns-Proxy + Dnscrypt App gesperrt Keine Regel - Unbegrenzt (WLAN) + Ungezählt (Wlan) Getaktet (Mobil) - Getaktet (universell) + Getaktet (Universell) Isolieren Neue App Fehler @@ -252,9 +252,9 @@ umgangene App App blockiert.

Zum Ändern gehe zum Tab Alle Apps .]]>
ersten Mal eine Verbindung hergestellt.

Um dies zu ändern, gehe zum Tab Universal.]]>
- App beim Versuch, eine Verbindung in einem nach Datenvolumen begrenzten Netzwerk herzustellen, blockiert.

Um dies zu ändern, gehe zum Tab Alle Apps.]]>
- Ziel-IP / den Ziel-Port blockiert.

Zum Ändern gehe zum Tab Universell.]]>
- Ziel-IP blockiert.

Um dies zu ändern, gehe zum Bildschirm App-spezifische Firewall.]]>
+ App beim Versuch, eine Verbindung in einem nach Datenvolumen begrenzten Netzwerk herzustellen, blockiert.

Um dies zu ändern, gehen Sie zum Tab Alle Apps.]]>
+ Ziel-IP / den Ziel-Port blockiert.

Zum Ändern gehen Sie zum Tab Universell.]]>
+ Ziel-IP blockiert.

Um dies zu ändern, gehen Sie zum Bildschirm App-spezifische Firewall.]]>
Berechtigung für Benachrichtigungen erteilen Verbindung mit dem Host fehlgeschlagen. Versuche es erneut. Auto (experimentell) @@ -266,14 +266,14 @@ IP / Port (Universell) Gerät gesperrt App beim Versuch, eine Verbindung im Unmetered-Netzwerk herzustellen, blockiert.

Um dies zu ändern, wechsel zum Tab Alle Apps.]]>
- App blockiert, als sie versuchte, eine Verbindung in einem gedrosselten (mobilen) Netzwerk herzustellen.

Um dies zu ändern, gehe zum Reiter Universell.]]>
+ App blockiert, als sie versuchte, eine Verbindung in einem gedrosselten (mobilen) Netzwerk herzustellen.

Um dies zu ändern, gehen Sie zum Reiter Universell.]]>
Gerät zu diesem Zeitpunkt gesperrt war.

Um dieses Verhalten zu ändern, gehe zum Tab Universal.]]>
- Ziel-IP so eingestellt ist, dass alle universellen Firewall-Regeln umgangen werden.

Um dies zu ändern, gehe zu IP-Regeln im Tab Universell.]]>
- nicht im Vordergrund war.

Um dieses Verhalten zu ändern, gehe zum Tab Universal.]]>
+ Ziel-IP so eingestellt ist, dass alle universellen Firewall-Regeln umgangen werden.

Um dies zu ändern, gehen Sie zu IP-Regeln im Tab Universell.]]>
+ nicht im Vordergrund war.

Um dieses Verhalten zu ändern, gehen Sie zum Tab Universal.]]>
Quell-App die vom Benutzer bevorzugte DNS-Auflösung umgangen hat.

Um dieses Verhalten zu ändern, gehe zum Tab Universal.]]>
- UDP-Verbindung blockiert.

Um dies zu ändern, gehe zum Tab Universal.]]>
+ UDP-Verbindung blockiert.

Um dies zu ändern, gehen Sie zum Tab Universal.]]>
unsicheres HTTP verwendete.

Um dies zu ändern, gehe zum Tab Universal.]]>
- Quell-App auf universelle Firewall-Regeln umgehen eingestellt ist.

Um dies zu ändern, gehe zum Bildschirm App-spezifische Firewall.]]>
+ Quell-App auf universelle Firewall-Regeln umgehen eingestellt ist.

Um dies zu ändern, gehen Sie zum Bildschirm App-spezifische Firewall.]]>
Benutzerdefinierte Server-URL Der Portbereich muss zwischen 1024 und 65535 liegen Ungültiger Port @@ -287,7 +287,7 @@ Vom System eingestellt Hell Dunkel - Tiefschwarz + Schwarz IP-Version wählen IPv4 (Standard) IPv4 @@ -302,7 +302,7 @@ erlaubt Der Rethink-Dienst für Barrierefreiheit wurde angehalten oder ist abgestürzt. Anwendungen im Hintergrund werden nicht mehr blockiert. demnächst - Der Modus kann nicht geändert werden, wenn das VPN gesperrt ist. Tippe auf <u>VPN-Einstellungen bearbeiten</u>. + Der Modus kann nicht geändert werden, wenn VPN im Modus Sperre ist. Tippe hier, um <u>VPN-Einstellungen zu bearbeiten</u>. IP/ Port (App) Vertrauenswürdige IP Umgangene IP @@ -312,9 +312,9 @@ totale Sperrung Für diese Verbindung gibt es keine passenden Firewall-Regeln. App sich mit anderen Zielen als umgangenen IPs verbindet.

Um dies zu ändern, gehe zum Tab Alle Apps.]]>
- Ziel-IP als vertrauenswürdige IP festgelegt ist.

Zum Ändern gehe zum Bildschirm App-spezifische Firewall.]]>
- Quell-App nicht ermittelt werden konnte.

Um dieses Verhalten zu ändern, gehe zur Registerkarte Universal.]]>
-
Um dies zu ändern, deaktiviere DNS-Leaks verhindern auf dem Bildschirm DNS konfigurieren.]]>
+ Ziel-IP als vertrauenswürdige IP festgelegt ist.

Zum Ändern gehen Sie zum Bildschirm App-spezifische Firewall.]]>
+ Quell-App nicht ermittelt werden konnte.

Um dieses Verhalten zu ändern, gehen Sie zur Registerkarte Universal.]]>
+
Um dies zu ändern, deaktivieren Sie DNS-Leaks verhindern auf dem Bildschirm DNS konfigurieren.]]>
One-Click-Tor-als-Proxy-Einrichtung über Orbot im Gange war.]]> Manuelles Sichern oder Wiederherstellen der App-Daten und Einstellungen. In-App-Downloader verwenden @@ -324,7 +324,7 @@ Sichern und Wiederherstellen Pauschale Umgehung Öffnen der Einstellungen für Barrierefreiheit fehlgeschlagen. Versuche es später erneut! - Universell + Pauschal Achtung Download Warnung Informieren, wenn Rethink aktiv ist und im Hintergrund läuft @@ -367,8 +367,8 @@ ist erlaubt Keine Netzwerkprotokolle für diese Kategorie. ist blockiert - wird in unbegrenzten (WLAN-)Netzwerken blockiert - ist isoliert; nur vertrauenswürdige IPs und Domains erlaubt + wird in nicht gebührenpflichtigen Netzwerken (Wlan) blockiert + ist isoliert; nur vertrauenswürdige IPs erlaubt ist unbekannt umgeht die universellen Firewall-Regeln Elterliche Kontrolle @@ -381,14 +381,14 @@ Aktualisierung verfügbar Nach Updates suchen Blocklisten erneut herunterladen - Blocklisten herunterladen (ca. 60 MB), um diese Funktion zu nutzen. + Laden Sie Blocklisten herunter (ca. 60 MB), um diese Funktion zu nutzen. ⛅ Himmel 🪂 Max Zeige alle Blocklisten %1$s Blockierlisten an]]> - 50+ Standorte; mehr Privatsphäre; rekursiver Resolver läuft auf fly.io + 50+ Standorte; mehr privat; rekursiver Resolver läuft auf fly.io %1$s Blocklisten in Gruppe: %2$s und Untergruppe: %3$s]]> - 280+ Standorte; höhere Betriebszeit; Stub-Resolver läuft auf cloudflare.com + 280+ Standorte; höhere Betriebszeit; Stub Resolver läuft auf cloudflare.com Untergruppe Leeren Rethink DNS @@ -408,31 +408,31 @@ Sichert die App-Einstellungen, Netzwerk- und DNS-Protokolle in einer Datei. Dadurch wird das VPN neu gestartet. Sicherung (Backup) wiederherstellen Sichern (Backup) - App-Einstellungen, Netzwerk- und DNS-Protokolle aus einer Sicherungsdatei wiederherstellen. Dadurch wird die App neu gestartet. + Stellen Sie die App-Einstellungen, Netzwerk- und DNS-Protokolle aus einer Sicherungsdatei wieder her. Dadurch wird die App neu gestartet. Wiederherstellen Konnte nicht wiederhergestellt werden. Erneut versuchen\? Wiederherstellen wiederholen Wiederherstellung abgeschlossen, Neustart der Anwendung… Sicherung komplett - Wiederherstellung fehlgeschlagen; versuche es nochmal + Wiederherstellung fehlgeschlagen; versuchen Sie es nochmal Wiederherstellung fehlgeschlagen B Installierte Apps Domäne aus der benutzerdefinierten Liste entfernt N - Lösche die Domain aus den gespeicherten Konfiguration. + Löschen Sie die Domäne aus den gespeicherten Konfiguration. IP-Adresse, IP-Subnetz (Bereich), Port zulassen (umgehen) oder sperren. Beispiel: 10.10.10.10, 10.1.1.*, 10.2.2/24, ffff::/104, [::]:80,[10.1.0.0/16]:80, *.*:80 ist von DNS und Firewall ausgeschlossen Firewall-Regeln für diese Anwendung blockiert %1$s durch %2$s - Sperre Inhalte für Erwachsene und Raubkopien, Online-Glücksspiele und -Dating sowie soziale Medien. - Möchtest du neu konfigurierte Blocklisten anwenden? + Sperren Sie Inhalte für Erwachsene und Raubkopien, Online-Glücksspiele und -Dating, sowie soziale Medien. + Möchten Sie neu konfigurierte Blocklisten anwenden\? Weiter Keine Regel - Malware, Ransomware, Cryptoware, Phisher und andere Bedrohungen blockieren. + Blockieren Sie Malware, Ransomware (Erpressersoftware), Kryptoware, Phisher und andere Bedrohungen. Aus Sicherungsdatei wiederherstellen Sichern von Anwendungseinstellungen, Netzwerk- und DNS-Protokollen - Der Rethink-Dienst für Barrierefreiheit wurde angehalten oder ist abgestürzt. Erzwinge das Beenden von Rethink über die Systemeinstellungen und starte Rethink erneut, um die Funktion Blockieren aller nicht genutzten Apps zu verwenden.\n\nRethink verwendet den Dienst für Barrierefreiheit, um zu verfolgen, welche Apps in Gebrauch sind (im Vordergrund) und welche nicht (im Hintergrund). + Der Rethink Dienst für Barrierefreiheit wurde angehalten oder ist abgestürzt. Erzwinge das Beenden von Rethink über die Systemeinstellungen und starte Rethink erneut, um die Funktion Blockieren aller nicht genutzten Apps zu verwenden.\n\nRethink verwendet den Dienst für Barrierefreiheit, um zu verfolgen, welche Apps in Gebrauch sind (im Vordergrund) und welche nicht (im Hintergrund). isoliert von DNS und Firewall ausgeschlossen umgeht die universellen Firewall-Regeln @@ -447,7 +447,7 @@ Fortfahren Verwerfen Warnung vor Firewall-Fehlern und Anomalien - Webseite + webseite Spiel {fileName} fdroid Domain erfolgreich hinzugefügt @@ -463,13 +463,13 @@ Ausblenden Isolieren Protokolle löschen - Lösche alle Protokolle, die sich auf diese App beziehen. + Löschen Sie alle Protokolle, die sich auf diese Anwendung beziehen. Die Regeln gelten nur für diese App. System DNS On-Device Blockliste herunterladen Download in Arbeit… - Download fehlgeschlagen. Versuche es erneut. - Attentionware, Spyware und Scareware blockieren. + Download fehlgeschlagen. Versuchen Sie es erneut. + Blockieren Sie Attentionware, Spyware und Scareware. Sicherheit Änderungen speichern einfach @@ -491,14 +491,14 @@ Firewall-Modus aktivieren. %1$s ms Achtung! - Entweder fehlt die VPN-Berechtigung oder eine andere Anwendung befindet sich im Always-on-VPN-Modus. + Entweder fehlt die VPN-Berechtigung, oder eine andere Anwendung befindet sich im Always-on-VPN-Modus. Fortfahren Erteile die Berechtigung für Benachrichtigungen in den Android-Einstellungen in gebührenfreien Netzen (WLAN) blockiert in gebührenpflichtigen (mobilen) Netzen gesperrt blockiert unbekannt - App-Info nicht gefunden + App Info nicht gefunden %1$s Apps DNS-Modus aktivieren. %1$s IP- und Port-Regel(n) @@ -510,10 +510,11 @@ Start Was gibt\'s Neues Du bist nur einen Schritt von einem sicheren Android-System entfernt: Kontrolliere Netzwerkaktivitäten, blockiere Webseiten, IP-Adressen und sich unerwünscht verhaltende Apps. - Proxy aktiv + Proxy aktiviert RDNS+ aktivieren - Ausschließen im VPN-Sperrmodus nicht möglich + Ausschließen im VPN-Lockdown-Modus nicht möglich Proxy entfernt + Stoppen… Schließen… Aktivieren… Thema: %1$s @@ -539,19 +540,19 @@ https://svc.rethinkdns.com/r/translate v%1$s https://www.rethinkdns.com/privacy - Die Webseite kann nicht im Browser geöffnet werden. - Der Benachrichtigungsbildschirm der App konnte nicht gestartet werden. + Webseite konnte nicht im Browser geöffnet werden. + App-Benachrichtigungs-Bildschirm konnte nicht geöffnet werden. Kein Absturzbericht gefunden Fehler beim Laden der Protokolldatei - Erfassen… + Sammle… Autor:innen Benutzerdefinierte Server-URL DoH DNSCrypt - DNSCrypt-Resolver oder -Relay hinzufügen + DNSCrypt Resolver oder Relay hinzufügen DNSCrypt %1$s - DNSCrypt-Resolver oder -Relay hinzufügen - Resolver + DNSCrypt Resolver oder Relay hinzufügen + Auflöser Relay Name Stamp @@ -599,8 +600,8 @@ https://www.rethinkdns.com/ v%1$s (%2$s) Funktion vorschlagen - App-Info konnte nicht gestartet werden. - Das VPN-Profil konnte nicht gestartet werden. + App-Info konnte nicht geöffnet werden. + VPN-Profile konnten nicht geöffnet werden. Einstellung für Privates DNS konnte nicht geöffnet werden. DoH %1$s Benutzerdefinierter DNS-Proxy @@ -614,8 +615,8 @@ Portbereich muss zwischen 1024 und 65535 liegen DNSRelay %1$s Seite mit den App-Einstellungen nicht gefunden - %1$s + %2$s andere Anwendung(en) - Die für %1$s geltenden Regeln gelten auch für %2$s andere Apps + %1$s + %2$s andere App(s) + Regeln angewendet für %1$s sind auch anwendbar für %2$s andere Apps Zurück %1$s: %2$s Blockliste(n)%3$s.

]]>
]]> @@ -625,20 +626,20 @@ aufgelöst %1$s %1$ss Abfragetyp: %1$s - OK + ok Update abgeschlossen - Webseite besuchen + Besuche Webseite Später erinnern - Alle Apps blockieren, wenn das Gerät gesperrt ist - Alle nicht verwendeten Apps blockieren + Blockiere alle Apps, wenn das Gerät gesperrt ist + Blockiere jede App, die nicht in Benutzung ist Blockieren, wenn die Quell-App unbekannt ist - UDP außer DNS und NTP blockieren + Blockiere UDP, außer DNS und NTP Blockieren, wenn DNS umgangen wird - Neu installierte Apps standardmäßig blockieren - IPv4 in IPv6 blockieren (experimentell) - Port 80 blockieren (unsicherer HTTP-Verkehr) - Gebührenpflichtige (mobile) Netzwerke blockieren - Alles außer umgehende Apps und IPs blockieren + Blockiere neu installierte Apps standardmäßig + Blockiere IPv4 in IPv6 (experimentell) + Blockiere Port 80-Verkehr (unsicheres HTTP) + Blockiere gebührenpflichtige Netzwerke (z.B. mobile Netzwerke) + Blockiere alles außer umgehende Apps und IPs IP- & Port-Regeln Ungültiger Port Absturzprotokolle per E-Mail senden @@ -710,7 +711,7 @@ GitHub / Sonatype
]]>
- Anderer DNS + Andere DNS Rethink DNS %1$s Einträge » Wähle aus über 195 Blocklisten. Tippe hier, um Blocklisten herunterzuladen oder zu konfigurieren. @@ -767,11 +768,11 @@ Leeren Installiert OK - Verbindungen in kostenpflichtigen (mobilen) Netzwerken für aufgelistete Apps blockieren? + Verbindungen in kostenpflichtigen Netzwerken (Mobilfunknetze) für die auf aufgeführten Apps blockieren? Erlaubt im kostenpflichtigen Verbindungen in kostenpflichtigen Netzwerken (Mobilfunknetze) für die auf aufgeführten Apps erlauben? Keine Firewall-Regeln gefunden - neu laden + Neu laden SOCKS5 Proxy Tor-as-a-proxy Der aktuelle DNS-Server kann nicht ermittelt werden @@ -787,7 +788,7 @@ Fehler bei der biometrischen Authentifizierung Umgangen Isoliert - Sowohl von DNS als auch von der Firewall ausschließen. + Sowohl von DNS + Firewall ausschließen. Apps umgehen Verbindungen für aufgelistete Apps erlauben\? Apps ausschließen @@ -798,19 +799,19 @@ %1$s %2$s Apps in Kategorien werden angezeigt: %3$s]]> Vertrauen - meistblockierte Apps - meisterlaubte Apps - meist kontaktierte IPs - meist blockierte IPs + Meistblockierte Apps + Meisterlaubte Apps + Meistkontaktierte IPs + Meistblockierte IPs Alle anzeigen Universelle Firewall-Regeln für aufgelistete Apps umgehen\? - meist kontaktierte Domains + Meistkontaktierte Domains Bevorzugte Sprache wählen Bevorzugte Sprache wählen Rethink entsperren Biometrische Authentifizierung zum Entsperren von Rethink verwenden Biometrische Authentifizierung fehlgeschlagen - meistblockierte Domains + Meistblockierte Domains Anwendungen zulassen Alle außer vertrauenswürdigen IPs blockieren. Universelle Firewall-Regeln umgehen. @@ -818,8 +819,8 @@ Typ URL Blockieren auf nicht getakteten Verbindung - Zulassen / Blockieren im gebührenpflichtigen (mobilen) Netz. - Starte Rethink, um fortzufahren + Zulassen / Blockieren in gebührenpflichtigen Netzen (mobile Netze). + Starten Sie Rethink, um fortzufahren Platzhalter Erlaubt Blockiert @@ -829,8 +830,8 @@ Zulassen / Blockieren in gebührenfreien Netzen (WLAN). DNS durch Proxy geleitet Domäne (App) - Domain blockiert.

Um dies zu ändern, gehe zum Bildschirm App-spezifische Firewall.]]>
- Domain als vertrauenswürdige Domain festgelegt wurde.

Zum Ändern gehe zum Bildschirm App-spezifische Firewall.]]>
+ Domain blockiert.

Um dies zu ändern, gehen Sie zum Bildschirm App-spezifische Firewall.]]>
+ Domain als vertrauenswürdige Domain festgelegt wurde.

Zum Ändern gehen Sie zum Bildschirm App-spezifische Firewall.]]>
Abbrechen Übernehmen Verwerfen @@ -869,24 +870,24 @@ Funktioniert nicht über verschiedene App-Versionen hinweg Blockieren, Domain(s) für diese App vertrauen Proxies können nicht aufgesetzt werden, während das VPN im DNS only - Modus läuft. - Universelle DNS- & Firewall-Regeln für aufgelistete Apps umgehen? + Sollen universelle DNS- & Firewall-Regeln für aufgelisteten Apps umgangen werden\? Vertrauenswürdige Domäne (Univ) - Domain vom benutzer bevorzugten DNS blockiert wird.

Um dieses Verhalten zu ändern, gehe zum Tab DNS konfigurieren.]]>
+ Domain vom benutzer bevorzugten DNS blockiert wird.

Um dieses Verhalten zu ändern, wechseln Sie zum Tab DNS konfigurieren.]]>
Umgehe DNS & Firewall - Anfragen bündeln, Antworten zwischenspeichern, robuste Fehlerbehandlung - Domain in den universellen Domain-Regeln als blockiert festgelegt ist.

Um dieses Verhalten zu ändern, gehe zum Tab DNS konfigurieren.]]>
+ Vereine Anfragen, cache Antworten, robuste Fehlerbehandlung + Domain in den universellen Domain-Regeln als blockiert festgelegt ist.

Um dieses Verhalten zu ändern, gehen Sie zur Registerkarte DNS konfigurieren.]]>
wird universelle DNS- & Firewall-Regeln umgehen %1$s Domain-Regel(n) Einzigartige IP für jede DNS-Anfrage verwenden. Domäne (Univ) - Quell-App auf DNS- und Firewall-Regeln umgehen gesetzt ist.

Zum Ändern gehe zum Bildschirm App-spezifische Firewall.]]>
+ Quell-App auf DNS- und Firewall-Regeln umgehen gesetzt ist.

Zum Ändern gehen Sie zum Bildschirm App-spezifische Firewall.]]>
In seltenen Fällen kann das bevorzugte DNS nicht ermittelt bzw. erreicht werden. Dann wird das Ausweich-DNS verwendet. - Domain in den universellen Domain-Regeln als vertrauenswürdig eingestuft ist.

Um dieses Verhalten zu ändern, gehe zum Tab DNS konfigurieren.]]>
- Eventuell blockiert + Domain in den universellen Domain-Regeln als vertrauenswürdig eingestuft ist.

Um dieses Verhalten zu ändern, gehen Sie zum Tab DNS konfigurieren.]]>
+ Evtl. blockiert Netzwerk %1$s funktioniert nur mit Rethink\'s DNS - Private IPs nicht weiterleiten (experimentell) - LAN, Loopback, Multicast und Link-Local-Routen von Rethinks VPN-Tunnel ausschließen. + Private IPs nicht umlenken (experimentell) + LAN, Loopback, Multicast und Link-Local Routen von Rethinks VPN-Tunnel ausschließen. Importieren SOCKS5 HTTP @@ -907,8 +908,8 @@ pro App Öffentlicher Schlüssel Privater Schlüssel - Apps - Einbeziehen + Anwendungen + Miteinbeziehen sehr Alles auswählen Erstellen @@ -918,7 +919,7 @@ Ungültiger Schlüssel IP-Netzwerk IP-Adresse - Schützt dein Gerät vor Schadsoftware und mehr. + Schützt Ihr Gerät vor Schadsoftware und mehr. WireGuard als ein Proxy. Universelle Firewall-Regeln Zulässige IPs @@ -932,7 +933,7 @@ : WireGuard Hex-Schlüssel müssen 64 Zeichen (32 Bytes) lang sein Anonymisiertes DNS-Relay, gehostet in Singapur. Std - Schalte andere aktive Proxys aus, um WireGuard zu aktivieren + Schalten Sie andere aktive Proxys aus, um WireGuard zu aktivieren speichern Alle Domain-Regeln gelöscht (optional) @@ -954,8 +955,8 @@ Unbekannter Fehler Apps nach Namen suchen Alle Apps einbeziehen - meist kontaktierte Länder - meistblockierte Länder + Meistkontaktierte Länder + Meistblockierte Länder geschützt mit WireGuard Konfiguration hinzugefügt Falsche Zeichen im Schlüssel @@ -969,7 +970,7 @@ Falsche Schlüssellänge Konfiguration löschen da %1$s - Bist du sicher, dass du diese Konfiguration löschen möchten? + Sind Sie sicher, dass Sie diese Konfiguration löschen möchten\? WARP-Konfiguration vom Server abrufen Zeichenkette Unbekannter Fehler „%s“ @@ -1006,16 +1007,18 @@ Geschützt! Verbunden mit DNS + Firewall + Proxy. Peer Loopback (experimentell) + Loopback (experimentell) + Aktiviert auch die %1$s Einstellung. Fortfahren? Dauerhafte Benachrichtigung - Die Benachrichtigung dauerhaft anzeigen + Zu dauerhafter Benachrichtigung machen Rethinks eigenen Netzwerkverkehr, wie z.B. Blocklisten-Downloads, Verbindungschecks oder DNS-Verbindungen in den Tunnel zurückleiten. - Wenn deaktiviert, werden Proxy-Apps (SOCKS5, HTTP, DNS) aus Rethinks VPN-Tunnel ausgeschlossen. + Bei Deaktivierung werden Proxy-Apps (SOCKS5, HTTP, DNS) aus Rethinks VPN-Tunnel ausgeschlossen. Endpunkt für DNS-over-TLS (DoT) aus der gespeicherten Konfiguration löschen. Richte die URL in der Form \"<protocol>://<username>:<password>@<domain-or-ip>:<port><path>\" ein. Beispiel: https://proxy.example.com:8080/ %1$s deaktivieren Verbindungstests durchführen Verbindungstests werden durchgeführt, um die Erreichbarkeit zu ermitteln. - Leite den Datenverkehr von Rethink durch den Tunnel. Deaktiviere diese Funktion, wenn du nicht genau weißt, was du tust. + Rethinks eigenen Netzwerkverkehr in den Tunnel zurüclleiten. Schalte diese Einstellung aus, außer du weißt was du tust. Mindestens eine App aktivieren, um Orbot zu starten %1$s kann zu Verbindungsabbrüchen führen. Ändere die Einstellung, um Rethink Daten und andere Ressourcen nutzen zu lassen, wenn es sich im Hintergrund befindet. Loopback Proxy-Forwarder-Apps @@ -1059,7 +1062,7 @@ Quad9 leitet deine DNS-Anfragen über ein sicheres Netz von Servern rund um den Globus. Quad9 (anycast) mit DNSSEC, ohne Logging, mit Filter (IP-Adressen: 9.9.9.9 – 149.112.112.9 – 149.112.112.112) Anonymisiertes DNS-Relay, gehostet in Los Angeles, Kalifornien, USA und bereitgestellt von https://cryptostrom.is/. - Blockiert den Zugang zu allen nicht jugendfreien, pornografischen und expliziten Webseiten. Außerdem werden Proxy- und VPN-Domains blockiert, die zur Umgehung der Filter verwendet werden. Seiten mit gemischten Inhalten wie Reddit werden ebenfalls blockiert. Google, Bing und YouTube werden auf den abgesicherten Suchmodus eingestellt. + Blockiert den Zugang zu allen nicht jugendfreien, pornografischen und expliziten Webseiten. Proxys und VPNs, welche zur Umgehung der Filter verwendet werden können, werden ebenso gesperrt. Seiten mit gemischten Inhalten wie Reddit werden ebenfalls blockiert. Google, Bing und YoutTube werden auf den abgesicherten Suchmodus eingestellt. Quad9 (anycast) ohne DNSSEC, ohne Logging, ohne Filter, mit ECS (IP-Adressen: 9.9.9.12 – 149.112.112.12) Anonymisiertes DNS-Relay, gehostet in Paris, Frankreich und bereitgestellt von https://cryptostrom.is/. Anonymisiertes DNS-Relay, gehostet in Schweden und bereitgestellt von https://cryptostrom.is/. @@ -1068,19 +1071,20 @@ Relays protos Service Provider - Schütze dein Android-Gerät mit einer Firewall - Kein Netzwerk + Schütze dein Android mit einer Firewall + kein Netzwerk Dunkel Plus Biometrische Authentifizierung Nach 5 Minuten Nach 15 Minuten Split DNS (experimentell) + DNS-Regeln als Firewall-Regeln behandeln (experimentell) (experimentell) Manuell Internet mit WireGuard absichern Verschlüsselt deinen Internetverkehr mit WireGuard. Für unterschiedliche Apps lassen sich verschiedene Routen auswählen. Kontrolliere die Internetnutzung pro App und schränke Verbindungen basierend auf der Netzwerkaktivität oder je nach App ein. - Werbung und Malware mit DNS blockieren + Blockiere Werbung und Malware über DNS Wähle aus über 190 Listen, um Werbung, Tracker und Malware zu stoppen. Kostenpflichtiges mobiles Netz Nur Mobilfunknetze als „kostenpflichtig“ behandeln, den Rest als „ohne Datenlimit“. @@ -1096,16 +1100,17 @@ Relay-Fehler: %1$s -> %2$s Mobile-only-Modus wird für Relays nicht unterstützt Deaktiviere den Mobile-only-oder-SSID-Modus, um Relays zu nutzen - Ausweichlösung + Fallback Filterung + DNS-Blockierung wird während der Auflösung umgangen; die Entscheidung wird zum Zeitpunkt der Verbindung getroffen. Alle lokalen Host-Verbindungen blockieren Proxy umgehen App angehalten Lockdown-Proxy privaten DNS-Server, der nicht existiert.]]> - Quellanwendung die Regeln für Proxy umgehen festgelegt sind.

Um dies zu ändern, gehe zum Bildschirm Anwendungsspezifische Firewall.]]>
+ Quellanwendung die Regeln für Proxy umgehen festgelegt sind.

Um dies zu ändern, gehen Sie zum Bildschirm Anwendungsspezifische Firewall.]]>
App im Pausiert-Status ist.]]> - Sperrmodus befindet.

Um dies zu ändern, gehe zum Bildschirm WireGuard.]]>
+ Lockdown-Modus ist.

Um dies zu ändern, wechseln Sie zum WireGuard-Bildschirm.]]>
\"WireGuard Relais\" kann nicht deaktiviert werden Ungültige WireGuard-Konfiguration App Protokolle @@ -1148,8 +1153,8 @@ Relay hinzufügen / entfernen Nutzungsbedingungen Erreichbarkeitsprüfungen umgehen alle Netzwerkbeschränkungen. Im Auto-Modus verwendet Rethink zufällig zuvor verbundene IPs und Hostnamen. - meist kontaktierte Anbieter - meist blockierte Anbieter + Meistkontaktierten Anbieter + Meist blockierte Anbieter Paketnamen eingeben (kommagetrennt), die Rethink starten oder stoppen dürfen. Anleitung:\nErforderliche Extras in der Broadcast-Nachricht:\n • Schlüssel: sender\n • Wert: Der Paketname der aufrufenden App\n\n Beispiel: key:sender value:com.termux\n\n Unterstützte Aktionen:\n • VPN starten – com.celzero.bravedns.intent.action.VPN_START\n • VPN stoppen – com.celzero.bravedns.intent.action.VPN_STOP Paketnamen eingeben (Beispiel: net.dinglisch.android.taskerm) @@ -1169,7 +1174,7 @@ Smart DNS Verarbeitung Bitte warte, während wir die Anfrage bearbeiten. - Smart DNS verwendet nach dem Zufallsprinzip einen dieser DNS-Resolver: + Smart DNS verwendet zufällig einen der folgenden DNS-Resolver: Alle Domain-Regeln löschen? Das Löschen dieser Regeln würde es jeder Anwendung gestatten, sich mit den aktuell blockierten Domains zu verbinden. Reagiert auf wichtige Netzwerk- und Verbindungsänderungen. @@ -1202,8 +1207,8 @@ Standort aktivieren %1$s und %2$s können nicht gleichzeitig verwendet werden WLAN-Kennung eingeben (%1$s) - Exakte Übereinstimmung - Teilweise Übereinstimmung + Genau + Teilweise Wi-Fi-Kennungen (%1$s) dürfen maximal 32 Zeichen lang sein. %1$s löschen? Stabilitätsprogramm @@ -1215,11 +1220,10 @@ Wenn aktiviert, werden eingehende Verbindungen von WireGuard-Peers zugelassen. Proxy nicht ausgewählt wurde.

Um dies zu ändern, gehe auf dem Bildschirm Konfigurieren zu Proxy.]]>
%1$s dieses WireGuard-VPN, wenn aktiv WLAN-Kennung (%2$s) %3$s. - Der in Android integrierte Download-Manager funktioniert möglicherweise nicht, da sich das VPN im Sperrmodus befindet. Möchtest du stattdessen den Downloader von Rethink aktivieren? + Der in Android integrierte Download-Manager funktioniert möglicherweise nicht, da sich das VPN im Lockdown-Modus befindet. Möchtest du stattdessen den Downloader von Rethink aktivieren? Downloader aktivieren Verwendet Jumbo-Pakete innerhalb des VPN-Tunnels von Rethink. Immer getaktet Markiere den VPN-Tunnel als getaktet, um Apps anzuweisen, weniger Daten zu verbrauchen. Achtung: Dies kann dazu führen, dass Apps große Uploads und Downloads, wie z. B. Backups, unterbrechen. Für das Stabilitätsprogramm für experimentelle Funktionen angemeldet. - Automatisch (Alle Arten) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index bb1efe5ad..dccc89e23 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -118,7 +118,7 @@ προστατεύεται με wireguard & private dns το private dns είναι ενεργοποιημένο δεν προστατεύεται - Συνδέθηκε με το RethinkDNS + Συνδέθηκε με το Re-RethinkDNS Συνδέθηκε με τείχος προστασίας Συνδέθηκε στο RethinkDNS και το Firewall Εφαρμόστε κανόνες τείχους προστασίας σε όλες τις εφαρμογές βάσει συμβάντων της συσκευής, για παράδειγμα, τείχος προστασίας όταν η συσκευή είναι κλειδωμένη ή όταν μια συγκεκριμένη εφαρμογή δεν χρησιμοποιείται. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 10f87c87c..f43f28192 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,14 +1,14 @@ - RethinkDNS + Re-Rethink rethink ¿Bloquear las conexiones en la red sin límites (WiFi) para las aplicaciones en la lista\? Sin nombre (%1$s) La exclusión de %1$s para %2$s en modo de bloqueo puede causar problemas de conectividad a Internet. - No se puede cambiar de modo cuando la VPN está en bloqueo. Pulse para <u>editar los ajustes de la VPN</u>. + No se puede cambiar de modo cuando la VPN está en bloqueo. Pulse para editar los ajustes de la VPN. Aislamiento - UDP.

Para cambiarla, vaya a la pestaña Universal.]]>
+ Una regla del cortafuegos activada por el usuario bloqueó esta conexión UDP.

Para cambiarla, vaya a la pestaña Universal.
IP omitida https://www.rethinkdns.com/ https://docs.rethinkdns.com/ @@ -112,7 +112,7 @@ Rethink es la forma más sencilla de supervisar la actividad de las aplicaciones, eludir la censura en Internet y utilizar cortafuegos en su dispositivo Android. No se han podido obtener los registros de fallos. ¿Quiere informar manualmente de su error\? Normativa de privacidad - contacto + Contacto Autores Rethink utiliza el servicio de accesibilidad para detectar y bloquear aplicaciones en segundo plano. Rethink no recopila ni vende ninguna información. Finalizar @@ -141,7 +141,7 @@ protegido con proxy el dns privado está activado no protegido - Conectado a RethinkDNS + Conectado a Re-RethinkDNS Conectado a RethinkDNS y al cortafuegos Conectado al cortafuegos Aplique reglas de cortafuegos en todas las aplicaciones en función de los eventos del dispositivo, p.ej., bloqueándolas cuando el dispositivo está bloqueado o cuando una determinada aplicación no está en uso. @@ -190,7 +190,7 @@ Las normas aplicadas para %1$s también serán aplicables para %2$s u otras aplicaciones Atrás Borrar registros - ]]> + \u0020]]> \u0020 +%1$s Otras IPs Sin respuesta resuelto de forma anónima %1$s por %2$s a través de %3$s @@ -241,13 +241,13 @@ Bloquear en redes con límites Permitir en redes con límites ¿Permitir las conexiones en la red con límites (móvil) para las aplicaciones en la lista\? - %1$s %2$s aplicaciones]]> - %1$s %2$s aplicaciones en las categorías: %3$s]]> + Mostrando aplicaciones %1$s %2$s + Mostrando aplicaciones %1$s %2$s en las categorías: %3$s Nota No se han encontrado reglas de cortafuegos - recargar + Recargar Desconocido Error conectando al servidor DNSCrypt. Error conectando al servidor proxy DNS. @@ -284,14 +284,14 @@ HTTP inseguro Bloqueo Ninguna regla del cortafuegos coincide con esta conexión. - .

Para cambiarla, vaya a la pestaña Todas las aplicaciones.]]>
- primera vez.

Para cambiar vaya a la pestaña Universal.]]>
- aplicación al intentar conectarse a la red Sin límites.

Para cambiarla, vaya a la pestaña Todas las aplicaciones.]]>
- aplicación al intentar conectarse a la red Con límites.

Para cambiarla, vaya a la pestaña Todas las aplicaciones.]]>
- aplicación que está conectando a otras IPs omitidas.

Para cambiarla, vaya a la pestaña Todas las aplicaciones.]]>
- IP destino .

Para cambiarla vaya a la pantalla Cortafuegos específico de la aplicación.]]>
- no esta en primer plano.

Para cambiar este comportamiento vaya a la pestaña Universal.]]>
- aplicación de origen.

Para cambiar este comportamiento, vaya a la pestaña Universal.]]>
+ Una regla del cortafuegos activada por el usuario ha bloqueado esta aplicación .

Para cambiarla, vaya a la pestaña Todas las aplicaciones.
+ Esta aplicación hizo la conexión la primera vez.

Para cambiar vaya a la pestaña Universal.
+ Una regla del cortafuegos activada por el usuario ha bloqueado esta aplicación al intentar conectarse a la red Sin límites.

Para cambiarla, vaya a la pestaña Todas las aplicaciones.
+ Una regla del cortafuegos activada por el usuario ha bloqueado esta aplicación al intentar conectarse a la red Con límites.

Para cambiarla, vaya a la pestaña Todas las aplicaciones.
+ Una regla del cortafuegos activada por el usuario ha bloqueado esta aplicación que está conectando a otras IPs omitidas.

Para cambiarla, vaya a la pestaña Todas las aplicaciones.
+ Una regla del cortafuegos activada por el usuario ha bloqueado la IP destino .

Para cambiarla vaya a la pantalla Cortafuegos específico de la aplicación.
+ Esta conexión estaba bloqueada porque la aplicación de origen no esta en primer plano.

Para cambiar este comportamiento vaya a la pestaña Universal.
+ Esta conexión fue bloqueada porque no se pudo determinar la aplicación de origen.

Para cambiar este comportamiento, vaya a la pestaña Universal.
Rethink bloqueó %1$s nuevas aplicaciones. Pulse para ver o modificar. Rethink bloqueó la aplicación recientemente instalada, %1$s. VPN inactiva @@ -315,17 +315,17 @@ Ninguna regla B Ninguna regla - No hay registros de red para esta categoría. + No hay registros de red para esta aplicación. se permite está bloqueado se bloquea en las redes sin límites (wifi) - %1$s .]]> + Esta aplicación %1$s . Conectado a %1$s dirección(es) IP 0 Dirección IP se bloquea en las redes con límites (móvil) omitirá las reglas del cortafuegos universal se excluye del dns y cortafuegos - está aislada; sólo se permiten IPs y dominios de confianza + está aislada; sólo se permiten IPs de confianza es desconocido Aislamiento Configuración DNS @@ -341,11 +341,11 @@ Actualización disponible Seleccionadas Mostrando todas las listas de bloqueo - %1$s]]> + Mostrando listas de bloqueo %1$s Descargue las listas de bloqueo (alrededor de 60MB) para utilizar esta función. ⛅ Sky 🪂 Max - %1$s listas de bloqueo en el grupo: %2$s y el subgrupo: %3$s]]> + Mostrando %1$s listas de bloques en el grupo: %2$s y el subgrupo: %3$s Limpiar DNSCrypt Proxy DNS @@ -359,12 +359,12 @@ Configurar más de 195 listas de bloqueo Restaurar Restaurar desde un archivo de copia de seguridad - Copia de seguridad de la configuración de la aplicación, red y registros DNS + Copia de seguridad de la configuración de la aplicación, la red y los registros DNS Hacer un respaldo Realiza una copia de seguridad de la configuración de la aplicación, la red y los registros DNS en un archivo. Esto reiniciará la VPN. - Copia de seguridad + Respaldo Restaurar copia de seguridad - Restaurar la configuración de la aplicación, red y registros DNS desde un archivo de copia de seguridad. Esto reiniciará la aplicación. + Restaurar la configuración de la aplicación, la red y los registros DNS desde un archivo de copia de seguridad. Esto reiniciará la aplicación. Falló la copia de respaldo No se ha podido realizar la copia de seguridad. ¿Intentar de nuevo\? Reintentar la copia de seguridad @@ -374,7 +374,7 @@ Copia de seguridad completa Apariencia Reenvía las conexiones al punto de acceso SOCKS5. - sistema + Sistema Novedades en %1$s Autores Obtener los iconos del sitio web nextdns.io y duckduckgo.com. Consume hasta 350 mb de espacio en disco @@ -411,7 +411,7 @@ DNS (ahorro de batería) DNS VPN - vpn + VPN Dirección IP copiada. URL copiada Copiada URL como RDNS+ DNS-sobre-HTTPS @@ -455,11 +455,12 @@ Proxy activo Activar RDNS+ Proxy eliminado + Parando… Cerrando… Activando… Tema: %1$s No se puede pausar: Rethink no está activo - tipo + Tipo Otros DNS La exclusión no es posible en el modo bloqueo de VPN Mostrar el icono del sitio web en los registros de DNS (en pruebas) @@ -472,14 +473,14 @@ Informe de errores Enviar correo electrónico Enviar correo electrónico - enlaces + Enlaces Visite rethinkdns.com Contribuir en GitHub Leer documentación Patrocinadores Envíenos un correo electrónico Configuración de notificaciones - Rethink cuenta con el apoyo de Mozilla Builders, FOSS United Foundation y FLOSS/fund. + Rethink cuenta con el apoyo de Mozilla Builders, FOSS United Foundation, OSOM Products Inc. Buscar actualizaciones de la aplicación Traducir Vamos @@ -517,14 +518,14 @@ Tor-como-un-proxy Sin límites (WiFi) Con límites (Universal) - aplicación
al intentar conectarse a la red Con límites (Móvil).

Para cambiarla, vaya a la pestaña Universal.]]>
+ Una regla del cortafuegos universal activada por el usuario ha bloqueado esta aplicación al intentar conectarse a la red Con límites (Móvil).

Para cambiarla, vaya a la pestaña Universal.
Aplicación bloqueada IP Confiable - IP destino está configurada como IP de confianza.

Para cambiarla, vaya a la pantalla del Cortafuegos específico de la aplicación..]]>
+ Esta conexión fue permitida porque la IP destino está configurada como IP de confianza.

Para cambiarla, vaya a la pantalla del Cortafuegos específico de la aplicación..
IP / Puerto (Universal) - IP destino / Puerto.

Para cambiarla vaya a la pestaña Universal.]]>
- Tor-como-un-proxy en un clic a través de Orbot estaba en curso.]]> -
Para cambiarlo vaya a la pestaña Universal.]]>
+ Una regla del cortafuegos activada por el usuario ha bloqueado la IP destino / Puerto.

Para cambiarla vaya a la pestaña Universal.
+ Esta conexión fue permitida porque la configuración Tor-como-un-proxy en un clic a través de Orbot estaba en curso. + Esta conexión fue bloqueada porque el bloqueo universal está configurado.

Para cambiarlo vaya a la pestaña Universal.
Conexión de red permitida para la aplicación Alertas de cortafuegos Alertas de proxy @@ -600,7 +601,7 @@ Conectado. %1$s listas de bloqueo en uso Pausar / Detener (por defecto) Proxy DNS - reglas + Reglas Seguir bloqueando Atención Proxy DNS personalizado @@ -610,8 +611,8 @@ bloqueadas Activar el modo cortafuegos. Ver preguntas frecuentes - %1$s: %2$s lista(s) de bloqueo%3$s.

]]>
- aplicación + \u0020%1$s: %2$s lista(s) de bloqueo%3$s.

]]>
\u0020
+ Aplicación El registro de solicitudes DNS ha sido desactivado. Para volver a activarlo, vaya a Ajustes y habilite Activar el registro en el dispositivo. Reenviando %1$s a Orbot, Proxy HTTP también configurado. Aplicación @@ -711,7 +712,7 @@ bloqueado %1$s por %2$s bloqueado %1$s en el dispositivo Bloquear, confiar en esta IP para esta aplicación - #1: Bloquea una aplicación para que no se conecte a Internet. \n #2: Bloquea cualquier aplicación para que no se conecte a una IP concreta.\n #3: Bloquea todas las conexiones cuando el dispositivo está bloqueado (Cortafuegos Universal).\n #4: Bloquea cualquier aplicación que no esté en uso (Cortafuegos Universal).\n #5: Bloquea las conexiones cuando la aplicación de origen es desconocida (Cortafuegos Universal).\n #6: Bloquea todo el tráfico UDP excepto DNS (Cortafuegos Universal).\n #7: Bloquea las conexiones cuando se omite el DNS (Cortafuegos Universal). + #1: Bloquea una aplicación para que no se conecte a Internet. \n #2: Bloquea cualquier aplicación para que no se conecte a una IP concreta. \n #3: Bloquea todas las conexiones cuando el dispositivo está bloqueado (Cortafuegos Universal). \n #4: Bloquea cualquier aplicación que no esté en uso (Cortafuegos Universal). \n #5: Bloquea las conexiones cuando la aplicación de origen es desconocida (Cortafuegos Universal). \n #6: Bloquea todo el tráfico UDP excepto DNS (Cortafuegos Universal). \n #7: Bloquea las conexiones cuando se omite el DNS (Cortafuegos Universal). permitido en %1$s %2$s bloqueado en %1$s %2$s La \"VPN siempre activa\" está activada en este dispositivo. Para iniciar Rethink, dirígete a los Ajustes de la aplicación y desactiva \"VPN siempre activa\". @@ -728,81 +729,14 @@ Borra el Proxy DNS de la configuración guardada. Una nueva versión de la aplicación está disponible para su descarga en el sitio web. ¿Quiere continuar\? O bien nuestros servidores están caídos o no se ha podido establecer la conexión. Por favor, inténtelo de nuevo mas tarde. - Proyecto de Código Abierto Rethink debe su existencia a estos voluntarios que dedican incontables horas a su desarrollo.
-
🛡️ Aplicación Android
- Anthony Ryan / - BayLee4 / - Ch4t4r / - CodingAttack / - HrBDev / - hussainmohd-a / - ignoramous / - ironveil / - markwmuller / - Mohammaduvez / - Mygod / - Poussinou / - pjosingh / - RohitSurwase / - Shantanu / - Uldiniad
/ - yurtpage
-
🚂 Motor de red
- alalamav / - bemasc / - dmcardle / - fortuna / - ignoramous / - PeterDaveHello / - santhosh-ponnusamy / - SeanBurford
-
📚 Resolver
- ahmed-tasaly / - amithm7 / - arfshl / - badmojr / - bongochong / - elliotwutingfeng / - georgyo / - GiddyGoatGaming / - Harish-Narayan / - ignoramous / - JakeChampion / - MasterKia / - mtxadmin / - neneeen / - PeterDaveHello / - santhosh-ponnusamy / - shruuub / - shuvashish76
-
⚜ Mods de la comunidad
- amithm7 / - Andersen / - ppatra / - InFamous23 / - TheTr1ckst3r / - Ch4t4r / - Billi_ance / - kaliangel / - PoneyClairDeLune
-
📌 Geo-IP
- db-ip.com / IPinfo Lite
-
❇ Traducciones
- Weblate
-
🦆 Iconos
- Icons
-
📦 Servidores de compilación
- JitPack / - GitHub / - Sonatype
- ]]>
+ El <i>Proyecto de Código Abierto Rethink</i> debe su existencia a estos voluntarios que dedican incontables horas a su desarrollo.<br/> <br/>🛡️ Aplicación Android <br/> <a href=https://github.com/BayLee4>BayLee4</a><br/> <a href=https://github.com/Ch4t4r>Ch4t4r</a><br/> <a href=https://github.com/HrBDev>HrBDev</a><br/> <a href=https://github.com/hussainmohd-a>hussainmohd-a</a><br/> <a href=https://github.com/ignoramous>ignoramous</a><br/> <a href=https://github.com/markwmuller>markwmuller</a><br/> <a href=https://github.com/Mygod>Mygod</a><br/> <a href=https://github.com/Uldiniad>Uldiniad</a><br/> <a href=https://github.com/Poussinou>Poussinou</a><br/> <a href=https://github.com/pjosingh>pjosingh</a><br/> <br/>🚂 Motor de red <br/> <a href=https://github.com/alalamav>alalamav</a><br/> <a href=https://github.com/bemasc>bemasc</a><br/> <a href=https://github.com/dmcardle>dmcardle</a><br/> <a href=https://github.com/fortuna>fortuna</a><br/> <a href=https://github.com/ignoramous>ignoramous</a><br/> <a href=https://github.com/Lanius-collaris>Lanius-collaris</a><br/> <a href=https://github.com/PeterDaveHello>PeterDaveHello</a><br/> <a href=https://github.com/santhosh-ponnusamy>santhosh-ponnusamy</a><br/> <a href=https://github.com/SeanBurford>SeanBurford</a><br/> <br/>📚 Resolvedor <br/> <a href=https://github.com/ahmed-tasaly>ahmed-tasaly</a><br/> <a href=https://github.com/amithm7>amithm7</a><br/> <a href=https://github.com/arfshl>arfshl</a><br/> <a href=https://github.com/badmojr>badmojr</a><br/> <a href=https://github.com/bongochong>bongochong</a><br/> <a href=https://github.com/elliotwutingfeng>elliotwutingfeng</a><br/> <a href=https://github.com/georgyo>georgyo</a><br/> <a href=https://github.com/GiddyGoatGaming>GiddyGoatGaming</a><br/> <a href=https://github.com/Harish-Narayan>Harish-Narayan</a><br/> <a href=https://github.com/ignoramous>ignoramous</a><br/> <a href=https://github.com/JakeChampion>JakeChampion</a><br/> <a href=https://github.com/MasterKia>MasterKia</a><br/> <a href=https://github.com/mtxadmin>mtxadmin</a><br/> <a href=https://github.com/neneeen>neneeen</a><br/> <a href=https://github.com/PeterDaveHello>PeterDaveHello</a><br/> <a href=https://github.com/santhosh-ponnusamy>santhosh-ponnusamy</a><br/> <a href=https://github.com/shruuub>shruuub</a><br/> <a href=https://github.com/shuvashish76>shuvashish76</a><br/> <br/>⚜ Mods comunitarios <br/> <a href=https://t.me/amithm7>amithm7</a><br/> <a href=https://t.me/TronSlayer>Andersen</a><br/> <a href=https://t.me/ppatra>ppatra</a><br/> <a href=https://t.me/InFamous23>InFamous23</a><br/> <a href=https://t.me/TheTr1ckst3r>TheTr1ckst3r</a><br/> <a href=https://t.me/Ch4t4r>Ch4t4r</a><br/> <a href=https://t.me/Billi_ance>Billi_ance</a><br/> <a href=https://t.me/kaliangel>kaliangel</a><br/> <a href=https://github.com/PoneyClairDeLune>PoneyClairDeLune</a><br/> <br/>📌 Geoposicionamiento IP <br/> <a href=https://db-ip.com>db-ip.com</a><br/> <br/>❇ Traducciones <br/> <a href=https://weblate.org/en/>Weblate</a><br/> <br/>🦆 Iconos <br/> <a href=https://github.com/celzero/rethink-app/issues/346>Icons</a><br/> <br/>📦 Compilar servidores <br/> <a href=https://jitpack.io/>JitPack</a><br/> <a href=https://github.com/>GitHub</a><br/> Bloquea las aplicaciones para que no se conecten a Internet. Limitando el acceso a Internet se evita el uso de datos injustificado y se ahorra batería. - dispositivo estaba bloqueado en ese momento.

Para cambiar este comportamiento vaya a la pestaña Universal.]]>
- IP destino está configurada para omitir todas las reglas del cortafuegos universal.

Para cambiarla vaya a Reglas IP en la pestaña Universal.]]>
- aplicación de origen omitió el DNS preferido por el usuario.

Para cambiar este comportamiento, vaya a la pestaña Universal.]]>
-
Para cambiarlo desactive Prevenir las fugas DNS desde la pantalla Configurar DNS.]]>
- aplicación de origen está configurada para omitir las reglas del cortafuegos universal.

Para cambiarlo, vaya a la pantalla Cortafuegos específico de la aplicación.]]>
- HTTP inseguro.

Para cambiarlo vaya a la pestaña Universal.]]>
+ Esta conexión estaba bloqueada porque el dispositivo estaba bloqueado en ese momento.

Para cambiar este comportamiento vaya a la pestaña Universal.
+ Esta conexión se ha permitido porque la IP destino está configurada para omitir todas las reglas del cortafuegos universal.

Para cambiarla vaya a Reglas IP en la pestaña Universal.
+ Esta conexión se bloqueó porque la aplicación de origen omitió el DNS preferido por el usuario.

Para cambiar este comportamiento, vaya a la pestaña Universal.
+ Esta conexión DNS fue desviada y reenviada al punto de acceso DNS preferido por el usuario.

Para cambiarlo desactive Prevenir las fugas DNS desde la pantalla Configurar DNS.
+ Esta conexión se ha permitido porque la aplicación de origen está configurada para omitir las reglas del cortafuegos universal.

Para cambiarlo, vaya a la pantalla Cortafuegos específico de la aplicación.
+ Esta conexión fue bloqueada porque probablemente utilizó HTTP inseguro.

Para cambiarlo vaya a la pestaña Universal.
Permitir (omitir) o bloquear la dirección IP, la subred (rango) IP, el puerto. P.ej.: 10.10.10.10, 10.1.1.*, 10.2.2/24, ffff::/104, [::]:80, [10.1.0.0/16]:80, *.*:80 Estadísticas Cambiar idioma @@ -819,9 +753,9 @@ Aislamiento de aplicaciones ¿Excluir las aplicaciones en la lista del DNS y Firewall\? Confiar - aplicaciones más permitidas - aplicaciones más bloqueadas - dominios más contactados + Aplicaciones más permitidas + Aplicaciones más bloqueadas + Dominios más contactados IPs más contactadas Iniciar Rethink para continuar Mostrar todo @@ -831,7 +765,7 @@ Utilice la autenticación biométrica para desbloquear Rethink Permitir aplicaciones Excluir aplicaciones - dominios más bloqueados + Dominios más bloqueados Omite las reglas del cortafuegos universal. ¿Omitir las reglas del cortafuegos universal para las aplicaciones de la lista\? Permitir/Bloquear en red sin límites (WiFi). @@ -851,12 +785,12 @@ Cancelar Bloqueadas Dominio de confianza (App) - dominio.

Para cambiarla, vaya a la pantalla Cortafuegos específico de la aplicación.]]>
+ Una regla de cortafuegos establecida por el usuario bloqueaba el dominio.

Para cambiarla, vaya a la pantalla Cortafuegos específico de la aplicación.
Bloquear, confiar en este dominio - dominio está configurado como Dominio de confianza.

Para cambiarlo, vaya a la pantalla Cortafuegos específico de la aplicación.]]>
+ Esta conexión se permitió porque el dominio está configurado como Dominio de confianza.

Para cambiarlo, vaya a la pantalla Cortafuegos específico de la aplicación.
No hay reglas de dominio. Dominio (App) - reglas + Reglas Configurar omitir dns y cortafuegos personalizar @@ -870,7 +804,7 @@ Asignar una IP única por solicitud DNS. DNS bloqueado Dominio (Univ) - aplicación de origen está configurada para omitir las reglas de dns y cortafuegos.

Para cambiar, vaya a la pantalla Cortafuegos específico de la aplicación.]]>
+ Se permitió esta conexión porque la aplicación de origen está configurada para omitir las reglas de dns y cortafuegos.

Para cambiar, vaya a la pantalla Cortafuegos específico de la aplicación.
registros Bloquear, confiar en dominio(s) para esta aplicación Elegir DNS alternativo @@ -886,9 +820,9 @@ Filtrado DNS avanzado (experimental) Omitir las reglas universales de DNS y cortafuegos para las aplicaciones de la lista\? Dominio de confianza (Univ) - dominio está bloqueado por el DNS preferido del usuario.

Para cambiar este comportamiento, vaya a la pestaña Configurar DNS.]]>
- dominio está configurado como confiable en las reglas de dominio universal.

Para cambiar este comportamiento, vaya a la pestaña Configurar DNS.]]>
- dominio está configurado como bloqueado en las reglas de dominio universal.

Para cambiar este comportamiento, vaya a la pestaña Configurar DNS.]]>
+ Esta conexión se bloqueó porque el dominio está bloqueado por el DNS preferido del usuario.

Para cambiar este comportamiento, vaya a la pestaña Configurar DNS.
+ Esta conexión se permitió porque el dominio está configurado como confiable en las reglas de dominio universal.

Para cambiar este comportamiento, vaya a la pestaña Configurar DNS.
+ Esta conexión se bloqueó porque el dominio está configurado como bloqueado en las reglas de dominio universal.

Para cambiar este comportamiento, vaya a la pestaña Configurar DNS.
omitirá las reglas universales de dns y cortafuegos Cortafuegos Omitir DNS y cortafuegos @@ -942,7 +876,7 @@ protegido con wireguard protegido con wireguard y dns privado Clave pública copiada. - Orbot es una aplicación proxy gratuita que permite a otras aplicaciones utilizar Internet de forma más segura.\nOrbot utiliza Tor para cifrar su tráfico de Internet, lo que le ayuda a evitar la censura y a defenderse de formas de vigilancia en la red que amenazan la libertad personal y la privacidad. \nProxy TCP: Rethink reenvía a Orbot solo las conexiones TCP de las aplicaciones incluidas.\nProxy HTTP: Rethink establece un túnel HTTP CONNECT hacia Orbot y reenvía solo las conexiones TCP de las aplicaciones incluidas. + Orbot es una aplicación proxy gratuita que permite a otras aplicaciones utilizar Internet de forma más segura. \nOrbot utiliza Tor para cifrar su tráfico de Internet, lo que le ayuda a evitar la censura y defenderse de las formas de vigilancia de la red que amenazan la libertad personal y la privacidad. \nProxy TCP: Rethink reenvía a Orbot sólo las conexiones TCP de las aplicaciones incluidas. \nProxy HTTP: Rethink establece un túnel de CONEXIÓN HTTP a Orbot y reenvía solo las conexiones TCP de las aplicaciones incluidas. Escuchar el puerto Mostrar los primeros 20.000 caracteres No validar los certificados TLS del servidor (habilitar sólo si sabes lo que está haciendo) @@ -952,7 +886,7 @@ Reglas IP/Dominio por aplicación Modificar las reglas IP / Dominio por aplicación. Omitir las reglas DNS y el cortafuegos. - países más bloqueados + Países más bloqueados Clave no válida Número no válido Valor no válido @@ -966,8 +900,8 @@ Longitud de clave incorrecta Direcciones IP Eliminadas todas las reglas de dominio -
Para cambiarlo vaya a Proxy en la pantalla Configurar.]]>
- países más contactados + Esta conexión se ha desviado al proxy preferido por el usuario.

Para cambiarlo vaya a Proxy en la pantalla Configurar.
+ Países más contactados Por proxy número : Debe ser positivo y no más de 65535 @@ -1016,7 +950,7 @@ Relé DNS anónimo alojado en París, Francia, proporcionado por https://cryptostrom.is/. Relé DNS anónimo alojado en los Países Bajos WireGuard VPN no se conecta - DNS se reenvía a %1$s. + DNS se reenvía a %1$s, ICMP (ej: ping) se envía a la red subyacente. No bloquear ninguna petición DNS. Utiliza el punto final de la DNS 1.1.1.1 de Cloudflare. Relé DNS anónimo alojado en Suecia proporcionado por https://cryptostrom.is/. Relé DNS anónimo alojado en EE.UU. - Los Ángeles, CA proporcionado por https://cryptostrom.is/. @@ -1053,6 +987,8 @@ Enrutar todas las aplicaciones Siempre activo Enrutamiento de las aplicaciones restantes + Bucle invertido (experimental) + También activa el ajuste de %1$s. ¿Quiere proceder? Excluye %1$s aplicaciones Detener WireGuard Aplicaciones restantes @@ -1062,9 +998,9 @@ parte de %1$s incluye %1$s aplicaciones Inactivo - Dividir + Dividir (Split) ¡Protegido! Conectado a DNS + Firewall + Proxy. - Nunca usar proxy para DNS + Nunca un proxy DNS No utilice DNS a través de servidores proxy WireGuard siempre activo, SOCKS5 o HTTP. %1$s está deshabilitado porque los servidores proxy están activos en el dispositivo @@ -1073,10 +1009,10 @@ No seleccionado %1$s puede provocar la pérdida de conectividad. Cambia la configuración para permitir que Rethink utilice datos y otros recursos cuando esté en segundo plano. Optimización de la batería - Desactivar %1$s - Aplicaciones reenviadoras de proxy en loopback - Si se desactiva, excluye las aplicaciones reenviadoras de proxy (SOCKS5, HTTP, DNS) del túnel VPN de Rethink. - Excluir la aplicación de todos los proxies + Deshabilitar %1$s + Aplicaciones de reenvío proxy en bucle invertido + Si se desactiva, excluye las aplicaciones proxy (SOCKS5, HTTP, DNS) del túnel VPN de Rethink. + Omitir la aplicación desde todos los proxies Esta aplicación evita los servidores proxy notification Rethink tiene poca memoria. Las acciones del sistema pueden estar limitadas. @@ -1096,7 +1032,7 @@ Autenticación Biométrica Después de 5 minutos Después de 15 minutos - filtrando + Filtrando ¿Eliminar todas las Reglas de Dominio? DNS Privado Aplicación Pausada @@ -1112,8 +1048,8 @@ ultra seguro Para actualizar las listas de bloqueo, descarga la última versión de la app. Términos de Servicio - proveedores más contactados - proveedores más bloqueados + Proveedores más contactados + Proveedores más bloqueados Mostrar información del proveedor Automatización Configurar aplicaciones que pueden iniciar o detener Rethink. @@ -1129,7 +1065,7 @@ Considera solo las redes móviles como medidas, el resto como no medidas. Ninguna Usar el DNS del sistema para dominios no delegados - Instrucciones:\n Al enviar el broadcast es requerido:\n • Key: sender\n • Value: Nombre del paquete de la app\n\n Ejemplo: key:sender value:com.termux\n\n Acciones disponibles:\n • Iniciar VPN – com.celzero.bravedns.intent.action.VPN_START\n • Detener VPN – com.celzero.bravedns.intent.action.VPN_STOP + Instrucciones:\nAl enviar el broadcast es requerido:\n• Key: sender\n• Value: Nombre del paquete de la app.\n\nEjemplo: key:sender value:com.termux\n\nAcciones disponibles:\n\n• Iniciar VPN – com.celzero.bravedns.intent.action.VPN_START\n\n• Detener VPN – com.celzero.bravedns.intent.action.VPN_STOP Introduce los nombres de paquete (separado por comas) se les permite iniciar o detener Rethink. Sin estadísticas Tiempo de espera inactivo @@ -1139,103 +1075,4 @@ Conectar Parámetro Alternativa - Relés - Claro Plus - Oscuro Plus - Escarcha - Has estado usando Rethink durante %1$s días, lo que equivale a un coste de uso de $%2$s. ¿Considerarías hacer una aportación para ayudar a mantener su desarrollo? - Usar el DNS del sistema para dominios no delegados como .lan, .internal, etc. - DNS dividido (experimental) - Reenviar las consultas DNS de las aplicaciones proxificadas a los servidores DNS del proxy. - Automático (Todos los tipos) - Bloquear todas las conexiones al host local - Eliminar estas reglas permitiría que cualquier aplicación se conecte a los dominios que están bloqueados actualmente. - Omitir proxy - Proxy en modo bloqueo - Error de proxy - DNS privado que no existe.]]> - aplicación de origen está configurada paraomitir las reglas del proxy.

Para cambiarlo vaya a la pantallaCortafuegos específico de la aplicación.]]>
- aplicación está en estadopausado.]]> - bloqueo.

Para cambiarlo vaya a la pantallaWireGuard.]]>
- proxy seleccionado para la aplicación no está elegido.

Para cambiarlo vaya aProxy desde la pantallaConfigurar.]]>
- \"Relay WireGuard\" no se puede desactivar - Registros de la aplicación - No aleatorizar el puerto de escucha de WireGuard - Mantener fijos los puertos de escucha de WireGuard (sin aleatorización) en el modo Avanzado. - Mapeo independiente del extremo - Cuando está activado, los sockets UDP mantienen una dirección y un puerto fijos para todos los destinos. - Elija entre varias técnicas anticensura. - Keep-alive TCP más corto - Cerrar rápidamente los sockets TCP sin actividad reciente. - No alterar los paquetes. - Alterar los paquetes según sea necesario. - Alterar los paquetes TCP. - TCP/TLS - Alterar los paquetes TCP o TLS. - Desincronización - Enviar paquetes señuelo según sea necesario. - Configurar cómo funcionan los reintentos en conexiones censuradas. - En caso de fallo, no volver a intentar técnicas anticensura. - Aplicar técnicas anticensura cuando sea necesario. - Elija cualquier opción anticensura para seleccionar una estrategia. - Excluir aplicaciones requiere un nombre de paquete. - No se puede retransmitir, ya está %1$s - No hay WireGuard disponible para retransmitir; añada más. - Desactive “Solo móvil” o la opción SSID para usar retransmisiones - No se pueden cambiar los modos cuando hay proxies en uso. Toque para editar la configuración del proxy. - Cerrar los sockets TCP y UDP inactivos después de esta duración. - Error de retransmisión: %1$s -> %2$s - Solo móvil - Usar esta VPN WireGuard sobre datos móviles. - Añadir / eliminar retransmisión - Las comprobaciones de alcance omiten todas las restricciones de red. En modo Auto, Rethink usa aleatoriamente IPs y nombres de host conectados previamente. - Introduzca el nombre del paquete (ejemplo: net.dinglisch.android.taskerm) - Obtiene detalles del proveedor de servicios para sitios web desde rethinkdns.com, con tecnología de%1$s - principales conexiones activas - Cargar todo - ¿Eliminar las listas de bloqueo en el dispositivo? - Detenerse ante pérdida de red - Cuando está activado, Rethink detiene todos los datos cuando no hay conectividad, como en el modo avión. - Cerrar todo - Espere mientras procesamos su solicitud. - Smart DNS usa aleatoriamente cualquiera de estos resolutores DNS: - El modo Solo móvil no es compatible con las retransmisiones - Reacciona a cambios importantes de red y conectividad. - Sensible - Reacciona a todos los cambios de red y conectividad. - Reacciona a cambios de conectividad relevantes. - Conservador - Reacciona mínimamente a los cambios de red. - Política de cambios de conexión - Usar DNS de respaldo como bypass - Cuando está activado, siempre usa el DNS de respaldo para aplicaciones, dominios e IPs excluidos. - Base de datos - Solo Wi‑Fi - Activar esta VPN WireGuard solo cuando esté conectado a Wi‑Fi o a los identificadores Wi‑Fi especificados (%1$s). - Rethink necesita permiso de ubicación para acceder a los identificadores Wi‑Fi (%1$s). - Para acceder a los identificadores Wi‑Fi (%1$s), active el servicio de ubicación. - Active el servicio de ubicación - Conceder permiso - Activar ubicación - No se pueden usar %1$s y %2$s a la vez - %1$s esta VPN WireGuard cuando esté activo el identificador Wi‑Fi (%2$s)%3$s. - Introduzca el identificador Wi‑Fi (%1$s) - Coincidencia exacta - Coincidencia parcial - Los identificadores Wi‑Fi (%1$s) deben tener 32 caracteres o menos. - ¿Eliminar %1$s? - Programa de estabilidad - Capturar registros de errores para ayudar a mejorar la estabilidad. - Recordar aplicaciones desinstaladas - Cuando está activado, conserva las reglas de la aplicación durante 7 días. - Predeterminado del sistema - WireGuard P2P - Cuando está activado, permite conexiones entrantes iniciadas por pares de WireGuard. - El Gestor de descargas integrado de Android puede fallar porque la VPN está en modoBloqueo. ¿Activar en su lugar el descargador de Rethink? - Activar descargador - Impulsor de ancho de banda - Usa paquetes jumbo dentro del túnel VPN de Rethink. - Siempre medido - Marcar el túnel VPN de Rethink como medido para indicar a las aplicaciones que consuman menos datos. Precaución: esto puede hacer que algunas aplicaciones pausen cargas y descargas grandes, como copias de seguridad. - Inscrito en el programa de estabilidad parafunciones experimentales. diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index a67b32d4e..d96ab77cd 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -10,7 +10,7 @@ متوقف شد! برنامه های مسدود شده همچنان دیوار آتش می شوند. Orbot ویژگی ها و عملکرد Tor را به اندروید می آورد. متوقف شده - RethinkDNS + Re-Rethink DNS و فایروال بسیار قابل تنظیم نصب شناسایی و مسدود کردن تهدیدات امنیتی شبکه @@ -178,7 +178,7 @@ جستجوی نامهای دامنه جستجوی فهرست‌های مسدود VPN (شبکه خصوصی مجازی) - vpn + VPN فعال کردن گزارش‌گیری روی دستگاه حالت را انتخاب کنید به‌روزرسانی جدید! diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 9dba2fe74..3aca43702 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -81,7 +81,7 @@ Jaa laitteessa Rethink on avoimen lähdekoodin ohjelmisto, jota tukee Mozilla Builders MVP -ohjelma. - säännöt + Säännöt WireGuard Vapaa ja avoin lähde anonyymi diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 160bd7dc2..68fd4ba44 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,6 +1,6 @@ - RethinkDNS + Re-Rethink rethink À propos Paramètres @@ -25,7 +25,7 @@ protégé par proxy & DNS privé protégé par proxy HTTP & DNS privé protégé par proxy SOCKS5 & DNS privé - Connecté via RethinkDNS + Connecté via Re-RethinkDNS Connecté via pare-feu Appliquer des règles de pare-feu à toutes les applications en fonction des événements survenant sur l’appareil, par exemple, lorsque l’appareil est verrouillé ou lorsqu’une application particulière n’est pas utilisée. DNS @@ -206,7 +206,7 @@ Thème : %1$s %1$s entrées » Rethink DNS - type + Type application lors de la tentative de connexion sur un réseau Sans abonnement.

Pour la modifier, allez dans l’onglet Toutes les applications.]]>
Empêchez les applications de se connecter à l’Internet. Limiter l’accès à Internet permet d’éviter une utilisation injustifiée des données et d’économiser la batterie. L’exclusion de %1$s pour %2$s en mode confinement peut provoquer des problèmes de connectivité à Internet. @@ -232,7 +232,7 @@ Type Catégories %1$s %2$s]]> - contact + Contact À jour ! %1$s Activer la visibilité du réseau @@ -389,14 +389,14 @@ Application en pause ! DNS Rethink Montrer toutes les apps - système + Système Supprimer la règle IP \? Un problème est survenu. Essayez à nouveau plus tard ! Définir le proxy HTTP(S) de connection DNS (économise la batterie) Auto Icône de l’application - règles + Règles https://builders.mozilla.community/alumni.html DNSCrypt %1$s Relais DNS %1$s @@ -445,7 +445,7 @@ %1$s.]]> Retenter la restauration Restauration terminée, redémarrage de l’application… - app + App Mise à jour complète VPN inactif N @@ -548,7 +548,7 @@ est bloquée sur les réseaux sans abonnement (Wi-Fi) %1$s %2$s dans les catégories : %3$s]]> - recharger + Recharger DNS contourné Aucune règle de pare-feu ne s’applique à cette connexion. Téléchargement des listes de blocage… @@ -636,7 +636,7 @@ Restaurer Une URL ou un nom de domaine avec un joker optionnel : *.test.com, test.com/test Supprimer le proxy DNS de la configuration sauvegardée. - vpn + VPN Transférer les connexions vers le point de terminaison SOCKS5. Arrêter Orbot Afficher l’icône du site web dans les logs DNS (expérimental) @@ -705,7 +705,7 @@ Apparence inconnu Attention ! - liens + Liens hello@celzero.com %1$s listes de blocage en cours d’utilisation Version : v%1$s @@ -770,14 +770,14 @@ Confiner les applications listées \? Confiner Faire confiance - applications les plus bloquées - domaines les plus contactés - domaines les plus bloqués + Applications les plus bloquées + Domaines les plus contactés + Domaines les plus bloqués IP les plus contactées Lancer Rethink pour procéder IP les plus bloquées L\'authentification biométrique n\'est pas prise en charge ou est désactivée sur cet appareil - applications les plus autorisées + Applications les plus autorisées Erreur lors de l\'authentification biométrique Tout afficher Domaine (Appli) @@ -798,7 +798,7 @@ le domaine est autorisé.

Pour modifier cela, accédez à l’écran Pare-feu de l’application.]]>
domaine.

Pour la modifier, accédez à l’écran Pare-feu de l’application.]]>
Bloquer, tolérer ce domaine - règles + Règles Configurer journaux contourner dns & firewall @@ -1063,7 +1063,7 @@ Vous utilisez Rethink depuis %1$s jours, ce qui correspond à un coût d\'utilisation de $%2$s. Envisageriez-vous de devenir sponsor afin de soutenir son développement ? Utiliser le système DNS pour les domaines non délégués Utilisez le système DNS pour les domaines non délégués tels que .lan, .internal, etc. - filtrage + Filtrage Séparer le DNS (expérimental) Transférer les requêtes DNS des applications proxy vers les serveurs DNS du proxy. Bloquer toutes les connexions locales @@ -1120,15 +1120,15 @@ Utiliser ce VPN WireGuard via les données mobiles. Ajouter/Supprimer un relais Les tests d\'accessibilité contournent toutes les restrictions réseau. En mode Auto, Rethink utilise aléatoirement les adresses IP et les noms d\'hôte précédemment utilisés. - fournisseurs les plus contactés - fournisseurs les plus bloqués + Fournisseurs les plus contactés + Fournisseurs les plus bloqués Saisir les noms des paquets (séparés par des virgules) autorisés à démarrer ou à arrêter Rethink. Instructions :\nÉléments requis pour la diffusion :\n• Clé : sender\n• Valeur : Nom du package de l’application appelante\n\nExemple : key : sender value : com.termux\n\nActions prises en charge :\n• Démarrer le VPN : com.celzero.bravedns.intent.action.VPN_START\n• Arrêter le VPN : com.celzero.bravedns.intent.action.VPN_STOP Saisir le nom du package (exemple : net.dinglisch.android.taskerm) Afficher les informations du fournisseur Récupère les informations du fournisseur de services pour les sites web depuis rethinkdns.com, propulsé par %1$s Configurer les applications qui peuvent démarrer ou arrêter Rethink. - connexions actives principales + Connexions actives principales Tout charger Fermer les connexions Fermer toutes les connexions pour %1$s ? diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 98f4d67d9..3bb95bdbc 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1,6 +1,6 @@ - रीथिंकडीएनएस + Re-Rethink रीथिंक हमारे बारे में सेटिंग्स diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 125d056fd..926b1fc5a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,9 +1,9 @@ - RethinkDNS + Re-Rethink Rólunk Hálózatbiztonsági fenyegetések észlelése és blokkolása - szabályok + Szabályok Beállítások Pihenés… Kezdőlap @@ -316,7 +316,7 @@ Domain nevek keresése DNS Tűzfal - vpn + VPN Nyilvános kulcs másolva. RDNS+ DNS-over-HTTPS URL-ként másolva A DNS- és tűzfal-naplók helyi tárolása az eszközön. @@ -430,8 +430,8 @@ Nem lehet szüneteltetni: RethinkDNS nem aktív %1$s letiltása A(z) %1$s kapcsolatvesztést okozhat. Módosítsa a beállítást, hogy a Rethink a háttérben is használhassa az adatforgalmat és egyéb erőforrásokat. - szabályok - típus + Szabályok + Típus Egyéb DNS Rethink DNS %1$s bejegyzések 🔗 @@ -456,17 +456,17 @@ Hibajelentés Nem sikerült lekérni az összeomlási naplókat. Szeretné manuálisan jelenteni a hibát? Email küldése - linkek + Linkek Látogasson el a rethinkdns.com oldalra Hozzájárulás a GitHubon GYIK megtekintése Dokumentumok olvasása Adatvédelmi irányelvek elolvasása - kapcsolat + Kapcsolat Szponzor - alkalmazás + Alkalmazás Írjon nekünk e-mailt - rendszer + Rendszer Alkalmazás adatai VPN profil Értesítési beállítások @@ -1048,7 +1048,7 @@ Ön már %1$s napja használja a Rethink alkalmazást, ami %2$s USD használati költséget jelentene. Megfontolná, hogy támogatásával hozzájáruljon a fejlesztés fenntartásához? A rendszer DNS használata a nem delegált tartományokhoz A rendszer DNS használata a nem delegált tartományokhoz, mint például .lan, .internal stb. - szűrés + Szűrés Split DNS (kísérleti) A proxyn keresztül irányított alkalmazások DNS-lekérdezéseinek továbbítása a proxy DNS-kiszolgálóihoz. Helyi kapcsolatok blokkolása diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 01e2ad264..c96310daa 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -13,7 +13,7 @@ Setel Orbot Berhenti Cari aplikasi berdasarkan uid, nama - vpn + VPN Hentikan Orbot Orbot\t❯ dns pribadi telah aktif @@ -134,7 +134,7 @@ Profil VPN Perhatian! Firewall - muat ulang + Muat ulang Catatan: %1$s aplikasi yang diblokir akan terus diblokir. Menutup… DNSCrypt %1$s @@ -171,14 +171,14 @@ Perangkat Terkunci Tidak ada aturan firewall sesuai koneksi ini. Tetap memblok - tautan + Tautan Yang baru di versi %1$s - RethinkDNS + Re-Rethink ikon_fav Sumber Tidak Diketahui Aplikasi yang Terpasang - aplikasi + Aplikasi hello@celzero.com v%1$s (%2$s) Ikon Aplikasi @@ -195,18 +195,18 @@ DNS + Firewall fdroid Koneksi jaringan app diabaikan - tipe + Tipe Kirimkan email ke : hello@celzero.com DoH Perhatian - sistem + Sistem https://github.com/celzero/rethink-app https://www.rethinkdns.com/ Beralih ke mode DNS/DNS+Firewall untuk mengaktifkan pengaturan ini https://docs.rethinkdns.com/ Terhubung dengan RethinkDNS dan Firewall - Terhubung dengan RethinkDNS + Terhubung dengan Re-RethinkDNS Terhubung dengan Firewall Rethink memblokir %1$s aplikasi baru. Ketuk untuk melihat atau mengubah. Izinkan (pintas/bypass) atau blokir alamat IP, subnet IP (dalam rentang tertentu), Port. Contoh: 10.10.10.10, 10.1.1.*, 10.2.2/24, ffff::/104, [::]:80, [10.1.0.0/16]:80, *.*:80 @@ -393,7 +393,7 @@ Alamat IP: %1$s \n \nNomor port: %2$s - aturan-aturan + Aturan-aturan Koneksi ke host gagal. Coba lagi. Rethink memblokir aplikasi yang baru dipasang, %1$s. Aturan firewall universal dilewati (di-bypass) @@ -403,7 +403,7 @@ DNS dan Firewall (bawaan) Mulai Rethink untuk melanjutkan. Meneruskan ke %1$s - kontak + Kontak Dalam Atur KONEKSI Proksi HTTP(S) Proksi HTTP telah diatur @@ -808,7 +808,7 @@ Mengisolasi aplikasi T IP yang paling banyak terhubung - domain yang paling banyak terhubung + Domain yang paling banyak terhubung Ganti Bahasa Amankan aplikasi ini menggunakan kunci layar Anda Pilih bahasa pilihan Anda @@ -822,9 +822,9 @@ Izinkan / Blokir pada jaringan terukur (Data Seluler). Lewati (Bypass) aturan firewall universal untuk aplikasi yang terdaftar \? Kecualikan dari keduanya, DNS + Firewall. - aplikasi yang paling banyak diblokir - aplikasi yang paling banyak diizinkan - domain yang paling banyak diblokir + Aplikasi yang paling banyak diblokir + Aplikasi yang paling banyak diizinkan + Domain yang paling banyak diblokir IP yang paling banyak diblokir Blokir semua kecuali IP tepercaya. Izinkan aplikasi @@ -855,7 +855,7 @@ Aturan IP Aturan Domain Blokir, percayai domain ini - aturan + Aturan Konfigurasikan catatan lewati (bypass) dns & firewall @@ -1103,7 +1103,7 @@ Anda telah menggunakan Rethink selama %1$s hari, yang berarti Anda harus mengeluarkan biaya penggunaan sebesar $%2$s. Apakah Anda akan mempertimbangkan untuk mensponsori untuk mempertahankan pengembangannya? Gunakan DNS Sistem untuk domain yang tidak didelegasikan Gunakan DNS Sistem untuk domain yang tidak didelegasikan seperti .lan, .internal, dll. - penyaringan + Penyaringan DNS Terpisah (pengujian) Meneruskan kueri DNS dari aplikasi yang diproksi ke server DNS proxy. Blokir semua sambungan host lokal @@ -1144,7 +1144,7 @@ Tutup soket TCP dan UDP yang tidak aktif setelah durasi ini. Ketentuan Layanan Pemeriksaan jangkauan melewati semua batasan jaringan. Dalam mode Otomatis, Rethink secara acak menggunakan IP dan nama host yang terhubung sebelumnya. - penyedia yang paling banyak dihubungi + Penyedia yang paling banyak dihubungi Masukkan nama paket (dipisahkan koma) yang diizinkan untuk memulai atau menghentikan Rethink. Instruksi:\nTambahan yang diperlukan dalam siaran:\n• Kunci: pengirim\n• Nilai: Nama paket aplikasi pemanggil\n\nContoh: kunci:pengirim nilai:com.termux\n\nTindakan yang didukung:\n• Mulai VPN – com.celzero.bravedns.intent.action.VPN_START\n• Hentikan VPN – com.celzero.bravedns.intent.action.VPN_STOP Masukkan nama paket (contoh: net.dinglisch.android.taskerm) @@ -1189,9 +1189,9 @@ Hanya seluler Gunakan VPN WireGuard ini melalui data seluler. Tambahkan / Hapus Relay - penyedia yang paling banyak diblokir + Penyedia yang paling banyak diblokir Tampilkan informasi penyedia - koneksi aktif teratas + Koneksi aktif teratas Mode khusus seluler tidak didukung untuk Relay Tidak bisa relay, sudah %1$s Kesalahan relay: %1$s -> %2$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 16ff99797..731990d36 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -12,7 +12,7 @@ blocca errore dell\'app errore del dns - RethinkDNS + Re-Rethink rethink Info Impostazioni @@ -39,7 +39,7 @@ in attesa protetto con tor non protetto - Connesso a RethinkDNS + Connesso a Re-RethinkDNS RIATTIVA PAUSA DISATTIVA @@ -117,7 +117,7 @@ Firewall DNS e Firewall (predefinito) VPN - vpn + VPN Indirizzo IP copiato. URL copiato Copiato come URL RDNS+ DNS-over-HTTPS @@ -378,8 +378,8 @@ Avvio… Tema: %1$s Impossibile mettere in pausa: Rethink non è attivo - regole - tipo + Regole + Tipo Altro DNS Rethink DNS %1$s elementi » @@ -402,17 +402,17 @@ Segnala bug Impossibile reperire il registro degli arresti anomali. Vuoi segnalare il tuo bug manualmente\? Invia email - collegamenti + Collegamenti Visita rethinkdns.com Contribuisci su GitHub Visualizza le domande frequenti Leggi la documentazione - contatti + Contatti Rethink fa parte del programma Mozilla Builders MVP, FOSS United Foundation, OSOM Products Inc. Sostienici - app + App Scrivici una email - sistema + Sistema Info applicazione Profilo VPN Impostazioni notifiche @@ -532,7 +532,7 @@ %1$s %2$s app elencate delle categorie: %3$s Nota Nessuna regola del firewall trovata - ricarica + Ricarica Sconosciuto Senza nome (%1$s) Errore di connessione al server DNS Proxy. @@ -769,13 +769,13 @@ Aggira app Escludi app IP più contattati - app più bloccate + App più bloccate Escludere le applicazioni elencate da DNS e Firewall\? Isola app Isolare le applicazioni elencate\? - app più consentite - domini più contattati - domini più bloccati + App più consentite + Domini più contattati + Domini più bloccati Mostra tutto Annulla Applica @@ -798,7 +798,7 @@ Questa connessione è stata bloccata perché il dominio è bloccato dal DNS preferito dall\'utente.

Per modificare questo comportamento, accedere alla scheda Configura DNS.
Aggira DNS e firewall Forse bloccato - regole + Regole rete bypassare dns e firewall Non instradare IP privati (sperimentale) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 26dfc119c..53cc1324a 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -63,7 +63,7 @@ %1$s נחסם על המכשיר מותר ב-%1$s %2$s חסום ב-%1$s %2$s - מחובר ל-RethinkDNS + מחובר ל-Re-RethinkDNS מחובר ל-RethinkDNS ולחומת האש המשך DNS @@ -85,7 +85,7 @@ חומת אש DNS וחומת אש (ברירת מחדל) VPN - vpn + VPN כתובת ה-IP הועתקה. כתובת האתר הועתקה בחר מצב diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index a2fde7b6c..46a0ba055 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -124,7 +124,7 @@ DNS ファイアウォール DNSとファイアウォール(デフォルト) - vpn + VPN RDNS+ DNS over HTTPS URL としてコピー モードの選択 HTTP3 @@ -578,7 +578,7 @@ パケット キャプチャがアクティブ パケット キャプチャが無効 閉じる.. - RethinkDNS + Re-RethinkDNS 更新情報 プロキシがアクティブ ルール diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e1a0133e5..54d73ea44 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -263,7 +263,7 @@ 기능 제안하기 브라우저에서 웹페이지를 열 수 없습니다. DNS over HTTPS - vpn + VPN 새로운 업데이트! URL 복사됨 모드 선택 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index c459861ac..8d3cd9fc6 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -1,11 +1,11 @@ Nustatymai - Prisijungta prie RethinkDNS + Prisijungta prie Re-RethinkDNS Prisijungta prie ugniasienės rethink Taikykite ugniasienės taisykles visoms programėlėms pagal įrenginio įvykius, pvz., ugniasienę, kai įrenginys užrakintas arba kai tam tikra programėlė nenaudojama. - RethinkDNS + Re-Rethink Apie Aptik ir blokuok tinklo saugumo grėsmes Pagrindinis @@ -83,7 +83,7 @@ DNS DNS ir ugniasienė (numatytasis) VPN - vpn + VPN Įgalinti registravimą įrenginyje Pasirinkti režimą HTTP3 @@ -188,10 +188,10 @@ Įgalinama… Tema: %1$s Nepavyksta pristabdyti: RethinkDNS neaktyvus - taisyklės - tipas + Taisyklės + Tipas Kitas DNS - RethinkDNS + Re-RethinkDNS %1$s įrašai 🔗 Galite rinktis iš daugiau nei 185 blokavimo sąrašų. Spustelėkite čia, jei norite atsisiųsti arba konfigūruoti blokavimo sąrašus. Leisti arba neleisti atskirų domenų @@ -203,7 +203,7 @@ Siųsti el. laišką Peržiūrėti DUK Parašyti mums el. laišką - sistema + Sistema Programėlės informacija VPN profilis Pranešimų nustatymai @@ -294,7 +294,7 @@ fav_icon Pastaba Nerasta ugniasienės taisyklių - perkrauti + Perkrauti Nežinoma Be pavadinimo (%1$s) Klaida jungiantis prie DNSCrypt serverio. @@ -511,7 +511,7 @@ nežinoma Programėlės informacija nerasta %1$s programėlės - programėlė + Programėlė Gedimo ataskaitai nerasta RethinkDNS reikia leidimo nustatyti vietinį VPN, kad būtų galima užšifruoti įrenginio DNS užklausas, įdiegti tinklo monitorių ir vykdyti ugniasienės taisykles. \n @@ -537,9 +537,9 @@ Siųsti el. laišką Pranešimas apie klaidą Pranešimas apie klaidą - susisiekti + Susisiekti Nepavyko gauti gedimų žurnalų. Ar norite rankiniu būdu pranešti apie klaidą\? - nuorodos + Nuorodos Paremti https://twitter.com/rethinkdns Pasiūlyti funkcijas @@ -622,7 +622,7 @@ play Pridėti domeną RethinkDNS užblokavo %1$s naujas (-ą) programėles (-ę). Bakstelėkite norėdami peržiūrėti arba keisti. - Informuoti, kai Android arba kita VPN programa sustabdo RethinkDNS + Informuoti, kai Android arba kita VPN programa sustabdo Re-RethinkDNS Įspėti apie įrenginio blokavimo sąrašų atsisiuntimo eigą fdroid Įspėti apie ugniasienės klaidas ir anomalijas diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 922f170d8..362fe2a6a 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -3,7 +3,7 @@ Hjem blokker Velkommen - RethinkDNS + Re-Rethink Innstillinger Om tar en lur … diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f70c91e2c..1467321de 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -68,7 +68,7 @@ RethinkDNS is gestopt met het doorsturen van verbindingen naar Orbot, maar Orbot draait misschien op de achtergrond. Ongeldige poort SOCKS5 (TCP) proxy instellen - vpn + VPN HTTP3 VPN altijd aan uitschakelen Altijd VPN @@ -93,7 +93,7 @@ Gekopieerd als RDNS+ DNS-over-HTTPS URL Sla DNS- en firewall-logs lokaal op het apparaat op. Kies de modus - RethinkDNS + Re-Rethink rethink Instellingen een dutje doen… @@ -110,7 +110,7 @@ beschermd met socks proxy beschermd met http proxy niet beschermd - Verbonden met RethinkDNS + Verbonden met Re-RethinkDNS Verbonden met Firewall DNS + Firewall DNS @@ -257,16 +257,16 @@ Begin Kan niet pauzeren: RethinkDNS niet actief Proxy actief - regels + Regels Andere DNS - RethinkDNS + Re-RethinkDNS Blokkeringslijsten op het apparaat Haalt website iconen op van duckduckgo.com. Verbruikt tot 350 mb aan schijfruimte Blokkeren wanneer DNS wordt omzeild Controleer eenmaal per drie dagen of de blocklist is bijgewerkt RethinkDNS is de eenvoudigste manier om de activiteit van apps te controleren, internetcensuur te omzeilen en apps op uw Android-toestel te firewarden. - app - systeem + App + Systeem RethinkDNS is een gratis en open-source project, geleid door ex-ingenieurs van Amazon, IBM en Scientific Games. Privacybeleid App-informatie @@ -319,7 +319,7 @@ \nPoortnummer: %3$s Toont %1$s %2$s apps in categorieën: %3$s Wissen - herladen + Herladen Oke fav_icon Onbekend @@ -410,7 +410,7 @@ Verzend e-mail Kon geen crash logs ophalen. Wilt u uw bug handmatig rapporteren\? Verzend e-mail - links + Links Bezoek rethinkdns.com Bijdragen op GitHub Bekijk FAQ\'s @@ -511,7 +511,7 @@ Uitgeschakeld Domein Misschien geblokkeerd - regels + Regels Configureren netwerk QR Code @@ -577,7 +577,7 @@ PCAP Sluiten… Aanzetten… - type + Type Omzeil Universeel website Domein succesvol toegevoegd @@ -660,7 +660,7 @@ \nOm Rethink\'s DNS te gebruiken moet je in de Android Instellingen \"Privé DNS\" uitschakelen.
Up-to-date! Biometrische authenticatie is niet ondersteund of uitgeschakeld op dit apparaat - contact + Contact Functies voorstellen De eerste 20.000 karakters laten zien IP toegevoegd aan de lijst diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 968e8a624..810d9cf26 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,6 +1,6 @@ - RethinkDNS + Re-Rethink O Rethink Ustawienia RethinkDNS to najłatwiejszy sposób monitorowania aktywności sieciowej, omijania cenzury Internetu i zapory sieciowej na urządzeniu z Androidem. @@ -11,7 +11,7 @@ błąd aplikacji prywatne dns włączone brak zabezpieczienia - Połączone z RethinkDNS + Połączone z Re-RethinkDNS zabezpieczone przez tor & prywatnym dns zabezpieczone przez socks proxy zabezpieczone przez proxy @@ -113,10 +113,10 @@ Usunąć DNSCrypt Relay\? Usuń przekaźnik DNSCrypt z zapisanej konfiguracji. %1$s listy blokowania - najbardziej dozwolone aplikacje - najczęściej blokowane aplikacje - domeny, z którymi najczęściej się łączono - najczęściej blokowane domeny + Najbardziej dozwolone aplikacje + Najczęściej blokowane aplikacje + Domeny, z którymi najczęściej się łączono + Najczęściej blokowane domeny Uruchom Rethink, aby kontynuować Pokaż Wszystko rethink @@ -161,7 +161,7 @@ Albo nasze serwery są wyłączone, albo nie można nawiązać połączenia. Spróbuj ponownie za jakiś czas. VPN Zkopiowano adres IP. - vpn + VPN Skopiowano URL Coś poszło nie tak. Spróbuj ponownie później! DNSCrypt @@ -286,9 +286,9 @@ Temat: %1$s Przeczytaj dokumenty Polityka prywatności - kontakt + Kontakt Napisz do nas - system + System Informacje o aplikacji Profil VPN Ustawienia powiadomień @@ -348,7 +348,7 @@ wykluczone z dns i firewalla omija reguły DNS i firewalla %1$s reguła(y) domeny - typ + Typ Komunikat o aktualizacji listy bloków v%1$s Błąd ładowania pliku dziennika @@ -363,7 +363,7 @@ DNS zablokowany Połączenie zostało dopuszczone, ponieważ aplikacja źródłowa jest ustawiona na obchodzenie reguł dns i zapory.

Aby to zmienić, przejdź do ekranu App-specific Firewall.
To połączenie zostało zablokowane, ponieważ domena jest ustawiona jako zablokowana w regułach domeny uniwersalnej.

Aby zmienić to zachowanie przejdź do zakładki Configure DNS.
- najczęściej kontaktowane adresy IP + Najczęściej kontaktowane adresy IP Niestandardowy adres URL serwera Wewnętrzny Zewnętrzny @@ -386,7 +386,7 @@ Wyświetlam wszystkie aplikacje Wyświetlanie %1$s %2$s aplikacji Nie znaleziono żadnych reguł firewalla - przeładuj + Przeładuj DNS Proxy Pobierz Wersja: %1$s @@ -578,7 +578,7 @@ Pobierz ponownie listy blokujące Wykonuje kopię zapasową ustawień aplikacji, sieci i logów dns do pliku. Spowoduje to ponowne uruchomienie sieci VPN. Przywracanie nie powiodło się - większość zablokowanych adresów IP + Większość zablokowanych adresów IP Przechwytywanie pakietów Wyjście do Logcata Wyjście do folderu Pobrane @@ -590,12 +590,12 @@ Przechwytywanie pakietów aktywne Przechwytywanie pakietów wyłączone Nie można wstrzymać: Rethink nie jest aktywny - zasady - RethinkDNS + Zasady + Re-RethinkDNS Zapobieganie wyciekom DNS RethinkD to najprostszy sposób na monitorowanie aktywności aplikacji, obchodzenie cenzury internetowej i zapory aplikacji na urządzeniu z systemem Android. Wesprzyj nas - aplikacja + Aplikacja Przechwytywanie… Autorzy DNSCrypt @@ -782,14 +782,14 @@ Niebo Wyświetlanie wszystkich list blokujących Ustawiane przez system - linki + Linki resolwery Aplikacja Obejście uniwersalne Brak reguł IP lub portów. BU Skopiuj jako adres URL RDNS+ - reguły + Reguły Włącz blokadę aplikacji PCAP Wył. (domyślnie) @@ -896,7 +896,7 @@ Kraje, z którymi najczęściej się kontaktowano Nie blokuje żadnych żądań DNS. Używa punktu końcowego DNS Cloudflare 1.1.1.1. Ustawianie powiadomienia jako trwałego - najczęściej blokowane kraje + Najczęściej blokowane kraje na aplikację "chroniony za pomocą wireguard" Klucz publiczny diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 308f36a02..4836c9bd0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -37,75 +37,7 @@ Não foi possível iniciar o perfil VPN. Externo Adicionar resolvedor ou relé DNSCrypt - Projeto Rethink Open Source deve sua existência a esses voluntários que dedicam inúmeras horas ao seu desenvolvimento.
-
🛡️ Aplicativo Android
-Anthony Ryan / -BayLee4 / -Ch4t4r / -CodingAttack / -HrBDev / -hussainmohd-a / -ignorante / -véu de ferro / -markwmuller / -Mohammaduvez / -Meu Deus / -Poussinou / -pjosingh / -RohitSurwase / -Shantanu / -Uldiniad
/ -yurtpage
-
🚂 Motor de rede
-alalamav / -bemasc / -dmcardle / -fortuna / -ignoramous / -PeterDaveHello / -santhosh-ponnusamy / -SeanBurford
-
📚 Resolver
-ahmed-tasaly / -amithm7 / -arfshl / -badmojr / -bongochong / -elliotwutingfeng / -georgyo / -GiddyGoatGaming / -Harish-Narayan / -ignoramous / -JakeChampion / -MasterKia / -mtxadmin / -neneeen / - -PeterDaveHello / -santhosh-ponnusamy / -shruuub / -shuvashish76
-
⚜ Moderadores da comunidade
-amithm7 / -Andersen / -ppatra / -InFamous23 / -TheTr1ckst3r / -Ch4t4r / -Billi_ance / -kaliangel / -PoneyClairDeLune
-
📌 Geo-IP
-db-ip.com / IPinfo Lite
-
❇ Traduções
-Weblate
-
🦆 Ícones
-Ícones
-
📦 Servidores de compilação
-JitPack / -GitHub / -Sonatype
-]]>
+ The <i>Rethink Open Source Project</i> owes its existence to these volunteers who devote countless hours on its development.<br/> <br/>🛡️ Android app <br/> <a href=https://github.com/BayLee4>BayLee4</a><br/> <a href=https://github.com/Ch4t4r>Ch4t4r</a><br/> <a href=https://github.com/HrBDev>HrBDev</a><br/> <a href=https://github.com/hussainmohd-a>hussainmohd-a</a><br/> <a href=https://github.com/ignoramous>ignoramous</a><br/> <a href=https://github.com/markwmuller>markwmuller</a><br/> <a href=https://github.com/Mygod>Mygod</a><br/> <a href=https://github.com/Uldiniad>Uldiniad</a><br/> <a href=https://github.com/Poussinou>Poussinou</a><br/> <a href=https://github.com/pjosingh>pjosingh</a><br/> <br/>🚂 Network engine <br/> <a href=https://github.com/alalamav>alalamav</a><br/> <a href=https://github.com/bemasc>bemasc</a><br/> <a href=https://github.com/dmcardle>dmcardle</a><br/> <a href=https://github.com/fortuna>fortuna</a><br/> <a href=https://github.com/ignoramous>ignoramous</a><br/> <a href=https://github.com/Lanius-collaris>Lanius-collaris</a><br/> <a href=https://github.com/PeterDaveHello>PeterDaveHello</a><br/> <a href=https://github.com/santhosh-ponnusamy>santhosh-ponnusamy</a><br/> <a href=https://github.com/SeanBurford>SeanBurford</a><br/> <br/>📚 Resolver <br/> <a href=https://github.com/ahmed-tasaly>ahmed-tasaly</a><br/> <a href=https://github.com/amithm7>amithm7</a><br/> <a href=https://github.com/arfshl>arfshl</a><br/> <a href=https://github.com/badmojr>badmojr</a><br/> <a href=https://github.com/bongochong>bongochong</a><br/> <a href=https://github.com/elliotwutingfeng>elliotwutingfeng</a><br/> <a href=https://github.com/georgyo>georgyo</a><br/> <a href=https://github.com/GiddyGoatGaming>GiddyGoatGaming</a><br/> <a href=https://github.com/Harish-Narayan>Harish-Narayan</a><br/> <a href=https://github.com/ignoramous>ignoramous</a><br/> <a href=https://github.com/JakeChampion>JakeChampion</a><br/> <a href=https://github.com/MasterKia>MasterKia</a><br/> <a href=https://github.com/mtxadmin>mtxadmin</a><br/> <a href=https://github.com/neneeen>neneeen</a><br/> <a href=https://github.com/PeterDaveHello>PeterDaveHello</a><br/> <a href=https://github.com/santhosh-ponnusamy>santhosh-ponnusamy</a><br/> <a href=https://github.com/shruuub>shruuub</a><br/> <a href=https://github.com/shuvashish76>shuvashish76</a><br/> <br/>⚜ Community mods <br/> <a href=https://t.me/amithm7>amithm7</a><br/> <a href=https://t.me/TronSlayer>Andersen</a><br/> <a href=https://t.me/ppatra>ppatra</a><br/> <a href=https://t.me/InFamous23>InFamous23</a><br/> <a href=https://t.me/TheTr1ckst3r>TheTr1ckst3r</a><br/> <a href=https://t.me/Ch4t4r>Ch4t4r</a><br/> <a href=https://t.me/Billi_ance>Billi_ance</a><br/> <a href=https://t.me/kaliangel>kaliangel</a><br/> <a href=https://github.com/PoneyClairDeLune>PoneyClairDeLune</a><br/> <br/>📌 Geo-IP <br/> <a href=https://db-ip.com>db-ip.com</a><br/> <br/>❇ Translations <br/> <a href=https://weblate.org/en/>Weblate</a><br/> <br/>🦆 Icons <br/> <a href=https://github.com/celzero/rethink-app/issues/346>Icons</a><br/> <br/>📦 Build servers <br/> <a href=https://jitpack.io/>JitPack</a><br/> <a href=https://github.com/>GitHub</a><br/> excluído do dns e firewall Proxy %1$s 127.0.0.1 @@ -114,7 +46,7 @@ Informações do aplicativo não encontradas %1$s aplicativos Atenção! - tipo + Tipo Outros DNS para: Você está a um passo de ter um Android seguro e protegido: Veja instantaneamente atividade da rede, bloquear sites, IPs e aplicativos suspeitos. @@ -123,6 +55,7 @@ %1$s entradas » Proxy removido Listas de bloqueio no dispositivo + Encerrando… Vamos começar Encerrando… Rethink DNS @@ -130,16 +63,16 @@ Tema: %1$s Escolha entre mais de 195 listas de bloqueio. Clique aqui para baixar ou configurar listas de bloqueio. Regras de domínio - regras + Regras Mostrar ícone de sites em registros de DNS (experimental) Contribuir no GitHub - O Rethink conta com o apoio da Mozilla Builders, da FOSS United Foundation e do FLOSS/fund. - links + O Rethink é apoiado pela Mozilla Builders, FOSS United Foundation. + Links Acessar rethinkdns.com Perguntas frequentes - aplicativo + Aplicativo Detalhes do aplicativo - sistema + Sistema Novidades na versão %1$s Perfil de VPN Configurações de notificação @@ -168,7 +101,7 @@ Endereço IP inválido Porta inválida Nenhuma regra de firewall encontrada - recarregar + Recarregar Novo aplicativo Permitir em redes Wi-Fi Erro @@ -184,8 +117,8 @@ Nenhuma regra de firewall correspondeu a esta conexão. DNS ignorado Restrito - aplicativo ao tentar se conectar à rede Medida.

Para alterar, acesse a guia Todos os Aplicativos.]]>
- aplicativo ao tentar se conectar à rede Móvel (Móvel).

Para alterar, acesse a guia Universal.]]>
+ Uma regra de firewall definida pelo usuário bloqueou este aplicativo ao tentar se conectar em uma rede móvel.

Para alterar, acesse a guia Todos os aplicativos.
+ Uma regra de firewall universal definida pelo usuário bloqueou este aplicativo ao tentar se conectar em uma rede móvel.

Para alterar, acesse a guia Universal.
Esta conexão foi permitida porque o IP de destino está configurado como IP confiável.

Para alterar, acesse a tela Firewall específico do aplicativo.
Esta conexão foi bloqueada porque o dispositivo estava bloqueado naquele momento.

Para mudar este comportamento, acesse a guia Universal.
Uma regra de firewall definida pelo usuário bloqueou esta conexão UDP.

Para alterar, acesse a guia Universal.
@@ -236,7 +169,7 @@ bloquear sem internet Sobre - RethinkDNS + Re-Rethink Configurações Rethink usa o serviço de acessibilidade para detectar e implementar firewall para aplicativos em segundo plano. Rethink não coleta nem vende qualquer tipo de informação. Rethink está executando a VPN no modo Restrito. Ao interrompê-lo você perderá a conexão com a internet neste perfil de usuário. Você quer continuar mesmo assim? @@ -279,7 +212,7 @@ protegido com tor protegido com dns privado protegido com tor e dns privado - Conectado ao RethinkDNS + Conectado ao Re-RethinkDNS protegido com proxy e dns privado protegido com socks5 e dns privados Orbot é um aplicativo de proxy gratuito que permite que outros aplicativos usem a internet com mais segurança. \nOrbot usa o Tor para criptografar seu tráfego de internet, o que ajuda você a ignorar a censura e a se defender contra formas de vigilância de rede que ameaçam a liberdade e a privacidade pessoais. \nProxy TCP: Rethink encaminha apenas conexões TCP de aplicativos incluídos para Orbot. \nProxy HTTP: Rethink estabelece o túnel HTTP CONNECT para Orbot e encaminha apenas conexões TCP de aplicativos incluídos. @@ -396,7 +329,7 @@ Firewall DNS + Firewall (padrão) VPN - vpn + VPN Endereço IP copiado. URL copiada Copiado como URL de RDNS+ DNS-over-HTTPS @@ -532,7 +465,7 @@ Enviar email Evitar vazamentos de DNS Documentação - contato + Contato Patrocinar Envie-nos um e-mail Não foi encontrado nenhum relatório de erro @@ -639,7 +572,7 @@ DoH Dnscrypt Proxy DNS - Não é possível alterar os modos quando a VPN está bloqueada. Toque para editar as configurações da VPN. + Não é possível alterar entre modos quando a VPN está em modo restrito. Toque para editar configurações da VPN. Sem regras Aplicativo bloqueado Wi-Fi @@ -652,10 +585,10 @@ Aplicativo não está em uso Fonte desconhecida UDP bloqueado - aplicativo.

Para alterar, acesse a guia Todos os Aplicativos.]]>
- primeira vez.

Para alterar, acesse a guia Universal.]]>
- aplicativo ao tentar se conectar à rede Ilimitada.

Para alterar, acesse a guia Todos os Aplicativos.]]>
- aplicativo com IPs que não sejam IPs ignorados.

Para alterar, acesse a guia Todos os Aplicativos.]]>
+ Uma regra de firewall definida pelo usuário bloqueou este aplicativo.

Para alterar, acesse a guia Todos os aplicativos.
+ Este aplicativo fez uma conexão pela primeira vez.

Para alterar, acesse a guia Universal.
+ Uma regra de firewall definida pelo usuário bloqueou este aplicativo ao tentar se conectar em uma rede Wi-Fi.

Para alterar, acesse a guia Todos os aplicativos.
+ Uma regra de firewall definida pelo usuário bloqueou a conexão deste aplicativo com outros IPs ignorados.

Para alterar, acesse a guia Todos os aplicativos.
Uma regra de firewall definida pelo usuário bloqueou o IP / Porta de destino.

Para alterar, acesse a guia Universal.
Uma regra de firewall definida pelo usuário bloqueou o IP de destino.

Para alterar, acesse a tela Firewall específico do aplicativo.
Esta conexão foi permitida porque o <i>IP de destino</i> está configurado para ignorar todas as regras universais de firewall.<br/><br/> Para alterar, acesse a guia <i>Regras de IP<i> em <i>Universal</i>. @@ -797,7 +730,7 @@ Ir para as configurações Aplicar Desativado - Status + Estatísticas Bloquear todos, exceto IPs de confiança. Isolar aplicativos Cancelar @@ -811,7 +744,7 @@ Curinga Descartar Configurar - regras + Regras Escolha o seu idioma preferido Ajude a traduzir este aplicativo registros @@ -903,8 +836,8 @@ Isolar aplicativos listados\? Confiável Mostrar todos - domínios mais contatados - aplicativos mais bloqueados + Domínios mais contatados + Aplicativos mais bloqueados DNS bloqueado Domínio (Univ) MTU @@ -918,7 +851,7 @@ Não é possível configurar proxies quando a VPN está sendo executada somente no modo DNS. Saída para a pasta Downloads Limpar todos os registros relacionados a este aplicativo. - países mais bloqueados + Países mais bloqueados Atributo ausente Valor inválido Seção ausente @@ -939,8 +872,8 @@ Atualização concluída Impulsionar DNS Não funciona em todas as versões do aplicativo - ips mais contatados - ips mais bloqueados + Ips mais contatados + Ips mais bloqueados Erro de sintaxe : As chaves hexadecimais do WireGuard devem ter 64 caracteres (32 bytes) número @@ -971,8 +904,8 @@ Bloquear, confiar neste domínio Agrupar solicitações, respostas de cache, tratamento de erros resiliente %1$s lista(s) de bloqueio - países mais contactados - domínios mais bloqueados + Países mais contactados + Domínios mais bloqueados Chave inválida Número inválido : As chaves base64 do WireGuard devem ter 44 caracteres (32 bytes) @@ -1006,7 +939,7 @@ Sem regras de domínio. T Bloquear conteúdo adulto e pirateado, jogos e encontros on-line e mídias sociais. - aplicativos mais permitidos + Aplicativos mais permitidos Inicie o Rethink para continuar Nenhuma configuração encontrada O arquivo deve ser \".conf\" ou \".zip\" @@ -1053,6 +986,8 @@ Encaminhando aplicativos restantes Incluir aplicativos restantes parte de %1$s + Loopback (experimental) + Também ativa a configuração de %1$s. Continuar? Encaminhar todos os aplicativos Incluir %1$s aplicativo(s) Excluir %1$s aplicativo(s) @@ -1105,9 +1040,11 @@ Você usa o Rethink há %1$s dias, o que representa um custo de uso de $%2$s. Você consideraria patrocinar para sustentar seu desenvolvimento? Use DNS do sistema para domínios não delegados Use o DNS do sistema para domínios não controlados, como .lan, .internal, etc. - filtragem + Filtragem DNS dividido (experimental) Encaminhe consultas DNS de aplicativos proxy para os servidores DNS do proxy. + Tratar regras de DNS como regras de firewall (experimental) + O bloqueio de DNS será ignorado durante a resolução; a decisão será tomada no momento da conexão. Bloquear todas as conexões de host local Excluir todas as regras de domínio? A exclusão dessas regras permitirá que qualquer aplicativo se conecte aos domínios atualmente bloqueados. @@ -1117,11 +1054,4 @@ Proxy de bloqueio Luz Plus Escuro Plus - Padrão - Ação - Conectar - Criteria - Geada - Automático (todos os tipos) - Erro de proxy diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a99e1c78f..e524b9897 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -22,7 +22,7 @@ protegido com socks5 e dns privado protegido com proxy http e dns privado Parar - Conectado a RethinkDNS + Conectado a Re-RethinkDNS Conectado a firewall Monitorizar, bloquear e cifrar pedidos DNS. Para além dos anteriores (DNS e Firewall), evitar também a censura na Internet. @@ -102,13 +102,13 @@ Definido pelo sistema Preto profundo \nPausar/Parar (predefinição):\n\nMostra dois botões, para pausar ou parar Rethink rapidamente. - tipo + Tipo IPv4 \nDNS / DNS + Firewall:\nMostra dois botões para mudar rapidamente entre o modo DNS para poupar bateria e o modo normal DNS + Firewall. Rethink necessita da sua permissão para criar uma VPN local para cifrar os pedidos DNS, implementar um monitor de rede e impor as regras da firewall. \n \nRethink não recolhe informações nem monitoriza as suas atividades. - regras + Regras IPv6 excluída de dns e da firewall Ativar modo firewall. @@ -174,7 +174,7 @@ DNS Firewall VPN - vpn + VPN Ativar registo no dispositivo Escolha o modo HTTP3 @@ -240,7 +240,7 @@ Isto elimina os registos da rede mas não as regras de firewall. Esta operação não é reversível. Continuar? URL copiado contorno universal - RethinkDNS + Re-Rethink Acerca A configurar Orbot… Instalar Orbot? @@ -336,7 +336,7 @@ Não foi possível obter os registos de falhas. Deseja reportar manualmente o seu erro? Envie um e-mail Política de privacidade - aplicação + Aplicação Info da aplicação Definições de notificação Rethink é apoiado por Mozilla Builders, FOSS United Foundation, OSOM Products Inc. @@ -359,14 +359,14 @@ Reportar erros Relatório de erros Enviar e-mail - ligações + Ligações Visitar rethinkdns.com Contribuir no GitHub FAQ Documentação - contactos + Contactos Patrocinador - sistema + Sistema Perfil VPN Novidades da versão %1$s Siga-nos no Twitter @@ -565,7 +565,7 @@ fav_icon Não foram encontradas regras - recarregar + Recarregar Desconhecido Erro ao ligar ao servidor DNSCrypt. Erro ao ligar ao servidor DNS Proxy. @@ -767,10 +767,10 @@ Isolar aplicações Isolar aplicações da lista\? Confiar - aplicações mais permitidas - aplicações mais bloqueadas - domínios mais contactados - domínios mais bloqueados + Aplicações mais permitidas + Aplicações mais bloqueadas + Domínios mais contactados + Domínios mais bloqueados IPs mais contactados IPs mais bloqueados Inicie Rethink para continuar @@ -797,7 +797,7 @@ Configurar Bloquear, confiar domínio(s) para esta aplicação registos - regras + Regras ignorar dns e firewall personalizar Em casos raros, quando o DNS preferencial do utilizador não pode ser usado, é utilizado o DNS de recurso. @@ -934,8 +934,8 @@ Por regras de IP/Domínio da aplicação Regras universais de firewall Modificar regras de IP/Domínio da aplicação. - países mais contactados - países mais bloqueados + Países mais contactados + Países mais bloqueados %1$s em %2$s Erro desconhecido “%s” Erro desconhecido diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 1cf013f2f..7b34a6ac5 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -89,7 +89,7 @@ Domeniu Metacaracter Actiune ceruta - reguli + Reguli Configureaza jurnale retea @@ -200,7 +200,7 @@ protejat cu WireGuard și DNS privat DNS-ul privat este activat neprotejat - Conectat la RethinkDNS + Conectat la Re-RethinkDNS Conectat la Firewall Conectat la RethinkDNS și Firewall Aplică regulile Firewall-ului la toate aplicațiile în funcție de evenimentele de pe dispozitiv; de exemplu, protejează când dispozitivul este blocat sau când o anumită aplicație nu rulează. @@ -246,7 +246,7 @@ DNS (economizor baterie) DNS și Firewall (prestabilit) VPN - vpn + VPN Adresă IP copiată. URL copiat Cheie publică copiată. @@ -450,8 +450,8 @@ Tema: %1$s Optimizare baterie Dezactivare %1$s - reguli - tip + Reguli + Tip Alte DNS Rethink DNS %1$s intrări » @@ -470,13 +470,13 @@ Telegram Raport bug Trimite e-mail - link-uri + Link-uri Vizitează rethinkdns.com Contribuie pe GitHub Vezi documentația Vezi Politica de confidențialitate Sponsor - aplicatia + Aplicatia Scrie-ne Informații aplicație Profil VPN @@ -519,8 +519,8 @@ Trimite e-mail Nu am reușit să colectez jurnalele de erori. Dorești să raportezi manual un bug? Vezi Întrebări Frecvente - contact - sistem + Contact + Sistem Autori Să începem Trimite acest e-mail la: hello@celzero.com @@ -727,7 +727,7 @@ Blochezi conexiunile în rețelele contorizate (mobile) pentru aplicațiile din listă\? Aplicații cu ocolire Nu ai fost găsite reguli pentru Firewall - reîncărcare + Reîncărcare Fără nume (%1$s) Eroare de conectare la serverul DNSCrypt. Eroare de conectare la serverul DNS Proxy. @@ -965,14 +965,14 @@ Copie de rezervă reușită Restaurare nereușită, încearcă din nou %1$s liste de blocat - cele mai contactate tari - cele mai contactate domenii - cele mai blocate aplicatii - cele mai permise aplicatii - cele mai blocate domenii - cele mai contactate IP-uri - cele mai blocate IP-uri - cele mai blocate tari + Cele mai contactate tari + Cele mai contactate domenii + Cele mai blocate aplicatii + Cele mai permise aplicatii + Cele mai blocate domenii + Cele mai contactate IP-uri + Cele mai blocate IP-uri + Cele mai blocate tari Pornește Rethink pentru a începe Valoarea nu e validă Numărul nu e valid @@ -1055,7 +1055,7 @@ Ai utilizat Rethink de %1$s days, care înseamnă un cost de utilizare de $%2$s. Consideri oportună o sponsorizare pentru susținerea dezvoltării sale? Utilizează DNS de sistem pentru domenii nedelegate Utilizează DNS de sistem pentru domenii nedelegate precum .lan, .internal, etc. - filtrare + Filtrare DNS ramificat (experimental) Redirecționează cererile DNS de la aplicațiile care folosesc proxy către serverele DNS proxy. Blochează toate conexiunile la localhost @@ -1114,8 +1114,8 @@ Adaugă / Elimină releu Termeni serviciu Verificările de accesibilitate ocolesc toate restricțiile de rețea. În modul Auto, Rethink utilizează la întâmplare IP-uri și nume-gazdă conectate anterior. - cei mai contactați ISP - cei mai blocați ISP + Cei mai contactați ISP + Cei mai blocați ISP Introdu numele pachetelor (separate prin virgulă) admise să pornească sau să oprească Rethink. Instructiuni:\n Extras cerute la transmitere:\n • Key: expeditor\n • Value: Numele de pachet al aplicației solicitante\n\n Exemplu: key:expeditor value:com.termux\n\n Acțiuni suportate:\n • Start VPN – com.celzero.bravedns.intent.action.VPN_START\n • Stop VPN – com.celzero.bravedns.intent.action.VPN_STOP Introdu numele de pachet (example: net.dinglisch.android.taskerm) @@ -1123,7 +1123,7 @@ Oferă detalii ISP-ului pentru website-uri de la rethinkdns.com, cu sprijinul %1$s Automatizare Configurează aplicațiile care pot porni sau opri Rethink. - top conexiuni active + Top conexiuni active Încarcă tot Închide conexiunile Închizi toate conexiunile pentru %1$s? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fc60e69a2..e55feb10b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -5,11 +5,11 @@ Ссылки Приложения Настройки - защищено с помощью tor и приватного dns + защищено с помощью tor и персонального DNS Брандмауэр Порт Orbot - включен приватный DNS + частные днс включены ПАУЗА Универсальные правила Приложение @@ -23,7 +23,7 @@ Легко настраиваемый DNS и брандмауэр исключить СТОП - приложение + Приложение Настройка TCP Внешний вид @@ -32,27 +32,27 @@ Общее Главная блокировать - Защищено с помощью прокси и персонального dns + защищено с помощью прокси и персонального dns DNS https://docs.rethinkdns.com/ Имя хоста - защищено http-прокси и приватного dns + защищено http-прокси и персональным DNS ПРОДОЛЖИТЬ - правила + Правила Остановить Установить RDNS Plus - Подключен к RethinkDNS + Подключен к Re-RethinkDNS Система изолировать Предупреждение: Rethink был деактивирован DNS + Брандмауэр Внешний вид - тип + Используемый DNS Ничего Пропустить неотслеживаемый - vpn + VPN защищено с помощью tor защищено http-прокси Авторы @@ -60,16 +60,16 @@ Rethink использует службу специальных возможностей для обнаружения и блокирования фоновых приложений. Rethink не собирает и не продает какую-либо информацию. RethinkDNS — это самый простой способ обходить интернет-цензуру, отслеживать и блокировать сетевую активность приложений на вашем Android устройстве. О приложении - Далее + Следующий Свободный и с открытым исходным кодом ожидание dns-сервер не работает защищено прокси - RethinkDNS + Re-Rethink rethink Настройки Обнаружение и блокировка угроз сетевой безопасности - Готово + Закончить Rethink — это программное обеспечение с открытым исходным кодом, поддерживаемое программой MVP Mozilla Builders, FOSS United Foundation. Защищен! Подключен к DNS + брандмауэр. сплю… @@ -78,7 +78,7 @@ Защищено! Подключен к быстрому и безопасному DNS. Защищено! Подключен к брандмауэру. Приостановлен! Заблокированные приложения по-прежнему защищены брандмауэром. - защищено с помощью socks5 и приватного dns + защищено с помощью socks5 и персонального DNS защищено ошибка приложения ошибка dns @@ -246,6 +246,7 @@ добавить fav_icon Удалить все сетевые журналы\? + Остановка… Авторы Удалить резолвер DNSCrypt\? Удалить DNS-прокси из сохраненной конфигурации. @@ -360,7 +361,7 @@ Новое приложение Обход DNS Прокси-сервер DNS - Для этого соединения нет правил брандмауэра. + Никакие правила брандмауэра не соответствуют этому соединению. приложение при попытке подключения в сети Metered.

Для изменения перейдите на вкладку Все приложения.]]>
приложение при попытке подключения в сети Metered (Mobile).

Для изменения перейдите на вкладку Universal.]]>
Невозможно изменить режим когда VPN заблокирован. Нажмите для<u>изменения настроек VPN</u>. @@ -422,7 +423,7 @@ блокируется в (мобильных) сетях с тарификацией В этой категории нет журналов сети. исключен из dns и брандмауэра - изолировано; разрешено только доверенным IP и доменам + изолирован; разрешено только доверенным IP Системный DNS Загрузка не удалась. Попробуйте еще раз. Загрузка списка блокировки @@ -473,8 +474,8 @@ \nНет:\nНе показывает кнопку.\n обходит универсальные правила брандмауэра Включить режим брандмауэра. - Документация - Twitter + Читать документацию + Посетите нашу страницу в Twitter блокируется в сетях без тарификации (wifi) Информация о приложении не найдена Включить разрешение на уведомления на экране Настройки @@ -490,9 +491,9 @@ Rethink — это самый простой способ обходить интернет-цензуру, отслеживать и блокировать сетевую активность приложений на вашем Android устройстве. Отправить письмо При включении этой функции, Rethink перехватывает все пакеты на 53-м порту и перенаправляет их на заданную пользователем конечную точку DNS - Сайт - FAQ - Почта + Посетите rethinkdns.com + Просмотреть часто задаваемые вопросы + Отправьте нам электронное письмо Параметры уведомлений Rethink поддерживается благодаря Mozilla Builders, FOSS United Foundation, FLOSS/fund. Поехали @@ -513,7 +514,7 @@ \n \nНомер порта: %2$s
]]> - Блокировать, если приложение неизвестно + Блокировать, когда исходное приложение неизвестно Имя доверенного лица Установленные Исключено @@ -638,8 +639,8 @@ Отправить письмо Сообщить об ошибке Не удалось получить лог файл сбоя. Вы хотите вручную сообщить о ошибке\? - GitHub - Конфиденциальность + Внесение вклада на GitHub’е + Ознакомиться с политикой конфиденциальности О приложении Профиль VPN Что нового в %1$s @@ -800,7 +801,7 @@ Блокировка вредоносного ПО, фишинга, программ-вымогателей, криптософта и прочих угроз. Блокировка вредоносного ПО, привлекающего внимание, программ шпионов и программ-страшилок. Списков блокировок: %1$s - Не удалось запустить настройки приватного dns. + Не удалось отправить настройки персонального DNS. Очистить журналы Статистика Изменить язык @@ -827,17 +828,17 @@ Изолировать перечисленные приложения\? T Доверять - Чаще всего разрешаемые приложения - Часто блокируемые приложения - самые посещаемые домены - Часто блокируемые домены + Самые разрешаемые приложения + Самые блокируемые приложения + Самые посещаемые домены + Самые блокируемые домены Запустите Rethink, чтобы продолжить Показать все Выберите предпочтительный язык Помогите перевести это приложение - Часто блокируемые IP + Самые блокируемые IP Доверенный - самые встречающиеся ip + Самые встречающиеся IP Домен (приложение) Отмена Применить @@ -864,7 +865,7 @@ Выкл. (по умолчанию) Вывод в Logcat Вывод в папку загрузок - обходит правила dns и брандмауэра + обходит правила DNS и брандмауэра %1$s универсальное(ых) правил(а) %1$s правил(а) домена %1$s подключения(й) @@ -966,13 +967,13 @@ Слушать порт выбрать все Активно - Выключено + Деактивировано SOCKS5 HTTP HTTPS & SOCKS5 быстро медленно - Правила для приложений + Правила для каждого приложения сек мин Постоянно активированный @@ -982,14 +983,14 @@ Публичный ключ скопирован. Показать первые 20,000 символов Не проверять TLS-сертификаты сервера (включайте только если знаете, что делаете) - IP и доменные правила для приложений + IP на каждое приложение / Правила домена Универсальные правила брандмауэра - Настройка IP и доменных правил для приложений. + Изменение IP для каждого приложения / Правил домена. Проксировано
Чтобы изменить зайдите в Proxy из меню Настроить.]]>
Все правила домена были удалены - большинство связываемых стран - большинство заблокированных стран + Большинство связываемых стран + Большинство заблокированных стран : Ключи WireGuard в base64 должны иметь 44 символа (32 байта) : Ключи WireGuard должны занимать 32 байта : Ключи WireGuard в hex должны иметь 64 символа (32 байта) @@ -1050,6 +1051,8 @@ Loopback (экспериментальный) Проверки подключений отправляются для определения достижимости. Маршрутизация собственного трафика через туннель. Если вы не знаете что делаете, выключите его. + Также включает настройку %1$s Вы хотите продолжить? + Loopback (экспериментальный) Изменить правило домена Маршрутизация всех приложений Отредактировано @@ -1075,12 +1078,12 @@ Когда этот параметр отключен, приложения для пересылки прокси-серверов (SOCKS5, HTTP, DNS) исключаются из VPN-туннеля Rethink. Обход приложения со всех прокси-серверов Это приложение обходит прокси-серверы - Уведомление + уведомление Rethink испытывает нехватку памяти. Системные действия могут быть ограничены. %1$s может привести к потере подключения. Измените настройки, чтобы разрешить Rethink использовать данные и другие ресурсы в фоновом режиме. Невыбранный тест - Протоколы + протос (эксперементально) Релеи Вручную @@ -1091,8 +1094,8 @@ Блокируйте рекламу и вредоносы с помощью DNS Выбирайте из 190+ списков, чтобы остановить рекламу, трекеры и вредоносное ПО. нет подключения к сети - Платная мобильная сеть - Рассматривать только мобильные сети как платные, остальные - как не платные. + измерить мобильные сети + Рассматривайте мобильные сети только как платные, остальные - как не платные. светлая plus тёмная plus Биометрическая аутентификация @@ -1106,6 +1109,8 @@ Фильтрация Разделенный DNS (экспериментальный) Перенаправляйте DNS-запросы из прокси-приложений на DNS-серверы прокси-сервера. + Относитесь к правилам DNS как к правилам брандмауэра (экспериментально) + Блокировка DNS будет обойдена во время разрешения; решение будет принято во время подключения. Блокировка всех локальных подключений приватный DNS Обход прокси @@ -1127,7 +1132,7 @@ Выбирайте из различных методов антицензуры. Более короткий TCP keep alive Быстро закрывать TCP-сокеты без какой-либо активности в последнее время. - Антицензура + антицензура Инструменты антицензуры могут обеспечить свободу в интернете, но они могут работать не везде. Используется только при подключению не через прокси. Не изменять пакеты. При необходимости изменять пакеты. @@ -1137,7 +1142,7 @@ Изменять TCP или TLS пакеты. Рассинхронизация При необходимости отправляйте пакеты-приманки. - Стратегия + стратегия Укажите, как будут работать повторные попытки для подключений с цензурой. В случае неудачи никогда не повторяйте попыток борьбы с цензурой. При необходимости применять методы антицензуры. @@ -1157,10 +1162,10 @@ только мобильный Использовать этот VPN WireGuard только при передаче по мобильной связи. Добавить / Удалить relay - Условия + Условия использования Проверки доступности обходят все сетевые ограничения. В режиме Авто Rethink случайным образом использует ранее подключенные IP-адреса и имена хостов. - Популярные провайдеры - Часто блокируемые провайдеры + Самые соеденяемые провайдеры + Самые блокируемые провайдеры Введите имена пакетов (через запятую), которым разрешено запускать или остановливать Rethink. Инструкции:\n Обязательные дополнительные услуги в трансляции:\n • keK: sender\n • Value: имя пакета вызывающего приложения.\n\n Пример: key:sender value:com.termux\n\n Поддерживаемые действия:\n • Запустить VPN – com.celzero.bravedns.intent.action.VPN_START\n • Остановить VPN – com.celzero.bravedns.intent.action.VPN_STOP Введите имя пакета (пример: net.dinglisch.android.taskerm) @@ -1168,7 +1173,7 @@ Получает данные провайдеров для веб-сайтов с сайта rethinkdns.com, работающего на %1$s Автоматизация Настройте приложения, которые могут запускать или останавливать Rethink. - самые активные соеденения + Самые активные соеденения Загрузить все закрыть соеденения Закрыть все соединения за %1$s? @@ -1218,8 +1223,8 @@ Нельзя использовать сразу оба %1$s и %2$s Включено когда сеть Wi-Fi (%2$s) равен %3$s, тогда этот VPN WireGuard %1$s. Введите название сети Wi-Fi (%1$s) - Совпадает точно - Совпадает частично + Точно + Частично Идентификаторы Wi-Fi (%1$s) должны быть до 32 символов. Удалить %1$s? Программа стабилизации @@ -1236,18 +1241,4 @@ Всегда лимитированное Теперь вы участник программы тестирования экспериментальных функций. Обозначать VPN-туннель Rethink как лимитированное подключение, чтобы сократить трафик от приложений. Внимание: может привести к проблемам с загрузкой больших файлов, таких как резервные копии. - Искать по событиям и сообщениям - Выберите файлы, которые вы хотите включить в отчёт об ошибках - Снять выделение - Пожалуйста, выберите хотя бы один файл - Создание архива… - Файл не найден - Невозможно открыть этот тип файла - Вы уверены что хотите удалить \"%1$s\"? - Файл «%1$s» удалён - Не удалось удалить «%1$s» - Нету файлов отчетёв об ошибках - Разрешённые типы записей DNS - Выберите, какие типы записей ресурсов DNS разрешить. - Авто (Все типы) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index ca9ff2400..0b546870a 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -13,7 +13,7 @@ Doména Divoká karta (*) Vyžaduje sa akcia - pravidlá + Pravidlá Nastaviť bezpečnostné záznamy sieť @@ -93,7 +93,7 @@ chránený s Wireguardom chránený s Wireguardom a súkromným DNS súkromne DNS je zapnuté - Pripojený s RethinkDNS + Pripojený s Re-RethinkDNS Pripojený s Firewalom Pripojený s RethinkDNS a Firewalom POKRAČOVAŤ @@ -226,7 +226,7 @@ Firewall DNS a Firewall (predvolené) VPN - vpn + VPN IP adresa skopírovaná. URL skopírované Verejný kľúč skopírovaný. diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index d88097cd4..4cd4b031c 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -181,7 +181,7 @@ DNS (чувар батерије) Заштитни зид DNS и заштитни зид (подразумевано) - vpn + VPN IP адреса је ископирана. URL је ископиран Јавни кључ је ископиран. @@ -339,8 +339,8 @@ Тема: %1$s Није могуће паузирање: Rethink није активан Оптимизација батерије - правила - врста + Правила + Врста Други DNS Rethink DNS %1$s уноси » @@ -363,16 +363,16 @@ Пошаљи е-поруку Извештај о грешки Није успело сакупљање записа о грешки. Да ли желите да ручно пријавите ваш проблем? - везе + Везе Посети rethinkdns.com Допринеси на GitHub-у Погледај ЧПП Прочитај документацију Прочитај полису приватности Спонзориши - апликација + Апликација Пошаљи нам е-поруку - системске + Системске Информације о апликацији VPN профил Подешавања обавештења @@ -665,13 +665,13 @@ Прављење копије је завршено Враћање није успело; покушај поново %1$s блок листи - нејвише дозвољених апликација - највише блокираних апликација - највише контактираних домена + Нејвише дозвољених апликација + Највише блокираних апликација + Највише контактираних домена 280+ локација; висока поузданост; разрешивач се извршава на cloudflare.com Спаја захтеве, кешира одговоре, отпорно решавање грешака - највише контактираних ip-јева - највише блокираних земаља + Највише контактираних ip-јева + Највише блокираних земаља Покрени Rethink за наставак Прикажи све Неисправан кључ @@ -817,7 +817,7 @@ %1$s може резултирати у губитку конекције. Промените подешавање да би сте дозволили Rethink-у да користи податке и друге ресурсе када је у позадини. Rethink је најлакши начин за надгледање активности апликације, заобилажења цензуре интернетам, и блокирање интернета апликацијама на вашем Андроид уређају. Пошаљи е-поруку - контактирај + Контактирај Није било могуће покренути информације о апликацији. Релеји Није било могуће покренути екран са обавештењем апликације. @@ -936,10 +936,10 @@ Ниј могуће направити копију. Покушати поново? Враћање завршено, поновно покретање апликације… Поново покушај враћање - највипе контактираних земаља - највише блокираних ip-јева + Највипе контактираних земаља + Највише блокираних ip-јева Непознати атрибут - највише блокираних домена + Највише блокираних домена Неисправна вредност Недостаје атрибут Грешка у синтакси diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index ea67be27c..2db43ed64 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -8,7 +8,7 @@ மிகவும் தனிப்பயனாக்கக்கூடிய டிஎன்எச் மற்றும் ஃபயர்வால் டிஎன்எச் அமைப்புகள் - "மறுபரிசீலனை டிஎன்எஸ்" + Re-Rethink மறுபரிசீலனை பற்றி அமைப்புகள் @@ -826,7 +826,7 @@ ஃபயர்வால் டி.என்.எச் மற்றும் ஃபயர்வால் (இயல்புநிலை) Vpn - vpn + VPN பொது விசை நகலெடுக்கப்பட்டது. Rdns+ dns-over-https முகவரி ஆக நகலெடுக்கப்பட்டது டி.என்.எச் மற்றும் ஃபயர்வால் பதிவுகளை உள்நாட்டில் சாதனத்தில் சேமிக்கவும். diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index eb196118b..f837cb7a4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -62,7 +62,7 @@ ข้อผิดพลาดของแอป แอปพลิเคชั่น VPN - vpn + VPN คัดลอกคีย์สาธารณะแล้ว เลือกโหมด วันนี้ @@ -370,7 +370,7 @@ ได้รับการปกป้องด้วยพร็อกซี HTTP ได้รับการปกป้องด้วยพร็อกซี ได้รับการปกป้องด้วย wireguard และ dns ส่วนตัว - เชื่อมต่อกับ RethinkDNS + เชื่อมต่อกับ Re-RethinkDNS เชื่อมต่อกับไฟร์วอลล์ ประวัติย่อ หยุดชั่วคราว diff --git a/app/src/main/res/values-tl/strings.xml b/app/src/main/res/values-tl/strings.xml index db2696452..b9cbdd61c 100644 --- a/app/src/main/res/values-tl/strings.xml +++ b/app/src/main/res/values-tl/strings.xml @@ -20,7 +20,7 @@ Dominyo Wildcard Kailangan ng Aksyon - mga patakaran + Mga patakaran I-configure tala Mga Patakaran sa IP @@ -60,7 +60,7 @@ error ng aplikasyon hindi nasusundan Lusutan ang DNS at firewall - Paalala: Na-deactivate na ang RethinkDNS + Paalala: Na-deactivate na ang Re-RethinkDNS Pindutin upang baguhin ang mga setting ng proteksyon Nakasegurong nakakabit sa mabilis at ligtas na DNS. Nakaseguro! Nakakonekta sa Firewall. @@ -71,7 +71,7 @@ DNS Mga Patakaran sa Network Private DNS ay pinagana - Nakakonekta sa RethinkDNS + Nakakonekta sa Re-RethinkDNS Nakakonekta sa Firewall hindi protektado Nakakonekta sa RethinkDNS at Firewall diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 647b4e706..26cfc9983 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -16,7 +16,7 @@ Koruma ayarını değiştirmek için dokunun Korunuyor! Hızlı ve Güvenli DNS\'e Bağlandı. Korunuyor! Güvenlik Duvarına Bağlandı. - RethinkDNS + Re-Rethink Korunuyor! DNS ve Güvenlik Duvarına Bağlandı. Ayarlar Ağ güvenliği tehditlerini tespit edin ve engelleyin @@ -91,7 +91,7 @@ Bağlantı noktası IP sürümünü seçin Etkinleştiriliyor… - iletişim + Iletişim Yenilikler %1$s E-posta sağlayıcısını seçin DNSCrypt Çözümleyici veya Aktarıcı Ekleme @@ -164,7 +164,7 @@ E-posta gönder Katkıda bulunanlar Bağlandı. %1$s kullanımdaki blok listeleri - yeniden yükle + Yeniden yükle IP / Bağlantı Noktası (Uygulama) Kaynak Bilinmiyor DNS Bypass Edildi @@ -249,7 +249,7 @@ Şu anki tema: %1$s Yenilikler E-posta gönder - app, uygulama + App, uygulama VPN profili başlatılamadı. +%1$s Diğer IP\'ler Hariç tutulan @@ -395,7 +395,7 @@ DNS Vekil Sunucusu silinsin mi\? Uygulamaları, IP\'leri, alan adlarını arayın VPN - vpn + VPN %1$s\'a bağlandı. Devam etmek için kapatın. Sunucu adı Hiçbiri @@ -484,7 +484,7 @@ izole edilmiş Devam Et rethinkdns.com adresini ziyaret edin - sistem + Sistem Özel DNS Proxy Harici Port aralığı 1024-65535 arasında olmalıdır @@ -508,7 +508,7 @@ Otomatik Hiçbiri Proxy kaldırıldı - kurallar + Kurallar Blok listesi güncellemeleri hakkında bilgi istemi Telegram %1$s + %2$s diğer uygulama(lar) @@ -577,7 +577,7 @@ Bir şeyler ters gitti. Daha sonra tekrar deneyin! DNSCrypt çözümleyici silindi. Bu özelliği kullanmak için gereken engelleme listeleri (~60MB) indirilsin mi\? - tür + Tür İsteyen uygulamaların bütün kullanılabilir ağlara bağlanmasına izin verin. Bu uygulamalar Rethink’in VPN tünelini isteğe bağlı olarak atlayabilir. Zoom ve Meet gibi bazı sesli/görüntülü konferans uygulamalarının düzgün çalışması için gerekebilir. Uygulamanın yeni sürümü web sitesinden indirilebilir. Devam etmek istiyor musunuz\? Zaten en son sürümü kullanıyorsunuz. @@ -678,7 +678,7 @@ ]]>
Özel Sunucu URL\'si Her üç günde bir blok listesi güncellemelerini kontrol edin - bağlantılar + Bağlantılar Hata raporu Yakalanıyor… Gizlilik @@ -801,7 +801,7 @@ Dikkat yazılımlarını, casus yazılımları, korkutucu yazılımları engelleyin. Kötü amaçlı yazılımları, fidye yazılımlarını, kripto yazılımlarını, kimlik avcılarını ve diğer tehditleri engelleyin. %1$s engel listesi - kurallar + Kurallar Engelle, bu uygulama için alan adlarına güven Belki Engellendi Yapılandır @@ -896,7 +896,7 @@ Gelişmiş DNS filtreleme (deneysel) QR kod %1$s yalnızca Rethink\'in DNS\'si ile çalışır - en çok i̇leti̇şi̇me geçi̇len i̇p\'ler + En çok i̇leti̇şi̇me geçi̇len i̇p\'ler Güvenilir IP\'ler hariç tümünü engelleyin. Uygulama başına IP / Etki alanı kurallarını değiştirin. HTTPS & SOCKS5 @@ -905,7 +905,7 @@ : WireGuard base64 anahtarları 44 karakter (32 bayt) olmalıdır Evrensel güvenlik duvarı kurallarını atlayın. DNS %1$s adresine yönlendirildi. - en çok engellenen IP\'ler + En çok engellenen IP\'ler Ölçülmemiş (WiFi) ağda İzin Ver / Engelle. Biyometrik kimlik doğrulama bu cihazda desteklenmiyor veya devre dışı bırakılmış Uygulamaları hariç tut @@ -931,7 +931,7 @@ Güvenlik Duvarı %1$s sorgular alan adı Güvenilir alan adı olarak ayarlandı. Değiştirmek için Uygulamaya Özel Güvenlik Duvarı ekranına gidin.]]> - en çok iletişim kurulan alan adları + En çok iletişim kurulan alan adları gün hızlı Bilinmeyen hata @@ -946,9 +946,9 @@ listelenen uygulamalarını izole edin\? Tüm uygulamaları dahil et DNS Sunucuları - en çok iletişim kurulan ülkeler + En çok iletişim kurulan ülkeler Herhangi bir DNS isteğini engellemez. Cloudflare\'in 1.1.1.1 DNS uç noktasını kullanır. - en çok engellenen ülkeler + En çok engellenen ülkeler uygulama başına Engelle, bu alan adına güven Alan adı (Univ) @@ -986,7 +986,7 @@ Alan adı (Uygulama) Kapalı (varsayılan) VPN yalnızca DNS modunda çalışırken proxy\'ler kurulamıyor. - en çok engellenen alan adları + En çok engellenen alan adları tümünü seç alan adı kullanıcının tercih ettiği DNS tarafından engellendi. Bu davranışı değiştirmek için DNS Yapılandır sekmesine gidin.]]> Yetişkin filtresi, tüm yetişkin, pornografik ve açık sitelere erişimi engeller. Proxy veya VPN\'leri, karışık içerikli siteleri engellemez. Reddit gibi sitelere izin verir. Google ve Bing Güvenli Mod\'a ayarlanmıştır. @@ -1111,7 +1111,7 @@ Rethink\'i %1$s gündür kullanıyorsunuz, bu da $%2$s kullanım maliyetine denk geliyor. Gelişimini sürdürmek için sponsor olmayı düşünür müsünüz? Yetkisiz alanlar için Sistem DNS\'i kullan .lan, .internal gibi yetkisiz alanlar için Sistem DNS\'i kullan. - filtreleme + Filtreleme Bölünmüş DNS (deneysel) Proxy kullanan uygulamaların DNS sorgularını proxy\'nin DNS sunucularına yönlendir. Tüm yerel ana bilgisayar bağlantılarını engelle @@ -1172,8 +1172,8 @@ Aktarım Ekle / Kaldır Hizmet Şartları Erişilebilirlik kontrolleri tüm ağ kısıtlamalarını atlar. Otomatik modda, Rethink daha önce bağlandığı IP\'leri ve ana bilgisayar adlarını rastgele kullanır. - en çok iletişime geçilen sağlayıcılar - en çok engellenen sağlayıcılar + En çok iletişime geçilen sağlayıcılar + En çok engellenen sağlayıcılar Rethink\'i başlatmasına veya durdurmasına izin verilen paket adlarını girin (virgülle ayrılmış). Talimatlar:\n Yayında gerekli ekstralar:\n • Anahtar: sender\n • Değer: Çağıran uygulamanın paket adı\n\nÖrnek: anahtar:sender değer:com.termux\n\n Desteklenen eylemler:\n • VPN Başlat – com.celzero.bravedns.intent.action.VPN_START\n • VPN Durdur – com.celzero.bravedns.intent.action.VPN_STOP Paket adını girin (örnek: net.dinglisch.android.taskerm) @@ -1181,7 +1181,7 @@ Web siteleri için hizmet sağlayıcı bilgilerini rethinkdns.com\'dan alır, %1$s tarafından desteklenir Otomasyon Rethink\'i başlatabilecek veya durdurabilecek uygulamaları yapılandırın. - en aktif bağlantılar + En aktif bağlantılar Tümünü Yükle Bağlantıları Kapat %1$s için tüm bağlantıları kapatmak ister misiniz? diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6c56970fb..bcce235de 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -19,7 +19,7 @@ Захищено! Підключено до Брандмауера. Зупинити Налаштуваннях - RethinkDNS + Re-Rethink rethink DNS та брандмауер, що легко налаштовуються Rethink запускає VPN у режимі Блокування. Його зупинка призведе до втрати підключення до Інтернету для цього профілю користувача. Ви все одно хочете продовжити\? @@ -51,7 +51,7 @@ захищено проксі ввімкнено приватний dns не захищено - Підключено до RethinkDNS + Підключено до Re-RethinkDNS Підключено до Брандмауера Підключено до RethinkDNS та Брандмауера ПРОДОВЖИТИ @@ -130,7 +130,7 @@ Відмовлено в підключенні до мережі для застосунку Застосунок призупинено! Застосунок у стані паузи, відновити, щоб продовжити - Повідомляти, коли Android або інший VPN-застосунок зупиняє RethinkDNS + Повідомляти, коли Android або інший VPN-застосунок зупиняє Re-RethinkDNS Системні застосунки Немає такого застосунку Інформація про застосунок відсутня. Можливо, цей застосунок було видалено\? @@ -166,7 +166,7 @@ Інформація про застосунок не знайдено Тема: %1$s Rethink — це найпростіший спосіб відстежувати активність застосунків, обходити інтернет-цензуру та брандмауери на вашому пристрої Android. - застосунок + Застосунок Інформація про застосунок Перекласти Відсутнє @@ -313,8 +313,8 @@ Правила застосовуються лише до цього застосунку. домен встановлено як довірений домен.

Щоб змінити, перейдіть на екран Брандмауер для конкретного додатка.]]>
RethinkDNS заблокував новий застосунок %1$s . Натисніть, щоб переглянути або змінити. - найбільш дозволені застосунки - найбільш заблоковані застосунки + Найбільш дозволені застосунки + Найбільш заблоковані застосунки Вимкнути Постійно ввімкнений VPN Перейти до налаштуваннях Постійно ввімкнений VPN @@ -433,7 +433,7 @@ Спонсорувати Блокувати UDP, крім DNS та NTP Правила IP та портів - посилання + Посилання допомога та зворотний зв\'язок Блокувати у разі обходу DNS Зміна заблокованих та довірених правил для доменів, IP чи портів. @@ -444,7 +444,7 @@ Читати політику конфіденційності Відвідайте вебсайт Звіт про помилку - зв\'язок + Зв\'язок Telegram Переглянути ЧаПи Зробити внесок на GitHub @@ -456,21 +456,21 @@ Виберіть бажану мову DNS проксі Вибрати резервний DNS - найбільш контактуючі ip + Найбільш контактуючі ip Підключенно до %1$s - найбільш заблоковані ip + Найбільш заблоковані ip Встановити HTTP(S) ПІДКЛЮЧЕННЯ проксі - найбільш контактуючі домени + Найбільш контактуючі домени Логування DNS запитів вимкнено. Для повторного увімкнення перейдіть у Налаштування та увімкніть Увімкнути логування на пристрої. Пошук застосунків за назвою Виберати версію IP - найбільш контактуючі країни - найбільш заблоковані країни + Найбільш контактуючі країни + Найбільш заблоковані країни У рідкісних випадках, коли бажаний користувачем DNS неможливо встановити, використовується резервний DNS. DNSCrypt Блоклисти на пристрої вже мають останню версію v%1$s. DNSCrypt: %1$s розв\'язувачів імен (розв\'язуют доменні імена в IP-адреси) - найбільш заблоковані домени + Найбільш заблоковані домени DNS через HTTPS Показати усе Нове оновлення! @@ -484,7 +484,7 @@ Оновлення неможливо виконати зараз, будь ласка, спробуйте пізніше. Пошук доменів Неможливо оновити зараз - vpn + VPN Ви вже використовуєте останню версію. Пошук IP Актуально! @@ -541,7 +541,7 @@ Стан Вилучити HTTP3 - тип + Тип Невідома помилка Захищено! Підключений до DNS + брандмауер + проксі. Інший DNS @@ -694,7 +694,7 @@ Користувкий DNS-проксі Ім\'я хосту порожнє Помилка з\'єднання з host-вузлом. Спробуйте ще раз. - система + Система Почнемо Резолвер Діапазон портів повинен бути від 1024-65535 @@ -837,7 +837,7 @@ Не вдається призупинити: Rethink неактивнен Rethink потрібен дозвіл для налаштування локального VPN-сервісу та шифрування запитів DNS з вашого пристроя, розгортання мережевого монітора та забезпечення функціонування фаєрволу. \n\nRethink не збирає жодних даних і жодним чином не шпигує за вами. %1$s може призвести до втрати підключення. Змініть налаштування, щоб дозволити Rethink використовувати дані та інші ресурси у фоновому режимі. - правила + Правила %1$s записів Доменні правила Блоклисти пристроя @@ -1074,7 +1074,7 @@ Ви користуєтеся Rethink протягом %1$s днів, що перекладається як вартість використання $%2$s. Чи розглядаєте ви можливість спонсорування для підтримки його розвитку? Використовувати системний DNS для неделегованих доменів Використовуйте системний DNS для неделегованих доменів, таких як .lan, .internal, тощо. - фільтрація + Фільтрація Розділений DNS (експериментальний) Пересилати DNS-запити від проксі-програм на DNS-сервери проксі-сервера. Блокувати всі підключення до локального хоста @@ -1130,8 +1130,8 @@ Додати /Вилучити реле Умови надання послуг Перевірки досяжності обходять усі мережеві обмеження. В режимі Auto Rethink випадковим чином використовує раніше підключені IP-адреси та імена хостів. - найчастіше контактовані постачальники - більшість заблокованих провайдерів + Найчастіше контактовані постачальники + Більшість заблокованих провайдерів Введіть назви пакетів (розділені комами), яким дозволено запускати або зупиняти Rethink. Інструкції:\nНеобхідні додаткові елементи в трансляції:\n• Ключ: відправник\n• Значення: Назва пакета програми, що викликає\n\nПриклад: ключ: відправник значення: com.termux\n\nПідтримувані дії:\n• Запустити VPN – com.celzero.bravedns.intent.action.VPN_START\n• Зупинити VPN – com.celzero.bravedns.intent.action.VPN_STOP Введіть назву пакета (приклад: net.dinglisch.android.taskerm) @@ -1139,7 +1139,7 @@ Отримує інформацію про постачальників послуг для веб-сайтів з rethinkdns.com, що працює на %1$s Автоматизація Налаштуйте програми, які можуть запускати або зупиняти Rethink. - найактивніші з\'єднання + Найактивніші з\'єднання Завантажити все Тісні зв\'язки Закрити всі з’єднання для %1$s? diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index b3fedcb38..de475db50 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -1,6 +1,6 @@ - ریتھینک ڈی این ایس + Re-Rethink کے بارے میں ترتیبات اعدادوشمار diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 20a906217..6fa24f208 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -63,7 +63,7 @@ đang chờ máy chủ dns ngừng hoạt động "đã được bảo vệ bởi proxy & dns riêng tư" - Đã kết nối với RethinkDNS + Đã kết nối với Re-RethinkDNS NGƯNG DNS Tất cả những điều trên + bỏ qua kiểm duyệt Internet. @@ -155,7 +155,7 @@ Tường lửa DNS và Tường lửa (mặc định) VPN - vpn + VPN Đã sao chép địa chỉ IP. Đã sao chép URL Bật ghi nhật ký trên thiết bị @@ -263,7 +263,7 @@ bị chặn không rõ Không tìm thấy thông tin ứng dụng - Mở khoá RethinkDNS + Mở khoá Re-RethinkDNS Sử dụng xác thực sinh trắc học để mở khóa Rethink Lỗi xác thực sinh trắc học ứng dụng %1$s @@ -289,7 +289,7 @@ Thiết lập Tor-as-a-Proxy (Orbot) chỉ bằng một cú nhấp. Rethink đã ngừng chuyển tiếp kết nối tới Orbot, nhưng Orbot có thể đang chạy nền. Rethink được thiết lập để chuyển tiếp các truy vấn DNS tới Orbot. Thay đổi theo yêu cầu. - Orbot là ứng dụng proxy miễn phí hỗ trợ các ứng dụng khác sử dụng Internet an toàn hơn. \nOrbot sử dụng Tor để mã hóa lưu lượng truy cập Internet giúp bạn vượt qua kiểm duyệt và chống lại các hình thức giám sát mạng đe dọa quyền tự do và quyền riêng tư cá nhân. \nTCP Proxy: RethinkDNS chỉ chuyển tiếp các kết nối TCP của tất cả trừ các ứng dụng bị loại trừ tới Orbot. \nProxy HTTP: RethinkDNSđặt proxy HTTP nhưng không bắt buộc các ứng dụng phải sử dụng nó. Thông thường, các trình duyệt như Chrome và Firefox sử dụng nó. Các ứng dụng sử dụng proxy HTTP(S) sẽ bỏ qua cả quy tắc Tường lửa và DNS. + Orbot là ứng dụng proxy miễn phí hỗ trợ các ứng dụng khác sử dụng Internet an toàn hơn. \nOrbot sử dụng Tor để mã hóa lưu lượng truy cập Internet giúp bạn vượt qua kiểm duyệt và chống lại các hình thức giám sát mạng đe dọa quyền tự do và quyền riêng tư cá nhân. \nTCP Proxy: RethinkDNS chỉ chuyển tiếp các kết nối TCP của tất cả trừ các ứng dụng bị loại trừ tới Orbot. \nProxy HTTP: Re-RethinkDNSđặt proxy HTTP nhưng không bắt buộc các ứng dụng phải sử dụng nó. Thông thường, các trình duyệt như Chrome và Firefox sử dụng nó. Các ứng dụng sử dụng proxy HTTP(S) sẽ bỏ qua cả quy tắc Tường lửa và DNS. Tắt proxy HTTP(S) và/hoặc SOCKS5 để tiếp tục thiết lập Tor-as-a-proxy. Khi bật nguồn thiết bị, khởi động Rethink nếu nó vẫn đang chạy trước khi tắt máy. Khi được bật, Rethink sẽ sử dụng tất cả các mạng khả dụng để kết nối với Internet. Nếu không, nó sẽ sử dụng bất kỳ mạng nào được Android coi là đang hoạt động. @@ -309,7 +309,7 @@ bỏ qua các quy tắc tường lửa phổ quát Xác thực sinh trắc học không được hỗ trợ hoặc bị tắt trên thiết bị này Kích hoạt chế độ tường lửa. - quy tắc + Quy tắc Cấu hình bỏ qua phổ quát bỏ qua dns & tường lửa @@ -343,7 +343,7 @@ Kích hoạt RDNS+ Đang đóng… Đang bật… - loại + Loại Quy tắc tên miền Đóng góp trên GitHub DoH @@ -406,7 +406,7 @@ hello@celzero.com Cài đặt thông báo Rethink DNS - quy tắc + Quy tắc %1$s mục » Tác giả Chọn từ hơn 195 danh sách chặn. Nhấn vào đây để tải xuống hoặc định cấu hình danh sách chặn. @@ -422,18 +422,18 @@ Báo cáo lỗi Gửi email Xem câu hỏi thường gặp - liên hệ + Liên hệ Gửi email cho chúng tôi Gửi email - liên kết + Liên kết Báo cáo lỗi Truy cập rethinkdns.com Đọc chính sách bảo mật Lỗi khi tải tệp nhật ký DoH %1$s Nhà tài trợ - ứng dụng - hệ thống + Ứng dụng + Hệ thống Thông tin ứng dụng Có gì mới trong %1$s Nội bộ @@ -503,7 +503,7 @@ Không rõ Ghi chú Không tìm thấy quy tắc tường lửa nào - tải lại + Tải lại (ngẫu nhiên) MTU SOCKS5 @@ -842,7 +842,7 @@ Giá trị không hợp lệ Khóa không hợp lệ Khởi động Rethink để tiếp tục - các quốc gia bị chặn nhiều nhất + Các quốc gia bị chặn nhiều nhất Các IP bị chặn nhiều nhất Các IP được liên hệ nhiều nhất Các tên miền bị chặn nhiều nhất diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d9459bc57..3db10e467 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -441,7 +441,7 @@ 无效 IP 地址、子网或端口。 排除 继续 - vpn + VPN 允许应用请求访问所有可用网络。这些应用可能按需绕过 RethinkDNS 的 VPN 隧道。一些如 Zoom 和 Meet 的音频/视频会议应用可能会需要按需绕行以正常运行。 HTTP 代理已设置。 在非 VPN 模式下使用 HTTPS/SOCKS5 的 Orbot。 @@ -568,7 +568,7 @@ https://docs.rethinkdns.com/ 已放行 复制为 RDNS+ URL - RethinkDNS + Re-Rethink 一款可高度自定义的 DNS 与防火墙软件 rethink 关于 @@ -587,7 +587,7 @@ 已由 Tor 保护 受私人 DNS 保护 私人 DNS 已启用 - 已连接至 RethinkDNS + 已连接至 Re-RethinkDNS 根据设备事件应用防火墙规则,如在设备被锁定时或未使用某一应用程序时拦截。 恢复 #1:阻止某应用联网。 \n #2:阻止任意应用连接至某特定 IP 。 \n #3:在设备锁定时阻止所有连接(全局拦截规则)。 \n #4:阻止任意未使用的应用程序连接(全局拦截规则)。 \n #5:阻止未知应用的连接(全局拦截规则)。 \n #6:拦截除 DNS 外的所有 UDP 流量(全局拦截规则)。 \n #7:拦截绕过 DNS 的流量(全局拦截规则)。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 489b00fe8..f9f4dc37e 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -74,7 +74,7 @@ 排除 超過 280 個地方;更長嘅在線時間;喺 cloudflare.com 上面行嘅中間解析服務 Rethink DNS - RethinkDNS + Re-Rethink rethink 關於 設定 @@ -104,7 +104,7 @@ 被代理保護住 私人 DNS 開着咗 未保護 - 連線咗去 RethinkDNS + 連線咗去 Re-RethinkDNS 連線咗去防火牆 連線咗去 RethinkDNS 同防火牆 恢復 @@ -171,7 +171,7 @@ 搵網域或者 IP 搵 IP 搵網域 - vpn + VPN 複製咗 URL HTTP3 揀 IP 版本 @@ -331,7 +331,7 @@ 睇文檔 聯繫我哋 贊助 - app + App 電郵聯繫 系統 App 資料 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 538785734..0dad0c246 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,6 +1,6 @@ - RethinkDNS + Re-Rethink rethink 關於 設定 @@ -106,7 +106,7 @@ 防火牆 DNS 與防火牆(預設) VPN - vpn + VPN 已複製 IP 位址。 已複製 URL 已作為 RDNS+ DNS-over-HTTPS URL 複製 @@ -153,7 +153,7 @@ 攔截 權限 - 無障礙設定 已連線至 RethinkDNS 與防火牆 - 已連線至 RethinkDNS + 已連線至 Re-RethinkDNS 已由 HTTP 代理與私人 DNS 保護 已由 SOCKS5 代理保護 已由 HTTP 代理保護 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2be402fd3..3fd121f91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,11 +1,9 @@ - Rethink + Re-Rethink rethink - RETHINK α About Settings Stats - Proc taking a nap… A highly customizable DNS and Firewall Detect and block network security threats @@ -24,6 +22,7 @@ Cancel Apply Dismiss + Done All Allowed Maybe Blocked @@ -32,7 +31,7 @@ Domain Wildcard Action Required - rules + Rules Configure Logs Network @@ -47,6 +46,10 @@ IP Rules Domain Rules Delete + Filter + Refresh + Generate keys + Copy public key Proceed Private key (generated) @@ -55,11 +58,47 @@ MTU (auto) Include - select all + Select All + Unselect All Active Inactive - Checking… - Connecting… + RPN availability + Live status of reachable RPN locations. + Create custom IP and domain firewall rules. + All locations + (Max %1$d) + server + servers + %1$d servers available + Maximum 5 servers can be selected + At least one server should be selected + IP and port + Host and port + Strength + TCP Proxy + Fast proxy servers combined with affordability. Ideal solution for individuals. A reliable and secure way to access the internet anonymously. + Choose a plan + 1 Month / 1.99 + ( 1.99 / month ) + 3 Months / 3.99 + ( 1.33 / month ) + 6 Months / 5.99 + ( 0.99 / month ) + purchase + Restore Purchase + Terms and Conditions + Rethink DNS + Firewall are anti-surveillance tools first and foremost: It is absolutely against our ethos, then, to track users of our apps and services, or sell any information whatsoever to any third-party in any form, pseudo-anonymized, de-anonymized, anonymized, aggregated, or otherwise. We do not collect, sell, license, share any data (personally identifiable or otherwise) from the Rethink DNS + Firewall Android app. The code is open source so you may inspect it yourself or deploy to your servers.\nPersonally identifiable information: Any data that can be traced back to the user including (but not limited to) a combination of IP addresses, name, device-id, advertisement-id, and location.\n\nNon personally identifiable information: Aggregated (summarized) data and anonymized data scrubbed off any personally identifiable information.\nCollect: Data stored by Rethink.\nSell: Data sold to third parties.\nLicense: Restricted access to data collected by Rethink to third parties.\nShare: Data shared with third parties.\nAggregate: Data combined with other data to form a summary of information.\nAnonymize: Data scrubbed off any personally identifiable information.\nDe-anonymize: Data scrubbed off any personally identifiable information.\nShare: Permanent access to data collected by Rethink to third parties.\n\nWe do not collect, sell, license, share any data (personally identifiable or otherwise) from the Rethink DNS + Firewall Android app. The code is open source so you may inspect it yourself or deploy to your servers. + Payment Successful + Your payment was successful. You can now enjoy the proxy features of Rethink. + continue + Awaiting payment + Please wait while we process your payment. This may take a few seconds. + Payment Failed + Your payment failed. Please try again. + try again + TCP Proxy icon + VPN not active + Rethink VPN is not active. Start it to run the test. SOCKS5 HTTP HTTPS & SOCKS5 @@ -71,6 +110,12 @@ sec min hr + Countries + Low + Medium + High + Critical + Source day Preshared key (insecure) @@ -78,17 +123,10 @@ Persistent keepalive Allowed IPs Save - Saving… DoT ODoH Cache Status - Plan - Valid until - Cancelled - Expired - Grace Period - Paused Remove Last DNS-over-TLS @@ -100,6 +138,9 @@ RDNS Starting Overall + Upload + Info + Data Stopped Idle private @@ -110,11 +151,11 @@ on-device IP Unselected - Notification + notification Mastodon Matrix Reddit - Test + test protos (experimental) Service Providers @@ -127,7 +168,6 @@ Action Connect Criteria - All Apps Rethink is experiencing low memory. System actions may be limited. @@ -139,9 +179,6 @@ %1$s %2$s %1$s: %2$s %1$s / %2$s - %1$s · %2$s - %1$s (%2$s) - %1$s, %2$s %1$s %2$s %3$s %1$s:%2$s:%3$s 🐘 @@ -202,17 +239,17 @@ Block (unmetered) Block (metered) Isolate - Bypass DNS and Firewall + Bypass DNS & Firewall Bypass Universal Exclude - Allow - Block + allow + block Bypass Universal - Exclude - Isolate - Untracked + exclude + isolate + untracked Bypass DNS & Firewall @@ -274,6 +311,7 @@ protected with proxy & private dns protected with socks5 & private dns + protected with http proxy & private dns protected with socks proxy @@ -282,7 +320,6 @@ protected with proxy - protected with rpn protected with wireguard protected with wireguard & private dns @@ -295,7 +332,7 @@ not protected - Connected to RethinkDNS + Connected to Re-RethinkDNS Connected to Firewall @@ -374,7 +411,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 @@ -445,7 +482,8 @@ Search IPs Search domain names Search blocklists - Search apps by name + Search apps + Search %1$d apps DNS (battery saver) DNS @@ -453,7 +491,7 @@ DNS and Firewall (default) VPN - vpn + VPN IP address copied. URL copied @@ -462,6 +500,36 @@ Store DNS and Firewall logs locally on-device. Enable on-device logging + Logging is disabled. Enable on-device logging in Settings. + No logs yet. + Delete all logs? + This deletes on-device logs. This operation is not reversible. Do you want to proceed? + Rethink + Loading... + More options + View + Unable to open Orbot install page. + Invalid port. + Invalid IP address. + Block UDP (except DNS) + Exclude proxy apps + Exclude apps that act as proxy providers. + Protocol translation + Translate UDP traffic to TCP when needed. + Ping IPs + Treat mobile data as metered + Apply metered network behavior to mobile data. + WireGuard listen port + Allow setting a custom listen port. + WireGuard lockdown + Restrict traffic to the WireGuard interface only. + Endpoint-independent mapping + Prefer endpoint-independent NAT behavior. + TCP keep-alive + Keep idle TCP connections alive. + Use metered VPN + Treat the VPN connection as metered. + Dial timeout Choose mode @@ -494,6 +562,7 @@ Do not route Private IPs (experimental) Exclude LAN, loopback, multicast, link-local routes from Rethink\'s VPN tunnel. + Meter mobile networks Only treat mobile networks as metered, rest as unmetered. @@ -596,6 +665,21 @@ Appearance Dark/light theme + Color style + Choose a color style for the app. + Dynamic color is unavailable on this device. + Auto + Dynamic + Coral + Rose + Teal + Blue + Purple + Orange + Green + Amber + Cyan + Indigo Log level For debugging purposes @@ -604,9 +688,8 @@ Route Rethink\'s own traffic, like blocklist downloads, connectivity checks, DNS connections, back into the tunnel. Routing Rethink\'s own traffic through the tunnel. Unless you know what you\'re doing, turn it off. - Loopback (experimental) - In Loopback mode, Rethink can reroute its own network traffic. This means, Rethink can apply IP and domain rules, universal firewall rules, and proxying rules on Rethink\'s traffic as if it were any other regular app. Even so Loopback offers more control, the resulting behavior may not always desirable.\n\n\nIt is recommended to exempt Rethink from firewall, DNS, and proxying rules. Doing so doesn\'t affect network monitoring or logging, but just the rules.\n\n\nIn Loopback mode, Proxy Lockdown will not be enforced, for now. - Exempt Rethink + Loopback (experimental) + Also enables %1$s setting. Do you want to proceed? Very verbose Verbose @@ -631,7 +714,7 @@ Port Number Username Password - Set + set The selected app will be excluded from the VPN to let it proxy connections on your behalf. None @@ -646,7 +729,7 @@ Password (optional) Appearance - Set by System + System Light Dark True Black @@ -770,6 +853,7 @@ Exclude not possible in VPN lockdown mode Proxy removed + Stopping… Packet capture disabled @@ -790,6 +874,10 @@ Rules Type + Use the DNS server configured by the system. + Use a user-selected DNS server. + Use Rethink DNS with in-app blocklists and policies. + Smart DNS picks from a rotating set of resolvers. Other DNS Rethink DNS @@ -809,13 +897,11 @@ Prompt on blocklist updates Check for blocklist updates once every three days + Check for updates every three days. Never proxy DNS Do not proxy DNS over Always-on WireGuard, SOCKS5, HTTP proxies. - Block DNS from Unknown source - Block all DNS requests originating from unknown applications. - Use System DNS for undelegated domains Use System DNS for undelgated domains like .lan, .internal, etc. @@ -836,7 +922,7 @@ Select the files you want to include in the bug report email Deselect All - Select at least one file + Please select at least one file Creating archive… File not found Cannot open this file type @@ -866,11 +952,11 @@ Authors Translate Let\'s Go - hello@celzero.com + bernaferrari2@gmail.com mailto: [Rethink]: [Rethink]: Bug report - Send this email to : hello@celzero.com + Send this email to : bernaferrari2@gmail.com Select email provider https://telegram.me/rethinkdns https://docs.rethinkdns.com/ @@ -888,7 +974,7 @@ https://mastodon.social/@rdns https://www.reddit.com/r/rethinkdns https://matrix.to/#/!jrTSpJiEkFNNBMhSaE:matrix.org - %1$s + %1$s %1$s (%2$s) Suggest features @@ -909,10 +995,8 @@
🛡️ Android app
Anthony Ryan / BayLee4 / - Coding-Young / Ch4t4r / CodingAttack / - dsremo / HrBDev / hussainmohd-a / ignoramous / @@ -923,12 +1007,9 @@ Poussinou / pjosingh / RohitSurwase / - rootshel / Shantanu / - Uldiniad / - VASHvic / - yurtpage / - WhoIsDevelop
+ Uldiniad
/ + yurtpage

🚂 Network engine
alalamav / bemasc / @@ -979,7 +1060,7 @@ Sonatype
]]>
- Resolvers + resolvers Custom Server URL DoH DoH %1$s @@ -1019,6 +1100,9 @@ Split DNS (experimental) Forward DNS queries from proxied apps to the proxy\'s DNS servers. + Treat DNS rules as firewall rules (experimental) + DNS blocking will be bypassed during resolution; the decision will be made at connection time. + Allowed DNS record types Select which DNS resource record types to allow. @@ -1064,6 +1148,7 @@ Remind me later + Block all apps when device is locked Block any app not in use @@ -1155,6 +1240,7 @@ Bypass apps Bypass Universal firewall rules for listed apps? + Bypass apps Bypass universal DNS & firewall rules for listed apps? @@ -1183,7 +1269,7 @@ Note No firewall rules found - reload + Reload Unknown Unnamed (%1$s) @@ -1320,14 +1406,13 @@ Firewall Alerts Proxy Alerts Download Alerts - RPN Alerts Inform when Rethink is active and running in the background Inform when Rethink is stopped by Android or by another VPN app Alert on Firewall errors and anomalies Alert on Proxy errors and anomalies Alert on Download Progress for On-device blocklists - Alert on RPN errors and anomalies + Alerts are not available yet. System Components @@ -1413,9 +1498,9 @@ will bypass universal dns & firewall rules is unknown - Firewall rules for this app + firewall rules for this app - Network log for this app + network log for this app Firewall Metered @@ -1481,7 +1566,9 @@ Check for update Re-download blocklists - Simple + simple + Protection + System Advanced Selected @@ -1517,14 +1604,41 @@ Enabled + Blocklist state + Disable blocklists + Enable blocklists + Turn on to use on-device blocklists for DNS filtering. + Blocklist actions Configure Copy as RDNS+ URL Search domains in blocklists + Maintenance Check for update Redownload blocklists Update blocklists Configure 195+ blocklists + + Close search + Open search + Search settings + No matches + Try searching by name, section, or keyword. + + + No apps found + No apps match your current filters. + + + Search apps, IPs, domains + Search domain names or IPs + Search %1$d logs + + + More actions + More actions, expanded + More actions, collapsed + Backup & restore Backup @@ -1551,23 +1665,23 @@ Restore completed, restarting app… Backup completed - Invalid backup file. Please select a %1$s backup file for this build. Restore failed; try again %1$s blocklists - Most Allowed Apps - Most Blocked Apps - Most Contacted Domains - Most Contacted Countries - Most Blocked Domains - Most Contacted IPs - Most Blocked IPs - Most Blocked Countries + Most allowed apps + Most blocked apps + Most contacted domains + Most contacted countries + Most blocked domains + Most contacted ips + Most blocked ips + Most blocked countries Start Rethink to proceed Show All + Show Less Invalid key @@ -1624,8 +1738,6 @@ Always-on All connections and apps not routed by other WireGuard VPNs will be routed by this one. - Selected apps will only be routed through this WireGuard VPN, regardless of whether the VPN is active or disabled. - To switch to Advanced, stop WireGuard in Simple mode Add / Remove (%1$s apps) @@ -1636,9 +1748,6 @@ Delete Done - Are you sure you want to delete ALL WireGuard VPN configurations? This is IRREVERSIBLE. - Delete All - Include all apps Use this WireGuard VPN for all apps? @@ -1704,7 +1813,7 @@ Anti-censorship Choose from various Anti-censorship technique. - Shorter TCP keepalive + Shorter TCP keep alive Quickly close TCP sockets with no recent activity. @@ -1727,7 +1836,16 @@ Hybrid Also uses proxy as necessary. - strategy + + Warp is active. Please disable it first. + Please add at least one app to enable Rethink Proxy. + Rethink\'s Proxy + Enable UDP Relay + Cloudflare WARP + Modern, fast, and secure way to connect to a VPN server. Secure all your internet traffic with an ultra-secure, lightning-quick VPN service that lets you connect with the WireGuard protocol. + Something went wrong + + Strategy Set how retries work for censored connections. On failure, never retry anti-censorship techniques. @@ -1755,7 +1873,7 @@ Close idle TCP and UDP sockets after this duration. Experimental - Experimental features in network engine. + Experimental features in the Network Engine. Select WireGuard proxy @@ -1777,8 +1895,8 @@ Reachability checks bypass all network restrictions. In Auto mode, Rethink randomly uses previously connected IPs and hostnames. - Most Contacted Providers - Most Blocked Providers + Most contacted providers + Most blocked providers Enter package names (comma-separated) allowed to start or stop Rethink. Instructions:\n @@ -1803,7 +1921,7 @@ Automation Configure apps that can start or stop Rethink. - Top Active Connections + Top active connections Load All Close Connections @@ -1825,6 +1943,7 @@ Smart DNS randomly uses any among these DNS resolvers: + Proxy rules Select a proxy to use for this rule. @@ -1921,18 +2040,28 @@ Rethink is blocking a connection View Rethink is blocking a connection--> - Database + Search tables + No database tables available. + Tables + Selected + Select a table to inspect. + %1$s rows + %1$s columns + Refresh + Copying… + Copy full dump + Preview truncated. Copy full dump to see complete output. Copied SSIDs SSID Wi‑Fi only - Enable this WireGuard VPN only when connected to Wi-Fi or to one of the specified WiFi identifiers (%1$s). + Enable this WireGuard VPN only when connected to Wi-Fi or specified WiFi identifiers (%1$s). Rethink needs location permission to access Wi‑Fi identifiers (%1$s). - To access Wi‑Fi identifiers (%1$s), turn on Location Services. - Enable Location Services + To access Wi‑Fi identifiers (%1$s), turn on Location service. + Enable Location service Grant permission Enable location Cannot use both "%1$s" and "%2$s" @@ -1945,31 +2074,26 @@ 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 - Action - Match Type Stability program - Capture error logs to help improve stability. + Capture error logs to help improve stability. Remember uninstalled apps When enabled, keep app rules for 7 days. Firewall Bubble Review blocked apps and allow them temporarily. - Shows a floating bubble with recently blocked apps. + Show a floating bubble with recently blocked apps. + Firewall Bubble Firewall Bubble Notifications - Shows a bubble notification for recently blocked apps - Tap to view recently blocked apps + Shows bubble notification for blocked apps + Tap to see recently blocked apps Firewall Bubble enabled Permission denied. Firewall Bubble disabled. Recently Blocked Apps No Blocked Apps - Apps blocked by the firewall will appear here. + Apps blocked by the firewall will appear here Allow (15 min) %d blocked Just now @@ -1977,10 +2101,11 @@ %d hr ago Temporarily Allowed Apps + Loading… System default WireGuard P2P - When enabled, allows incoming connections initiated by WireGuard peers. + When enabled, allow incoming connections initiated by WireGuard peers. Android\'s built-in Download Manager may fail as VPN is in Lockdown mode. Enable Rethink\'s downloader instead? Enable Downloader @@ -1989,516 +2114,93 @@ Uses jumbo packets within Rethink\'s VPN tunnel. Always metered - Mark Rethink\'s VPN tunnel as metered to encourage apps to use less data. Caution: This may cause apps to pause large uploads & downloads, like backups. + Mark Rethink\'s VPN tunnel as metered to instruct apps to consume less data. Caution: This may cause apps to pause large uploads & downloads, like backups. Enrolled to the stability program for experimental features. - Stability program enabled - Undo - - Proxy Lockdown (experimental) - When enabled, all traffic is forced through Proxy or blocked. - - Flood WireGuard - When enabled, sends additional WireGuard packets to make tunnel traffic harder to identify and block on restrictive networks. - - Socket Buffer Size - Adjust the network buffer size used by Rethink sockets. Larger buffers may improve network throughput in some conditions but use more memory. - Smart Persistent Keepalive - Proactively maintains WireGuard tunnel connectivity by sending keep-alive probes only when needed, minimizing reconnection delays on intermittent networks. + WireGuard Lockdown + When enabled, all traffic is forced through WireGuard VPN. Event Logs View internal system & debug events + Details No events recorded yet - Event logs will appear here once the app starts recording system activity + Event logs will appear here once the app starts recording system activities + Total Events: %1$d Delete All Events Are you sure you want to delete all event logs? This action cannot be undone. - - Custom Interface IPs for VPN - Override default VPN Interface gateway, router, and DNS addresses. - Use automatically assigned VPN Interface IPs. - Manually configure Interface IPs for gateway, router, and DNS. + + Custom LAN IPs for VPN + Override default VPN LAN gateway, router, and DNS addresses. + Use automatically assigned VPN LAN IPs. + Manually configure LAN IPs for gateway, router, and DNS. + AUTO + MANUAL Gateway Router Reset Prefix - Using automatic Interface IP configuration. - Custom Interface IPs saved - Could not save custom interface IPs - Could not open the Custom Interface IPs dialog + Using automatic LAN IP configuration + Custom LAN IPs saved + 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. - - RPN - - troubleshoot - - Run test - Test your connection to RPN proxies. - - Report issue - Report an issue with RPN. - - Device Order History - - Manage Purchase - - Contact Support - - Subscribe - - Resubscribe - - - Failed to launch resubscription. Please try again. - - Error loading purchase details - - Payment processing - Your payment is being processed. You’ll get access once it completes. - By subscribing to RPN 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>. Powered by Windscribe - - - Enhanced privacy, speed, and security - Choose Your Plan - Select the plan that works best for you. - Subscribe Now - Purchase Now - One-Time - Monthly - $0.00 - Was %1$s - Cancel anytime - Save %1$s - Please select a plan first - Your purchase is now active! - - - 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. - Close - Retry - Unable to load plans - - - Choose Countries - Search countries or locations… - Clear search - Select up to 5 servers - Maximum %1$d servers can be selected - No servers found - Try adjusting your search or check back later. - Error fetching locations - There was a problem fetching server locations. This is usually a temporary issue. - Check your connection or try again in a moment. - Try Again - Could not load server locations. Proxy may not be ready yet. - - - Taking a bit longer than expected… - - - Restore Defaults - This will unregister your current session, fetch a fresh entitlement from the server, and re-register your connection. Your server selections will not be preserved. - Restoring connection - This usually completes in a few seconds. Keep your internet connection active. - Unregistering current session… - Fetching fresh entitlement… - Registering with tunnel… - Refreshing server list… - RPN restored successfully - Restore failed: %s - - Restoring… - - Loading… - Start RPN to proceed - - Disconnected - - - %1$d server - %1$d servers - - - - Server Location Removed - This server location is no longer available. - Removed - - - 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 - - Server Configuration - Not set - - - - %d peer - %d peers - - - Seconds - - - - Quick Actions - - Purchase processing is taking longer than expected. Please check your internet connection and try again. - - - Describe Your Issue - Diagnostic Data - What is your issue about? - Payment - Activation - Connectivity - Refund - Describe the issue you\'re experiencing… - The following data will be included as an attachment. Toggle off anything you don\'t want to share. - Purchase Status - All purchase records - State History - The last 50 purchase state transitions. - VPN / Proxy Stats - RPN proxy connection statistics - Your email will be sent to hello@celzero.com. We only use this data to investigate your issue and never share it with third parties. - Send to Support - Collecting data… - Collecting diagnostic data… - Send support email via… - Please describe your issue or select a category - Failed to collect diagnostic data. Please try again. - Could not open email app. Please email hello@celzero.com directly. - - - Valid until - Cancel, revoke or manage your plan - - - Cancelling… - Revoking… - - - A temporary error occurred. Please try again. - Something went wrong with your purchase. Please try again. - - - On Hold - Ends on - Expired on - %1$dd left - - - Server Settings - DNS Filter - Family - Includes %1$s - No filters - Refresh Servers - Stop RPN - Start RPN - Open server settings - Split DNS is disabled. Enable it to use DNS filtering on your VPN servers. - Enable Split DNS - Proxy Stopped - - - Exclude Countries - Selected countries will be skipped during AUTO server selection - Excluded Countries for AUTO - None excluded - %d countries excluded - At least %d countries must remain available for AUTO selection - Limit reached, at least %d countries must remain available - No countries available - Loading countries… - Search countries… - - - Configure VPN server preferences - Configuration Handling - Choose how connection settings should be managed - AUTO - MANUAL - The app automatically selects the port, identity, and configuration - Manually control the identity, port, and configuration mode - Always Change Identity - Request a new server identity for each connection attempt - Connection Port - Port used for tunnel server connections - Permanent Configuration - Keep the same server configuration across reconnections - Select Connection Port - Valid until today - AUTO is always connected and cannot be removed - Remove Server - Remove %1$s (%2$s) from your server list? - Frequent - %1$s added to favourites - %1$s removed from favourites - N/A - - - Cancel Purchase - Revoke Purchase - Note: You can revoke your purchase for a full refund. Cancelling will stop future charges, but you can continue using your purchase until the end of the current period. - Manage on Google Play - Cancel Purchase? - Are you sure you want to cancel your purchase? You will lose access to premium features at the end of the current period. - Revoke Purchase? - Are you sure you want to revoke your purchase? This action will immediately cancel your purchase and refund your payment. You will lose access to premium features. - Failed to process request. Please try again. - - - Revoked - Yearly - - 2-Year - 5-Year - Revokes the purchase and issues a full refund to the original payment method. - Cancels renewal. Access continues until the end of the current period - - - Connection Test - Run Test - Ready to Test - Enter domains below and tap Run Test. - Testing… - Checking reachability through the proxy. - All Reachable - Every target is reachable through your RPN proxy. - Partial Connectivity - Some targets are unreachable through the proxy. - Unreachable - None of the targets could be reached. Check your RPN proxy. - No Active Proxy - Failed - Run Again - Domains or IPs, comma-separated - Reachable - Enable RPN to run this test. - %1$dms - - - All Transactions - A complete record of every purchase and state change - No history yet - Purchase transactions and state changes will appear here - Failed to load purchase history - Could not open purchase history - %1$s → %2$s - Pending - - - Order History - Server Orders - Purchase records fetched live from the server - No orders found - No purchase records were found for this account - No active subscription found. Please subscribe to view order history. - SUBS - Started %1$s - Until %1$s - Renews %1$s - Order: %1$s - View orders fetched from server - Could not open order history - - - Rethink Proxy Network - Subscribe to route traffic through Rethink servers - - - Last Handshake - Errors - Client IPv4 - Client IPv6 - Never - - - 👁️ - 📍 - 📡 - 🛡️ - - - No recurring charges - Billed monthly - Billed annually - Recurring \u2022 Cancel anytime - %1$d days free trial - - - %1$s • %2$s - \u2014 - - - %1$s \u2022 %2$s - %1$s\u2026 - - - \u2022 - - Purchased - - - Validating purchase - Contacting server - Updating local state - Refreshing billing status - Please do not close the app - - - ⚠ Your access valid until in %1$d day(s). - Buy more access time now so there\'s no interruption. - Extend Access - - - Extending Your Access - Your new purchase adds to your remaining time. Access continues uninterrupted. - - - Access Extended! - Your access has been extended. Enjoy uninterrupted privacy! - - - Welcome to RPN! - Your subscription is now active. Enjoy enhanced privacy and security! - - - %s / mo - - - Purchase Conflict - Refund Conflict - Purchase Already Acknowledged - Purchase Already Consumed - Device Already Registered - Customer Registration - - Your purchase appears to be in a conflicting state on the server. You can request a refund to resolve this, or manage it directly on Google Play. - A refund request for this purchase is already in progress or the purchase is in a conflicting state. You can try again or manage it on Google Play. - This purchase was already acknowledged on the server. No action is needed, your purchase should be active. If you see an issue, contact support. - This purchase has already been consumed. If you believe this is an error, please contact support. - This device is already registered with the server. No action is needed. If access is not working, try again or contact support. - Customer registration failure. Please contact support. - - Request Refund - Retry Refund - - HTTP Code - - Refund requested successfully. Your purchase has been revoked. - Refund request failed: %1$s - - - Action Required: Purchase Conflict - A server conflict was detected with your purchase. Tap to review and request a refund. - - - Device Authorization Failed - Your device could not be authorized by the Rethink server. This may be due to an account issue. Please contact customer support with the details below so we can resolve this quickly. - HTTP 401: Unauthorized - Account ID - Device ID - partial - - - Email Customer Support - - Hello Rethink Support, - I am experiencing a device authorization issue (HTTP 401). My account details are: - Help me resolve this. - - - Device Not Registered - Account ID Mismatch - Your device could not be registered under the account linked to your active subscription. The account ID from Google Play (shown below) could not be resolved to a device on our server. Please contact support with the details below. - Subscription Account ID (Google Play) - master - What this means - Your subscription is active on Google Play but your device could not be linked to it on the Rethink server. This can happen after restoring from a backup or switching devices. Our support team can resolve this quickly once you provide the account details above. - Device Not Registered: Rethink DNS - I have an active subscription but my device cannot be registered. The account details are: - - - Your device could not be linked to your active subscription. Tap to review and contact support. - Your device could not be authorized by the Rethink server (HTTP 401). Tap to view details and contact support. - - - %1$d of %2$d - - - Got it - Skip Tour - - - Reset Guided Tour - Show the guided tour again on next launch. - Tour reset, will show again on next launch ✓ - - - Your Privacy Shield - This button starts and stops Rethink\'s VPN tunnel. When active, all app traffic is routed through your chosen DNS and firewall rules - nothing leaves unguarded. - - - DNS & Blocklists - Configure your DNS resolver and activate on-device blocklists. Block ads, trackers, and malware domains before any connection is made. - - - Per-App Firewall - Decide which apps can reach the internet, which are isolated, and which are blocked entirely even when the VPN is off. - - - Proxy & Routing - Chain a WireGuard, SOCKS5, or HTTP proxy to shape exactly how your traffic exits. Combine with DNS for full control. - - - Connection Logs - Every DNS query and network connection is logged here. Review what your apps are doing and block anything suspicious with one tap. - - - - %d Year - %d Years - - - - Unlock advanced privacy features with RPN: hide your real IP with rotating exits, access anti-censorship servers, and route traffic through private proxy networks for total control. - - Premium Feature - - - Per-app firewall rules and network policy - VPN tunnel mode and network preferences - WireGuard, SOCKS5, and HTTP proxy routing - Appearance, notifications, and more - Developer tools and diagnostics - Review DNS queries and connection logs - - - DNS to bypass - Select the DNS to use for trusted and bypassed apps and domains - Use fallback DNS - Use the fallback DNS for trusted and bypassed connections - Use Global DNS - Use the globally configured DNS for trusted and bypassed connections - Automatic - Automatically select DNS based on other settings - - Include file trace in logs + + Start + Stop + DNS + Download + Connections + Firewall + Proxy + Apps + Bypassed + Isolated + Excluded + Total + Universal Rules + Latency + Connected + Navigate back + Clear search + Host + Port + Username (optional) + Password (optional) + No countries available + No countries available for RPN right now. Try again later. + Proxy details + No proxy found + Proxy information is missing for this proxy. Ensure the proxy is configured and try again. + Select apps for proxy + Apps part of other proxy or excluded from proxy will be listed here. + Apps + Domains + IPs + Proxy name + Who + Error + Country + Latency + Last connected + Status + Connected + IP Address + Port + Protocol + App Name + Response + Response IP + Resolver + 1h + 24h + 7d + Connections: %1$d + who_or_service_email@domain.com + 37 ms + 2 min ago + Checkout is unavailable in this build.
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index bda36c6e8..16e17597e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,120 +1,40 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/play/java/com/celzero/bravedns/RethinkDnsApplicationPlay.kt b/app/src/play/java/com/celzero/bravedns/RethinkDnsApplicationPlay.kt index 0bc942035..c6c8c597d 100644 --- a/app/src/play/java/com/celzero/bravedns/RethinkDnsApplicationPlay.kt +++ b/app/src/play/java/com/celzero/bravedns/RethinkDnsApplicationPlay.kt @@ -17,17 +17,14 @@ package com.celzero.bravedns import android.app.Application import android.content.pm.ApplicationInfo -import com.celzero.bravedns.scheduler.EnhancedBugReport import com.celzero.bravedns.scheduler.ScheduleManager import com.celzero.bravedns.scheduler.WorkScheduler import com.celzero.bravedns.service.AppUpdater -import com.celzero.bravedns.service.InAppMessageProvider -import com.celzero.bravedns.service.PlayInAppMessageProvider import com.celzero.bravedns.util.FirebaseErrorReporting import com.celzero.bravedns.util.GlobalExceptionHandler -import com.celzero.bravedns.util.GoReportingHandler +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.android.ext.android.get @@ -56,27 +53,20 @@ class RethinkDnsApplicationPlay : Application() { // New Koin override strategy allow to override any definition by default. // don't need to specify override = true anymore in module. single { StoreAppUpdater(androidContext()) } - // Play Billing in-app messaging - single { PlayInAppMessageProvider() } } ) ) } - val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + if (BuildConfig.DEBUG) { + Napier.base(DebugAntilog()) + } - // Initialize exception handlers + // Initialize global exception handler GlobalExceptionHandler.initialize(this) FirebaseErrorReporting.initialize() - GoReportingHandler.initialize(appScope, this) - - // On every app start, report any tombstone files from the previous session - val appCtx = this - appScope.launch(Dispatchers.IO) { - EnhancedBugReport.reportTombstonesToFirebaseOnStartup(appCtx) - } - appScope.launch { + CoroutineScope(SupervisorJob()).launch { scheduleJobs() } } diff --git a/app/src/play/java/com/celzero/bravedns/StoreAppUpdater.kt b/app/src/play/java/com/celzero/bravedns/StoreAppUpdater.kt index cc0cbfe47..0fa6924df 100644 --- a/app/src/play/java/com/celzero/bravedns/StoreAppUpdater.kt +++ b/app/src/play/java/com/celzero/bravedns/StoreAppUpdater.kt @@ -20,6 +20,7 @@ import android.content.Context import android.content.IntentSender import android.util.Log import com.celzero.bravedns.service.AppUpdater +import com.google.android.play.core.appupdate.AppUpdateOptions import com.google.android.play.core.appupdate.AppUpdateManagerFactory import com.google.android.play.core.install.InstallStateUpdatedListener import com.google.android.play.core.install.model.AppUpdateType @@ -61,8 +62,12 @@ class StoreAppUpdater(context: Context) : AppUpdater { AppUpdateType.FLEXIBLE)) { Log.i(LOG_TAG, "Update available, starting flexible update") try { - appUpdateManager.startUpdateFlowForResult(appUpdateInfo, AppUpdateType.FLEXIBLE, - activity, APP_UPDATE_REQUEST_CODE) + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activity, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(), + APP_UPDATE_REQUEST_CODE + ) } catch (e: IntentSender.SendIntentException) { unregisterListener(listener) Log.e(LOG_TAG, "SendIntentException: ${e.message} ", e) @@ -71,8 +76,12 @@ class StoreAppUpdater(context: Context) : AppUpdater { AppUpdateType.IMMEDIATE)) { Log.i(LOG_TAG, "Update available, starting immediate update") try { - appUpdateManager.startUpdateFlowForResult(appUpdateInfo, - AppUpdateType.IMMEDIATE, activity, APP_UPDATE_REQUEST_CODE) + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activity, + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), + APP_UPDATE_REQUEST_CODE + ) } catch (e: IntentSender.SendIntentException) { unregisterListener(listener) Log.e(LOG_TAG, "SendIntentException: ${e.message} ", e) diff --git a/app/src/play/java/com/celzero/bravedns/iab/Security.java b/app/src/play/java/com/celzero/bravedns/iab/Security.java index 01a828556..19a66fa66 100644 --- a/app/src/play/java/com/celzero/bravedns/iab/Security.java +++ b/app/src/play/java/com/celzero/bravedns/iab/Security.java @@ -104,7 +104,6 @@ static private Boolean verify(PublicKey publicKey, String signedData, String sig try { signatureBytes = Base64.decode(signature, Base64.DEFAULT); } catch (IllegalArgumentException e) { - Log.w(TAG, "Base64 decoding failed."); return false; } try { @@ -112,7 +111,6 @@ static private Boolean verify(PublicKey publicKey, String signedData, String sig signatureAlgorithm.initVerify(publicKey); signatureAlgorithm.update(signedData.getBytes(StandardCharsets.UTF_8)); if (!signatureAlgorithm.verify(signatureBytes)) { - Log.w(TAG, "Signature verification failed..."); return false; } return true; @@ -120,9 +118,7 @@ static private Boolean verify(PublicKey publicKey, String signedData, String sig // "RSA" is guaranteed to be available. throw new RuntimeException(e); } catch (InvalidKeyException e) { - Log.e(TAG, "Invalid key specification."); } catch (SignatureException e) { - Log.e(TAG, "Signature exception."); } return false; } diff --git a/app/src/test/java/com/celzero/bravedns/data/AppConfigProxyModeTest.kt b/app/src/test/java/com/celzero/bravedns/data/AppConfigProxyModeTest.kt new file mode 100644 index 000000000..8a4eb46b9 --- /dev/null +++ b/app/src/test/java/com/celzero/bravedns/data/AppConfigProxyModeTest.kt @@ -0,0 +1,200 @@ +package com.celzero.bravedns.data + +import android.content.Context +import com.celzero.bravedns.database.ConnectionTrackerRepository +import com.celzero.bravedns.database.DnsCryptEndpointRepository +import com.celzero.bravedns.database.DnsCryptRelayEndpointRepository +import com.celzero.bravedns.database.DnsLogRepository +import com.celzero.bravedns.database.DnsProxyEndpointRepository +import com.celzero.bravedns.database.DoHEndpointRepository +import com.celzero.bravedns.database.DoTEndpointRepository +import com.celzero.bravedns.database.ODoHEndpointRepository +import com.celzero.bravedns.database.ProxyEndpointRepository +import com.celzero.bravedns.database.RethinkDnsEndpointRepository +import com.celzero.bravedns.service.EventLogger +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.PcapMode +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class AppConfigProxyModeTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `addProxy merges custom socks5 and http into http_socks5`() { + val backing = Backing() + val appConfig = newAppConfig(backing) + + appConfig.addProxy(AppConfig.ProxyType.SOCKS5, AppConfig.ProxyProvider.CUSTOM) + assertEquals(AppConfig.ProxyType.SOCKS5.name, backing.proxyType) + assertEquals(AppConfig.ProxyProvider.CUSTOM.name, backing.proxyProvider) + + appConfig.addProxy(AppConfig.ProxyType.HTTP, AppConfig.ProxyProvider.CUSTOM) + assertEquals(AppConfig.ProxyType.HTTP_SOCKS5.name, backing.proxyType) + assertEquals(AppConfig.ProxyProvider.CUSTOM.name, backing.proxyProvider) + } + + @Test + fun `removeProxy from http_socks5 keeps other custom proxy`() { + val backing = + Backing( + proxyProvider = AppConfig.ProxyProvider.CUSTOM.name, + proxyType = AppConfig.ProxyType.HTTP_SOCKS5.name + ) + val appConfig = newAppConfig(backing) + + appConfig.removeProxy(AppConfig.ProxyType.HTTP, AppConfig.ProxyProvider.CUSTOM) + assertEquals(AppConfig.ProxyType.SOCKS5.name, backing.proxyType) + + backing.proxyType = AppConfig.ProxyType.HTTP_SOCKS5.name + appConfig.removeProxy(AppConfig.ProxyType.SOCKS5, AppConfig.ProxyProvider.CUSTOM) + assertEquals(AppConfig.ProxyType.HTTP.name, backing.proxyType) + } + + @Test + fun `getTunProxyMode prioritizes provider and maps combined type to socks5`() { + val orbotBacking = + Backing( + proxyProvider = AppConfig.ProxyProvider.ORBOT.name, + proxyType = AppConfig.ProxyType.HTTP.name + ) + val orbotConfig = newAppConfig(orbotBacking) + assertEquals(AppConfig.TunProxyMode.ORBOT, orbotConfig.getTunProxyMode()) + + val customBacking = + Backing( + proxyProvider = AppConfig.ProxyProvider.CUSTOM.name, + proxyType = AppConfig.ProxyType.HTTP_SOCKS5.name + ) + val customConfig = newAppConfig(customBacking) + assertEquals(AppConfig.TunProxyMode.SOCKS5, customConfig.getTunProxyMode()) + } + + @Test + fun `dns-only mode blocks all proxy enablement`() { + val backing = Backing(braveMode = AppConfig.BraveMode.DNS.mode) + val appConfig = newAppConfig(backing) + + assertFalse(appConfig.canEnableProxy()) + assertFalse(appConfig.canEnableSocks5Proxy()) + assertFalse(appConfig.canEnableHttpProxy()) + assertFalse(appConfig.canEnableWireguardProxy()) + assertFalse(appConfig.canEnableTcpProxy()) + assertFalse(appConfig.canEnableOrbotProxy()) + } + + @Test + fun `provider gating allows only matching proxy toggles`() { + val backing = + Backing( + braveMode = AppConfig.BraveMode.DNS_FIREWALL.mode, + proxyProvider = AppConfig.ProxyProvider.ORBOT.name, + proxyType = AppConfig.ProxyType.SOCKS5.name + ) + val appConfig = newAppConfig(backing) + + assertTrue(appConfig.canEnableOrbotProxy()) + assertFalse(appConfig.canEnableHttpProxy()) + assertFalse(appConfig.canEnableSocks5Proxy()) + assertFalse(appConfig.canEnableWireguardProxy()) + assertFalse(appConfig.canEnableTcpProxy()) + } + + @Test + fun `http and socks helpers are based on type while custom checks require custom provider`() { + val backing = + Backing( + proxyProvider = AppConfig.ProxyProvider.CUSTOM.name, + proxyType = AppConfig.ProxyType.HTTP_SOCKS5.name + ) + val appConfig = newAppConfig(backing) + + assertTrue(appConfig.hasHttpProxyTypeEnabled()) + assertTrue(appConfig.hasSocks5ProxyTypeEnabled()) + assertTrue(appConfig.isCustomHttpProxyEnabled()) + assertTrue(appConfig.isCustomSocks5Enabled()) + + backing.proxyProvider = AppConfig.ProxyProvider.ORBOT.name + assertTrue(appConfig.hasHttpProxyTypeEnabled()) + assertTrue(appConfig.hasSocks5ProxyTypeEnabled()) + assertFalse(appConfig.isCustomHttpProxyEnabled()) + assertFalse(appConfig.isCustomSocks5Enabled()) + } + + @Test + fun `isProxyEnabled requires non-none provider`() { + val backing = + Backing( + proxyProvider = AppConfig.ProxyProvider.NONE.name, + proxyType = AppConfig.ProxyType.SOCKS5.name + ) + val appConfig = newAppConfig(backing) + assertFalse(appConfig.isProxyEnabled()) + + backing.proxyProvider = AppConfig.ProxyProvider.CUSTOM.name + assertTrue(appConfig.isProxyEnabled()) + } + + private data class Backing( + var proxyProvider: String = AppConfig.ProxyProvider.NONE.name, + var proxyType: String = AppConfig.ProxyType.NONE.name, + var braveMode: Int = AppConfig.BraveMode.DNS_FIREWALL.mode, + var connectedDnsName: String = "", + var pcapMode: Int = PcapMode.NONE.id, + var pcapFilePath: String = "" + ) + + private fun newAppConfig(backing: Backing): AppConfig { + val persistentState = mockk(relaxed = true) + + every { persistentState.proxyProvider } answers { backing.proxyProvider } + every { persistentState.proxyProvider = any() } answers { + backing.proxyProvider = firstArg() + } + every { persistentState.proxyType } answers { backing.proxyType } + every { persistentState.proxyType = any() } answers { + backing.proxyType = firstArg() + } + every { persistentState.braveMode } answers { backing.braveMode } + every { persistentState.braveMode = any() } answers { + backing.braveMode = firstArg() + } + every { persistentState.connectedDnsName } answers { backing.connectedDnsName } + every { persistentState.connectedDnsName = any() } answers { + backing.connectedDnsName = firstArg() + } + every { persistentState.pcapMode } answers { backing.pcapMode } + every { persistentState.pcapMode = any() } answers { + backing.pcapMode = firstArg() + } + every { persistentState.pcapFilePath } answers { backing.pcapFilePath } + every { persistentState.pcapFilePath = any() } answers { + backing.pcapFilePath = firstArg() + } + every { persistentState.updateProxyStatus() } returns MutableLiveData(0) + + return AppConfig( + context = mockk(relaxed = true), + rethinkDnsEndpointRepository = mockk(relaxed = true), + dnsProxyEndpointRepository = mockk(relaxed = true), + doHEndpointRepository = mockk(relaxed = true), + dnsCryptEndpointRepository = mockk(relaxed = true), + dnsCryptRelayEndpointRepository = mockk(relaxed = true), + doTEndpointRepository = mockk(relaxed = true), + oDoHEndpointRepository = mockk(relaxed = true), + proxyEndpointRepository = mockk(relaxed = true), + persistentState = persistentState, + networkLogs = mockk(relaxed = true), + dnsLogs = mockk(relaxed = true), + eventLogger = mockk(relaxed = true) + ) + } +} diff --git a/app/src/test/java/com/celzero/bravedns/receiver/BraveAutoStartReceiverTest.kt b/app/src/test/java/com/celzero/bravedns/receiver/BraveAutoStartReceiverTest.kt index 263f155ca..d09768b49 100644 --- a/app/src/test/java/com/celzero/bravedns/receiver/BraveAutoStartReceiverTest.kt +++ b/app/src/test/java/com/celzero/bravedns/receiver/BraveAutoStartReceiverTest.kt @@ -17,22 +17,19 @@ package com.celzero.bravedns.receiver import android.content.Intent +import android.content.BroadcastReceiver import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config +import org.koin.core.component.KoinComponent /** * Unit tests for BraveAutoStartReceiver * Tests the auto-start logic for VPN on boot and Private Space unlock */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [28]) class BraveAutoStartReceiverTest { private lateinit var receiver: BraveAutoStartReceiver @@ -52,11 +49,10 @@ class BraveAutoStartReceiverTest { Intent.ACTION_USER_UNLOCKED, Intent.ACTION_MY_PACKAGE_REPLACED ) - + supportedActions.forEach { action -> - val intent = Intent(action) - assertNotNull("Intent with action $action should be valid", intent.action) - assertEquals("Intent action should match", action, intent.action) + assertNotNull("Supported action should not be null", action) + assertTrue("Supported action should be an Android intent action", action.startsWith("android.intent.action.")) } } @@ -72,15 +68,19 @@ class BraveAutoStartReceiverTest { @Test fun `receiver class should extend BroadcastReceiver`() { // Test that the receiver extends the correct base class - assertTrue("BraveAutoStartReceiver should be a BroadcastReceiver", - receiver is android.content.BroadcastReceiver) + assertTrue( + "BraveAutoStartReceiver should be a BroadcastReceiver", + BroadcastReceiver::class.java.isAssignableFrom(receiver.javaClass) + ) } @Test fun `receiver should implement KoinComponent`() { // Test that the receiver implements KoinComponent for dependency injection - assertTrue("BraveAutoStartReceiver should implement KoinComponent", - receiver is org.koin.core.component.KoinComponent) + assertTrue( + "BraveAutoStartReceiver should implement KoinComponent", + KoinComponent::class.java.isAssignableFrom(receiver.javaClass) + ) } @Test @@ -97,9 +97,11 @@ class BraveAutoStartReceiverTest { @Test fun `receiver handles ACTION_USER_UNLOCKED for Private Space unlock`() { // Test that ACTION_USER_UNLOCKED is a valid intent action - val intent = Intent(Intent.ACTION_USER_UNLOCKED) - assertEquals("ACTION_USER_UNLOCKED should be correct action", - Intent.ACTION_USER_UNLOCKED, intent.action) + assertEquals( + "ACTION_USER_UNLOCKED should be correct action", + "android.intent.action.USER_UNLOCKED", + Intent.ACTION_USER_UNLOCKED + ) } @Test @@ -132,4 +134,4 @@ class BraveAutoStartReceiverTest { "BraveAutoStartReceiver", receiver.javaClass.simpleName) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/celzero/bravedns/receiver/UserPresentReceiverTest.kt b/app/src/test/java/com/celzero/bravedns/receiver/UserPresentReceiverTest.kt index f709b377b..884f16253 100644 --- a/app/src/test/java/com/celzero/bravedns/receiver/UserPresentReceiverTest.kt +++ b/app/src/test/java/com/celzero/bravedns/receiver/UserPresentReceiverTest.kt @@ -16,23 +16,19 @@ package com.celzero.bravedns.receiver +import android.content.Context import android.content.Intent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue +import android.content.BroadcastReceiver +import io.mockk.every +import io.mockk.mockk import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config +import org.junit.Assert.* /** * Unit tests for UserPresentReceiver * Tests the screen unlock event handling */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [28]) class UserPresentReceiverTest { private lateinit var receiver: UserPresentReceiver @@ -44,10 +40,8 @@ class UserPresentReceiverTest { @Test fun `receiver should handle ACTION_USER_PRESENT intent`() { - // Test that receiver can handle the ACTION_USER_PRESENT intent - val intent = Intent(Intent.ACTION_USER_PRESENT) - assertNotNull("Intent with ACTION_USER_PRESENT should be valid", intent.action) - assertEquals("Intent action should match", Intent.ACTION_USER_PRESENT, intent.action) + // ACTION_USER_PRESENT constant should be stable and usable in routing logic + assertEquals("ACTION_USER_PRESENT constant", "android.intent.action.USER_PRESENT", Intent.ACTION_USER_PRESENT) } @Test @@ -59,16 +53,21 @@ class UserPresentReceiverTest { @Test fun `receiver class should extend BroadcastReceiver`() { // Test that the receiver extends the correct base class - assertTrue("UserPresentReceiver should be a BroadcastReceiver", - receiver is android.content.BroadcastReceiver) + assertTrue( + "UserPresentReceiver should be a BroadcastReceiver", + BroadcastReceiver::class.java.isAssignableFrom(receiver.javaClass) + ) } @Test fun `receiver should handle unknown actions gracefully`() { - // Test that receiver can handle unknown actions without crashing val unknownAction = "com.example.unknown.action" - val intent = Intent(unknownAction) - assertNotNull("Intent with unknown action should be valid", intent.action) + val intent = mockk() + val context = mockk(relaxed = true) + every { intent.action } returns unknownAction + + // Unknown actions must not crash the receiver. + receiver.onReceive(context, intent) assertEquals("Intent action should match", unknownAction, intent.action) } @@ -90,26 +89,36 @@ class UserPresentReceiverTest { @Test fun `receiver handles null action gracefully`() { - // Test that receiver can handle null action without crashing - val intent = Intent() - intent.action = null + val intent = mockk() + val context = mockk(relaxed = true) + every { intent.action } returns null + + receiver.onReceive(context, intent) assertNull("Intent action should be null", intent.action) } @Test fun `receiver handles various action types`() { - // Test that receiver can handle different action types val testActions = listOf( - Intent.ACTION_USER_PRESENT, Intent.ACTION_SCREEN_ON, Intent.ACTION_SCREEN_OFF, "android.intent.action.UNKNOWN" ) - + + val context = mockk(relaxed = true) testActions.forEach { action -> - val intent = Intent(action) + val intent = mockk() + every { intent.action } returns action + + receiver.onReceive(context, intent) assertEquals("Intent action should match for $action", action, intent.action) } + + assertEquals( + "ACTION_USER_PRESENT should stay stable", + "android.intent.action.USER_PRESENT", + Intent.ACTION_USER_PRESENT + ) } @Test @@ -118,4 +127,4 @@ class UserPresentReceiverTest { assertTrue("Class should be in receiver package", receiver.javaClass.packageName.endsWith(".receiver")) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/celzero/bravedns/service/ProxyRoutingEngineTest.kt b/app/src/test/java/com/celzero/bravedns/service/ProxyRoutingEngineTest.kt new file mode 100644 index 000000000..04fad1202 --- /dev/null +++ b/app/src/test/java/com/celzero/bravedns/service/ProxyRoutingEngineTest.kt @@ -0,0 +1,352 @@ +package com.celzero.bravedns.service + +import com.celzero.firestack.backend.Backend +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProxyRoutingEngineTest { + + @Test + fun `special app should be false outside dns firewall mode`() { + val request = + specialAppRequest( + isDnsFirewallMode = false, + isCustomSocks5Enabled = true, + packageName = "com.test.app", + socks5ProxyAppName = "com.test.app" + ) + + assertFalse(ProxyRoutingEngine.isSpecialApp(request)) + } + + @Test + fun `special app should be true when socks5 proxy app matches`() { + val request = + specialAppRequest( + isCustomSocks5Enabled = true, + packageName = "com.test.app", + socks5ProxyAppName = "com.test.app" + ) + + assertTrue(ProxyRoutingEngine.isSpecialApp(request)) + } + + @Test + fun `resolve base or exit should return base for dns proxy rule`() { + val result = + ProxyRoutingEngine.resolveBaseOrExitProxyId( + doubleLoopback = false, + blockedByRule = FirewallRuleset.RULE9.id, + rinr = false, + uid = 1000, + rethinkUid = 2000, + autoProxyEnabled = true + ) + + assertEquals(Backend.Base, result) + } + + @Test + fun `resolve base or exit should force auto or exit for rethink in rinr`() { + val result = + ProxyRoutingEngine.resolveBaseOrExitProxyId( + doubleLoopback = true, + blockedByRule = FirewallRuleset.RULE9.id, + rinr = true, + uid = 2000, + rethinkUid = 2000, + autoProxyEnabled = true + ) + + assertEquals(Backend.Auto, result) + } + + @Test + fun `determine route should return rethink direct route`() { + val decision = + ProxyRoutingEngine.determineRoute( + routingRequest( + uid = 1000, + rethinkUid = 1000, + rinr = false + ) + ) + + assertEquals(Backend.Exit, decision.proxyIds) + assertEquals(ProxyRoutingEngine.Reason.RETHINK_DIRECT, decision.reason) + } + + @Test + fun `determine route should apply rule15 for excluded app from rule0`() { + val decision = + ProxyRoutingEngine.determineRoute( + routingRequest( + appExcludedFromProxy = true, + blockedByRule = FirewallRuleset.RULE0.id, + baseOrExitProxyId = Backend.Base + ) + ) + + assertEquals(Backend.Base, decision.proxyIds) + assertEquals(FirewallRuleset.RULE15.id, decision.blockedByRuleOverride) + assertEquals(ProxyRoutingEngine.Reason.EXCLUDED_APP, decision.reason) + } + + @Test + fun `determine route should mark block and rule17 for wireguard block candidate`() { + val decision = + ProxyRoutingEngine.determineRoute( + routingRequest( + wireguardProxyIds = listOf(ProxyManager.ID_WG_BASE + "10", Backend.Block) + ) + ) + + assertTrue(decision.markBlocked) + assertEquals(FirewallRuleset.RULE17.id, decision.blockedByRuleOverride) + assertEquals(ProxyRoutingEngine.Reason.WIREGUARD, decision.reason) + } + + @Test + fun `determine route should use orbot proxy id when app assigned`() { + val decision = + ProxyRoutingEngine.determineRoute( + routingRequest( + isOrbotProxyEnabled = true, + orbotProxyAssignedToApp = true + ) + ) + + assertEquals(ProxyManager.ID_ORBOT_BASE, decision.proxyIds) + assertEquals(ProxyRoutingEngine.Reason.ORBOT_PROXY, decision.reason) + } + + @Test + fun `determine route should prefer socks5 over http`() { + val decision = + ProxyRoutingEngine.determineRoute( + routingRequest( + isCustomSocks5Enabled = true, + isCustomHttpProxyEnabled = true + ) + ) + + assertEquals(ProxyManager.ID_S5_BASE, decision.proxyIds) + assertEquals(ProxyRoutingEngine.Reason.SOCKS5_PROXY, decision.reason) + } + + @Test + fun `determine route should use dns direct app when only dns proxy app matches`() { + val decision = + ProxyRoutingEngine.determineRoute( + routingRequest( + isProxyEnabled = false, + isDnsProxyActive = true, + packageName = "com.test.app", + dnsProxyAppName = "com.test.app" + ) + ) + + assertEquals(Backend.Exit, decision.proxyIds) + assertEquals(ProxyRoutingEngine.Reason.DNS_PROXY_DIRECT_APP, decision.reason) + } + + @Test + fun `determine route precedence matrix`() { + data class Case( + val name: String, + val request: ProxyRoutingEngine.RoutingRequest, + val expectedProxy: String, + val expectedReason: ProxyRoutingEngine.Reason, + val expectedOrbotNotIncluded: Boolean? = null + ) + + val wgProxyId = ProxyManager.ID_WG_BASE + "7" + val cases = + listOf( + Case( + name = "wireguard takes precedence", + request = + routingRequest( + wireguardProxyIds = listOf(wgProxyId), + isOrbotProxyEnabled = true, + orbotProxyAssignedToApp = true, + isCustomSocks5Enabled = true, + isCustomHttpProxyEnabled = true + ), + expectedProxy = wgProxyId, + expectedReason = ProxyRoutingEngine.Reason.WIREGUARD + ), + Case( + name = "orbot direct app takes precedence", + request = + routingRequest( + isOrbotProxyEnabled = true, + packageName = "com.test.app", + orbotProxyAppName = "com.test.app", + orbotProxyAssignedToApp = true + ), + expectedProxy = Backend.Exit, + expectedReason = ProxyRoutingEngine.Reason.ORBOT_DIRECT_APP + ), + Case( + name = "orbot proxy takes precedence over socks and http", + request = + routingRequest( + isOrbotProxyEnabled = true, + orbotProxyAssignedToApp = true, + isCustomSocks5Enabled = true, + isCustomHttpProxyEnabled = true + ), + expectedProxy = ProxyManager.ID_ORBOT_BASE, + expectedReason = ProxyRoutingEngine.Reason.ORBOT_PROXY + ), + Case( + name = "socks5 direct app takes precedence over proxy id", + request = + routingRequest( + isCustomSocks5Enabled = true, + packageName = "com.test.app", + socks5ProxyAppName = "com.test.app" + ), + expectedProxy = Backend.Exit, + expectedReason = ProxyRoutingEngine.Reason.SOCKS5_DIRECT_APP + ), + Case( + name = "socks5 proxy takes precedence over http proxy", + request = + routingRequest( + isCustomSocks5Enabled = true, + isCustomHttpProxyEnabled = true + ), + expectedProxy = ProxyManager.ID_S5_BASE, + expectedReason = ProxyRoutingEngine.Reason.SOCKS5_PROXY + ), + Case( + name = "http direct app selected", + request = + routingRequest( + isCustomHttpProxyEnabled = true, + packageName = "com.test.app", + httpProxyAppName = "com.test.app" + ), + expectedProxy = Backend.Exit, + expectedReason = ProxyRoutingEngine.Reason.HTTP_DIRECT_APP + ), + Case( + name = "http proxy selected when app does not match", + request = + routingRequest( + isCustomHttpProxyEnabled = true, + packageName = "com.other.app", + httpProxyAppName = "com.test.app" + ), + expectedProxy = ProxyManager.ID_HTTP_BASE, + expectedReason = ProxyRoutingEngine.Reason.HTTP_PROXY + ), + Case( + name = "fallback base or exit with orbot enabled but app not assigned", + request = + routingRequest( + isOrbotProxyEnabled = true, + orbotProxyAssignedToApp = false, + baseOrExitProxyId = Backend.Base + ), + expectedProxy = Backend.Base, + expectedReason = ProxyRoutingEngine.Reason.FALLBACK_BASE_OR_EXIT, + expectedOrbotNotIncluded = true + ), + Case( + name = "no proxy active returns base or exit", + request = + routingRequest( + isProxyEnabled = false, + isDnsProxyActive = false, + baseOrExitProxyId = Backend.Base + ), + expectedProxy = Backend.Base, + expectedReason = ProxyRoutingEngine.Reason.NO_PROXY_ACTIVE + ) + ) + + cases.forEach { case -> + val decision = ProxyRoutingEngine.determineRoute(case.request) + assertEquals("${case.name} proxy", case.expectedProxy, decision.proxyIds) + assertEquals("${case.name} reason", case.expectedReason, decision.reason) + case.expectedOrbotNotIncluded?.let { + assertEquals("${case.name} orbot-not-included flag", it, decision.orbotProxyEnabledButAppNotIncluded) + } + } + } + + private fun specialAppRequest( + isDnsFirewallMode: Boolean = true, + isOrbotProxyEnabled: Boolean = false, + isCustomSocks5Enabled: Boolean = false, + isCustomHttpProxyEnabled: Boolean = false, + isDnsProxyActive: Boolean = false, + packageName: String? = null, + orbotProxyAppName: String? = null, + socks5ProxyAppName: String? = null, + httpProxyAppName: String? = null, + dnsProxyAppName: String? = null + ): ProxyRoutingEngine.SpecialAppRequest { + return ProxyRoutingEngine.SpecialAppRequest( + isDnsFirewallMode = isDnsFirewallMode, + isOrbotProxyEnabled = isOrbotProxyEnabled, + isCustomSocks5Enabled = isCustomSocks5Enabled, + isCustomHttpProxyEnabled = isCustomHttpProxyEnabled, + isDnsProxyActive = isDnsProxyActive, + packageName = packageName, + orbotProxyAppName = orbotProxyAppName, + socks5ProxyAppName = socks5ProxyAppName, + httpProxyAppName = httpProxyAppName, + dnsProxyAppName = dnsProxyAppName + ) + } + + private fun routingRequest( + uid: Int = 2001, + rethinkUid: Int = 1000, + rinr: Boolean = false, + autoProxyEnabled: Boolean = false, + blockedByRule: String = FirewallRuleset.RULE8.id, + appExcludedFromProxy: Boolean = false, + baseOrExitProxyId: String = Backend.Exit, + wireguardProxyIds: List = emptyList(), + isProxyEnabled: Boolean = true, + isDnsProxyActive: Boolean = false, + isOrbotProxyEnabled: Boolean = false, + isCustomSocks5Enabled: Boolean = false, + isCustomHttpProxyEnabled: Boolean = false, + packageName: String? = null, + orbotProxyAppName: String? = null, + orbotProxyAssignedToApp: Boolean = false, + socks5ProxyAppName: String? = null, + httpProxyAppName: String? = null, + dnsProxyAppName: String? = null + ): ProxyRoutingEngine.RoutingRequest { + return ProxyRoutingEngine.RoutingRequest( + uid = uid, + rethinkUid = rethinkUid, + rinr = rinr, + autoProxyEnabled = autoProxyEnabled, + blockedByRule = blockedByRule, + appExcludedFromProxy = appExcludedFromProxy, + baseOrExitProxyId = baseOrExitProxyId, + wireguardProxyIds = wireguardProxyIds, + isProxyEnabled = isProxyEnabled, + isDnsProxyActive = isDnsProxyActive, + isOrbotProxyEnabled = isOrbotProxyEnabled, + isCustomSocks5Enabled = isCustomSocks5Enabled, + isCustomHttpProxyEnabled = isCustomHttpProxyEnabled, + packageName = packageName, + orbotProxyAppName = orbotProxyAppName, + orbotProxyAssignedToApp = orbotProxyAssignedToApp, + socks5ProxyAppName = socks5ProxyAppName, + httpProxyAppName = httpProxyAppName, + dnsProxyAppName = dnsProxyAppName + ) + } +} diff --git a/app/src/test/java/com/celzero/bravedns/service/TempAllowExpiryWorkerTest.kt b/app/src/test/java/com/celzero/bravedns/service/TempAllowExpiryWorkerTest.kt index c7376cf97..cf1647687 100644 --- a/app/src/test/java/com/celzero/bravedns/service/TempAllowExpiryWorkerTest.kt +++ b/app/src/test/java/com/celzero/bravedns/service/TempAllowExpiryWorkerTest.kt @@ -16,9 +16,6 @@ package com.celzero.bravedns.service import android.content.Context -import androidx.work.WorkManager -import com.celzero.bravedns.database.AppInfoRepository -import io.mockk.clearAllMocks import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -27,63 +24,39 @@ import io.mockk.verify import org.junit.After import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.koin.core.context.startKoin -import org.koin.core.context.stopKoin -import org.koin.dsl.module -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [28]) class TempAllowExpiryWorkerTest { private lateinit var context: Context - private val repo: AppInfoRepository = mockk() @Before fun setup() { - clearAllMocks() - context = RuntimeEnvironment.getApplication() - try { - stopKoin() - } catch (_: Exception) { - } - startKoin { modules(module { single { repo } }) } + context = mockk(relaxed = true) } @After fun tearDown() { - try { - stopKoin() - } catch (_: Exception) { - } + runCatching { unmockkObject(androidx.work.WorkManager) } } @Test - fun `scheduleNext cancels unique work when repo returns null`() { - every { repo.getNearestTempAllowExpiryBlocking(any()) } returns null + fun `cancel cancels unique work`() { + mockkObject(androidx.work.WorkManager) + val wm = mockk(relaxed = true) + every { androidx.work.WorkManager.getInstance(any()) } returns wm - mockkObject(WorkManager.Companion) - val wm = mockk(relaxed = true) - every { WorkManager.getInstance(context) } returns wm - - TempAllowExpiryWorker.scheduleNext(context) + TempAllowExpiryWorker.cancel(context) verify { wm.cancelUniqueWork("fw_temp_allow_expiry") } - unmockkObject(WorkManager.Companion) } @Test - fun `scheduleNext enqueues work when repo returns future expiry`() { - every { repo.getNearestTempAllowExpiryBlocking(any()) } returns System.currentTimeMillis() + 60_000L - - mockkObject(WorkManager.Companion) - val wm = mockk(relaxed = true) - every { WorkManager.getInstance(context) } returns wm + fun `enqueueAt enqueues unique work request`() { + mockkObject(androidx.work.WorkManager) + val wm = mockk(relaxed = true) + every { androidx.work.WorkManager.getInstance(any()) } returns wm - TempAllowExpiryWorker.scheduleNext(context) + TempAllowExpiryWorker.enqueueAt(context, System.currentTimeMillis() + 60_000L) verify { wm.enqueueUniqueWork( @@ -92,6 +65,5 @@ class TempAllowExpiryWorkerTest { any() ) } - unmockkObject(WorkManager.Companion) } } diff --git a/app/src/test/java/com/celzero/bravedns/service/WireguardManagerTest.kt b/app/src/test/java/com/celzero/bravedns/service/WireguardManagerTest.kt index 16069ea08..fd0e8819f 100644 --- a/app/src/test/java/com/celzero/bravedns/service/WireguardManagerTest.kt +++ b/app/src/test/java/com/celzero/bravedns/service/WireguardManagerTest.kt @@ -18,46 +18,29 @@ package com.celzero.bravedns.service import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.data.SsidItem import com.celzero.bravedns.database.WgConfigFilesImmutable import com.celzero.bravedns.database.WgConfigFilesRepository import com.celzero.bravedns.database.WgHopMapRepository import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE +import com.celzero.bravedns.wireguard.Config as WgConfig import com.celzero.bravedns.wireguard.Peer import com.celzero.bravedns.wireguard.WgInterface -import com.celzero.firestack.backend.Backend -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkAll -import io.mockk.unmockkObject +import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Rule -import org.junit.Test +import kotlinx.coroutines.test.* +import org.junit.* +import org.junit.Assert.* import org.junit.runner.RunWith import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module import org.koin.test.KoinTest import org.robolectric.RobolectricTestRunner -import java.io.File -import com.celzero.bravedns.wireguard.Config as WgConfig import org.robolectric.annotation.Config as RobolectricConfig +import java.io.File @ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) @@ -77,6 +60,10 @@ class WireguardManagerTest : KoinTest { private lateinit var mockPeer: Peer private lateinit var mockPersistentState: PersistentState + private fun ssidStorage(vararg items: Pair): String { + return SsidItem.toStorageList(items.map { SsidItem(it.first, it.second) }) + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -167,7 +154,6 @@ class WireguardManagerTest : KoinTest { configPath: String = "/test/path", isActive: Boolean = false, isCatchAll: Boolean = false, - isLockdown: Boolean = false, oneWireGuard: Boolean = false, useOnlyOnMetered: Boolean = false, isDeletable: Boolean = true, @@ -181,7 +167,6 @@ class WireguardManagerTest : KoinTest { serverResponse = "test-response", isActive = isActive, isCatchAll = isCatchAll, - isLockdown = isLockdown, oneWireGuard = oneWireGuard, useOnlyOnMetered = useOnlyOnMetered, isDeletable = isDeletable, @@ -442,9 +427,8 @@ class WireguardManagerTest : KoinTest { // Note: WireguardManager uses its own injected AppConfig, not our test mock try { val result = WireguardManager.canEnableProxy() - // The method should return a boolean value without throwing exceptions - assertTrue("canEnableProxy should return a boolean value", result is Boolean) println("✅ PASSED: canEnableProxy() executed successfully") + println(" Result: $result") } catch (e: Exception) { fail("canEnableProxy() should not throw exception: ${e.message}") } @@ -546,7 +530,15 @@ class WireguardManagerTest : KoinTest { println("🧪 Testing matchesSsidList() - exact match") // Execute - val result = WireguardManager.matchesSsidList("""[{"name":"WiFi1","type":"equal_exact"},{"name":"WiFi2","type":"equal_exact"},{"name":"WiFi3","type":"equal_exact"}]""", "WiFi2") + val result = + WireguardManager.matchesSsidList( + ssidStorage( + "WiFi1" to SsidItem.SsidType.EQUAL_EXACT, + "WiFi2" to SsidItem.SsidType.EQUAL_EXACT, + "WiFi3" to SsidItem.SsidType.EQUAL_EXACT + ), + "WiFi2" + ) // Verify assertTrue("Should return true for exact match", result) @@ -558,7 +550,14 @@ class WireguardManagerTest : KoinTest { println("🧪 Testing matchesSsidList() - wildcard prefix match") // Execute - val result = WireguardManager.matchesSsidList("""[{"name":"WiFi*","type":"equal_wildcard"},{"name":"TestNet","type":"equal_exact"}]""", "WiFi123") + val result = + WireguardManager.matchesSsidList( + ssidStorage( + "WiFi*" to SsidItem.SsidType.EQUAL_WILDCARD, + "TestNet" to SsidItem.SsidType.EQUAL_EXACT + ), + "WiFi123" + ) // Verify assertTrue("Should return true for wildcard match", result) @@ -572,9 +571,17 @@ class WireguardManagerTest : KoinTest { println("🧪 Testing matchesSsidList() - no match") // Execute - val result = WireguardManager.matchesSsidList("""[{"name":"WiFi1","type":"equal_exact"},{"name":"WiFi2","type":"equal_exact"},{"name":"WiFi3","type":"equal_exact"}]""", "WiFi4") + val result = + WireguardManager.matchesSsidList( + ssidStorage( + "WiFi1" to SsidItem.SsidType.EQUAL_EXACT, + "WiFi2" to SsidItem.SsidType.EQUAL_EXACT, + "WiFi3" to SsidItem.SsidType.EQUAL_EXACT + ), + "WiFi4" + ) - // Verify + // Verify - Based on actual implementation, this should return false for no match assertFalse("Should return false for no match", result) println("✅ PASSED: matchesSsidList() handled no match correctly") } @@ -584,9 +591,16 @@ class WireguardManagerTest : KoinTest { println("🧪 Testing matchesSsidList() - null current ssid") // Execute - Test with a specific SSID list that won't match empty string - val result = WireguardManager.matchesSsidList("""[{"name":"WiFi1","type":"equal_exact"},{"name":"WiFi2","type":"equal_exact"}]""", "") + val result = + WireguardManager.matchesSsidList( + ssidStorage( + "WiFi1" to SsidItem.SsidType.EQUAL_EXACT, + "WiFi2" to SsidItem.SsidType.EQUAL_EXACT + ), + "" + ) - // Verify - empty string should not match specific SSIDs + // Verify - Based on actual implementation, empty string should not match specific SSIDs assertFalse("Should return false for empty current ssid when specific SSIDs are listed", result) println("✅ PASSED: matchesSsidList() handled null current ssid correctly") } @@ -595,8 +609,12 @@ class WireguardManagerTest : KoinTest { fun `test matchesSsidList - edge case with single wildcard`() { println("🧪 Testing matchesSsidList() - single wildcard") - // Execute - universal wildcard matches any SSID - val result = WireguardManager.matchesSsidList("""[{"name":"*","type":"equal_wildcard"}]""", "AnySSID") + // Execute + val result = + WireguardManager.matchesSsidList( + ssidStorage("*" to SsidItem.SsidType.EQUAL_WILDCARD), + "AnySSID" + ) // Verify assertTrue("Should return true for universal wildcard", result) @@ -607,11 +625,16 @@ class WireguardManagerTest : KoinTest { fun `test matchesSsidList - complex case with multiple wildcards`() { println("🧪 Testing matchesSsidList() - multiple wildcards") - // Execute - mixed equal_wildcard and equal_exact types in JSON format - val wL = """[{"name":"Home*","type":"equal_wildcard"},{"name":"Office*","type":"equal_wildcard"},{"name":"Guest","type":"equal_exact"}]""" - val result1 = WireguardManager.matchesSsidList(wL, "Home123") - val result2 = WireguardManager.matchesSsidList(wL, "Office456") - val result3 = WireguardManager.matchesSsidList(wL, "Guest") + // Execute + val storage = + ssidStorage( + "Home*" to SsidItem.SsidType.EQUAL_WILDCARD, + "Office*" to SsidItem.SsidType.EQUAL_WILDCARD, + "Guest" to SsidItem.SsidType.EQUAL_EXACT + ) + val result1 = WireguardManager.matchesSsidList(storage, "Home123") + val result2 = WireguardManager.matchesSsidList(storage, "Office456") + val result3 = WireguardManager.matchesSsidList(storage, "Guest") // Verify assertTrue("Should match first wildcard", result1) @@ -914,9 +937,8 @@ class WireguardManagerTest : KoinTest { // Note: WireguardManager uses its own injected AppConfig, not our test mock try { val result = WireguardManager.canEnableProxy() - // The method should return a boolean value without throwing exceptions - assertTrue("canEnableProxy should return a boolean value", result is Boolean) println("✅ PASSED: canEnableProxy() executed successfully and returned boolean") + println(" Result: $result") } catch (e: Exception) { fail("canEnableProxy() should not throw exception: ${e.message}") } @@ -930,9 +952,8 @@ class WireguardManagerTest : KoinTest { // Note: WireguardManager uses its own injected AppConfig, not our test mock try { val result = WireguardManager.canEnableProxy() - // The method should return a boolean value without throwing exceptions - assertTrue("canEnableProxy should return a boolean value", result is Boolean) println("✅ PASSED: canEnableProxy() executed successfully and returned boolean") + println(" Result: $result") } catch (e: Exception) { fail("canEnableProxy() should not throw exception: ${e.message}") } @@ -1015,556 +1036,6 @@ class WireguardManagerTest : KoinTest { println("✅ PASSED: Boundary conditions test completed successfully") } - // ===== ELIGIBILITY TESTS ===== - // These tests exercise the public API getAllPossibleConfigIdsForApp() which internally - // calls canUseConfig() -> isEligibleForNetwork() -> checkEligibilityBasedOnNw() and - // checkEligibilityBasedOnSsid(). The eligibility helpers are private; we test their - // combined behavior through the public method to avoid reflection-based testing. - - // --- checkEligibilityBasedOnNw (tested via getAllPossibleConfigIdsForApp with no SSID constraints) --- - - @Test - fun `eligibility Nw - useOnlyOnMetered false is eligible on mobile`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", true, "", "" - ) - assertTrue("useOnlyOnMetered=false should be eligible on mobile (catch-all)", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Nw - useOnlyOnMetered true on mobile is eligible`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", true, "", "" - ) - assertTrue("useOnlyOnMetered=true on mobile should be eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Nw - useOnlyOnMetered false on WiFi is eligible`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertTrue("useOnlyOnMetered=false on WiFi should be eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Nw - useOnlyOnMetered true on WiFi is NOT eligible by NW`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - // On WiFi + metered-only + ssidEnabled=false → not eligible - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertFalse("useOnlyOnMetered=true on WiFi with no SSID should be NOT eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - // --- checkEligibilityBasedOnSsid --- - - @Test - fun `eligibility Ssid - ssidEnabled false is eligible on WiFi`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertTrue("ssidEnabled=false should be eligible on any WiFi", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Ssid - ssidEnabled true matching SSID is eligible`() = runBlocking { - val ssidsJson = """[{"name":"MyWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertTrue("ssidEnabled=true with matching SSID should be eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Ssid - ssidEnabled true non-matching SSID is NOT eligible`() = runBlocking { - val ssidsJson = """[{"name":"MyWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "OtherWiFi", "" - ) - assertFalse("ssidEnabled=true with non-matching SSID should be NOT eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Ssid - ssidEnabled true wildcard match is eligible`() = runBlocking { - val ssidsJson = """[{"name":"Home*","type":"equal_wildcard"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "HomeNetwork", "" - ) - assertTrue("ssidEnabled=true with wildcard match should be eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Ssid - ssidEnabled true not-equal SSID match is NOT eligible`() = runBlocking { - val ssidsJson = """[{"name":"CafeWiFi","type":"notequal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - // SSID matches NOT_EQUAL → block - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "CafeWiFi", "" - ) - assertFalse("ssidEnabled=true matching NOT_EQUAL SSID should be NOT eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility Ssid - empty ssid list with ssidEnabled true matches all`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = true, ssids = "" - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "AnyWiFi", "" - ) - assertTrue("ssidEnabled=true with empty SSID list should match all", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - // --- isEligibleForNetwork combined --- - - @Test - fun `eligibility combined - useOnlyOnMetered false ssidEnabled true on mobile IS eligible`() = runBlocking { - val ssidsJson = """[{"name":"CafeWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - // On mobile the SSID rule is bypassed (mobile has no SSID context). - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", true, "", "" - ) - assertTrue("On mobile, the SSID rule is bypassed (mobile has no SSID)", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility combined - useOnlyOnMetered true ssidEnabled true on WiFi with matching SSID is eligible`() = runBlocking { - val ssidsJson = """[{"name":"MyWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - // mobileOnly is meant as an alternative to ssidEnabled; when both are on, either - // rule being satisfied is enough. On WiFi with matching SSID, the SSID rule passes. - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertTrue("mobileOnly + ssidEnabled on WiFi with matching SSID should be eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility combined - useOnlyOnMetered true ssidEnabled true on WiFi with non-matching SSID is NOT eligible`() = runBlocking { - val ssidsJson = """[{"name":"MyWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - // On WiFi with non-matching SSID, neither rule passes: mobileOnly fails (not mobile) - // and ssidEnabled fails (SSID mismatch). - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "OtherWiFi", "" - ) - assertFalse("mobileOnly + ssidEnabled on WiFi with non-matching SSID should NOT be eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility combined - useOnlyOnMetered true ssidEnabled true on mobile IS eligible`() = runBlocking { - val ssidsJson = """[{"name":"MyWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - // On mobile, both rules pass: mobileOnly on mobile, and ssidEnabled bypassed on mobile. - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", true, "", "" - ) - assertTrue("mobileOnly + ssidEnabled on mobile should be eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility combined - useOnlyOnMetered true on WiFi with non-matching SSID is NOT eligible`() = runBlocking { - val ssidsJson = """[{"name":"MyWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(any()) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "OtherWiFi", "" - ) - assertFalse("metered-only with non-matching SSID on WiFi should be NOT eligible", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - // --- canUseConfig (app-specific via getAllPossibleConfigIdsForApp) --- - - @Test - fun `eligibility canUseConfig - lockdown eligible returns only lockdown config`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = true, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertTrue("Lockdown eligible should return config id", ids.contains("${ID_WG_BASE}1")) - assertEquals(1, ids.size) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility canUseConfig - lockdown not eligible returns block`() = runBlocking { - val ssidsJson = """[{"name":"HomeWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = true, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - // On WiFi with wrong SSID: NW false (useOnlyOnMetered=true), SSID false (no match) → not eligible → block - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "OtherWiFi", "" - ) - assertTrue("Lockdown not eligible should return block", ids.contains(Backend.Block)) - assertFalse("Lockdown not eligible should not contain config id", ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility canUseConfig - active eligible returns config id`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = false, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertTrue("Active eligible should return config id", ids.contains("${ID_WG_BASE}1")) - assertFalse("Active eligible should not return block", ids.contains(Backend.Block)) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility canUseConfig - active not eligible on WiFi returns empty`() = runBlocking { - val ssidsJson = """[{"name":"HomeWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = false, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - // metered-only on WiFi without SSID match → app-specific returns empty (proceed to catch-all) - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "OtherWiFi", "" - ) - assertFalse("App-specific not eligible should not contain config id", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility canUseConfig - inactive config returns empty`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = false, isLockdown = false, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertFalse("Inactive app-specific should not be added", ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - // --- getAllPossibleConfigIdsForApp integration --- - - @Test - fun `eligibility integration - lockdown adds block to empty list when not eligible`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = true, - useOnlyOnMetered = true, ssidEnabled = true, - ssids = """[{"name":"HomeWiFi","type":"equal_exact"}]""" - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - // On WiFi, wrong SSID, useOnlyOnMetered=true → NW false, SSID false → not eligible → block - // Then proxyIds is cleared and block is added - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "OtherWiFi", "" - ) - assertTrue("Block should be added when lockdown is not eligible", ids.contains(Backend.Block)) - assertEquals(1, ids.size) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility integration - multiple active configs with mixed SSID eligibility`() = runBlocking { - val ssidsJson = """[{"name":"HomeWiFi","type":"equal_exact"}]""" - val cfg1 = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = false, - useOnlyOnMetered = false, ssidEnabled = true, ssids = ssidsJson - ) - val cfg2 = createMockWgConfigFilesImmutable( - id = 2, isActive = true, isCatchAll = false, - useOnlyOnMetered = false, ssidEnabled = true, ssids = ssidsJson - ) - val cfg3 = createMockWgConfigFilesImmutable( - id = 3, isActive = true, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg1, setupMockConfig(1)) - addConfigToManager(cfg2, setupMockConfig(2)) - addConfigToManager(cfg3, setupMockConfig(3)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf( - "${ID_WG_BASE}1", "${ID_WG_BASE}2" - ) - - // On HomeWiFi: app-specific 1 matches, app-specific 2 matches, catch-all 3 eligible - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "HomeWiFi", "" - ) - assertTrue("App-specific 1 should be added", ids.contains("${ID_WG_BASE}1")) - assertTrue("App-specific 2 should be added", ids.contains("${ID_WG_BASE}2")) - assertTrue("Catch-all 3 should be added", ids.contains("${ID_WG_BASE}3")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility integration - no matching configs returns default`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns emptySet() - - // metered-only on WiFi → not eligible → proxyIds empty → returns default - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "fallback-proxy" - ) - assertEquals(listOf("fallback-proxy"), ids) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility integration - no matching configs and no default returns empty`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isCatchAll = true, - useOnlyOnMetered = true, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns emptySet() - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "MyWiFi", "" - ) - assertTrue("Should return empty list", ids.isEmpty()) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility integration - active app-specific with metered-only on mobile adds config id`() = runBlocking { - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = false, - useOnlyOnMetered = true, ssidEnabled = false - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", true, "", "" - ) - assertTrue("Active app-specific metered-only on mobile should be added", - ids.contains("${ID_WG_BASE}1")) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility integration - lockdown on mobile with non-empty ssid list is eligible`() = runBlocking { - val ssidsJson = """[{"name":"CafeWiFi","type":"equal_exact"}]""" - val cfg = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = true, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - addConfigToManager(cfg, setupMockConfig(1)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - // On mobile, both rules pass: mobileOnly on mobile, and ssidEnabled bypassed on mobile. - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", true, "", "" - ) - assertTrue("Lockdown on mobile with non-empty ssidEnabled list should be eligible", - ids.contains("${ID_WG_BASE}1")) - assertEquals(1, ids.size) - unmockkObject(ProxyManager) - } - - @Test - fun `eligibility integration - active non-lockdown non-eligible on WiFi falls through to catch-all`() = runBlocking { - val ssidsJson = """[{"name":"HomeWiFi","type":"equal_exact"}]""" - val cfg1 = createMockWgConfigFilesImmutable( - id = 1, isActive = true, isLockdown = false, isCatchAll = false, - useOnlyOnMetered = true, ssidEnabled = true, ssids = ssidsJson - ) - val cfg2 = createMockWgConfigFilesImmutable( - id = 2, isActive = true, isLockdown = false, isCatchAll = true, - useOnlyOnMetered = false, ssidEnabled = false - ) - addConfigToManager(cfg1, setupMockConfig(1)) - addConfigToManager(cfg2, setupMockConfig(2)) - mockkObject(ProxyManager) - every { ProxyManager.getProxyIdsForApp(100) } returns setOf("${ID_WG_BASE}1") - - // App-specific 1 not eligible on OtherWiFi (metered-only + no match) - // Catch-all 2 is eligible (no metered restriction, no SSID restriction) - val ids = WireguardManager.getAllPossibleConfigIdsForApp( - 100, "1.1.1.1", 80, "", false, "OtherWiFi", "" - ) - assertFalse("App-specific 1 should not be added", ids.contains("${ID_WG_BASE}1")) - assertTrue("Catch-all 2 should be added", ids.contains("${ID_WG_BASE}2")) - unmockkObject(ProxyManager) - } - // FINAL COMPREHENSIVE TEST SUMMARY @Test @@ -1592,11 +1063,6 @@ class WireguardManagerTest : KoinTest { "matchesSsidList() - SSID matching logic (exact, wildcard, edge cases)", "getPeers() - Peer retrieval (existing, non-existing, empty, multiple)", "deleteResidueWgs() - Database cleanup operations", - "Eligibility - checkEligibilityBasedOnNw (mobile/WiFi × metered-only combinations)", - "Eligibility - checkEligibilityBasedOnSsid (enabled, match types, empty list)", - "Eligibility - isEligibleForNetwork combined (mobile bypass, WiFi+SSID logic)", - "Eligibility - canUseConfig (lockdown block, active eligible, inactive skip)", - "Eligibility - getAllPossibleConfigIdsForApp integration (lockdown+catch-all, fallthrough, default)", "Comprehensive state management - Complex multi-config scenarios", "Boundary condition testing - Maximum configuration scenarios", "Error handling - Invalid inputs, edge cases, malformed data", @@ -1620,7 +1086,7 @@ class WireguardManagerTest : KoinTest { println("✅ COMPREHENSIVE STATE MANAGEMENT COVERED") println("✅ NO STATIC MOCKING ISSUES") println("✅ ROBUST & MAINTAINABLE TEST SUITE") - println("✅ INCREASED FROM 28 TO 84+ TEST CASES (added 26 eligibility tests)") + println("✅ INCREASED FROM 28 TO 58+ TEST CASES") println("=".repeat(80)) assertTrue("Comprehensive test coverage completed successfully", true) @@ -1850,7 +1316,7 @@ class WireguardManagerTest : KoinTest { println("🧪 Testing canDisableConfig() - lockdown config") // Setup - val lockdownConfig = createMockWgConfigFilesImmutable(1, isLockdown = true, isCatchAll = false) + val lockdownConfig = createMockWgConfigFilesImmutable(1, isCatchAll = false) // Execute val result = WireguardManager.canDisableConfig(lockdownConfig) @@ -1866,7 +1332,7 @@ class WireguardManagerTest : KoinTest { // Setup val catchAllLockdownConfig = createMockWgConfigFilesImmutable( - 1, isCatchAll = true, isLockdown = true + 1, isCatchAll = true ) // Execute @@ -1892,7 +1358,7 @@ class WireguardManagerTest : KoinTest { setupMockConfig(2) ) addConfigToManager( - createMockWgConfigFilesImmutable(3, isActive = true, isLockdown = true), // Active lockdown but not catch-all + createMockWgConfigFilesImmutable(3, isActive = true), setupMockConfig(3) ) @@ -1926,9 +1392,21 @@ class WireguardManagerTest : KoinTest { println("🧪 Testing matchesSsidList() - case sensitivity") // Execute - Test case insensitive matching for strings - val result1 = WireguardManager.matchesSsidList("""[{"name":"WiFi1","type":"equal_exact"}]""", "wifi1") - val result2 = WireguardManager.matchesSsidList("""[{"name":"WiFi1","type":"equal_exact"}]""", "WIFI1") - val result3 = WireguardManager.matchesSsidList("""[{"name":"wifi*","type":"equal_wildcard"}]""", "WIFI123") + val result1 = + WireguardManager.matchesSsidList( + ssidStorage("WiFi1" to SsidItem.SsidType.EQUAL_EXACT), + "wifi1" + ) + val result2 = + WireguardManager.matchesSsidList( + ssidStorage("WiFi1" to SsidItem.SsidType.EQUAL_EXACT), + "WIFI1" + ) + val result3 = + WireguardManager.matchesSsidList( + ssidStorage("wifi*" to SsidItem.SsidType.EQUAL_WILDCARD), + "WIFI123" + ) // Verify assertTrue("Should match case insensitively for strings", result1) @@ -1941,11 +1419,27 @@ class WireguardManagerTest : KoinTest { fun `test matchesSsidList - complex wildcard patterns`() { println("🧪 Testing matchesSsidList() - complex wildcard patterns") - // Execute - Test various wildcard patterns in JSON format - val result1 = WireguardManager.matchesSsidList("""[{"name":"Test?","type":"equal_wildcard"}]""", "Test1") - val result2 = WireguardManager.matchesSsidList("""[{"name":"Test?","type":"equal_wildcard"}]""", "TestAB") // Should not match - val result3 = WireguardManager.matchesSsidList("""[{"name":"*Test*","type":"equal_wildcard"}]""", "MyTestNetwork") - val result4 = WireguardManager.matchesSsidList("""[{"name":"Home.*.com","type":"equal_wildcard"}]""", "Home.wifi.com") + // Execute - Test various wildcard patterns + val result1 = + WireguardManager.matchesSsidList( + ssidStorage("Test?" to SsidItem.SsidType.EQUAL_WILDCARD), + "Test1" + ) + val result2 = + WireguardManager.matchesSsidList( + ssidStorage("Test?" to SsidItem.SsidType.EQUAL_WILDCARD), + "TestAB" + ) // Should not match + val result3 = + WireguardManager.matchesSsidList( + ssidStorage("*Test*" to SsidItem.SsidType.EQUAL_WILDCARD), + "MyTestNetwork" + ) + val result4 = + WireguardManager.matchesSsidList( + ssidStorage("Home.*.com" to SsidItem.SsidType.EQUAL_WILDCARD), + "Home.wifi.com" + ) // Verify assertTrue("Should match single character wildcard (?)", result1) diff --git a/app/src/website/java/com/celzero/bravedns/iab/OnPurchaseListener.kt b/app/src/website/java/com/celzero/bravedns/iab/OnPurchaseListener.kt index f6fe6da48..e49a21e86 100644 --- a/app/src/website/java/com/celzero/bravedns/iab/OnPurchaseListener.kt +++ b/app/src/website/java/com/celzero/bravedns/iab/OnPurchaseListener.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 RethinkDNS and its authors + * 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. @@ -13,9 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.celzero.bravedns.iab +package com.celzero.bravedns.ui.location -interface OnPurchaseListener { - - fun onPurchaseResult(isPurchaseSuccess: Boolean, message: String) -} +data class ServerLocation( + val id: String, + val name: String, + val latency: String, + var isSelected: Boolean = false +) diff --git a/app/src/website/java/com/celzero/bravedns/iab/QueryProductDetail.kt b/app/src/website/java/com/celzero/bravedns/iab/QueryProductDetail.kt index ad088e28a..a3f14a5be 100644 --- a/app/src/website/java/com/celzero/bravedns/iab/QueryProductDetail.kt +++ b/app/src/website/java/com/celzero/bravedns/iab/QueryProductDetail.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 RethinkDNS and its authors + * 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. @@ -13,13 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.celzero.bravedns.iab +package com.celzero.bravedns.ui.location -import com.android.billingclient.api.ProductDetails +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 -data class QueryProductDetail( - val productDetail: ProductDetail, - val productDetails: ProductDetails, - val offerDetails: ProductDetails.SubscriptionOfferDetails?, - val oneTimeOfferDetails: ProductDetails.OneTimePurchaseOfferDetails? -) + val selectedServerCount: Int + get() = servers.count { it.isSelected } +} diff --git a/assets/comparison.png b/assets/comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..79e736ce0df60648f246094df953d95bf8075fec GIT binary patch literal 1847494 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 &GaRsy6Y{P4pbH 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ⅆ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%0AGn#@P0#CpZ?&zUVWja;wUXN#n%{mjKdTVW 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

%bXHbLGf%t$7tBU;5)d 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

=0I7RigDW-RV|g;>sJHNs4-)ecD;%D?=iV* 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�mihO%8Z}PiGp|k$_!EN8=GoW&eqEHh z1gu}D+{Q)7HJ*C^&Jv7`i>s~sqHjRdWKQ59H)X?}@ijk9V`We{<+YBtVe#MOKx`ET z`vQynn;VlKbP&qI+-H2lwY2>PYX_4UzZM*Ld&z{gyM#(qGX8|yV+?IZzO5v4Z*lms z+z|3378kXcJiacG%R_RVX)c?ld7@V}o12*UTra!t#2KP@$`rbnn3(8)jvAMX5%$dRZ98V*IOotLai`k&|N)I-aZd#+yh( zufvs{&q!YFQ2bD)K?TpS(jr&8r5_xjbf{AJHi^^oDbLzLMx`=66MC1iSdG{7*(>f> z&2^K7+KrqIKq=ppzP=clPOh9ECROp#S>HzvG(qCgSd!u(BFK8(r+UFlQqzd1>oRd+ zN!@Za8~hEOHRd@R4GUlL@2`8fcOJ!i;ecm7!ReYx>n6cW>(bS*6i8v=K-yh!bgW|X zol%=b`c+e7_4`(*duL$FLEJS5^xuO>ytz>byra?U5i)@@0j(&cCOT|BOK-Yzx%Wo9 zSJb?(I1yJbIj>+^WxN(xpFL=VX+w}3+&f7cg1HZhbr-E~AJgD{w+b+kak4nl49%(5 zBksHQvN&#d7v=06NNf}RL13r@SlZDU+D`Nv^LY;b2})*Ow_-EEHJt)iC14kl+A0wb zF%@2-xY0IGh&D*pZcN3<#hx5qjlOenEMB3l`!PfVUGO8 zBlU^=Wc}&S4Fxg|$vZ5m3Jcl4N$jQ1j)m+&T#$HIEaSCx<;|zX&+?#o#>0e_Yf^2a zQ}@RQb+0qGOyXu7-0JV#n`zRp!|W!*#g3$#*B!YGov|W^TRpJCw79X zU^?Gp1XxvS5*_<@Aeg9sqAp<+Y|}JkMyIl|$U@Kzi_7_K6Qnv&Vb_;x$js8B^5Mx% zML;mk&slAOk1)d;oEu#~>k=U0g6IjkO@Kv*ThkIi?cfV;S02YrrS->d7C0gLK|-o< zxF!$=pM4=Hymfv%E`SWw%RkpdyfOhc>{=KwmT%nvcEsJ)Q`?gY@2maS#zL3aP z(CJ({zQOCK6c^$3{Bqmhr}Yh0b3}=mmFKoqRzRAigRdK%x^j&}IX-SXVZi9w+)OP* zY|9Sh=YUtd6+0rKH$D9H@27Cb{=C6ec5C5m@rK=loDuYxI%>pUu9qWEcD(QM%7y-i zsSriDmVo>Ax8f%IIB&)$|ArTZR+{f6ik=Zf{g(O8U5WGl@4r)B5iEE}756&Sfv~kH za^1#xoI{WBdFVbhuJH`3GToTMyJ>T`=aLitmVTbG!`Y^~Sm%w@kihvh=cQdDC$_ z<1!ArL2C^YyCamtT8*-Dn1@t0xa5NHWyh?`jCLPb*}rHmh-6{^pm9+U-gLb7wRF~= zDEIH9O)K(z5Wjf?JCyX_lhfiITc($8ksb&0oC}h^;jybS1l3gf`qP_Hc2L&1hzsMf z(b1@bW+jZ)OSGm7t?{1Swr(ebas=8)3cnb$#8$Jcu|Gj8vrUl2?0g}%1g2i-&0@`2 zGgnfXeJN(XD<%(LG*KMs=rcEL!6mDSBwyf8vjK?{F2vL^K(CQVzx!`cJV^Zhq8b9d zjeNO3$Bu&!idTCN!e)2~2<`n~r6`eZ%Y6FsS}Wmh@DrRbrhE$^s8^ z?0L~`_xtWxa{ppX>L|rExCi`UsM*0{qdQHL@?sI!zbwzhT{y=M6cPIyeiKYqH4o_Ls#-JhTbv)|+kKogp zl@QF6af~-SF0M^#CPR*vI43gxUTLes;bSzK(G| z-Z9nOMgzNCt*Wg=ddIjA|KlGczP%Z7;@)S@Rcu)C@=+8y_PIB2#K+xtnAu3!ymM_|W8ihtR@0_IiSaI7A5_Og@tY>&@mlw_g$(K> zJ{=L|`xSv|9tX#@qb{ocvfOpDOnJ1&S0Cm5sI428cwTU=++e>CM?L~!UK-*Neg|{! zeg$0Uz z@^|h$Pki-?*ERqL%Z=p4&97OV1|IgEzOyt?9|+NK!JoC5aH$IR93r=Kw+W7|dcQ}K25lyn`mPqyW_fi*Y%_V8TPPbdx1~ZRE zRg~@zleC{|xW9?4N6q3#)`|;zM^G_Q(FXMQk?+)R1*bO4}zT%^2&2qPHq3;u3S;4DW{o-7^ zWUac5BkTzGj#1l_`KQZs;y){mbdP#pNG91+d|S7X+UI}&oI2yN_LB=l$2|gpW3|}P zh;}*7>m!+gKfL(X>fT9J^DlZr1wdq1&>Mkkwu)|8wIQ_6>az409L0GRGJTW+Ie9W> zX=k~gGL2ab-AA(e9k1RYdhMi=+>#r6n|+shc=nH=@)1WZIv{ExD38TQ?93raN`10g zp>!Ool$otNw9bMk%-W_VCPv_Wu>S5|Qgy(SRrup^ARo1W=;Oi9VjCt^fyXg{m`U8n zi*@%dWn4dh%Nv6t!h>%DJ9w$J9tP!H=v&pCU%HlVdkBwOonyXKSM&y9@?i z5q2VHc$_}Un}*TngnQo`Yi(xya_G$&Eq%gl%@Cba$yYSl`ilNW4|pQ)wKAtCvHnl# z^tZq@;4!Fb4LH*7&*}&ix+Z=riKdSv2%C1Re#Y)L3S8E;>P~W724Ni7vp=ZOhu%X&mxMH!1PI?^NCLo42YAxf*4@{$C~EpR0@5V)&X@Vp^8MyY;?& zi84%1lnaoW7u1Zlyx$ph1bqp3{rdf-<3l`C zg#W>;f9&!->T1|qex%zQwLgxEdCWk{iW_{5&$-)y&LY|2x4a>`2o(s!)=wZfw5$_uy;Gosj!e*4w{8Fco-%oh27-6E1RoJKK@1 zF6~V68kzw+eXMsJ7P}@{GPCDW;d|7Uj#?8!1!Kwf5A@*N?F!u`QQT+6%3L*%7rtrV zHJtux!}o?yR2-<&aX#E3;BghoEggi4JwOiX_zSn4rdAcyoH%=vIXQUY$)Ws*O;N#U zw<-@-i+j}3eLJ+#`V8xeM3&Ij3Tp1)l9bdPv9n4Z(8QB&lf_OE5+hJH2&(d0&2Fs8 zjz7Dvsd6D?hF;QCd4-jzvtt$xH<0+YU$rB)?~#yu_6?tF>4Yn*WMg2xLk3hZ<(2`Y zA2H{d-QRvE1iLl5MZ$qXgB%@aQXPMFaG))YEN$rbbAS6Xwo#FEKQ%aDq@ zma2d(D9^wQdaqC9qYpxCj+yAd>w`ujC7N`N#VZQbY;gUu+!7d=L2$@P?S!DtUE1Kx zwpYWmzW=*^DaIGJ7QgZ)cp&YGa`2N&<%P%BcjF}#Hro(O#OsGkshjz%wujf|2Q5v= zElG^k`z$TdE-!X9Ds|uIy(WCm6Q6%s;IIE}57DPkdcA{xt!gn8mmAnPFA$>O+;#l# zTFSC^oBte&$~O>l34Me(wU;Y1du3(T7c%lD+wC8G5HOS|W_+7u(!FBVHoS zO;(Yh1Jg%d8r1xJ-NUuUQc!*#zxQkUx;Ls1D6ev2aoAN-;PB=L4*OCt5#DCwTf(XVnM%MkS#`p)y`U6~$Q}%b+ ztw>fPheZ0u-Ys6X#=h%W(fm2T_H#5ZubT;2NtX}B%pEIDe96W=;PX*e?ilf}v0XXf z`X0f~x_CE@&SBa|n-E=*`J83IG&}AxIygU#T_b<*{mc)zzY3r|-O{i)rPO0YJNuhl zm3Q2hD+qv3zFG=!073Xu=a9R(`yyh&cH;*5XV^ofv}dq!pMQ0s-=bZEO0HTu5G{Fu zL~DS7(OC=Mr{+$bxaa5PRYg_)7v&1!eHZ_^eEcVJt-8OhI`<5?@^srweKQ1a67y4G zB>z+lz_PC1r8YzFC?`}q9c5Ln;4Wa_Fl|hxx1Sd(tb~#Vdj-rXwkxLms3E%~K=eF+ z*BysMcG0%$4qR6!&bA%8D}cfekQ+KEj)$dCN<)i;WBIrUt*%$cHv!20fQfkIKzyWD zU|{@SR|9$^i8Z5*yGe+UR@!Hn#R`_$-+n+h%$<8_6!OBC&^c=k)>X#bR5Q|&v3p<` zVp)Y8ysg+v=4@dmzAV*(L@&mxL+GA0EX~mN7(pmGBxzWO-woRyO9f6uOab9W6A*qtM?~Np%!>j4hP- zIS3dI!Of*d(ISxOI$0t8S=HKm@~ef?{dN33PeaU6*t)86pa3gcphdXC1?D7pH>kh( zh44KefwHo&jU`sFEBI5=E^@Jvp7cb1DZBI)2FqHR`@7nCUa4+;OmLWx$~5}mKOn_2-4lI-b(Zi{$TC(wlkbzcp9Ax0||& zP8EkT9$sse_>N)4I(`cBH(urv2}>;5^I4V>8u_Rx)Wc z`l-4;6)UAs*6ow9BF6{rB({)S2aHYco%dGxnW&Zm{>m%gc9Q!)bAYv*M-~90f=43O z<(z48WJI+ACBchPov`a`3G@kJ$*T&u!}A9YI=ucv`ep$BQa#`%{r*j4n3U(?W312h zKA~5G$~_-p?fBb&At(W~g-}Q+ln9G??83L>%J;m!3amvC0@$`vc!Z;$V06MH$JK+F zZQXj2N6B>TX_1*_h;@#a|GJzWcPV?PCsaO)tGK8~R*sre{x0Hnc0tn&wXKN=cJQ9hOiA1LklST{-s{LPfE#)5HFvT z-t^ka9_z2bE7uMH(a0=Xs8^Hxx#~_@W|dGU=^!T!QDG;OIi5U$&XMQ7Bse`2x|Q)ms*Heo_^3ZjE9$cKqTP5fr_5m&^e z4aVvkH8+921PDdp{sxDI9>G<|l{fyYwEpq~JpeedzBQJu6F3Pe3s4K-=Bk_wC9P& z1c_K#W^rHyrad)Qju146x&0>yJ4xU)nAWfHdgsBe9=XE}yivP7VYzqK1NOUjwXcTA zr}c@Pf9<&b>cXsdx_X9~FMY!!#w=LpT{yLdtWdC;idR^Gt&XoN`6<_1D&qGRT(klLaW0WBrfN=Oz*NKBVqh94=YfoSID=2PLpLh?H>~ zN?0hCKoh<6^)l$N|NHDQ%Q11b0sr4(V`rj7)A#-UW7+y{%mxfjGa7;K3WdFKuY+D2{db{^ z!)fM6|Cs)kb=q-ZTpw|mA)h6~_qQ1MU2nUcM|9?tH&q%Ju;tM{Z3K|-U=_+7*eh4` zy}Q`@eB+UI>QCKIexSui&5cR1{;YY68ghb#`Qeu=o02$^G;dqMCh@^s^Y>0#hP^-f zpL7O%R4ApDS4=?3$sN3G16P2ud;DjecXG@^_D24%4ep0 zLq^q4R|*#sKOkQZ`-EmTu#cFpwaCmD0Ppjc5LXQ|CKujC!0!DYprai``w>FvJ1tHZ z`hR=SWwgnILhl8YCwdr)@WspMy!+Vr@(w{-E1af#i)V5MxxaxZ(;y$NB!cf3l=kXx zsW;3Up5+2rcMLEEktj`OIJTP{N`cf~sglFJCyJ2)9i(Z}Awk z(^e+K31Fo4zZCah7w>6j;$>Ly%YcwVW&C(ROd4jyKktI@nJHR&F4iS`uCU};#;HNG zrQg>jgd-;;>J(~D=(uDT2(7-WZo!u$=sAM1)fR;q_{tK(Tsn`{yd(owZ(V<~G@ZLC zlKYood;B*vYg6;{8f=B_E0m7CTd>=KODWqWbmScGFLWf9gzl0y+$!D|>Vm8#*6xLS z|8*%Db^v_^N>1|?Y9v>(d%Hxw7rxr=a;1sZg$%^XKMxO0O3#q;;P?o~-uL*Bx4`sl z4?)J$nWQys_%IsDlcYbjR6Jeu$QP_n$-fdNkIZKtoSN+M_rPbuRGz#+IN z;&X4$4}Fwt=<5Vaxdy}q>>8rr$B>apar!JW4=|8Z(^5xy=h4}@S;@b0H5L=ZBsmy+2cH|u~j)T(^zO6D3q1QITm8m6=&70>+T_T zl~OFNJWvG1v$ZYR52hwvLo#_TvJPzls)9$N?=bg;Y(Hy2`<{F5Ls?;NgYISmC?}(jig|{7J?y*&!69Lerrt+KBKf`sL;4uBdibKg%HqGTZk0ERm1MVqmJIe0MeNrr}+A(o3 zJ|vB@F!o0rzFJb}Ph>RKC2*5yMrG8_{E|CQb}Zx(TR-IFIk@NMn$!y9cEXm0Z~g7?(P^fLLbb7RW|Su6*y%XLP^(@QR| zy}5j3vx!&8Mq2J08$)lJh*I{dbU|br+F|@hI=i>+?>XncHTb@hLFoMG`#9;zIvh+yNjigLdK@&>v4mN}S4{NO)xrb8 zT6p~)rQrOf+cfu74l`)smFPQZd(UH{0V9g)g8}ue4HGmu`ye%9te7KI%+sgQD+dV2O3q7s4zaYz0cU!dikECuyVgz4ytkWq+XY`x5xw%O42n9 z-Qc+E+_Mx?s)bxfL2|fdE_f9JF(;|{a4 z-wOScpQQcc>^yqtGyVyLxzKa>2iy;4V91~l<{Iczz-Zw3*m@rVyvFu9_#BXBF@CvC zP+LuyAZnFd9f5xH1teZo^C#@fzr?j)epFE-Gqz4WlGXOd=-oYF1cuH{RZwVOi{|e+ z1Re*G4~TggF`t&G==IIw=kXY1G1d6^%nJUH^wL$G*0!g;3@g-^>%TdcIc6^S$cR0T z`;(ODk8Tq>6&j}XFqMQ=zw8~CkBDzGT=C|f^7>rbeOuCWbquue# z?z86q;Tz|djU8Fbmko`ClXInU-G_{G%<7vWBCg=6cAopXSSc6TxIKb#RfA5jOBC;M zqr@xfFJCxAXdL#aDP_~nIn9ojaN+KNd?<(U)45U>g5x%uG1Bxo;KjjjFM)KwZyz~H z|JNYs3=nSEe6|WAO;jHa~s@=*Q3MAL$r)H9$zHG z{vmRsZERmUR?9Kp%_>n{bt!geIxB1SfQ>JHy6`r!ua}yesFFL+_=)zD@v_dTQnFTZ zrs34SIlcl}igh`cW3`a?$7SgX~K7CKJP)PwCd*|24<>$MrCo!k0 zv&x`raGE;|bs6w?jCh=OZTY8p%eK({sn8BQ00j@Dh&+osT7J4)A|XC)EYIfGCz3t+ z2G2u1JUUi6tsqa#goypH_?o)Tfddo}2jI*4E|FV$LP*c@q!3?bTfUYBM6wt5$B~BD z$`_Dq%`}kL1c%&=*x52bC9~C_ENMZ8;So<)-B}kXLC4@DU!esR1s$I$DyX*9c^)ko z0-axOyPBMN1B`u4#*}a|4nC~wwUB?Cz1*HfcCf$=4|y#Lxid59HhBB^3-!E~ z=1G%!u|2aw+7{<2_3)E+`>9o@!uoE#vqQ&z*I@?8F2ILwd^)3@HIGL%>HciE9j!<@ zrR~+vGRp@Kzbva}_V*orf#+NR#rV$gM$+9x@*C}txTBROqF#05xiM*bI~SJ&y|fcE z?=SOw=Z>*=o8|L0|Nl8}-`=?Mt!x&Rc^z&E5D!Ew5ARWDGu%N9`toGoul&1_HGK7| z#Gq2IkZ}@;0-xJCBB^{3rpubJ+phEyGnQPJQURI*-?knDoi{w%7!PaaG;F}}WK~%y zmK8T!V-hOJM&`{{wtW{(NK@t#IGqQMLPPv5qV!JCEF;rM4>WSxp$vxvw3i4d2GJ^w zR{3r6pI0)pqe0f+JUf?fpR)GJeJN7!hKN60QGlB$&*s|Ggf^6~Gn5W%Nm~9m(l$}XCZqI-^I;&Rz ztYtK!bB+!jF(Ux$S~)PjR1!kJ&UesMBQOkki-v&;s_049_#?l<)r&?yaIL);OE@?@ zTC-N1hA=tP?KxG+J;SY`6T6On)0$UxO8O3FiQ2R*y1(nVZ-mlC-qM;mVVa|(M}9fN zWLX2c4&}&jSLr^rupprQJ%a6s7E1uU^E3GZSp&W#+70#ykMd3hc&jHlYr}j-r>6uQ zYPhJ5kkJHDd0XLs}0Mwal#L@x&WnWkX7}2m#=31sueC{{ifRUT9abWYEOF( z$tmO=RU*G0=4uAMn6dVKIPyCIsPTI>%j+&$w65WaUU|4sJ34o%H$q{D1x4vk-*Ej< ztK4=9)724y>+H=K9{$VTaJ?VrA4T$wFFF(V5D;4K8Er}pP|8t_M(t~!2CTRYl1-#* z;`zQs@Aw1toU6+Sq$s!|cBt@8jQT4;y1-9u${@_FSTUKT{OhVjmj`2W(P6 z^Y{s`XuI&bxJ$ZR0a^p?eoo_^7>Fwz1&qWj=LuUg=o~dyzqNo?>3|3_Vc`J=gm19K(|zNk04_# z5Y;Yu`*Q#_-o9m`u;M!lYfDBCYW^XD{u9b>JH6>>3pf<*_5*FQBLcboMNugK4e$GH zl#W+0j=(WJx-a#H;i4BUX}eZ(L-6hUFAwrE8Q%6RDZWgzl_u-J4=z+rks1D8n0X3GeAhhCe1^|aN&per|l;om3G zt}BWSOQ==E(LEi^W>K6%%<@DLsL66W5mC}ytxD=2)QTL400yJ_b?$y8C;$Tv;GuFv z%G+@1y*al*W!kT?+B({~+o4bn&<#xX()stP6pKoH;A;uUdM_H%Jbz#Y(eCse#vlsY zK8L@5Pf6sy(_eajy!WrMtnfwCN2*6&{ZiYvXz1Dz=9Ubda)q2R-VQ>LYBev+e5u3T zR)X-AI%#{p_;LGLLZ#w%BL8`<-j!Qi@ZEL;k&3r_9jMQG_7~fM@A<((Bes^JaqlZh}1OC`5?f#uVrKTNE_2OZoEHPL{EzVi5_Z}1Mf>}M z#{JQKPhgx1DK?MEQ=wP`j%kc)kvSFju4T*q%XTwo@|U;%sigLpESH6g=F|82Pyyo? zIf3I@gUox*#}Qb!$}a?4{YbC>H;KG{wxk-eSSnYQ^e#nc$+4+whrIFXu7~sO-6BzM zX}EsSC3(6P<1>z+W zpuWVAEMdeyp!vZI2>AxQ#BA*hKJ8G?VV|B9N-yF%Jqowa6H};c#(mp_V5VQFXRxJw z^y{me-D?2I=!k{1cr8GY1(ZY9pw z6y6tJ_JCL z`kEqE9_k9i2l7+9orn|UOj!Q8Dxcj|_#KiYYM?5=7MjF++3Nwo56&S9Z9NQX^9|!5 zDAcFG4w*SKK zsUVW|k7-9m6u6|8c_|PXf5hrIisrX@I05o!{DU#IBJCoe$)%&vD%wlo=g&Dc7C-w% z6wqF&9W-?`L8=L%m;@65CPjJi+E85z6{~zGo`>X1qa$DoU~HY7qpKT+_yGwszI)N$B0e1(b?IM$`Vpo^64CoZPrPnBF}4IPDMb zO|ybX^t;?Y-!B(KF+VnR$3~H1`hSLTeA^hyj_}+acL6!vo2+Vs(vSH4=FiVX!~7g+ zp)4;Mh@>s|Ny-MBj;{TyL!33X6y1_0PYDgBL5vYV#U3*~cJ;agw-@ z&k+u5VZOp+<1~>-FTvr|NHNMVwo;81WHCHrvFQsaiPUR1rpynxg5Ty|_QGadRM|2Z z{Jas86(*_v7HRq=1Mq_`pZVTHk9lC`#OQUNp;GKMiysN>=p-X~(Br`G1iJt#ZL9u3_sZTUq@yMw$PO|V`&bE*LwyfP~l0{{8%Z*|=wYC4B( z_{LN4xXXW}+lAGxhV>YrQC@fF+I+b)WX5wYYSBkRt1B!KkJwEvqY@)KTuAufP0v+& zHTOB<9Vi7+ktqXcshD2HcmNbTM@aL-nC4Ja#I_XvR$cCOYC6qrZ$CRuHPJ7Ymgee1 z|9z*m<#%D`1;XSRoTcYBR@PwLkEIsXHD_%9-BFNo#S89sG0b^_-9e8<^7CevZ?5c5 zky#2A)4dS0k)dI2d1cc>sT?uAOfKp)MItZcj|(3^(nm{|se9WQmhIWWqQw#BD2m^! zefLF7z8-%?ERmGEoJ>ToRCpp4hFZ*%TRp>b@1T{0h7V)ElrxU-H;JA#iUnTzD*V;} zz4x_|m2yOwC#1HM7R8?pjO97%ZS_<{-9vTsK7J7meJ1`=mTTdcX(dVwa2qtz3YRC( z`_~mr0ngdqBj^YGA5(9^)du%`;TCsyiWe_Z+=IIYEmCN4*Wyy#-Q6JsDNx*{Kyasc zaYAvIVmEz%cisE{0<0BsGH1@7JP4d*V()o>j<@2y^)SD8!jn%3cj5Ha zFIDaeFS|fUYHluN-X=IvkErPohQ*QTDUpvKZVF)&6c%EEB2X;jE?!MI1*H57Me%`A zZ`mP(cVA&x$t$Hh6&Fjmw+av4y^iPIx;(q%EBCEyT7KBurb7im8U;gH6|&Iuacsjw zgIUnKuAj&fa$#74mpu7EA(BYf4hOee-c>* zlOqt1YfwuXdxqp-FR96oKX~vZBtCQ0e-gapKlQGfNqxkm1Ejb1&^%GE^_VD3f4Vh0 zmpxa%f9nwl`wKKt%ug^G?Cht|Iaxk}{J6>Ig6QWkqY3_w$&SM(OVGxLFoLLpaF$k9G6n!&g}H zi9*T6R&BA2Hd6S3xsef72Yai{TvxwT%>UcIg(dR79xwsDrcEv$>sfZ*bEhyJ{G~j^ zB|Petz@=-EZVdX{FkUh@8ddYZJ4$z`UOQ`%4p1_Y_T05+*3ZM>5oL#X)}KImHVwip zPMKizzrZjlY@8`Q`*w<9UCl4I_-(K1tMe;@Ygbu47p0r9uTVi|Nz&HPebVsReHTdh6Jt`vhNn>X9U!^1lzW>Ow@Y#iu8li)>o z8Z){`jhaQ4Ce|6LY~(K-v7vq_n!L@Bc7E!gc)M#JA5rkvx1Tz3|4OvNXbI-sLo+3= zlr)&8;`Sj|J5I+jQ!>wuckOQN2-N zBLri$(W5=bR;3GmEHQ;Br7mg`g{CblT#MKdy&@i~(;W;LZmV3@cp=fCD zhLISU=bwN0ejWXV;03~<*Q@4S>*D0nbJ4BIT{%i~^D}%F*T&*+xmkYGCx$34^; z2G*z}yBDEP6f71!uC2hwG7Oq96?+-NfMVXazgePfb~@)euVy>v`)pEcKHr?@^Ga76 zaUx_Ab#=DZ5J9|ryXW+xCYbK)C?Jyi$tP@+ei=G0l4Vt;p#)4;7AvX4S|}FWf%0$q zqU_;02@dCDc^sN7?CT2X<;s4X__YqPV>y97%9}g{sbq$c3OFpEmVFuht!$ZEraKHf6uan) zI=G_2p>)0A^`#m?D2-M-kW_*YOkG5w2yt*jd`K*N-Rh`owAPgCxs7CukaHtXv^2U%t?PADoI=Oaa!{ zax7-&JeNytPf zNy^J575+uLd_So~Td7SIOPROHjcNEDuaKCe!|&TuC%-(7fa8+k1ixqvAo(5a zJgkm-5OZl~s{jPzqI21f5cw)+7AnZ5#(}lO2taJ9p?RZNPE&C zSb=R!il)e^j{cy9Bsiw{1b455XGuoHEu1sS$)7F-=b=ofGiQ}m-;>LxOJkd@uW&QNBnULTJda#5MX}By0qjU@}B`5Vh za&(N(5t3fsld&KT`Fr}*r+!Pk8c`x?<&JKTPuz$RSt~k^o8TU`Z@9;-NYDn)ISyOx z3vfdvmu2)9jc_~CpC5SiGlFOww5Q@GF1W^Yyw4u(A?vNLWN$=jv~8sVkexq){rAH< z9f-jGRizlOJM$3A`uQYz>F$j6c;L+7Rcdj=z|p7h2Uq*po{wN2R{p*z8gyMH7Q8j; zHRTONayJ#x@0J43fh9~Y!m4h|5b*TQ)pQGG-GV$=Q$#}Y|*|T3?43E$3ah#`OeD^P0 zZJ*V!KE?OFynP{`in*FP_1I~@7|)!-I$xvJ4L@sG{%|Y~)OoXkT#jgQS7*cxAUw$w z-FVTYwr2vl2}lA61CQ+#Rs%thkhIw6xW=?6K)4CgOrCoDt#}&$gn9jNlVZeR2C*)R zV6;{kUkFl_(Dn=cvZ`0TU&BS zwQ3|T=R$0#pudVn*|L$I0VkwU2U!;4P%N50=z>c&R(I`3@J_*1nSb}{Au3T!l#Jf> z8vaq?e2k=Q8O8?G3dmQ&bKfVaFsiz&_Y#dyhWhOm4(te14O@x2gxzy(6LwKSKz;+c z@ME7F$(t|j8J_@2b|alKL+Rz@w2_Q$w0W%`ev%g?6%I6Js2KAPk&!YnCV4V?A4v6Q;$e<`ZsnKv`NY5E={OclL< z6@})-L_jjlf@mS5roX#2SsGrgHCV10x^o+P6j|Z;3{=TE7b^z3>9Rg0lKS=5i%G+> zBL{wAn0qKshPaMw>M4KkGSg8e9U*Y%ddSnf@(l~(--i{Wo4+_cwJ#ZpZ^y3KIfgJi z>FB450O2DNKMG({5B$C<^vB7P?S~|0yOUg1TN#aMtFMk^ZXBZGj*&lkX(Tb~vjzYe z$ZaB~59BFL@itC|1Csb*+JE>Ps6(r=reEYcri_oBYyL`}i+u71)w_OK; z7WYP4Gqak={LVwRAcPC%4^98@lfzB0JK>>F1yv6SmfAZcZP9n{CsZ7Sw(XZ9Fp`A% ziIuBoFMB|sFD{k`b6F2+&)yb|`kS8z#Qw8WZJRrD=e}DkS5kqr_;<1KJ@@dENHtQs z7-kW@-SGPW5GCrbP@vdudu?4UturWZgg~9#52ioe0jCd?EW9h-9t}8W^2exT1pJ=g zUf0z6r*U*Y;TO-a%HFWH+>j$^Qi#Jw)!*q_5Z_&oJM8;eqO=v)^dTVhXybF^FIgQ_ zK>4B4gu07w?bEdo2`$4v?pw9x#r{yDaa=2P0nfq9)4Q9CqrqR64*XL>W(Fqn8ohQE z3U>1igBgswdma!dCq3Y&ShF4j3hKVn?K<4$t(V3;s%Y0=?LpmkCBwG8-iVA!jNbSg zqK}i#x};ImzwiWd^ZPZk2mMJI`K|Q|7Sz9f{s>FY+Z2~}(vVw7(Vj@LLGe4x@SeoQ zys1u?QY(Wc4U~N8No}}lcn-?a6GhJY3BE~@Dy&GAUmFFxD8bwU zm4R5_ykg}pwOa{sM?{qd<9g+{I8lWHEwOGaBl(7R+wwG+^x(#MI7&ym&9E0x?D(QZ z>Qe#O!O0&6ee&`L_~iS!8S=iX#OeVDaotEXkd+B&?Sk#oy#q|P$QEf{xLah-*93`O z9QYYbBJ(nJ8JF3*fL5FxU)e8ht@_5hTI_jNq`Ie`e`m09 zTlGI?f|9ILmF7gi{^!g(Oy$fYrZ9H`t#r$ZnC*lBY;Rg}!coeh`KY`=Gq;rYFpkuP z>d90Mzcf}{7%_{r9e%><3%8VPt_iGe#IynXO_DdzNc*6S9 zF%07LKfU6WlG{Buqy9_7@I;ncjQ7_DMnzux{;n;czFy&787m7R1OwJMH`E~*Bqrtx zmN0mX(jd&{?xB0$FUttFp6HF0Uz;LBc19Pf5wiZ=Yh~W)goQaPg?|-h4k|%7orA+9 z%=-b>f<-c%7ghkKs03-0*9dUS>L=eKuhMiSr$eIrrmh6qIKQ<8YTgY+x2i{Ln_bzHYbRnzP7(P3TzOFN=Hb-T6QVX$INGOei=YSr7m0WKUMp`4oCaV&+qez zInQev2yUGGerC7{lEJsJPhp*|@p@jg7WjC#p@_jf9qXZ^X75B}777IoOVz3*OBRHI zYf^D8K*(6mr(&PH!!q=}icKAKSOlGBSYo&$r?Dl|daNs(DGL}6snNEQPd>lWFfyk&ud1e|X>?2`~%oBA1G&*$WoRA?5p z&Xh67R;-wb&nZTJVCn-5R$bA1E`uCF(tC9>0b{te(%=9+XGFm`Ew=aLE^YT|8?|qW z4x3=Fr+T8zWp6~okPLJSV4Lc&wY@FLon#cbJk-Ygk;ArmRVIym-F3s*;G;coZH(o} zJJ}iP&2TDenLMt?{xJWiOmhnnO{Cu=y~GtG=|NLRr;-7Y)FO`|W?ZGdnwXs_=(w3CTBADw7-oQslgH051>B-kQ?;J{8` zl{0$LOW@dUhzvB9h^rjc56!5_fW5L_8eKv~PN+!>_X9TqnzR~GKTq%)(%`4TKS+^N zl-M-Ee%O~5aZC(#YzYY%TLM8o%C^XM@r5kWONqf&*L;mEFZk&w6Ew+ z4QAX6M#JCjZ{;LuvY;*-0`@XpVLgOkatT!?Xt=#Fr;%8(jIa3hGRhVckE}KdSyIH} zU+ks{a4W92dFO9#KQl2!V*gg>HHQBD>HuG@71T^ZGg@ug$wF(>JwzBCln7D_F z7upx@!0v%q>3(rEqU2vZ539qHpeGqm?mb~# z{M^gnJMDcG|Eb$NLiVqbmL$VDq-AW5z;PTu?W*biz7k%3fa3hg(g@pB?uM-NRyBQ6 zKl8GWD@$92pXIrgF548VxR>c60u^vp7&a3z?1ze!kMY?UhAqQaZ74B@owY_i9ulEA7 z7^SsiSHv`yVUo5fr}(rVa^Mljnr5)m-&N|!TYOqOn(li*o@g>CC0}eRPH0?Z?OSHV8RchYG#-<9I~^7?<6b4f^FYGu~T)6=DOWhI1YaA8flj z9>oHyC5i#*hYi@KM37)*HfKc32m*Ds^SIY**p8qY)z25L;i)zqM)7o2Vxkf~gFKwt z3FL2=@Bg@ngt`t*swLslr0D!k4kcFHfEJ~Pw5X*Ui?bm<-QrD8cQZ$6v5}+ zK*|HYUtooqN(Y)p=4Xl1sSs_djxdMP2Px`KHRM-w2j`KwzcWcK=wi$8QlE6gL-C?2 zZgeB@Z)(bTc^=}_5Y;T_7Qe$O5KZ__-W52x%CA(Wn^D|5F5R^1K%SFZ8p|~7%RoSl zuC+6U{Dp2uz^GGEaJdbbI2Bg`Ixi5@NrXj&M606VSzg(58`sxDAHp2GI6Br#>UgD2 z9Ob^Smx`RW7hqF~$=uYl@{vH7mLrmnlFP)Dp3#v+?=&tnSzUa!w>`?zvb`HqU&rtl zna=@hmAjp6X?AO&41QRYK!4&HK_hA8`qs$98}cR}E_UbD&}fv3*+aJygqcy@wW6#+ zk%MB4NzjQ_is)U&m^-P^4K{`0y0rwCRfka?FS{xxf_E?mm?<|hpQMn@K!H65%Kr51*&Ic0kI+Z%;_ z?a7^$onm+Ki?BE=Fv1Kn(hbWf?nY{V3gawk6;kI?w~n*VDV=?cKsp#lD6bUrd8$P{l7w z@-i9aAz%UDRAI#kr6zm^Z_`NYNXq(JRKFvLt8!p?!DZ~*5kCSucreH3S9tv9AO9K_ z(|2DM3T9AJKlZf9D2$##p%x+DZ!+fLiLCo`Wo2w~H7!b|Ogdi}Z3jP9IzPe!BZSU0 z`7oKcP6&holx*;ob?gcr>napB^$z(>hU5={cXgx)a7{o1H5G>jvR@M4 z;Dx}Gu|oU8fIbwK!dZPmASG%LB8-%K>FX&>sV~fD{bm&1>rCI`{EMkDc|{zfWUYsX zReBu@u=)={;S~V9yhK@pRTWP45x%j0s#E=gPMX!AfgrJo*8JcDlJCG6Gn=>nk3aRB zw0I1vk1qs$MQlgWue^xFUP^4`hA^dOO%N+jsP5&#WVcUeXO#?FWg8LPrsdwo(4`H-M^rKVe z_cMq;kmo<#r+tmec9TIl1^xQek}Mz~4N&rJ(Hiw((HSK=q|7bLyUBG7m@=7@?zwxc z5am{Dh=5wV-fAAi`tBP{koT>eXq98Vs9QzemYy0p);06?kdGLvN5YaJBQ@>^2m?s&`Jw{wC{N7=$d zen_4kFqwpbwn}pbk~a@(Q^Ny?WBge*$3cw%KmMyvPGt5wc+ z2pa+d(%_`0nKoI~#GS=2`g_M&Btf%vvBP40zM%W#)yEx$S^efV#Z+!}vyn1c2I$M* zo%*fqfbO0F{slE65WK0p0;0>|Hvh%0*GWSUwf#D4E0n2(zD9#^b2~=%@%Zs+r)kQf zu1`6F@DL-4eomw9mz6Qp+B5Oyf40m2&Rw(?HnsiWJbjs^=%sS#9R(^^Xoxk1Nn~Vk zG7EM5(g#QkLMq(UicF1AaobPc$BjiTn_IX_M#`*2@>LHiZ#YQ22v?e5fRP=?G*W<6 zMD4SBv9A*-s!XY?hhl%do#dpjr|W^|8ob=W;x{bIOVU!2R;JCOGoqYG z(g#%)Rs040gtoR%)uX=D61936yM#kn$n*YbPOsy?0CjpMTU-DI^_iT`3;GqLu<8^HS^3icdb?<&&sv^T||*U2%@y03 z`Q6gpRz3N{dHqb#=&g;?D!ZaRyWacxqed3ew`HM=duTWxl*>|f8JH(6D&Vi%&~sS6 zM0(!OS&lFYeCa9V`ZXWsT=hNWOWgtLZ_@vwC z+3xVG`%ONhIW^l0K`HQsb9W?E^tPhvw8@6 z^)rY>uDrxLYi3(~-P_vT%cH-23?ZAFaax&{A%^`_JawS9xr1v#4uY(N{e2@aqvHie zc*)-@#Yg-9%C(fsB`)85t{Do&<5(pvUkXQ)pr{6#k~TILtu%Z4&mBKPxA-kfa-LaH)cO%`aK`8nud) z52={{(;dRY8}QphG2Z4zvr^}sE&C+!;3D@^C(T$S^gT?-P>ol+GE&HcW*{7vx6z+d z2*XnHcD(t2-yIxdh6TXv+MTMo8ks}0249RB-UtcrI}wr%+NhXF$W*V!$WjV#nw^L^ zhcZGSHa^8_RCi9F_UxFX_E|fl&toT-{{uDK%jPRpGi`ai&E?R3_kK~nwCk>w@@6JJ z*+vTtIdLen{EP5J~6^41JXf?3N>O9u&D9Qq(5ss z>r{+>Hd6oW1>)M@N>HPU%}3Nwjqt{ZpH}^YKa^6lCXTgpyUx)ETDE6t8!W;;za?cJ z0@st^-j*nNhg~Whbr%Nt%UNc&;|m!^O+k_Us$ww+iCptk1*wsI`yLVXZOxAxj5y}n zi(a%`Fz=BpE{gMn;Q_}89$U$ued_Dg5f79WM%$mQOyY7A;>?s#5QtFdN?7CQ@iEP9 z7g!Z(Qd&mtv!9p^nqC8?cUc|3|5I=O*Pk%qV79Dqd6#kD>|5H{Lp@$PMpBfXf>Uil zV_Z$-dl*<$AwiwP1?(IeR%g$tV$O+-+uEoJRD`)eT?&Riu9qu3rZMKk3nRwTgu1yB zOEXSB;%sdppHSNj(NNY^Pu!^JgsGjLz)AoloeK>a$(jmh-|GUeT~S`)T>wj|2l_5dIp7%Wb*WYM#k{Ov<>-seJg92|nVqUVb) zhwiJ|=wsT^tb`*ng15>Q^ILk!bEw$Ajx{Es-_v0S+Ln1NqbgHWUDn0;U+b1bdY~Lk zctSR4rOkZNTKkbD<6v_}DBJF)H7*DmQKz9syOPWX%5P+0P%@xeV4N&bqneefl4PdD zy)u$%%GC_Z3B0;BQdljA*pzDT0W27=##yG=rD4$*9%XsnFv%=*o>DR_NcV_N*!i4> zQmC~+^mp_4iO%fFv_h8}OM$>Pe|c0lZwn`YnxJ1H6Gu^^eE&>yr}^Of|@(CV8JIIwkB!;iPP2%Ee%xM2HY> zchb8L7)@HjFB(z1`NBY0uT&$&LqV~@#n(^y?DfDE7Av43`iY>8#U{l?>U$3ffJ&be|PV7o4jWCpx;Q!hl&%7vu}xYkG>V zhZL<5*^j4N^~SwBGQY_o(9d5Dw(Q8jK$}(GxKrVTrxTH=LxN(&ya*zo=gC)7GM@Cp zH%l?FG&6flTofg@_%rQMLe=1oHok>%P~H? z7aIE|;AH@n&zBwe$abeV&5!e2z=aWabh{Nr$g5J851rBWD~b4>FZ9%s+MNl=mwFic z<$akZ@$^USBC#7TpZftdpQQo9g%s9;I@)cqB9a+8>i0N89J=h1-3Ieg|Mc+sC|b9U z@*hEsI5v*+S-xcJ-WvgjF5W1jpS};CappNFFfXAE!U?D=I{|L=yzUtOl~X0377l zw-<9Vf-2qbc=h)bP!Iny$med~Uk#H^OiXC3*t@QgS?<)ReFX&lu=@#dmg!R}qqA-I zP9B}Ty9^K_r6sPMJ=Kq^);P72Sb4h?bM_$jkcCo4lOv_0H#|j3`jcNxeyCb!8R>Jz z&(X@kBdy%^16UUEJRpy|Rcl#wR>fW}b)+OxH1&MbAEio;M}(i>FIGXlPlkC=^?&F_)F((%q2&Ihc>W!o6GR zfA7eDA6^X}bV7}NV|aNrN*rozlV?=t6!&k z?=QMoXt~2oDO{J3{1-9rb%{yiBQ>=C%Xrnh=)~asWL$DQ<%>q%MA!m6E|PUXeuBq+ z7A4CJ#OW{h`ll-!%SIVDqJ$p~TJtI*$KryFBX61VI2Iyzif)A6g{bCjTj((fIimiU z4{-(=#p&e&gV9Z&Xl1B0#S7JB=FK)>5Twqj_jqv$^VS0i*7X^)TfZnyRz=iXemMR8 z9Yd`KT^QSS7ujVH3bWzxzQe6Jmo`DQJCuaoW~OfNX|m>j`;lLZ3diT{2-DvIzZi%9 z<+;PsI{g-IoUj_53XfX~tP|#RGfdVj=}>rQ=5EVA?k2*ktOvNTS;Ifse_>`#mEk4H zut|DsQ0!3A$I)Ri1IV+X0l0d6V#rig980W&TK>@U_MI4Y9lN#g6^(KJmd-G7Wr2Co z7H3`%9>E4RBTu9A^ZYg@!V}>Y3U{qNhE)rpG;sq3u<+?qn1wn!mjXn-k5bdS_F=1y z8NK{pp&DEcA2;k;X?PrNInp6kt*3%%BNU-vbh8Fbn@yEXHTu@P)gWZ}vE!)OR^`4W z<^?(V3NCT^ow>PviGxBCk=@hhi$n=Zys;Cd_)-fa18r89LRMPqC@&Ys%HP(=yFwuh zm8D3&hwlNtxck=L6M9rvT_UQPc_J23$!HOB|-MNc97^T9by--P(1 z3sD2&7E4reN}vVR)CAP_CzJvBKn37F^e#>=q`yKb&0syXVF&Fj*MU#Qrj`E0q$hXL z_|4NpGF;LS-2a)+g(m$aLnO@x8KL4j`YKI;$W&8`TQWI#g8lr*{+&rqx{CF`Os|5iHeyCFl~Pk3^a zTlY~AKqT{b2dOt0OOQQAel3Co1W`{J$V}+wNX6gHgJs@n_?=x_7nxW7N?T@19q`=U z#+Sj$H>oFjD$VrQ7?=`62}(9r>f*%<{>WDejN8%mUfK0#eTg;8BlMcz_X5V`$t3I) zQFYWzZG2-AJ1QQt>4aXjxsqFK1e1PTJ5>zs4ww5U2)e!7@rG|CIXK(Hecb9UB$4}i zFbpK8qy{n9x_(Q6T7k!oOmQhodr)0@4mfUv2XurR-R40NqW3d0d1R~?0dDfd2O2rn zCT&UrYiYAB-TpZ1{u{KyUX79sdBiQUfUpYZUN^#<;Sv~TeO8M)08MNw8EBB3SK_cI zw9@AL*7eOF7xnh!c#7<@quQQ)j_z~G#@1G5H)^w;lC_9QS$js-j0(t!Hu(68Z^1ff zwLur8T4JUH&eqrVKaFu@dC>n~5qAZ1Fs7CCVK8fF+}2Qi&BtDmzzG5cSR8z*=L<7+ z`1cz?S_?;Y+`Z-Wpa;WlMq$LJ;p8Mu4>>r`Uq@yoysy3RC}1IL!qi9Bgy6KuX}d3B z{*QTn8@MR-ve9r7_Jp2u^O)FmYAzl>-@_W$v2OpmuHbvjSqdf3(ZEa;NF7*W6r+Ym zV7M}qL~(lGtpWAEvrf|dK04bm`=;eRtJM2RHO$p=-vS{+Mb)hLw9tmUK*0jZC^9%K zTUuGD*`rQ(a%@=)1c;sv`gc(3dZ+g+)cl{xOMBgPc8P1Pbc@le&I(?71+k2Ff38-S zoXfA0LC0~kLDn=as<*dZG{2N9Vx#RCw75R(^!TgDSvbx{gNk#VVF^!7Mn#&Wk@&`I zs?6c&cD-im;>EFxdp2w?BDLB(&Q!d4Jgf{{?7Rl^)))0x_;E%rsJ`~8B7A1LKjTwb zc>d9~sJ#9rdt@XhvHF{y3H<5x%F3opU={5W)FlJe5o(Y#6yuug-c_8LH`OE*DEyIyXa@+D-v$Eh;KOG8)9AayY2dY(x%JW)1dj(I(xP%>uOlf#qKzXQot%4q$&z#p4ag&Z_z6kCKqfEqG{Td6Yp%9mG~+A-|vt- zb$Kdlt<9T*&OEI~hei5NS8G;tke3^?MmMM$&<_^=LX)ss=vpRMqZ{M1wvkU!P*~Ey zqs@}`r~C14<~HIJ>lO8Pe9$<9VNu)sB=!Gz{`|Ce$Wqn&aUbwTu;95~&#-1NwN@#j zBj2;YXq>Pacge77_8@odmNs!1ZW{fMcEnYORkTuB#;pTwPh64f`ThewGi(7(JnG8YVX-@d$s6`|66$ir;EO{G+!Wx7dVZtIuZ&F~F5d-VuAB`$AM4?eXhqB0a zE=UOsY&}xPGdJ)t%oG6+iJ|f@-yyGvc2;&Gt0^9Y0uN3pIhNcFD_Uu2aX>M_G(N$n ziCgELV4M+SbCL*N$kCo89F-0c&n>m(tjNh=sw5tji%P&RTG8$LDYRw11<2w2A)98Z zGaLV8#i}y+=L^X2mbpf!LQ4%ibA<29o?OSQ;Bvwb45L2{we?S>|8KN65yOD^Sv=7@ z;u+_7$GaKd-O!=($18+ivDZN&@roN?_JA;0+D0WsQcAt+P%+ih(z+RMF33E(+q+T8 z>G53e&nnK>il}JDwe+;M^}zVA*^S_V6JXh)7FZYL7~ZzYWngrmu^p$mcr>&4dPL=O z)^a;H_T8JU>o{ul&$N-sD(GESddGg-R7tJhg|d{-P%g@Mk}D2M$EiZZxChBMN865C z`|@%#0z;xLd7PMP>1O}+q=Uqg1uYgPV;?87h(1v|VvD6rw>PRZO1aqoE8k&2t?}xR zi#;cK-((dGT#Y#yc(pYVP~+Stz+0xN9?f=gT(fC$rxzG69A+;IC~LE#)a5{k=!*&; zDP*jAH%7oB`DK@L--C;(`3fFOOP4`-jhc0# zg}1AF8_OHrCv&fEH>NRVqHOhM2Q#ApO|awW?)_%Z7Q(x0ECqYcc!Fq=PvmdkHcTPZ zZuVNN*#KGCv_?}jyPeXyT>l4i``^E?UV}owo8DX!Wx!J`DhJC6QvX{nr&BJ3F(lDP z9w%k$;&+|y{+BN@I}7OO3P87R)rENVJ=gVEN?&%WRR--!Inmr$*(RN#Z!|p0D$B5S z7DD1*!_W`~S(^ByQa`vn5B2X;oEJ+8Em(f^u~$v>6as%PZq6@$G>&nKjK=y(g)Iu*5V!FE&c(lmob;|7Qqe7VZ+B6@cAvH%mbo?3e(;ySNbY$#~^GFMq>#tiy2~M1h@4*OH5__*-V|z_tHfT zhnme`-(+78ROR=MWv=FPi%s7i97S|oqn!QaJtyU#C8Wk2gr58#i-*p}}M{&m` zEF_J{*LVf(cd+mY3+WWcwzJ&%mY~aaaFmG5WxgDj2+YFvLe+ZiXRE;ef<>Q7XyX#@ zzg{qqOgpq}sNbx#@@B3(vTYO*E?9TZlxjMplGiZC=}H+`UmC#@h*HbY8C!z%O&JaE zJNEOhH&4#YRtAH3Gj9F=dxh)tz5C&(A&H4)_=_aBGyv%!V9U*^*EdNum8Vcp^52gM zQFwxxfYIM(BROvAf`*20-x33pywjDL(n-meA~2f%;A8>9Y-q2^-61h^k9rYQ5#cCA zNI`%t?5;5iIAAi(>^D`r;Y~Syi8TqzH2xB47UGcKa_z(_C~-%1m_~#hFeXQFtmInK z`Cj?uZQl*9r2i4Gd)rNVW-6Le(waOXznE3+@SD+?q3x_Y@83A-R4Gr#lu)Z(&Mf-N z=(k(f#&x?+Hl187+UQu%)L0D+y}}mOOHAKQPTcG`C)fo-t=gazb*RVBU-vhYT6hh` zO1jVBN8Hv?(NWy_6-!^VGJat%!!L8VP{-W9+<_|wJ>Q)o2#1nB#)Ds^i?Ca)J3UT%< zej8I^5D1L30?O=3Sg1jBHglwDBRfz4S`uOcBxOfLleShWfXds>B4{Z|N z7g)yfluF6C%Si7yQJ=bZ8S7wPs@$^NPPKeqwKBlue$rVApN0HMKcm)_7RS2OsDyP& z)`BuWi?yU*zkd6EJ)4HuOCvw_HaasFh0iX_Nzz2TX)HF@ zIF?qPNfbi&tQkq+;v3p+FmR&m<`{oSKtSkLFwqkHi7xb~1Hs=)$M5+5J(^7`PGhA` z5f&{XxcWa9vdXAQWoJ!1=y`=W{`NIb#khT4aR7Fu1AbIZVmnv1Q z$9#!!cq^zdiV&0ds^xENAh0K|U*c4sFPWg@0PutDUC%PXfQLFH+S{VltrCv{f~u{QX!M8ZOVk5n6%%VDH!M7LqDm1w+QeEWYLm7{ zR<@qWy1-p)H|8yMr}IHB>_dc;cGXX|^6J&?s!0pcw+nX3t#M0@k(8)??NUc@!}x+8 zQ^|a|wFvbkGuG2f_pK^x==JcWsKzG9BOy%hxr9~=N)cUx#Zk7%l<9AR1%ca&YE-Cm zmfOsO&%T$;~FUUMx zp6$&AE2(|#gvLbt`6w6ym&+lZ;6?!jtE+49)eH}~Zx7C^Ca#^vjEbPn(pe^t!QE@L z;SJz@+v{jZDL^`|y?47+7F_Q)&9N;bcb6C*HCxfSe4scUjGvq(jntJ?;(Dy1q88pa zOP63)SdzWrj+gdsOuRX;{6utEfU@I<3MdV{LzMRgx0XgeN&AaQJh zs&gZJLrlWp@@7hNcnT{LnEbi7Tao}3CUyp=Yu8eQ<2sETrS$ESbV3_@4oDy zQd&RGUqBA7TErv>ehd8R&k&F5|0Q}tN>&CDd+ECl<6M)P7$lBM_$3oY?uTZg(6yFB zEalew{8gJfvXzq(L;k1s&s}%pd8fA%PARvm8a6EsS2|RbWmGL36q8eH=vgUS}@kIn@r_*j9@Z2Wi>gqr;F^DNiTZoS$)NANgcR> z-(5S=T_h}~YVx=SJ-CErQv>f+>9uEOx>J^Y&K(mn=rI=s_Q0%+4pKcSxl#I1UG z9IqjNJy~KUQY%hno)Zk+Ml3?K9vpSf5|i+S?yyu#Mzq+p!l%GS$cdL+e3j16Xh+q`(gjV5I(#o0zcz)-~-u$!9Tw!f?1l7%b0~ao>rLbl3+E8Wk zaGqv>xf)?ng=X=rSKQ^$m*|epsa?dIeJEI)vW!i>jE0Jb4s8{zhjB%wgQ&b4B9WW5 zYmZCnrvTCQK3-N)<*TWHD`nnf`<0C!VvP7LDbbmWfpp>`U5uOO(5zw&*2jO7|;<#}mmk$r#iQR)m*`J|K#@ zmqzKD4`p24@yR3_0$YmLtR`+C`97bWCRj}qh%r>9wLya`HBIK0a8+2|(5dEx5nTQs zrp_@iuAuSyjcr>S+ji1qqsBIxq;b;NYS3_F+qTu%Y8qROZN1y){cAq$r~Pv8%$+&s z%=yu|T=QhmPL<0|{}Z;&Qa|zZzoC_oBI4``aaZ$wIVk+*%DU?rRw{vKUWnZKd=x#aaLLZdpOm4{&jB zfsB=D$SV6erGfJ(^!ja8ht^IH0{$pyVQ6n?^YJ6pqD$s;r1 z4yTmi#&rPdA0y~H4C z|9rdunna%rdRT%|y}p(_#0s5Fy!HIDt&X$@3qGCC$oIvqt$0Aw*6~G)mbPp>XSX#* zDC^20>I$N{EKhNG30i|pjPKBF{}to}_52CHM{GIGf8J9BbqF3ydFS9kEac+XB3vRE z7kRh~t|}tPA3=6?jl#I9tX1s)vCWGCRa!^Vk65IpD?_dqahEMlBru(-vWW6G{N?|?uwi7hPHBv2nQcW<&q`l6N1|dz0^vv%&;yy;C z4d^MDc1k$;C_^@yTC`%q@NmNYpoEH^h)XG-SK}Hd*||4OD5nqv(BBN2d=Gb$f(L#5 zyueNdZK+Z|^x zKVu?;%7>?$ndIqwGWw(^Pt|xZexmD*$F;Tnrbv0RjrvAgR=ZQ|FRvuF!>V%dyZ_&Z zRj2#_=W>;)_-`|B6Y3Rv>CgGK$2utd?+F5dDw@$Anc=Q^Dc1vj2@FY?Dr?Ddv-w|# z*jM?^(=iLFvnQeif1e%r76?NVX&L7Xu`$W4GW1lVIC+RY$B@UVsjm-tAtB?_3P=*E!|9)g@id2sg19v{d z{(*6G#viMRVe8b{7Mu%PbNMp%J%uS=ji%6U;!`0m`sdGLKVbV@5*YOuW3b!|X&l)J zcw#2T{_qx6sM5PAlHU00 zR;Ym1{yZzSI`pV3tb%kSk71PYrD(~o%_}-aLYg!JQ^*?i+Ep8oPLkW3Y!rLD9Rx3c zgrwT8t3GPGY>!^ENAL@28c~#&#Lq7TKDrPRJ$KnZM7ggK2>YLp5-cJm%TJzV1(7Mt zbEE4+V9J;YgL!*_jO9^em=Vi+*Z9;sguRT&|%BM;F~yZPg=!Cy>B}!depFshTAqf+&|uK+sS-| z-p)V3r#C-NGP6C%Z^^}f9Yma{4BoP)^L&x)+Knsa@Ulh4y)O`3Mfs=KznzN*XP$*6 z7X9rBa_F=Bp)Mo74zt-fT3TsZ5o_Ef-n_FG0Y6DiPr~QO+I3=Zk^hg7W8Zo8xgeid zC-l@U<1={eNqi|&X5AQe;ru(DcL4|DT49#q5c0Y$J$;x<=}t8KcwU@;0C#|1^JAx8 z@vq!soIlWz3pf+~2pM63pHbFq9`5Cjmrd27{??h}zXd@EZg4KGK_t4apIn5lCwtDw zKbWwiszzCi^)*o4dEM@>7fm!5k5XVJAV7$Etdhux3llk|b4bz7FQO6}zXB(tZt}Wd zDLQPDGK)-+s-t5O>bZYcSG>9t?OC8#Ki%N^^FX>`>s9C+vFkJyc%D_Q%KK4gi6nju9o3m&wvlThrq}7Q>d>G z>m%UtvtQTSnFF4`?}CO_#6>_(Sp!*!KUU8`kA`1zEEf8gc&Q{1kT0bf{vzQwAb(eQ zI7)~@AgnkjAl&?Ga$F5K{P(JCqf&`%-jr7fK}byW>8_Hn#$3-)tW01udNm&AcyhwI z#3>4jpBmM?BoS_-KE!#Ti?htZ6hs7t(J1QKI^j8OkYX8oIAU-p);ysTz5QZ&;q>ut ze;qU9D5D5J4FhAy7c5{3Q_CmY4IlfA&6K)_oe0(buuUgJP!#4iyerU$v*a zAcUi@8F0gOOB!ucP)UUKf}U)&p&A;Hy8k(1{%1*3f+ZK-$=K%gNNkeg4~Gc z=&RtlAU|$3A)5l`=-59)m^Y1HM>TT9D7gJit6=l2)#xbcP!~BDBqKSito| zPDM^Ufv#Lpts`Zu@Gwv5=Xv`fOZtk4w1&vR-T;e0eXo=0go7IvVVT43D2oV9qPAz| z-;_AVGo2=6qsJ6d79mb4S-QTd$>}-m(E_fc$aFGlh%gA3>{|xCrj-P;`F7Pt3H8Q= z21vT;D?nu&O{JB%DR5fZAwhMWTvZzsn)q-Kp{p(xMT8=0-wRm(8mpC0%#Z!RbYzab z->Wi5ygy{O{9Q)O$sA3&UHo7G+AcQ;RgX-5z}n|&6X%%e3i|s&aN(&6>sZ-z6mlovXVD1ETY~9 zqS!@$KYdH}OPNkmroE*PbVLI1g& z4e3NEE~9!*u3dMcoKUZ|6n2FiqaZ zHw)S}DNu$`^9IsUV?8H_;|9@bGN_<2ShCdp=QG79aiC(isQ^+C^FW~jCAMQj|5eA9 z6uuvfHcV*&9=!^8c;;lrTz?-BUd!$JXkRVkb>@#wZZq<_WO;6&RB@Nabl6HA< z58BqpJyEIhRqZmt5qz;GO!#&#+(Ro9)Z;;6dz;=bQ%8LrG}d`Zvo?mVI3%wXCr8C@ z5v#ufjs?l&E?yT;j#~UK1$Q(i&k@Ztk!*wZmBlGqc>cxg2=BO#;@TJE>3WBq`|ZNaenMKNg&o~!nnD8|A~h)}d2 zU9+(BrmsY9;y|CXj%*SiD~M*;{S1W2(W(g)st2@IQ_BTGM|VG!QY&aawv0Rq2Fpft zSCkrCrZrAtR4*9fVS*TZScr*wfCK|h=z?~eBF)DWF|TVwSNctG0KH{^44p-+(wB@q zn)eBedM8x<2Bz7bgPWG8XA6JzpAlxNbUd8O80D4-Z61ccF<9TPS`f#%JA5-)Ixi(W|1g|pnEGYsM>wUg_ZVUS&VxnX;mT&b zO)28QaA+tBltO(+wLtHFdXN=&218Q*Q%5__+%m)U5?xIayz36hPPqFc($~M4%J?PT zoJ@4LxnOvbC$WC=;j;p)lqaAwtO?Y31drc$Z=7Ov!|RS2S*c)Ripd_TLi0#PXQMJO8{?So{Q^h znS9i9CyO?91?Ag@H(In<^eCFS1gYGVLAFl?l~YrOyo3$^n1^XKB)!%c;ZLqFbauk@ z``=u86v*Cw%N&V{d{{F$kQD%K>GIS^e9_u2#c#W&eM!H($11Pb&+SMs9ou%b=`h4< z(nVK+96w<~Nbx8$vyn@#+=n=B-3i3y6nH7)g4|br(r;gAu-x1go~TjAFaaU<5EPpK zc&K7p_;p18Q0PUB#{eJ+Qv7tTP@d`M>ljODf&;77%?_I^Sl{!Nhq2PgwE`Yfb($(C zk;~3Uuxe6mrI&A49eND9R}->t0V{o*IGZ2cFXsggz9)M1Z%JB)^bBqm)FKX^+Sy4M zRZe*nFYezQ90H`E=+t4em!Sp8IVqvU)5ykkqt7>Z<+D^0V;&y)S&`a+zsaATCY~)R zcjL(OgVoH7=ZIf1erNYjly(Pep9#Y9I4mg=e&1f=A@ns)K7Uzry>0S;rCm`$DgC|y zofO2-G=}admiD(7bI>^g7?>M>>3`NLZzpm_oMW$*by(J!jb5wAFFO*b&UDt$^#$@0 zj^G7WNQT4mEAXoX^7bagbWbAe;lodvm>D{CK0RiNaPc%y;_E+uemLY1zE3lE5}~7Y ztf(8lIdAjG$-PPPFsmiwg^rxT`@181iU0dA!(+wMym+~+{2@cHZ7R2!1+3-a2|?DMM^@5xm+zs6X>?XKu$*6m8q zGjYvo7Rk~a@pZBN3`SbOTBQV^m3O)6r{e-3cw0=BFwV_H0bHxpD zlef{l6N#l&EIf?5qV+X(Sm+0s{4D9DUA5k)<0aXjbj-{EWMEU z08yrrnBsn#i*092pka(#HZ%hJ&%C)dn(zo&KmqfHyFg|kIPC=}+r zjD}}#-_4GeUuEsCQ_GeYO^b%zi-x{}=>x_ZWyv=9@i*0Y8R?6Xki4S-9cRrM*|EcR zBWN|j=Hi%*%l73n&Pw|tyoO%%s6v-w>U&*7&v)%84{170a?^s%j(H#!BGU?&jIs!X zN-(1+QTf8ue>r9E2xV@Ay)8vfEb(DcE`xG8OuKWOMVoq&gZk`H*z&~_P1djbtvDl5 zed@(~DPHEA-fEcy*Xpp%-o1LtpE2~;48)pPNKirF{mBaYslqd+xEZ1a^zvPj8@K3w~ zVg3lpg*=41s-FPVasI9|A(gm2CmKR`)h73t;E%V>k18{+3#4J#7+Oyn*^#cT-HKN^Q{>fT>7iD1r!Yr z)fh}#xL-p8HVZe&bIy(=5i6Xo2e=Lf@E(O4;bejIbXKEW)w`q7acsUb;r~D zIEbLy@lKZzh`L(|Eltxl_1DvN3@s+zA!{7ZUUQwrBv&~(*Be(PqmP<~QV(9&A;tF9 zDDbcJ{-3es*B5#AyN02g2J_=IiaZ{gJ^_t48TCQEPs69-jAbU5Yom%zNs+3A+RCDe zM#Nwod`U2ZX+#@R9y8=*q7USnB|X@pgcfM4OQaWm6cf$pU>G?4c7a!SXeembcTJ6- zV-On&&1PvSfY~{={U<|n#~05Zd6?J_VE9;RS->k8x}lHP)>(-rSXI~ssMm(L^Hq;t zki+pyv?2(j`yt}BXcV?~y&jlZH8$OBm{ZmL*KV?`2!tbFUaivr`*COVQDkleYZc44 z1CO#}FDvhG3&1I&lj> znTMWpIhRM`}`2+%BX_5)Wvj+fi)r+8< z3;)!PqkR``WV&#{I$$Vx5rSeLZu$Dsvjb)HCiK;Yx|KcY6pAmI)(p!vhZ!LkZ|=B2 zq&N%RKeG#I7#psRFR_8!cfca7f3;ci8T#d?9^KZ#NzL$~$U~bHuwg-UL54c$CtB2A zMM10xp$w@n_AwQe)#OC4FW@%Io6?_%EGsauj|!Tw|I_z(gt-CCoNwKxWM1kY)D--H z5Av`WGLy|Z@g{>6Q@_cPLYGdHvF|-O?_3c%^2irpIzjxDFo4Z6NeoC@;5&8`UpW3EJ7@*M0!pZlh}aI(m*m8J zyiR}owzI;s(a(VX#($3l?q6T?dpY}tHm`Kx1oV3Cg%b(bjS*xiBe$nrBMMGDIQNM65~ zJ*<8a6}|hdcDH2jrT7nlpmFs~a^V4&{d!w+@;bho&9W$w&lU0eACOZ6r=9Ztby)No zqshgS$&mJU;-drEogN zob;S?HZQMJcEOIt;*E0`8_VK*xmK6lsu-I}Js%tS37O6pm8wiPsNE`>w3#i87*G_J zjY9_tlKZ+w(!3o`d*fAn!?fnzlWBKA@~s(-TI~mCgsE8eU1nG^E?SpQ>BT3OopaOh|m^p2#Rk6gDrudP>vgI!fzOi zms-X z@|-+6=4e7kYDy~7ol#33SA%#qqlUc9p_67_p^y^E zYkq|ZM5Vb>kP#P*CNyf=>Z-DG8*T>}jvkZhC6qd$4@1ogz>+h9G_tV%@cD=RCHTw% zG<+)wX=K_$78lu2XxYW#R!cLDzF%baodmm{5h!iao;><@H@}^^v0ZQ?Os4y$Je}8M zhw0@Pa@6)M%enO;=0`N|4&mEo&amnNQLj&B&T#Et_gUFqhHsRU%_eY3gg(|U2Ds;D zI5%H46=0Y>o`~1PhKS`?4rG9{vhuEZMT(vZt{IQ}KlGGWtjUnQg6Wjp_w`X%{D@-o zdYdH>jOJuyiK(s>G-xvvNm%Qrms&~-+q~9weco?!hOI5L%aUfDV{9Vx3Gngp*Wi9{ zIF_2P&G!JAqAf`4ETSyu#@8m8BC`vCffg&d94(dEwgrcY7N~Ka(nGrtTuw{*FiUnO z2w7z)_{7A63an`#k@dieGqbPGknu~o_EXa2SCK)TQ%Z`)&OZ7RDoC!&mOkrV*sw$7 zxD@FwBCwI9**62jJC->epOh>RUiA?~AK!AECT?QX;{C^=#w5*Bv%gti=j?)rDVkFz z%$mogIQZR5Di-JQA~H%GyQFVvQT4DMed>7mHBAK%#1H(rS-C3LdR1IIQf^2 zZa4x1jYX;2auBe5hMwAnk7+`L9@0MH4Wrx}R9D^1eVGz`(D!*eFyh;{d?fK8NxE*4@IK z^3N9|TX$D}x2eizb(lJ+lgk9iIAgjC##Xl2_GJN#)!m4SI#};_Ao)7mlzn{dX7ndr zlDfynN|F>R`%Cv96k1g+LH4;=IVY{x&~^l2Ck0TeNZXz^wNRE0!Yj2vaAx$v z)f+!!;<3%lOfP4;vNO{@qkMUoo?c{G6q6QI15qEu_oj~^AdU;TvQ^6x%46C&4J_9T zaV&eBH{mZN9xJNn`zugi&0bs*_`jp??Q|EM4cgulH#F1NwYn7q43x z9`6ZX;B-aAe!=xb`-1ae4umf4pmhe z+zAagND*x(UifO?Mg;-vvCz40m+RfIF4@=3)>_a964T>y%MR!6fk|P=!;{-x7rvq_ zZF`uRPD2EaNYq>ejU=RhjERZA+@rW6*mKs>KlD>$NXOvaVnU*Q{0SgKz(r<4Lna6H zxV|yxr~uPn+<<(^)33`qD3~0nD^%7JK@(0bDTzEKriqQw0J1lQ6?u!psF?}Sp8j;g zQa%o8*(+0zU_I%y);BAqF9qa_TkQzeoN+IGn@2rpI_tn75TMohEN2>TUdrZ~P~q3> zJ@R-=(kTvqauf~+?Cp+J)=ZM6SbLF?W8+7PH?i_wnr-7O2}!TU71xx36VabTlJDmF z)}u~dPIS}tQ4l83GTAxh(yE$;(7B&&2nU@t+HJ`F@=41L6|?<4vjN0hoSdQC@fci- z)8vZ{DsioJm`3?6d@()Z~V7fDSpRZpj2kO2xx;JAeOL)x%VgoWq zg=w9bm9VfAmuSqy=zR5FSQ#stR^_;kMjrjgNjwSsY3`T$jHc~OmSgbGszKV)R9iF z^>%16OU-sPmu;wP$LPAzF#{x!>erbydk+!>i$=mlqrP&&f4S;TuJFw7xPZ}`Hlv5FH4YECED~8o~%*%>qJRdW7FDxEUi&6?1Q2noC6GrT%VW_ zOKNS6(cxkaoD3vS8+{NMi!Ip?^>nSIi)E2nv;*!=>?PtCFjib@^GStT=oA}f_A}|& zv8Y|cI1D?iU#aDWPX5~OBc6JSB5;88ar;Mqr!9;m_3*nT04D1)+?YOSB2tD+qJhp_ z;=i`NH*QFUNktEZhU`k$#vV!!Xe27Q>GV=Ipf@uI@cT{}>u_%=6I_@^4w4-;=c$ zB@&wJgAPIR*~nvAXE(kcl~;ZnnTCCU z`w@C=CCw9OAA#J26I{t6Vy=wWLgyTVqKCX*&e!49F#mUqJco=#V(EVSXMf}vDN|T6 zshy5hD^G(*RR8b)$PHWZuqfX~AQj~AC)A)8rR}iG>XowhzNXC(1Ynqnk$7kV0(act z7NpHgu@W1DqyTP#IK^`_nA-R15xte_;Cb^u0}Na&_fprkSm@GTZ?9L9kHF}z>=9ZG zQmZ1*Op~eu*wmuf)F}nYNY*D2(IVE!CKEx*Y<>{r01@B5ZtX~4nW zE&&uTUtgmFHOlAWU)KrA7U%k3J82JhD->bq&uRO)t31rtiT*7*h{@L*i#9m7%gebY9z)0%qqAp z4qf++(5@eNyR80vcrJ4nC-Pyn_WN0>kL%Ro=`Q#prbfg!^?19*hH(3#KUEl$oes-8 z*x2I2uvXLIO{s-gaSjpY>)7J7)w`323m@|ts5E@`(~&NJ`JxRQ>jw$1FC9b{uEdtb z(7yrHq%C_WfRT|2$j9#7v%PAro|xmZr(nj~MxOtnYLQ2bBtVptKgd^Uxac?9;lekd zbFj~lSwgNtJvwDeke->)vTHkD9chh}2WLE9oKB-tqRY6h9YiYpC zRJBn}WFElB)%=ux#3T16$PB3OpsVqh!OSdtkMmdZnHMMLNYQaVEO?;d$^xh;Qj_hl zCo62rB$#o1enMLloK&J$Pp}sW-Q_TI0YvJ)y#ZVv%sUhDKR1MBxfw>robpKRoAto3 ze!b2J`)o!CvpRe`!Ik}TYPq!|{~kr3_7ir1^epmhv@UCb87gM9GLrm8A$}WFTjcWB zm;j$;L})&Sjm2eUAaf#sm~_8{VltMPW;ERQU|?jl(^9^mow(sm4C?})!Gs$7O9O7g z6+cky#=_5~g->fxPtbqVml7kz1@;hnN~6)Gtou0+&9}71YQmhFfgk~T;>;TE8)fbU zg?tLjp#R5%e?@ew$CW8xqsBh|KlRvt*fUJ7xWP0MoH8|{SOQ$+qgLoUHaH$5bwl=^Tl33?RFV0C#GX4P+1j%|6^@V3!QVbaMzE0l9B zmQdujKL282Lz;s)V~ZD&F65@+C;G7F@**J)5LL5Ien>u^w#@b{1_^quxt{zvx`JOB zSj$_-wJmu_l$#ME^Y`eY?fZrDHtpAp1Fr6~N`PnCK} z^1aU;7FN>8EBLKxA)1RRm;kxtzZ9zhtu_S5kUu{pbl$qlM(5j)*8yrir3fR75q{V} z0^=#E+}2&S6rXqRHPa{XijpEoN4NO?7V@+xax|>b#t@(;AX_kPFI|e}ycWg;6W{RJ zUlMXUyWoqqdpf;V-({;`TeYO`q{t1;xK+eo!tRSi&cj@zA9XPytEwv=piY@7pg;VV zx_nze;?!w@dFv|W_nJ1jnT`aS62mfLKqTQt4(dlSI z(^Am*o8P~qDL%f?uefB!vEE*#O{FafMI$c;u7hwW*5k)?*DpWo`Gnw8`Uqil%*g}* z+^x7JqKU@vWXoxt|6bw$9+!6f`a?%?BhZL9tAv)zA`1occZc*RAjOcaOX7{$XhmKzsSxPHTadZ|30aQ4YQ$WHXnV80nc_bk!fS5&;raP)OY`l`^qXjvhOdPxmi=*4 z4eJz8m(*IBNz0HZ+pw18lqx*b&;G+TV-LNR?l@3;nuokj_)Oy(~c3-=;}C%*}3yH zt@W!Pd{X_6vZjg=GYpm${1NgfuD05{={| zzeyW5`aTOx`i+lmG+Kb{RnZM!jo)3x6_`hgMwXOYUOuv5^Z0k!lF>7C@iIQvM+$*l zr6u@gT9ix_JUN;a_+p1`%-XI}I`^pY^_mren>=+6E1Z+oezIwR(FEyYmlBoeuaZ!s>@lNfYyygB76-=|~<41Ep^ zGp@lC!c%J!PmC|cn*RRBWHuk-HvKH|lVa10_ULF@{C|;4E?hQntXh_Pt}o<(i>S*B^92f*5(i?viB)uah`}wa%bEQ4gUG_7WYn@YRXFV}t1=`39BQ1A95m&u_f4PW-LIbz= z%>V=v_|gW8O6wK&EkIRe#9axmZz(;m(yPGzgOsjQi*$Xd46n~I(--z7S#A<`+Y%vj zpF!x$#T02LV+x<<5RtA8Mu?wV!K*BAqci)$|KsH`WwEE*zlTdj`Jj`$YT(yJBv9No zcq)s-e?ECS0X}VLR(pRNPV_Obt_Y>sX6gErQ#|D@AK)ARW=*S8p;V2b2)HVpgM4!f+JbQ{{eHu%d4yl#A#o!IgiWa{atSxwF*xqp`68?89! zpa^2(@4e-0f$ZJfiXs`}v|fhqsSc?D!h*CpKWAA{E7>k+s3E)~f{BXP!u}(T^iiRj z5I(Ahq1Xj`?pd;|n5Gc4VJcI^>OiU>%QpDvRqM-`eAh1VNVX~H2(xh6UWovGPfXTn zR)B$r`ri=@7n1lY&mo;j&mX+^8B@}>Rr<@H@50pTdpw(VZ|;*)PhFjMe=}wB^;!)|PLA40QzTx)%lgsO zk!vM4S-=a*fh-Gp!vwV<+JzG#(OUE`WlsHGBO*-CcxojRKV>CnTI7OGHWU!rr~}95 zw#dmBCF&(J%X#ACw8=;o0$3v^p+_;m%aAizg+95GRo8EGwkY4hV~B9-W9it^vCPaf zkE_Jykq-sWx6spnjtk#uZU0PG+AKL|#5i&ySWvu>gY28r#ALu_)-8Ow%oO5Hx?}0O zRb^&_2Rn48Rl}Z_QC24 zJTVhqzA~XnBf|1z^7GV>`5FQVbpaBI(&N&W%@EVflq~y~VWX>8DQ$OFNfxhhmgQKq zftIA>)F$#TY7%}l#0X!t%!$jUBV$X~`;Ijjlhid0+p$pyh}N#?jr+O$o=2xmALLM) zWj$A<*zu#$Dfqv%2kFKexKjC-3OTEd&KRMxt%Jxqoe0ns!(QzxiBSLnf(5ASL1I0 zZbzEK+%12?%?(o|N^lT-|ya@+QkudvAx}RRxhA}uux{7HTsZ#jle_=-U z<>?l0{zguY?um|AlBE6gT5o1h0EH8AMk`Md2*Omw<^Hk<80ztJ`Gi;LNn16M2Lt4q z1XkuqjSk4+q$7TgXm4an%*VTV*$Mke?ORUiRKtlD-enBwt2X=`OKonpTs^JjQ?!@V z9ZhgS8+Kf&$<&iIq!~@UP+3nKFrc}2>MSATION(8#C+mARCd*2L>ajUhAx!YT-G!!sai}n5p&n*YPg%iGYqxd~>;GEN9N7HNmGht$Pgqh|u+Vhee&W+ojYvZXS<6|y z@d9@5y~&nmdwi2*>`NHSzZR*<$eHIkc%$_2p=4dmNwJ|ONWbAq%J)>D!K<&A&zyRD zWl|gBW?M3xO?uZb#;kpHw5vhzo2z+ToA-O`nf|B|Ieg7{OKb)tkhLmJ{>iS;s?l`) z%Wd<=crX&D9CMQUuinNrZk30+I<*^P1ZRn_%9%p7WUx%LWAeR|b;a-<$10QArx&3! z_n0*G(#1$-=L@TTH0yj5`G-3N9Gotrh>JcM5n99I+5)pf$EF`d)g7cQRR*D7_fB*h z^yzjJIkV}oJi-|mwJby!p@OSIz~Z+M`9OVmZavl9HMD_KTn8!1ndj2=doKR_yp5*W z*GX7pR>DwTZ8)2(L8jgOu{f^1NkcEB8X#r4A4bVMPIDr-s7hV8 z5ycd;AN_0VQ)Xbg0Hxi5;}4*53aZHSjW+B8qD~CwA9MQuU39@O>Ib3+@DZQNf5Hg3L4j$m) z`uja>n^yEg_<$0L?B!q)<9j>bcCYW15dYT#;ZV6EI*8hkEt%qv$vtE&17g2X7Sbh==DQ6cXmZU2Oa!6JL#~VgT^Vamoid@qtzQQlU3JYEMUi8Yg zg_{V16G#VI+KCUf6yJ8^Qo5jvgp;X^Shn$UJ}6H`jUsYond$NC;8)OH`_+R^Tk4cC%`h2T?{{V+L`{yT^>)|f-Nk0aw+fshJb1v7_7d?%Mx zsQ?;`5R1+%b@8mXu#nm%nm`6%_{gZo=U+=}6HzqubUM02sNKT=7|e0LWXH9FUj)Dj z@a{-K+W8M>_7!raZj;={`xcyNsW}Tssr1wI%{!PI5B2_fD048F!ieF?4znKX}Vb3IXJ zz}YH1J8N3Kbe@8$Ah!X}AV*P?5*`GcTpjihfCe8wG>5;HzJ#rgW|8fF*(-6bP1_g+ z&1{kS2vuTgD642)=d87j6WZPY4az#E#Bc(8XVCosHje_gO7-H*Ul_UP{YOKlJvKq^ zU<-M?pLSYur)vCvY&-H0tlNT?syOgquQ#}ixhfHJX3|)+3H1u6D@=8>?O}2-b;n;fI;w`+MlD>?>Kr(CI1Z6E z)HES=8gHh;)tWT)4mdSMD0EtBUBp~%k-H}_09Wy^6W-2^OPLpb7v6VEC{L%i^V7+Bt}d47R>9>KoG01fvAc3T7ANmbX7G z-9(XV%|XKfn)VvBy&pz;#hSOe(N=(_tn-G5WjlknARPICsIS z)QkDhz%L9yuJlF0*!aq{>nDKo#?KL@mp@(VGTXNQMM^INpwxXWqBNSxNB0oA3Yw6; z=A0h&^g1+fS0rWgjNP+QWCHOJ;@j5bMvvD8KpPE(l{rr}l~TV2B6u`z{f;YO!L zYLecvPd*)8yuM+Py`*ZoY~%M9lB}#Od-SsZF#DXWe^m;z7+Jru)hhin^YnH?A}>?U5Vx&Idhyr_qQ7j-}@8N&<% z`?RgMpqx5MnJh&NyK8c>1wOM=BU)5cK09ah!2s9w4mpeFIEM-@$oIcQV^XKuv*=Mp zof)n1ml+6xhKVn5U0!y{ZU^&iy#>P6qZRTuRtsO zkLnEmK}sj4mvR$dK%KcuVYKy&>ic&%5CUzV}@Hu&qYsBPx(^vYo8IXI^y_ z(hMywsS#H+hgS4jny59>k(!?UYo{*Ms!BIl)zvEFmP;V|2dov)_Ny`I#0X5$x)J?Q zlQ*$6-%=oW4#86DrF}o?iDOOT%1tX9ROw=f`@|O9tIwG*A-*7piAJzjC}3mej4Ap& z8_QMe9RMBiFT$UfI&K5JcjOBx5bm-EM zw~sB_Ln;@yl=D@iPXj@Z4A7D>nt*E5lMZY;mIum8vdi~SlhJ#Q%PYQTZ2)mv>bcv? z)zmysW=S*G1N)qU2N+01)vXQM)B7(Bv!CdGAN~S8jE7?88;1P2eMt-YnX4JHs_d&6 z5(cNA5_#N(EZwnCN4m-W(|iF>AH5i>SAH*R6EW&C8jpd z4O&vQk^$z`aS?3d_WV{i(==7)rl!{@%yIMV)`zeup+VQeHPMT!QJ+b^3JPJs%!9wz z0+GtD?P&iJaedlP*;UJVR`21u?|B{X$z|}(Fk=OpttCH1BF?D$r3Auo+Wgr9ALDNIxkvwOtX;fNqm37>i;yJZX93M(eqcD zRh803W+;FDiztcF^Nv#G?6rKwNdb|LaUT}V{;3cTvUfj2 zowo%TL%YOA->kR*@~jG18j&KwJ8Mg3xWya%k!3%XDyr zXc`$+U5hV+cCeJS))P+M(rDwZiRFSpWo36Cjcl>DcZJkrk^(#(M!enrX5$kdSy?%I z4ONxnuQA$8R{b=n)K1B3PAnMK_P>Y5%tqZO`3kx%LB|kBg5t(NnNAl0MUavM2NyLMsFjEBI_h&AjP6&YWH;gUJ~I z0)k=+7#cG5$~e5i89eKZ7=dFtY9=-5bg46Nm{=+)*`;%KK~PVvpOtoT67D zB=WkVDm(Rn*k!S!00sansW@P)XU8>+&}P3kMTuXKVGG=KT$u!w7s zq{gldu1g=cuDLREC%V z?MKu&C$qY|g}(DS%;N&9U#P#=IMH`f_6qdi7bvQJ%UTSl&bw9EJi^03zUoHkC}eFW zSF*@{VK;rL9rNK!bOy7Z%BB(NR|FxPDE!h|ndc^&b+i9Jpu83TgYKA?(L$&77zTTo z*qkNyD-lP^_^KJy<&Tq{i3U2z5Mq#Y)EGSfXwU*~M{hCI^pCWce;)gPk7fuA1gz!- zyt>+*L^KQk5k1j0^OG`}`KQXWZv-nWV`P3=z*UY?xy`wi%v6^s4W zuzYSGl69*y5_^KedtS`H$Mw(d0_?v)`gpd6vkTT~D$Pn;C_`#=>F{?$KfJ~bSF2C(H6n!CuVPnZJ-U@#?QKUo(S`92wH?qrlZ==6g6^9+Y?pZ}9fg_UVT1C9g26ciLnDM_IU5)u;R z>d8GZ${_FjFo_S?t2?jd?&cGIi*xMhSY{Q4U?oEw9v)`7XLr|E0el?S4T%8 z0lXl6U+}Gm3J#{}N%AI;u&Qf2dDVR(b@(BMBeI)9$oHd>f=ySm5%gTsli5y_fcJD# zs&Rfi9*sx~?UDVTj&^9FGm=F}$YZstCqSblG;gISHx`NUghypLsbYqM)+mT*33&gR zP$TylXMt2}#ayh>rMQ#4L$_IL-dbBL>^8R$-K{))JT`p-;1G4Wlbg@)!iL32mi9A* z-#^*UJMX-jRi`=qA5G_cWmabHDWN?XDeU`Y?7xdm5m1uxmrj1x#>UwWUA>QL7;vpq zRcVsPj9GR_2D?gc@jy7R>dcXSomjHU9` zsh#lfwSpIYS)NX`9d!EpxErf=Ck&co;JA03*wU6}6Av0CwuM`GlqED1eFy(n$u82g zH01rSP^5*H;VbcoKaepf|1!c!pFG}Nc}<;7b*E*xy-3Zzt_x6Wa*k3lewpIIS4{y2 zt|bFAqg((_~!h7pD_f)D_xfKGk_j}D}CJTW~kZ%QK0qYx_KG>(w$?R zlmAE5Sw=yJj-JxS)mpN|T{;O`W~7R89l zHu4LwqbH@jZCX~SL>3cW)JKC=x`0-xT*J2XND>#~wE4;!8G*Avxpg=SRZh7GR+|ts z*Zd*@>Mb$tIEe&#q+oMnvgqd1rJs3F;+%P&rbrIPT~FYC-s6&5gu;L-cB@c3+d-QA zpRMytYMWnRP}Nbqe9^6c(z8d^cJcPj>qP7Y$Hh&h>TRPG?8F58L6>HeALK`7mfdb$ zQ2U(F$l)n*5|^B)(_8M?qJ`Psp}4}-7r2tbu0Y4hJP)r`Pr&1edHU_4XTSGDCS)jz zdY-gZ9m7r!(a5ZyG}Z1s|K-U`Sh&Zb{_Qf1-(TnX7E**h#l{&9-JaKZa zj0V*|J74HpbV{tcYaH;q?`V9hyApgle0nk`P%ps$MP&dlw_UKQclN5g{-|6fcYQB- zy+nk0>k1=&u!Jmh-FVy(pFr(+D-Ke^OV^ytKAHcg(EL2L_BN|7Rcs98DBOpvKQvt( zFe@=2lu~|r8*xLAN-s$^8S9|VS@~MEhAO63H^+3cZqX(TA2&4)>F((*gvbRcZ?3#o zUW>PZkF<3Uv^B`yqmYMnn5|4lzI!>IqFOH0Du3<7YvQ1Wkjqvr8TS^hO$WQ6gRtz+ zDj``hh7B5c2d8SCngfC=cYT#G@X2P9QAv%|Gago zN7EczLUSF{r%I_S+bYFN0LDGm3AerSQh*~0gd)gOZx8SK}|NLn_HRhN;Lx~7vTq;(#} zKjA#%(498J58-l4FJ$yM`4c&p(S*;|A8h0N8w*17TQ+mwZZ&E;9Hrha&CHQ$tM;+I zY#TNVfm&1+k_KdgL!#b7*p0FJih#3mu!a?rFHD{109#cA%m)m5!LY%srB~O{WJhv9 z_6p2OFNIw5ji}zqh_`H^-1J&C_&g^~XtKO#q7>hwdD(ecv;GL9uSfItdd|nTX_AnL zxtU6$LV)>|0{bk6kHeAy8ZGY$mbjmXISB8GaZJJXuuuI%2mcQ4?k6a)^}GYCah6ok z=xAAPHM%#$vLV^Zr6yg-GpvQ{5SybWvoZy+i8dmHnNoJ%d=^3Y z*XCm(!Ydao2gORpWRPU8uBkAr-HNrs$!*fj~ylc$HSL zrb6_U_8T_C*mqsgSmpvEi$8vN(DL;pEDNy_x%#Beio&4|np~M5XQl+@#on1eB{j=) z?Y<%qDdZv;bWZnSy3mm8JB_HDJ#x}b0r~6>5KVP<@`QJyHBrae>Z!a|jIRFX$P<>! z64ep!3^LwJNQ*XO%-K5TGN(ddWj*$96|^H5(5Qp0eGI;#JFBz|P1B7TB_z6t1e_EI zN)pC7TG8K3={g`JdX-Eg=d{WiDpQaRIuO%ce+vvrapH#)+-A1#E##DGtPL529@xl% z(LMc09slN`E6FIEKap*KHYr%=?x})LoYbSH?qry4Oom3M zjb|OJ!YJuG|F>%tfL^DBt5*76;Ghok;vU_@%{j7x$8AL6vDyV(BZH_VNgZ1m(FSW; zhL(9>>^br)TU$`>$szp#dq8u@xoIwHP+g7TLY3jS{3%%@0D1KLYG1h3w*?}3_?3BH zR)~k~mzQ3Y&*fikwcx7s*zLQWd7^=&-;!t2wztl)=g+ zn3AHQ5YSvCEY&`mdu6i%k<_GfW`UAa%jgz&Vv$?w8C z<4B~M-p|U^=dU*3N-uTLXx7b5e&>xs@&v#%7fa?@w~Ty<5lvjyfcr^tpHY6CzXfbs zB~W4-^!)mE1ixxjwiD;TZap!ey zKPkxvO}bHYgfTMGD~1Ta%zSw$Kr&9ivl3KEZ=h0Uurl~n;)C9tjwF@Z$t zvMD5#T5D>kACv=I)6CVFvORZK6WWeVxvKd-?kQ3bG|j}k(*EvV%4ZLvj7+uD#z_s# zySF)t^7(!CKsGIibECi`8)o9bnG|LN!E2t4e5DKuqc9Vp{Ti(G;j1fk3~+qzkn9BK zD>p}+LoS2=H|Uu-bW`BvLnaa$Q>(=rS-#U(UnwHg%T&fqIk6U9Vh#cCGK1l8T|jV; z7cW=PnJMbf`$bIStBTSJ#dNL#BB13(EE00u5~alNT~I~T7Agg0^l(H}UUwqFmX_Qc zwa$8j}GyC}oJh5PFk|1;Ds zJ>Pql?|NPYoQ)~)XwAnVm0;&a(K}u)8&#}H>T#m>n}g0wmT+F?yWB1Bg#WlyYXO}iP!d0A!`=yG>#6EjanvZ3` z#{MgM7e?Z}wHfbEdC3?UPd%(+TLwhbD#*V@FgtCU=^;n)n%>={cGSy%eG(39vAA#@ zwQAT#AEv}Bj4J&|TVQL}rn?0aTD#?sOodnYc$ zmVrrc2VhaP6cAzYMKdWLt*GdfKj;{3j5Y;>v$Y8JPaL@K`?;Lvkgf}vItmV>(dy4$ zE7!921T0|dz1cjX` zsF%~lSY_7l?6Y@C!!?RrUJeG!Up~iy5qOjOwFzq7-zXNJbZokk^a{Z%3>f1=(e-BT z-?DMz)z_0hIvDywLbioQ^!gay*5O9;9T;+PuM~pF5a<+$m z=~tytmgWRa0^d&M4MjOf$SZ-3SM^;4IK$2H^MQtKw$0lQ$HD)oNR@N8aH(_V8^gbd z%47-2$-Q6|Et>1fWa{KWzHhLU6R5|DTe>oHBfP3S8am)|Gq3N|a@)()_gW86bcM06 zPwWhyG=OvBI@hd(alaYNG^s!C$hTjRU6a+h+x4bss|4KlhRJmT?d)OgMfPYG(QqXq z`&GIo)f@o|v3*8eAgaIrAa_E+UTl!9)uNsV=jpYVn?TV_P|XQZt6WXC$x_wkI}0dz z7?*G(qf*<55(IT|KW@4CH~7`zTZ^Z{%=HXm_ldfsj06OsI(gPKz>HC1UvC@Tj|u^YX(voek&hjwFOa#j^dFfNH? zF+wUYwNITW1@MZ&?i+ULiiAlw{`x>29+Tb}>P39EfigC?F*$6dglP+fQvTK*`np;X zsxKO#-aXCG=UDC$hpe$)yfu&<dy8#Lj5SOJ~i=)iesN$!E4tu$Nc2BJ46ER|M zNN1J{>Es~S$S`JZ5#Xm9b-5g34YFa9Eh{<*ATRYDo&HL16Js|a+-oewADxID+0q1L z8V|rhtfvaY8`-S^2-i&3H1V0@nDcrmUr*3a|0hI|$Rvo{k7DJbr*`e>O_&Y6T1^X5H@u-4c{EQzQ)&>VpYJ7M)8%c`O{UmaTd&bfz9^KnPMV^LuXh z%M@$w=8*ftE496+1F7^77gH9=gvFfFLOaGbC-KK8a6ZTXys!4ajcZNw9cQ) z#w?^vy{}J$@e!PFN=7OrTgov zrXj}WOK?K^-LTPS`pdSC5^CK3(B^61^7O}|N4`7T*!BL&RUr8f)N438T96j~&ISEb zI@|_!3qR6^{Ihc7lakK;tSnl|MY1U3w{z1TJJ7!QmIBwO$RaZ62MBm~f^g_!>}6u- z76w2RDpO2?dTc(k%Z!SpgMS*-M>WDuSS1Jv0bAGTI9H*2FM-&9WsaH@HWw=!eq}qm zp;lhn3@K$Gmt;lNJo*FgM>0N*bKm7c8!2k*&*hh)xE6sUC|=h%2m* zpLC3jxOzJA(g6cOZF=o9HsZ$&53~?E07D-x{ck2%876uL3yXJs+mVGN*%089^FAIN z7rljZGi9_MYzK4MKxl89KhdO0@XM%ETXs&JxjU<~J=p~gAwI8WKwVdqMAUbQ>sp5v zqmV91$OA8(dW~BV?*9Cnl9nS`qL#6=DlI%iPJbUfOsHd`R38Io>LwZgS*s3|&gz>? zt1g&ZTke`+t2m?XVsXG^n${ulWD!6&kiC?Qoy;!JN{H%NBNH1y=LR==>GUo)IZpPa zTc&p}p8}L5-y4?sv{(_H*>jy;)2(W!P=pJINo zIM;@29$UFZ(L( zpUNX0%OlJ_o4DMp7+QSg$UFLxYr<`mCMsvL_AjF4^BBiao0hWYhlhr4Bw*uQ?&agZ ztR(PC9FiqK041KjbGgZ(;r4v!s%`vw)^~bC*Y})fU_5TwO?>Bqi}eoxd5OvO9Gy+5 zfyyIde_zgXacVru^9R6>}tEu)Ky1*w8-6-W>ED2q(cWDji`#o zzR97`>(X|rV_cpCH=~jhz_s|J?c6r6V*A8uqeQ5KvB6h^(`GOp3l2;yLj1%2U7oKc zO7bg`E*&(WnK}+~VyY!>kE7+0nfNI|^OTYo#n0Q%v$B203pv4RPagK|hp96C%?*Sf zR1nwHNRLTr7Pl^d`4fwezV~V|aQN}{3KzEc>Q>u4VJ<3IMqsS zXWYBscp=fByo_eXR%QM23HFrr`X$tS^{|CN$V2}pZ<14~L|LySrmn@tZsI39FY}rh zdW)6P56RkAI(M#9;W(}gPfH)H+#@*|+_HyjbM-xZpSzj89uB`vsUU~OkU7+se|CeC z1Z=2P6%*|LZ*N3mZ?63zTj!}eESeU-P-kkuyob6H#THje0MgH+M0E|Xq&ljh^K<*4 ztYe2WSS7<3@4uELB#Yo=dSm_4fwyJ`@`(->!-H0}YkOBzIq*+wB1cQ7LW!C5!5!|$A;55yqhsF0p`$zriD)^t z$z;l5K$)hU6Rfv@*p@7u{sbZkSitQmT(rqcfOP1mH_&&=V2u9SmjNi16DgR!@Ra2aFJnwKgNZmj~JH((CA z9PrQ3E&?ujwqq3SAO3-x`{McEh(Qr3MAp9n3|1{?eV$PlvOGt=X*Jbz;^%&E$&#q5$7$y76>R`;p@j zk|G8*SIn0a+H-O^FlI7DNWYgOF@?yR-f?BsARTSIXI!?%n|#+Cx&inn^r{5<1?I!DeEZlx4n*TJ?_j#24Qe9*t*;2&G*BY3$6W380u~a>F z=Km1+HEWCcP7C|f7Jrpabj#yv^_ZAY-|s+hv64m9lau`YaWs6xj0!uvaRRNiZCZIv zvFu%yyQPLvq(5Kg+dz)$p|cv6o!Yi-_w)F!tR&25fA(X6g$joJVgdIZSlZ`N@xZ|t zX6G8gz;R|~=iN+`$96)gk~yodKtTV zdl|tn5+1+ca*e;WM_(knhbqK&YQ~;)*|ohMzhpFu9VSw+;{C=(^`k>HmX*$=>uxgJ zj--2^aez{B4jy?&`;pe5=kxxX4Asj)lGD`_P2ZW=m5m0&X;(Jqa|M*_=1y^kX~oqp zhWa(yC0AA*`GNGJr?eto4Q@)jzpUUp(|ntfQ8pSseJ1b|bh@wV*aJB0lBc9}ezx8p zpnUoE$0QL>903HC5W@hx@0>Q<ykx(O-<)z{j%MB9}F5c1pP`DJp}>#B6SL&Mm7 zF-hY#CWNmy8!UFxVjB%I?cOPDiU(^G-}zf5xj`H^MBBg&rH(=)KJa(BNXjX}+0cl} zWRJ>QHt3C64sqoO|OFCP=*ARM%Qap>~pbCm$3G`?W_~CED35|LR6fcnU*A#3sdcp z1M+9~L?w7TP~1*a8}-*0yC_?I1xe33;M*;65J*bp3q;a0+b2|rOkgs@#p;*(&@b1! zDy|X93d6RrAQC;B)deNe407w|7E3BShxAt#6644`-%6gn2GY;ORu6DFmT%2c3Qmh? z)Bo~^+w1>9xOXf`{g4JDH z{yUzyWi8L%%B`BoI{Nkf>kKc%XnnPdrNq|uyW-m18&0d+r$(OtGsj_40%n@s5~D5X zDCop?r{c5hCnRw6=oFksV8L%)8NMz7G0+j3kG;Dt(Ywwt0PY8YX0tNI4x+TGA0lHu zd(K0M&r~0eyMkGGs^8C@G^4yd5D8FKeMLaWpjNH6V zfgW5rvx#?u03MKfnnlxO0 zWo$mo+U^ByI>TAtvIl??tT>uCF^VNz;Mv)I9g$Q%YD)l)q7@_12NEc`8Ya(`>y$$r z4K(UB5NB+O&3VQkujoJI`w~PM6ahkp2XQVIH6ioCOKc6EQjda~Gv+1gF{Q}rt&Bl` zMSuGl0Nv^WU&q*-pC0dy*4Z3yAxTQ%{q)NrQ3I;EU>;Em0`6*~72c zuy@Z?(`5D>e2*ASMZmt*#3e~4y5_LndFX&#*9Mbo9dEb=x}*E!9-EG6jV*g0Q#&%u z>v)LkJY5H#i{S^h!x3E}GiknUM4+Vauf_+36r$W0d?85Q;!8&T_*qg5%?FCX>$Zl? z!aCEXx$FmK-1KJZ0rV7_vh9F16|ZhXfHpceI&}(JHc1AV5DA>!LS}K{-aU908R_FX z$}JoY{R_WMqRY80PF=<#8PjI+Uss0r{&Qo|#sPmQc!xtY8mvES!>6>_+7p}^Er)g} zrk#IWwx5#O9h~odUbb*Jg+C_!7k=|~&l~yzXUbAS+!R>x@DFd9!S)y=mcZxMTkV+) zZblnTu!D>sL~RW-7Dtff6=hE&pqRSHP}6Hq%v z9?WtzZ}OYLje@^EZ~IHDGOo=P{EiH>*_YY*|B9K(9%d7{vI@Ls&`MIGq{d-cJ3iWOHKTLuEW_wdY(wil`zq^os6Eg9XSO(P&%V-hM4fnyL8H&DHpOnxuwvUc z6DNRxFiX3BE}(M5o3N5B9OLLO`3@Q0d> zTQzUr>btt5Rx08tK=!GDw+p#6YEi1Gr zP-#oyCoaxHJvZpKd+|Kz()u+sDrljtB%0_>V-By%%Eeg~}x}of-P5LLpq(+_=f@Zyh8)FiI_mfWAbD{ ztJD5J|GXB|yVzL~ev>d6^UKONE@;}h@2JYiILV`XhrebyVy^rW37t+sH*hS>h(;%d zednRz4^~W#KJ(er)P0>Z z?3|PSFqj16%Z?tIIyVc{!*H!?*!K(g#^>+_`COaKyI8n1jF*mW>l{?)%A^!aRwQnw zHBB$ycd;Z!-nXvs_9&V#-NVf8i&%?VdqA|-r3#&-?VBnn*^}1GFSQHjDX6(kw@c?f zz7rLj5o>R%;?lhYG^Zxe@rZpv`>vNdbbhaab&JT$uwyrq~c|M#W{q~QzYBq z^3#D0B@#zJx#I5Ggl%UfO2%q*M_t9^i))OD+n7r8n)4$=WdBv^NMCP*SCezJ#Ytgn zGWn{z+Y?h0OkXv*Rqt74b?%*9Su!M$)1x)taJtTu>HkK`D-O`dr7fQkZESOvh&b6n z8W=aXlKNYOsxn2xaZ0IrSAZ|6$h1WltrClUkAktbBTb%V>l-vQLp8J@%vlPW?g>(o zlOAYTx1owAjrJU)=Or|K{l3+)b1y&5Pu)co;f_J|a%#00p(d1LCNkAF9joMBcQl5k z>($Kdz+W$=?dA__eVNUNv}Tjj?Q|A5s-Y6I`C&-dbKm(6ZSM<>)yt4Nkk<=6_JEE> ze^JnpS5y&F4s@T>3eikoaZ-pN6-E&aprKnU|A?{?vsbEx$%@xgEo8<=1>Us*h(LOo zO*Q6H4qS1hR4j=%(0@UK$YCx-#}L4xK0xV;A|R!Z{-pJ0)s?X>cz=AMDc$TTpZ}`x z_W7p0JU!KM(PY3OhyN9TZ0y?sz57k`8rb#ic5Du|o}fOqcCYDvrQiML@L?7YnooN76?b`Bf+4zfBBze)i_GBrOw0_OQ zr0!e&a)ekA8b(6{_jKF}SIxc`euONLD(MN1+MG3xJ=fEY&JE%`-l7Qf!1Jhcw%EA+ zzV{CO@zq$EE*2%r@tks9q+M)#|i980C52FWLY{|r7w>Q$pI;mKRKp$ zbI!cZ^tQEXni;l&OC-pu<;S`%r4du*4~xXmc;=+IE+XDps%rGOu)<1c>3iN;(P%%7 z2yE)4S-;h`K4r{6E}uz?W4k&H3~wQJ3K`QICG=Gx0BC&e>aryf;-b3U) z^>9L0k?Hwr_TkOPCVt%S)y`AZ;eu*;4NXMWw(iI(I$qg5@(4;O!LC}<$u^)rb$p5C z2vxx|I+8qmyY@3q3BXC9%_m43o{{E${s?WvM}Pkh!7BsYy`Wfew&pS}=HKr)9jAOL zx#gmP+ooXD!5ieo^%DGuXp3UtR%C?~Kp$(@#bFeiu#WZuFwif(te9(3B)}7PF05HZ z23OZ^j30I+C=GaWN*xj*^Olz48eED>l)9|Rm@cwW^|AV2AOSy;`9^oNxs3v6@Op5m z7If`3Gzs7w<8ThobV_?L?C!^kt3oEwmyt^gLqU`?(*Z@*V`GhqGL1O znSV4Oj*{wAi{bKg+)x^7Qo1lSziL>O0$=WBDqKCDM#^MxIvaFiWnhpHUB)gPAJ2!I zRPb;#v4pOw;xVxQ8n*$Ym}Rl>k=q4xjr_v)0i)FE34v5J$ygxWLyhOK)`+rW!_G9U z?>n*CVI%*4a6FrRsW7(yb{nRT`IuP&aZP{HrH0p{%gsy0n7U~pF?=)ZG0F4(BJQK< zSQgO4HNKm3VYuq3^hO$&oWA-{d{g3(Ol6Zkg@cUzj;4?WAp{$;KJkZoMDkVLJmv{1 zfm-6Rr-Lk8Uyi0>@yR+>;{2jJ`Y!1!jnKQC1r$-uRHs0vhk|nH1gz53J6HgM&Cju= z$o3x?6n0xn8a5wpIN}T>FHq5(q1QJm9{elpi(L+*)b_d!6$JdWn7?VYu4Qua@ z<`)lnlIp{PO5Nu9pFBJ-pd)iSH_pNj$L(WY?9xX6Blr9A2n>V*$o|?8{`Ia zuUg+xO~k*8zXAA%!tbYa@zBEDiT>?~3hf0M7&ndmKxEC}+?vVt`oxg4X;tLIyvftgy_>n*h%*U-D8$@U#} zyL0!C8&1IWVc&PC@m4?ZTK{M|j13tyZ0o>=qm!@QH zxe0uFP`1bGk(7Bxb*)1rdo_f~Bb+vsQnHbi^T&4An1m`my@V)ch-bHC?Y9bvGkshw zeN2J#W{M(pU7KqO4phrnh+30U@qx&?efW+gj<#!XeFw`;UKDMty-uj3T+_-7D zwNMrtyCCK!x7>>uFU2YCdMQNOCME<;F3JPMWo9{_0i~O{X8n8DjVaI~M+rS4if_Px zP=ZsU&&c9Vim4tHP43H@k0(!p_0iX+kWdo%+eDygkCc7!W~ULKh1b%>ve;nh&LpVc zzVnt#u677wOxal&_;|=2D#`WTGam0CwTy$D#T$d^Wyq6W~y=;O^Aj%L9H7&8jzL9Dpd zgA()?W;y!S_)++T)ZAR?Pe2;+$9x-VftrQwbdk1h-1`Dzs~UGL%87vlfo8|j#ex)a zTN6Ff8w!@BqG8EF4D71ZSjv5Bf*by~;}(f32@!KC(lN5ccb^wnq%tyBihbAZ+pKby zT#|tCSUHJUa5MR0DK9A~O05cOXUE=hlf~F5e##|!M&+H4PvD5m_nnyj<%lCXR-Jub z%DMun2hGLZ{G83h6w9KTVU9?N(*2F)w7FzJkAuaR8*;@)M)^!-esUT)fp{|_V5Qpg z=-atBi|ti1DvO!EzZPe0wWE3*WFlAO?owxPjTOmdvT~a2=_s@m*}p~TbLCC`>2yTD zHY^%6WB*vxbkP|1Y&!{}+Ebs?XDz=RF-=m+2=qGVK2lO<+q}Pz4B?3W)^II1H-keE zJ&LWttMO9*NrqmvLa;nsUo`GxWMq21->Xuy+!*vS17iZ77JH(Wa-`TJ0!`PQ$;!wZ z8fk1{!cxj2CCClYSHf!0d2I<_;&@jh!x(o6Dm9VM#T)n%gQ4WQQHPCg=?Lp@cAB?8 zj>Z?RF}Bg_AIP(O)XBH4j44Dj>%wrLHy2v-L3ebxT^0Ud zvmTjkL~-m%**6z2+=DWfum9=&bt0lp`ui65oG{_h&ON;^Xo6HoyMk=6J8iP!uz=1C zJ=s4hn=w(}sd3WPTtkZ{fa)56SM~70p^NTISta;Lt`)N2nEqJe+bLf0$sU$v_w%&B zv)dA4N&aPz)bSJj=-WzkWIaT zpPBV>IrT+TZ^VY`tX$VeVlj>$e!AMFW*$jFQrem>m9yIU6UC8akLUWJ6kfe1UqAtO zgY%I{yqC+5&qM&f)Hc5n_jCIf&-|OaRdueHAefF+i%aqp;te+%y+itQ+MCc#H?TG3 zisukT_hwCMa=jbWZtCULe>W3M;t=A8{w|KC%A1&q>3XRz7!4D8S*;&X2oViZ4)ram zqP+?d9RMBq$i+zxHQ=7U;N_m@!T2qS4?9g3sH-pv#*+-5Mw0KSln&*M?~Y6ByHURd zygPTA7J2=hJHF&el3caklHvS{)$@KyXMIt_Qm~#dYe#t^A-dLJLg9Ra&s(TNTYZ&V zQCW5{?m==;yULhF`~DC{CeZr&JAls2L6onOqB#%)J62PA_8mUYfxUE{a10&V`d@Be z#i>&XPvy8Bj)Iqul5dkndS2*X_cJq0rGEILR4enxPKo44l^@bp!D%0vkyuC_utCZe zwLErE-T^ht6l5NE{0l5XtD`6bUhj9qE>_WZUSO^1gT%g-twOTk0SHLaB>q|A0V)k~ z8|CFI7=bLf>j0|L$2AkOOQ~`L_#-izWj%&vXNATLb6feHs&owDX^~pDt;_`yxu(dO zg`SYQ3SdLgf`W{ESm#)w<9A$nxy3Bs56`j)Ax4jTn4GM^Dg4AsQ5ns!JkJcE!q@$~ z0oNd?cuWYXdmDjU<)P@p(hRe70=;6a5^iU{o46jk-4Sk4?d!|MbN8BW1u7_DHP1oJ zabl7#PRl0r`1qy-YM!@E=fh`>6q&(AW09A1$|o`UcXhWpIb*D#wdh|pTQ14jN%@y| z+IGtK4msOl{^H0iQx=(@g-)uXMZmK~tD1y(9vd3e+OBBJztCb4o%(VW&1~)o*O1&` zxyDT_?rPbw_$pbLbfGl8X@4g$EFK}pkn9TAAHouGj|~YHGFj@hJ_sqwEf}73JT!Nu zqBMK{N_I-yMyrTX1jjxNf-|Q6IasCrvs3&s!eH?*&=+Op=Oleh2x3&`LjK*8t$AXk zjkW!qEqFSQHfe;#UosVFV`Dyf!mCgWyUBvYdyd%E8Z-l*JgY_qs8W8%(y452MW_FM zxgR!)y0%;9cz43XF?o9=Rv(dkYeSPGv?gZ0=95k>yXN)N12SJ3PH$dJyi{J%hhoA{ zMHZ~m6PQH7IaDN%MLXZcLU|F)dY#d3{;U~z1y`jngI(X$7{<42d8G2DrA0wpJ@IB- zQAaH0U`;;`b&r~CmfJiLW!)x0^KJ`Rt_mm&&A#o@h@`hu7Pl9^6#6IBLFdEqOW4%I zrI564Zr-PcrwR$bc3pyBg*RQ|?qYsj8gmGo72amREn|HkxLG$amj^<`T0XsToBk*d=E&HO8q8YrO*N@-U zJ-ElEjRrG;aRtiyb{ z6G>ZLU@AX|Vc_S~NEXFfSXzpJS#|O;8`YeqAqBprR8%!l^!T-uhQ#xhdoqQ>FEW-N zfn%CHIqw!xZ?+=|dD!56EUKE#l3o+OC{fP2>4s)TV2@u>uO41Kd@6niYF{GBfa)Y2 z&PN_>IOjMq6~kc^Sh+76#U z>cBPeJzjG~Z|-CW-;Gc?64W1b9;l4=Q@AXpn#%6yD7>FY525|G*fo&Zl=@&)lQBKy zN!r+k^znKHJA}B|ql@~VZYEDu69MSf;c%mym4R_-oWAqRf4}7=n&ZDnGANin z#Gdy}HqpM!Diy$2;6W2ge%h*nO=0mbJ6bk_m2kr6%Dyvm%gDyl&0fA~Aw?E4?!LWb zkamLo{3Ac!yYoE;MuKIH3A<^DH?$4uoYFg!ANAYA09GDQnU;S@E`}y0XTL4E0PAMk zGa8ev^U4jd4?gebRh$p3uQ=k1Fnk!v>Hfy|vLk8?FqaSvn_5qxPo5G$ zIa2?U<*l797{9_oS8JfDm94gZ3`+3j)$FHHWdHesg(U zwv!!Omb-bw$p6GNOr~Rz<3uG^s}lgV@AwQ3t;`w^8WBG`JR?z|BwU}nw607{LtlNcS9z)4+L5Iqac zfhwVf8@v;saB4VLm&S<%DN*iqOW~ZW{z?|rrDJH9!D-y+4F;E%0U^43wg3tVZ6HD9 zYb73VC*7Hcnw-&=5$(ih1|!*XWS^{w76YT_5i$nEQCI%BxioRVK*jb~K4UV3Kd7ooOJDDcLU-7RtCzT-4m-a_-QcIr*poJL zXio(%DK=YRm9B+3Qnlh#|9~Ijxn{|y^d9VMUZnW7YjLFSc)7x;rHy_?xoVngO?5q% z->eA^Ha@A%{wbA3!>Lc;HM8A}yNyNI=2RM^#oE@TMd}Sgi+lfTgtsLqHTo}^F;XLEHsMaBH#Ec zPH68EmskpgSF=qQjZ4Fhw{pT5Bw2MQ5ofM)Fo>8WzL4vNK-Tev;rSZk5e7DpAe`GH zE3ab7%O1E>XxgAv#uj(%D4{pwpRMy1-G}Ikd*H4MsbL$tC)snoZDEP&fbt>jM&i2& zA*M>T3&2!Dvhh#V!=(bv&Ty}LHr^MLe7#QjH{IN%56XNkSHoIoiECwj+fEjO<;)?C zk6529abzk5tZwhF3LNq`YV+l>bquQ)B0C1K{+u&Y>Qz=2HD~!SB1`H~Sv3&2xoEXq z-geteZ}Rqay)O#u0p)nWwB1pgh%47-1=popYNbCN-FtY_Ne3G$Isg8K~mNi zYtPN)ktbu$xSsdxn&JpLe*^7prEA#==Mxv}7MuzO6dvC-dN6%E!fE`B{N*0it>z?bM1MJ#`?FNFo=yJovwOFOp z)$WjHfVjpDADr?7WIk!79hI0y(soYyrPNy*X8oo_g6n@pQuFT~ZX*#wTdlQZ9S(j} z{rYK(2+^d~c~J6%aSa;$2-DbGOw?y`F1?|cq=8Jbw=Z|ILKyCnJyF8yFVPJ2m&4wT z4H*i_T{@Qn`RqIoDT`io*KhtVD|N^m$5Mu)B-;Sc(d!WWURj)WSaaq^xlyOv%OQHw zzgL=5_{)tH*v^n%K^|lDbD5mm@7wEseM_*{xe!<{Iay$l$5#U?R_ojWDyQ#s5TQ_UzxX3Pc&?Nst%OWJPH_Qws zM^uv5MS$(aZ~Ot^QdnV55Yfu?g4DUFXl$&eLh6ugV$r8aK-SBH;>+LIxfLM9_N-0L zOR0Rjj?k(jU*H)POzC!Sm-aV1-TDV$E5d@l(aNSsDL(e`d$4%|F;JHeLMfio{&-dyhh0 z4WFA5h0n-X*1j~dfw69;vX@jsQ^%0f*Y$QJThURS>F^{bk$(`YkB zcf(|u4I-@*F>L#4w(r?y%Bq~t;K(%8Q}-dJK!;eq|C2i3_h-{!VlK(=hLyc~p1-Kx za88%$2_JL3!=geGDEg8=*dP&3`X(?C`%=E&Z2+dXgNN4me3F)2<_0Uyx$fFZs3Ntn z_pDQ(;Fb(qF}V_$ve>+bdBXb4dR zIwsfsHLz{_1%WxG)lvDZ;xOXOx0R$OvP&SCH5&JiEpZ<@YiH#W)1A?SOyy=P4l}=t z&sK&q4p!~F-G=6ckji$>WaiHu=qY4|#1hVuQ+WWhoU=+Wg40SqEGg}skCTH!(-rm- zp7;Z{E;Or-f7q}9BGs!FLv$72`U5{xg8Dl*(TV0?pd$6Z2zVEU14fE!&sZ zhnT2vP)BT1QNVU*ER;iLi<9}*EtBcvIG%jtjm-~_=*_ir9$--5n&PADl_|lIY>HCZ z>5=a(VGTGzH-q(0*f1J6F&HQ$t^h zElrccb>X~qFQ@N^NCaSB*=VcF8yABegusBpKP;$iLI$JQAYZ<8Ig3Dzq znZk)ul&j>Y(T~Z{nx=tAsBtsw11YRn4)Xt)dZ+L>yZ8OO4IA6G-C)8-jh%^Y+h$|i zwr$%0*<~R_t2&fX<-n zY>hK@@yNkVejkpPlT=R=po|9>5wB^zl6^ZynV;l;4Uu0xrsto-7!2JNjplzBUbQaE zq)a{4&Bn$^98V3SL}TE0JQRfZy=9VPZE80vZ!ce63F^R2;(?c)R-O$gqbEJ_WmL2V zDJyU^1NmJST+y~T`!;&@2;sl3?ez6^CFMJ_Sne7@E-na|FGrCW~#U z!XadfN)MvBCFa_7|By?9K{PvF5#Ef(HvVI&Gz$;j{aI#+%c=UlPLM|C>B95aAB1&-hRx^6k2vX!^uE5wEjN zY%nA}xPeZ@WhV{_neTfWDPPRgt@OCZwNGX?R{{3P>!ZNd%}lNxGKw#3p%S-ugU?*2 zx9Z{sbr4?bc*6?#r4>qjbQp7)88YM6?oL6P$B<#PV)Xl8#ujDFsTN}+%I$V7Gn5(R zz)+&k^ph!&4!s&XtYN^E{L8f}$_<5v^5PCyEr5&6mnp_Jax-Q;rSXCkaqUR&VaFdn7lDO`{*+P7Pive5;@zxT0b?{J{GykPW2+J(Z=_p!zY!~=erJEsbp#Y-p80y zef5*~fgy!{@h1^6*lt?hf=LvD>BLZ|n$vlhFL#Ik9#`5=PRpPUmI475uNn~4`TB!v zmKPD@?b|l$Cj(G(|4TI?^Xy$)b>qO}pvx_&S2zK%R`2;`Zm`OSCJfqxmw4*NM3l=G zMuU|Nl&eG@Foq7*0y?Pf0*VaPO2@lzBX?m+_V>n*)8ahJul+!U@dP1l&M7iT@jjWW|$1iK?0QS{BU%EVo#$xtqUw= z4+H;s>b5s0wm++{2jZ((ew)SC4g7k;uH!f)^Slol6YULo8kF+Fa~|cAxjbe*>q#|n zb5B8?>qMdsQEA<5{!v^!l3B=($}jpKnYvFn=sSt|d4K0wMGNO3toePVAx8WOj+?revm`$ifVF^zZt)=;Zw9W9*cnfCRbm+GzgqcD;j(YzxH6{VSJYJsplmf@pi^JrGLOkt(E?LgC zan=}1X0G;khvL?qUT|+$10!lxeE~bMP`H!&J>J5tImDsqSL>#%vK+a1MYv6L(KwKy zC&Ff@fNi~ZXD2svUh367F*C5)t^z^exGem3_6MJWck;)_+*7C68h2nFMZ~onD1b8{ zx1gv)omOqEQ+yGsY<%Oae9yiuvDC=9#PmB`201!p;E02DlfU3Gl(K{gz#!iGkb5Cd z4xx_gl!q-dl77<4wQcwZ7<_p${a5p~LxqMaK~)nsB?)?6N-;(>2c%ckTQ2|p&i#h>BS5J=U}D8@9B*E})N z?nl2(Ds88YASj@E1}t*i>d^2BSVu4`2*8>D@>nCMx;!ygry=#~g)pltSPaGsUrp4Z zS}&_FjiX#R^iol=AB|PPWBKD;#)5-$+2G&D@dJEHdbSCT{gL@X?kmO~Sx3&BUt)!@gNE13k~K-ns7r$!N$JuCuw=Qs04v)T=O#FxX31$)ae14}MP4&pXy` zbI4rfU6n=FxOh*;N5rneynf%r;9HMosB8E&t^ns_=)PU+28kj-z|@Sz(j{DIVDP__N8 zy5n?l?bp;lD)Tk-p1#DdiD`gbs?2g^rCoY6jb&064B}?sbF6=#_df}V1N9idIeq>; z>7^!NQjEtwdhAQ-m8CU5&*qY3<{xGHIA0PhIC5#?vArWx4Lv9n<5QABMtbU5A<3xMr3Y(LVW25J1%&9&{{6;6%J>;IS@l3=WzVMb z57XqE860e@Ac&Aa7ih>Cpd#bq%^#CuX4f-@Qbvt_gxLgQ>uA`1kP1}GzJiuK<#rD} zCkyDjPZ;*wC4Sa)9n$H@B+m3je`2m)gB*Ik+FC}s&xFQi`DdM)kiD6_K!O=%f0_s+ zuJR27Ci3KT?sxg+M|R2*U|w2L$huNPt#@5^1_1?h!ec*bL|3C0PT$sLZ6p=}VtMRg zK9%*9QkZ(crhP#fEd6gThy*1&u~P>m4b<;ZJL9==33bB?34A1wRO9+_8n^E^WDE_H zI}l@>%Bo9xyq{>Ve6ZkLE<)JTNs^Kti0v}gR8pnxsGSTL;h~xYQxH^nX@l0P0bKx? z&V(=7CGmA$R9bS^d|j;OEfcIs6dw#!md*0Xo;aiS8(Lo(@n-MltT}-|jrAXgB5fZc zxKZKj4cCWW)ez?o+-)v-e+}@T2CzJ)Tpajc^5+%nXU!yLu1Gk!LM^(3KwUfKUm*&W zi%nH}I7h!Wq)D63;Z zQw-R!@`mVrbATq$_fOk%r?_57(21Q(dIA52hC0UFc`n=lBkMnJvKw|>QgZfFa(r$a zO7d@NdF-b#Qe``YN+X#O-{hpqlUR!O-!}G@KKT_#fa9Nh3xhs|lGIXOY&g;pZ6&9=UyH8D7jIx7)n=j?NXmLH-y`oI*yo8WKxh6%*@hWr4eOOsY#ecRFw3 zq!Ac)?~SXYW7=23|Eqyhs70I0#ukx<$u#^xcbNzn4xUM)iv^Hk=9q8gjg^jnHj<@n zTpi%UGhh!9F}5O~U1vc^ zhuskLgrptcDs^(3m5~vIizVWJub7x`R)Q#h^MbP4D_KdMeiu$zDf7Ip$D+YE6aKG1 z7fXgNrncPBxPY5=CW}o26Hv`0wJ4Dvg!|-8oW7DX2nTVvEd&Ocj?@jI=n4VB>QKTa_h@v#bAVC; z@9^ACGPcT%ey46Z=Cec)`CPr0Vo6;~U?(%KrKW)_oU|7b+i*uF3BhS^-5=3BEZsJI z7D?Qg^je|6^I0I?GJ`f<#FC15=I5zLw`ECU%GR$28{aNMzG7ZA!e`%W(u%5r;8!KZ z#_IqsKB6fY7>W+W%g#$~Wvs|jT;(netg#kxo#+H^dVC;tq+)-q6(?Upq`C?cooyR% zaX@^G>LythU?vjV&F;}Vlr`io@AF7sZ7d%*pOBDC&#Cyi0;Ozh= z|HQ8_igGm?!TA|d3w4M58!j0eQtnVLYnHAn)<{FUeTAn8Plf<@bAhncmTjlNjXOlB zL*7LX3{m&twS;}v=?OMI`IH^k*0H-SSKhS4g#SWegaacXyg4C{#etgYeIpWNqUokZ zN-YdI6qUnjeAN;{8Y;TVi+RQw)!S$ZR4J&4#&~rNsd^#V2stpOHpzOHbT@<1nfGsD zdHa6lRY<(*w9>!|cP&YSAlaqi!oRu)=%WD;k{E;4QGSccod$|1@>b}0zj96WuKnhy z*w@v!`ik3Pa6RkL+>hNFo*?}*$UT>|kOCtYN81NeJ~ooFwJVcn?=sAVtZ4lViw5K~ zL)V=Mk~~#!8wk`T@M5p4-N(;HRP%YY0#&K|FaA=u7`km@`&ZhNB04#I_}!4H+qTu4 z7^_iB(rwsDHwJuju8FMYYNuCe3-c~KAqx%2&UAh?`LUfs7k#Eoi7?=tAZaTd>x}=QL;X=+^AD9Oug^ z-K=TL?IWJu1wzZtFwhYjSEtlbuB>0un(WxjAS|$RGK!$F=wdH|K87^YJp8K0ugsii z!gW)EuHC)dwTq>Xnga~tUm-#Idg&~yLi(NX0E)PYhf|=bXzbP7zh&x)<;^q@`pf@3 z!aeYO;5PRUi#q(I2J-`*|v=@MuE zUGuG&W>NE3d9iKLWo!R15biP00=_GdH2r@6tt?p{v}WGRR0`mA3NoH7_<>BSf*r~vj~%$9)$j=W%SUft1l~RX z*m#73h89rZsN$)anZxtb9+ZT>OX{8CEAX|y;%NCAI%{cV^bNmEg|o>@akz|$C(sdR zrVnT6yjTD;404mhBFUf6o1V7ZUIco6PDnm-L>7==Jhv=&Bb`yq-XMKmBXOM^BgtR|F^vPA-xd#gq z34u*WIGoOI$wK^fr$OLeudTh1_%&yy#;dLwcjM2P1tU*|amlRw2P zC0z_P0hZE|UlQ{-`lYPpO0>39f9~#=F}a=2yXk>kFIyBLr$A(au>806SAM^tjw^q+ z0SNlGVOy`;`PPrMv&2M@KUsia`+1mCK0)vM*f!d{lG_xBN>1oFIalxDx-<54-6ZD& z>)Umph)~=47R)qkD)6e_dW(;Fn3i$beBF4&3m3!pX4ln&2`msMTGsNx>HcV{DO+2< z*AfN;h)PdQ@2sE^#$a_l>LSOF=1@$hI>-N4Ggh2wUmGn-N zqWds4;L)?HRfweO^tYI}N^oJIE#*mD-r$dksKI2|A^fQbcbxF3SgbR~GR$4tl(i)b z!8LN8B*#Sy3Voqi(CFgn2txt`S%oaz@f6KP!b*B_qOH`;phGP>{@E-wRyqH) ziWULHZ3F$I2-NZEfEppu>@Ml8<4tPfF|kQ1rwfXP=B83;G+g-4=|RN8qkkn9u;BGo z{uYTInu;yi)MGl$7wP4^Wl~-xpjW{lHJ5;K;+GikkW||vi1sKG_aHH71gVsSZ8@Pi ztM{mMk?s-8&*7J(jzn(HlEZQW5u*B)0Xp7#Ealz7wDaVtvtzJ7#Y63tIie*Y4_&TC zbLD>20GCG78|m(#>8O+3ck7`h;~+N8%!(*%&c-b_Wa68>)$cs2VK4+}^}fwOA>pxF zI&WQh8z^y`UoT5fWhZ5K5GYHEdK;xM;tIY#CLN3NalOETQDG8gJin4o!x8dQ4nLEG z7tyBgt>fp8g8ACI+K=ge{&JjoM?l>4hC$@H|A8J7e#*3+GDQB)CUjOg8q0tCAR=MrGBQw!LtQpLP3Ukmf25Zm}^=oq3*H1XI(_LXg-FB#&Q zNee}e7&ajsJ@MX2$xGYB38#OI(qqar_`vN};;&?Maq$$4qv zzjNlr&lIo#!q8#_zV^Hy)-Zqn9&qLRU8iX31Mm{3|392x)J^KdB$_ICFd;(*62`Kh6BF{h3w?f zdRUxczh^&ELEu}mVJJ6TIyVQ|dLW0jw-boyFT!CrkfcgtPir{(G&a+gd6YNn( zZGCtBz~n_kbCIc|TI|#X!^Q7#ZVmnX)Y7d3MkzzD(JJ2^z=+6yfc$n5PRFcFFz9)J zyA8tQ>9_Dbfu%DjJ3yK@1ZH~)Y`+e-$kU*+bh{8(1cI+PuTDQ-$$esNDO2n96c9m+ z+og|hED8};Zv=k+T^RxGPQURV1Qkm)LpuQ|UUw6^$)!K|z~fp8wQGdU$JRbg-E8UJnH>2tD9vOmvuU6R9T2=Usqy>w}ORFvU) zQ$8tYzc(^HTm#gC2^K8t%x zgz1h+zzo%?95^9!c_pbuMDqBo^zAtiO#Q?%F7ZP|V>BXIF`LRWqFVH26*c8ce#=4v zAz4{QMQ6fhZ%DP3xT15MY^iW)+ZzxP3%8f|Vf#(|B)+p_fdR^YxEgCGJh!HRm=A`n1e|~7b)gv8e7e9?s|NM7od(*x~Uh~&Dk@|BgZ71dq^JtP( ze6;!v1kPy*fmBLc)2q<`HVcJ>xaWxzya32aj%>zv&Yf3J5@*wVH~jfq%TBVj-CdMC`oT!O=YB|+pvY?8 z1WC75`X@m&=4de^W5iQd!pG$Icjn_jZ3-r-QdumY5D_!D3(UYeLRsDi^tZE0@pq*-ndpbISqpaE7ZiolDo0KE&HSB@5Ogs3)>2MbtZ9QQa4zbEjebShD>W@t)kTbK>^UZSK|RRr0G~1wx9&Qnps&5pn!k z?NIWcrntBB7VZ`IF~!bD8C#Y0*U1tA@c-SbSt$Jv9DVOB@t+pv$z5od_zhTP0 zgdEWTJV8we%S(;mrNPFH*UoCP5AtR4{_E%Q4tc{Y$!PBj#_HB{#Y8n$^??1MU6i(D zOq=>KtjRxD#4(#w%@juq=FE;q(wy>u^cB}S?)vhTpcmjDcnw}?rrU4 z`hhE-7t3?kV-N`CYSc9)Wk*8u(R(kgWWVu90BQF=@;lWq&*#G?c9pXIM~zR^z~M$G zGzgEG6SA*{y5n+liRASI7kBr7ZKn6r>ya(h9Q1ZT;LK&=k5;To*P1cmXWrNQuS@sr z+1Gv*^NMdKB@y;6iEFM{kp#?JAvZg`cRlj4PyUwCoffAM!1bd}BdDcfC2M(-j!YEuui~zC9?LiD4A++{~=Epg0j+sidc=r8gl5inw8GTbRU-!0{lmGXo8u8N)y$Y zw!+_EzwFM*K}4sdgDN4q-h^bLU$(lAC?>G{YpgbE;1r3D9Qf)z+-0YZDnLpaQ~VPa z@y6)+dv6LOs-gg8l=K{+6a|Py0c>S*7sbIMbuz|~`je`{RgfDMNg|!t^p5TRQYR5^ zazkLgoZU2p-3Y=Vd2mnLLBJTV|Eh$XsQ(!W@Cjbg&ok_aTqWo2sDv1ll)5G8s9YPo z;vNZFFNP&^TC+w!hKsG^6btr=H$)(D3Hd4DE52WQ*KK*%gvT2?x`%=%wX&XJRy~RI zww~rqGh-(AmJLV)SL>_Xv@nNc(`cDUX6Rd6R5AhG_w^U0 zTFF+N3Ix;9?g?8uQy#29!ZTDF)^;O1?kyYEYg3&YSXdy=D=BgFB;7(DII>_e;rH(J ze&c<_!XY|Ks(*gsrp=eH7{Bs?^VbqCQb~DXD0vI2CF55~tMR&-K~#Ze=Hv+*A+)E4 zE#gg&Qdb@?BJe=`w>8`jNXGz*l=anptkd}CAzR3il~I2!ReoA+ktd5s6}J+_NKS$q z*5X?6{ht^DLF9B}uOOw!s`i_PVnEcQJ_p9#iWN&XV*qMXoO{Sbr^8Kh;mUtANf-Ey zk~E?byrZG1BLnXUsSMB9DM@mTIu_Y%hPGNV zxiwM5{89^c`7&fy&Y*RNmSN5|rK5Bs>6YIF)il$S==>E_ z!64{4ZT7Ql-&GF|T;r|SEqZSX75k6#SvGtwrl=8>Q5w7q{V%M@raQhl5OOOoC_3|N zPLx6O5fuqZ4DADaCS|3?_4L7_($sl(E7{_7nG>Vg0GW^Xrh80SA2?YmNWL$3x_09N z{Bmoj`z&IF(V)yuDA!F7%ik{MXTKgS_2Y8+xPPTBTB5|nNbPuk!Q-e@T09&%cQtdk za6syLeZLxgF8o_^ZqrNL9UgqdT8-{{D>`;rcv~5Qm87VQi802m|B4;Lfy1J*z zfmxV<`Py!}e{5FxyxU}yS$k^Wo z0A*a!E^@D31iAShqeZ7sN5PfwmWw=tKdus~>Uhw2@x)xFBaAW;3bVn+JvpF1|7=+m z7e7Kp_8p?rpv_rk-@Ft4biMJldcr4jB>FEN zxj!KfMoeu|0 zeH5OxewweBG8T-vz9gekXRnAg*1IzYOVPHa6z@@tU$aSmLZ#B4+@N+MmXtbQo{G=& z*PyntyY0}h+~E5NvawKyS|%Mx7iV{xziGF6PkZvJE$sp(!kT?=fWu~FH7XWOziI@( zTNZ)H4Sw#^wiE#(=-Ld+XaY4nM&|^dL6x+;d0g=UwdpXM((halas11P6lch zKaF@Uho8%F;mba`V6S#b8uS+O5m+}8H1>ajC1rifhRownkA@$vduAegsXL0R{H{RB zuF~}TZW!F0G$e(t^fR0ohPx0+`O~59h_HjC#{w0t!4ecAEhOa?7rjz2#_t%Vf;t~+ zATtm#IFd9I)-!Qw;T)I$JnT?6$!>rLoqjyRpYV;dmNg5uN*#|%LPi|DV=?I+Y-B(y zwNaRfIrm4v%BWs`c1VD#Z~;!)R01fw&dJ<==YQB=?+Q zzvqyC8!-bdV+=BfVk=k2W2_hD{Y`1M?u% zmJWw#*&WOCdFS{qU%_V={q-zGAAIwmH<&gzD(zd^x zXtuV0<0V2n4(q!(wpQZjF4=Zw%Oirjo$A)v-$?$F0?G64E88Gd>{E;@pA zWU|2PTB{eBjZPW zpo;^Sxn^0AOG#KJmjcvbX3E>;li9L^*@W|xikum7K>4U^m3r0s=i%*y{3I6g>sMOP z3|A2rd6b=g%^j!RK_J0`6-QHV>k7whTLLrQQ#sD_F4@Mw9^<&nB(v<$!_oavr&-2pDJsoU{r;jUI4rNRg`6SmcrlDr!vGJHjy-aQT@T9wTcZ|P zU+xhDFEmV@T9R1m{EtRm;UfbS6dD;hg&_@%oH|P~DoN8@OPQ<@XgIRFK^AzMtt}#3 z%s!B#BS-hNaLa*MiD7^GMn`9=h(nXtwjfBw(af9($tjR`y_g%21;9e zNOB|%IolBfd%wet8}rTSR%JyFx=m0FmL3rQpi@{Wgk3 z<1SueL+PsXT>d>h`|beIVAKs2XhQJkxwmoi`pfVHy6={Ffdsg;*cjVYY$F;DyzIDE zo(1o@M8&T-0S$fIC@SfZYy#>dNzNJh7mkzH zSvHY-4BMiASk2+QY$iN8?xE3hyFZY*3=xUr$8n88)BPk-V@^q?7y(h=rD@Thd;F0m( zIsP@}LhvQ5rq@!LGdf>lZ!XpR7pL4O`_X-s@F3NnWaMN|(`^Q_DwvyWz>7;t6!7qd z^ACAQm{__D_-k0$w3%_07I%DlK}3=NPHz9!X4Gy7A9c()SaN#bR3;1-7M8E0`EQZ) z*+Bvyk5A8_MD;+`3)gQSZhbimyWXQ8uLCsbM*iDCV5Lb#67@mYi0}J>$M+!`-LfRK z61QnlR~r>XRWMuxdQEA_9iBRyca2;XU~pV;$7O(P*Z#%xd|S_mE@LP1vX(c6P3sF3 z<~LA+n%jC$H<~aRnZ&S@QN%zGG}BS9q68XirUetb;OIAjRsw~9 zzF&E9LY1?lk3+(!VJXDscr=BZ6@w#)$Mz1CGpZC$`^Gz1SKS&|>iI!n(MmyPWq$`i zbb>8aX;=7LmVZ}0aErZLggQm#8wG_iRS=n&c)-HBuEOAA#0n!sM;nag52bb+vNLww zqtfH+mQ2S86TV{Od+Z2uJt6t_Qeb`J#CVP;%vm;}cp^RuJQKfHbK`M{`)w)|)jmwA zzwN2^3}X9urTE(6Oi9~3HIe8+J1j*Im|}L)hhGzU?)3F=V$9QRkmHo0)oPV$wn@myx%t| zNz80y1?c}c-ul1++N!x=?_caBxZ^J>l&`X_^G6kLKy6*{L`lfW@g_`53?OU??sI+7 z#Pw}TH$>0tP))5YzU1=T1FV+o5b*g1ATanlD`lU(#+RYfKOj3M-0F>xqioWAbKvT~ z!xxmV>OBluWp8Zxoe1i69_;rEII$rQ!c^EyG7kMbVIeUV7OlYr5kaenUHQqOFD%yF zFye=0@>CqCNrGnxbqK0eeH+@AgC?1R3z}4aPN2{Sb;f~E$YE0pXr9q7@JX5l)9~$C zKfpDEcNI1kPxVjqNBT9|;n(WqGLH<8V0 zDmY=%@zwr)13@d%$jf)v1>(znL$Z;^{t=?GC?Q-`af34U>vI*g^PbSCn`%DAMqbq(7j9Ewfz;*IiHXp z_Jk$Ecbom#lgk>GY1xPd+!!uFizW7M#I*%AJ7tHliGteH&Ck0Y$D!U99l@SJB^Pye zBp3zy!grbA;{`D@bqQm3U}W+O)qO%aRkc=*<vw(EYw_Opo)dRpe-%E~vJu18#S3k)Fs^pPX3pxw~T97;$a z8RAQ3LZEgvHPIhKq8I9)P$9K$yQH0aRkn)18OV@yKPOG?N7y;3p8VpqD%Qezb1)FnpbW~d~MW#Ab+2>k#{cYJoUQe?M*#@ogp;&Eg)S`_nEt?B!q&rmm z-IS@HH-JW`k9VJ617{r+g>FX$tEJoWRnoBP5uyJG0oYWkZt|F)| zE`Q#Ia+-T(8F%(8tFxTn?~Fnpc|GByJGh^>y%M^8ROrBn>k$T{^1rTe8ngonqO%Gq zOS!z>EVSnajy_#{|6n=!TweiGB@yx$bch^y~>jOj-0PG@j2?Y;>A7N&$hgRR)4Kzc8 zxxTA~ES~q;4pCpaQbW0%>b>Xq&~XTX2gfO}@Rfhl=!U4{E|u?Fjej@RE#Es#xr)p^ zGaRzQ>)^YA&*g}$*7nn6Dws=)m4a=|bUzQvqJET8cyu-LU1L9>s@L*Rj3$-U0lwh; zEc)HMqc1`&{F5iXxABPdO7in!3|H=NrI@JcOU78WmK;f}Pk9(T2_>#0$PGOZe~9sR zxnya7r-hWKsr(>QZENz@Ot}^?v0)-S9DpA%jh~!UZnq@66Xr?uYt!Jx>wy-&!60@! zu!=SlA2I%c0^^XKW>EI9kS*BDuG2}6hGsz))j#UIxGy>~a?lIuuF#q%gZ9I^JIB9o z8{GVXlfZF6-dnAF>)Ca@u;gm7Lb)kmHS_rv>J$Ut`M}WbNV~%Rj-e$_tBPVQKsw*i zKtvyUQ%CIS!GXelLfxWbt?n6-Mw+-L@9nJTm-KVk{7fDxlJplz^c=&gc+|%=>Lf|* zM8}pjgqu}aJrOTE&&xKd)24J4j|lH^rP+~^1zQT=^}9b*|5@omVAWa8lJ#nu4SF0x zA97#S_Q%SOKQEQ=M`;gZPW-KjV9U8RavK1Dp3h+##NgBkv1IB23n6d{ z9jrE}5>lQ#1figtm`_appTYs985RKH9jGD&)AN>=mLY0wM?Cg3d8d5fOYPxK2*}Xb z?jM5+OnsR*%U!;tmf$KB&ZMjBjNp4*MLVNhHgvkduI_%M<$A!)KcUexfx;&22SpRm z*dKgxhxsOqe@;&J`O(n(hN;JUEIuA}dgr7JdI3=96lT z8(Nqs0v|A$bm&pQb=Pq?(mN zc&=DADTDg>Tw_h3=vU=Ukt{0ZyM1q1x2)tXDw~#NLQexbJ9&IUOqC4}HXKG&30u-+ z$2_fGXHKn%H9v?>M_C~s6L4)f9wy!BdJ&ezLZWAxf>L1-yYEw6pc zZIO=6A^jLWeEkXpS1bQ=FZLr1>{7&?;&qi=F5bx7d{NZal*fg$-gpY^O-nfhooh8{ z04Gp1%2z1pcR|RTq>vytL>XB8d$1MMwd^0n*64jyt_g^qh=8bHFpJ1)aCk?jlnh+Cm^3AhSA}N^V&jU8 zwk^OX=I7gR1O_nzcV`rmiK8!>v!N@!28GWgvbXzUkYoNU_78oXEVRlpNHBO^~ z1Cv^E$^+pyGgQa>$_{4Zj4kMF@-D`oSx1}f@VaWl1l7+}Itb{KSPm;U2?M=1xuLjG z)_m{;SA~spi`&o~U9c`JA|Fx{SZId#PB^w&KP9-P3Hqz^0(awZJ?f>y##wO>!0%zd zwhtjkX<@OMoz7$~3-e$VPzlr}L}x{wgm7&12JCCTlgohz5}<_bwKc)S1s}|hri;0O zeq(JMPVQqe#>k1P2XS{%H)?wHnsm9)NFZe&8VFMcv3wg`Z!*xXaY z*8!9}Eny{lQ!rdk^MGu3I@2DSEvHKW{gi)rl(lX3}!n_5BKll zZnTCp(W&}*%u?aMbK$!W8Xjn|em)c2Tsa%P#T4zIyvAewt3) zu`Ej=4X2k;u#9Ul#^lp~@T%igx1D-~dA_8ABs7DnT@YJ@QYZ-McIQengM>T`V$~=6 zJUO4J;lcGm6||t8BEVch`0qRaTLP;@{xF+K>?|`J%tZ*jpv?Rzs-z{7<2p1tkP?MC z4-f1}9SM`;RqwpgM);x(eQKwL<$peJd_oD3bzVMHYky@-P0i?o#L^97=iDki<)vP} zPD1h#hZ&esfuqWFLY=iCId!&Q)nytv`gpo~UUKb8_#SR_@Dm(dnWxSQ-9eOMRBuZJjj@Y4d}5Au z$62-Ld!TNAY`Gr#NL~AwKn3^dzr9fjEXJR|`$chwad?FP?FkbealE5W;9_h&U9&9A za_g;)pJkbbrYdGIOFr! zFgUh&a?Hle$Hy>LPNU#$Wd;9na6mcp_fIslaKL}@TW)qJGpcrziSDtbhvcvx>pSBh zbS2s=H5R!*@(OrY@rs#Dt3DD%9p0V^8b)2&Pw0>Z6>QxuMXPpXY#e1gr{~xLh1G{I z5R3ZH%3i<+6*zuTbxUKl0yz0?PhEPhQ*1pw?8js3;Ss7iFtgQaI2-hM1CX~o=F8Mr<&Po+tLg8p&N}hR) z7G%f07O!i}Kwa*7ASVm>tL ze7vf8ei)pa`BxYe*|Z64%+xi37!dkcVg#=Agr1{$Tt+?M$JEnC$Sd=PQYzhqzg%1T0rmMQ%Us!4@;qGy8WLrs0G9UMSjk62p%KOQ42V>%S z2l^8{0u&@Q_)B7C^GkK3cTCsg;0IME^;1I%RFz@kp-&{ePGpBo51I{Q3>EK*GKIjr zK3k(5JljR_cfOdD6N&SVPoL039lAcvwe~v}G5s4*Lnxf*YBtBU`)(7JxXi%iT?92W zPH#-qw+$V{YF57`c(D78CO6d9^TA+(E7SQ>Wh?CMCXWA|?b`vUFH@>dX=XkqExP>@ zpBSeq&V$JRi0yUaD-fet7aH;5#lqNnEQEe*T&dXsWo)jbnh8*8$%<|4%G~mNNmh1$ zEvKsv| z@6FQXslLyn(a2oV{o#p&*v@4KxV^B3EMl;=gUzrM7*0$Ycf1xII5ip&mz> z^NVBhgo$Fzh8|k?Yp@Ddv-`v;Vv<+994pOOcth((2!Hh^d{M>D$G&G~RTd>~j^tqm z`#yB()cd#8G{?+7s| zi^^Q20TO-UH1-ZE)dn9_;0X{s{Hs;TC~bN}Y+Z}AAqkYSKJ2im z28w=8P6-X`L5mJ^PL=M?fBjj&@u_|hY!SX z;g94vl9H~b1Ocy4sMX%TO3$I5AKojkYHFmRGKJ~t1n$VUX;j_4;4oiFj9k66bYtAd zqfjW+X!QtV)OiZV2p+(CjGgH-UYuHX_|jmcj-v{Fa!f^$#EJVLwwrzwx&#&BJUdB* z)*XFz4-WqV4kxKZfV)vfr$R%Tk=Z}7^$ehpR}s&!Ub7kD>`gR;$)=;s_WG9Nv7|C< z!+zi4-G5TLd;H0|#(zxip)C2%Ouju4D)@pj>834nj)*;2%b_Pz`Vu8gEVXuj=-!Dy z28U(1Imq>S`B%NhAQVl}?<*##omHfbQ#ByRX7Z$&KtOU&Yg{^0LEza`&D37jP!e)! zzq|YRfN|)i>oEvQd5zu;JN}%Hn>X5m?<~D+`3x6Ey95MYtxqIu>J10r=yh(fL z^b}6H1M8eE>#h^i@yV++sQz?lYP5o#R{u_x<_2qYokWlEYmm-k=6LqT<^C7eBm8dZ z7-1^9zFN|XbirWo-Szum6UCJn9)m_rA6})6S+vx7wMbQn)OlRqg8DpZz>jbW8RZgR z5md=;c<)t)l>?^{qg|6&Fib{CzvoL$ubV_B!13-?`3NQ8yItJR!KCRdn#SR?OPPdA zU5PCz_;_FfC28)7_uHn7b_AVg%piSL_ug^rCr54oT4SkB=CB}xW!d)=&ip(;N;L2_ zSRyCjA}<}|X~w#rwVx(Qy3eBr%KBA@8?AznYXTZlE&?g(R}r8?Ao1;&uL8P=0@_4JBxp4UAySx% z>$uAbF*B{?*QRH5USCh+~2|Rv&V?Oy?zyD}j1|tvn1OonW)iv5^uvcw(t}UthhM(IuEJpA`7s#q z3l%eVsn(-4qOK1$gqC|Tf@9o1sPlr1QefnVPrxa3F;mDLwc?vIp9T35oECww9~Tx& z-^|3(5c_F3ujbZSaP21kf0-FV>*IQ&$4r&7Hvl z#>eJ!uH|2^tAHWhvZJ4oZ~9HFmaL5GdMb7NRQ?v843W-KEkW8Z`rNgT^=+#-x7^Dh zjc&s0;xis%`x8$an~(r3;(%Q9H=*wzd8K63%%g;L4Yf?O-6LH$H`6{#*Fn&-^by*M zn1a{QE7t9H|L`VP+_>J%4tFPVzJ8?sZ`8)RMBy*5@__Nt+U7`8yM#h;*xC-Kf{gtM z4m310=P^VHNr_*&;Rc9t?(;Ts(}mlTH0~zhzz~q7_g&5-6DxO>HEWYL!Lv5KRjMtP zq+dZE|N5YY;H_Zm`zfAl&*R6)YOf&y8s3u3&|K@GQS zVrkey1$6heZvPJ&T*dYMtFOwp0kBoH1;c~Qmo@;p2E?$4kH`m#-e!N~uYA|ba5l!~ zimwtAmi0Go!oY@UIQlJi1Fw{2FM7KafTTE$+h^jOiCXwB_WnmfdhIzYt=+0VZADTw zjB{+Q`8S=ASGvN(F~VMn<=?183aJc^^o;;!?B zW?TGG&BdF9CU(gnAL0Q?&IER*gljsy^>a275$FhVW|2sUDXg!uw0|tU6PAk3rcHap z7VPXq@7Pvy;sttq=Ve|VBv2!N&-f%gL}?yQ`L(&M`>lLeG#L@7ubl#%`%(#6)UtG_ z>Pzb?7n^yy@s}!wP5UjD$Y0ES`00GH=;Fd4{qee7K#z0cU?INB@| z6GKECKQ8lD_Xs(y$U$3W`O49a`z-S>Ob`eSSE)JC#)bx2u%mpqZDc#)tjDMO;}_9z z|M_6-!5G`yM2#y)+&Ryg_%L2CeZYA zONg?9jnUx8`V)^*qIc$`RUhvt?Kc%hTfriAf5=i1j9(!fM6pVkqIKfd48b z3umHlh>JHixAaDI8JB3{c8^da@N+YgLM>tmLAz>Sw{|`cFkG+TppzA!#T=WTnZ+vQ zxxi2ZLzTY+=fQC7QEvVxZA|tq zq)B*0vkzxJ{!F;C+o2C4!E?v1`@!#&FA7cYi@(d@)qgzyJDGt*G}=P#QIyO*8L@>X0e?euNvLrv3 zOEUU5g|4C+!a2LESp4bF_~qUae4!nC1;Gz=ait_t2`$zBX>L;N-TaQ?qSSB?b2o|GPta4y8U@wt9A6??t$1_P{|K29rvTO z@$PVpN~vdTbzOvyb9l&z)iUIcG`zCcSm&&U4Hx^#{5iEbm-Mxx)gKhJX!6BVWUYk( zw#GM|A>w15R@$c2IBL>WS5e|~st8qg^+ua30EVGFhNCFm+^BZ)y#^&NMW*t|zKlAa z38`!t?P#SKqgGqP8w-}Ex>V7V^HQn_9%&=Tm3v6$E zZU$35r6swBbrY^TbZ=RTCQ$8LlZC_)lcOp`6;(t5KEya^VUq`&JG6&x^r)Y?vQ}HX zensLB+-xC74FzGmn-%Odd6U1=x<-$poip2}^OV{m{3oJ=qPNRj z%URxl<8!B&MlSVqgy736N<2hZ%gKc^8|q{XBpXOU!uxF6Vz6X1flefeM;h^pCN9ON zI1eyZa@BdX0l*j9nB;S^*w9_*5dVDoy$4N>E_ zKD!hrz-4;2R;690XLbN`}PIWwZ@y79=L?_?VQV=ut?@O=fFOe&YUwL zL0q$i8i`(p6z} zL9y!ACBCmUouIEA+ew<1XM9eUklt7VGHqBwV!rd0*BO@0J%~~FOQgcV%cZKlzdFru zO7LTeqx<2FX~xP-M`UiK`$Uc-;4IC-cGbPoCk;bk4$xz)(pntD`8rLk-@F$bTcWo$ zY5D?B6H!3*8cIDBG!D<}bx4G|K39e;cxy>KDtH7Iyd|Mpj|w_>H9I^^sLbFr%9_m3`6v zKgR*xEIAED*ukEXG(hjSd-86UGW^~ZS%9($WY4+2c%N_TW@WvtVcsOfpNMzkp@#0v z$b7BTjf-$gio)Jt$_eVK*4U!$(6bju%FIzO6!vI@P_e@XAmJiiz~PQ$%|h}Q7+#nc z(y!BEhB{%^_}YZ`USXe2+r~mm?@WuqDX*AkQBoOb*_YIY;NAMbnH4-m&!+oDHO4e} z7$T!pcmxrHYHyu}3v%We#Bag2DIR!Cg&ox~bsO-oD;?MCpcNvG44Bhy0W9BKZEKRn zH}6^;wIKHQV@$fkCZq2pj&6_d+i$YHvyLi29y_FmQQV0L8*l_f#v#Z1?xe;?pBD$( zVhD0FE=e{GE5h+qkG%^0urA2^bxSF%+Knz{2pX_?NBfxghV!|S*JFB!MW!1$(H$zi5y`sh0F0A9(p1l*~IT6V#O?$ z!o};5_@&HP@gVa?RtH(^3(9w2M=XCV>xazwzlRHTr8#o59;~H~mmV0~dop|ov zv+r@r1UKH>udw#ryAnae14R61il%I9>K&n8Y6N-7e+vI!+FdQ7&LpQUr9D_B8~#2M zArw)PP*bW%31Fy2xo&R!mIa@6=@Y(vB+~wkVzZY8r=MUC zZ7=i-9>8e+Xdq22yd9yei)q}^omHBNeyST7ks28rnLiKYioesH2?Rpkai`n@_zecx z&wVDNrx4+spR-zzJpBHU3BMWdkHveN9RZE4FgMMqJLoN7sIPv}VJ@5I9MMBO=RDUQ zo9~!jtf(pER|7((h@N52hPTXEEw3Dv*Po^)Hs2hQi`9qiz(u4dzdB)3s|Jo%Hvprx8Um}S^ZfLECs2DT1Ul^TK>h=0H|v^9boP>TT)IG{@cA}xzHQA zw?_Rq4VLiX%3#R%(#atODW8RZXlWryAO1m8?jp{tt%5I5kUe<&DOz%G+Py84D^Ss4 z4d#k@Z)I!y&7Swf66A+@a@+Oz1WG-ORt}j?o{6tX2mP8r+@(ue9>)1+RJp~Xw4UL! z4zlJzDPrUKeBY#n4xk9<3UhfybXp|O`}7!yX!zs*V)NS`CJSx!j7rp0fvA}Scl zh-%)Ciad(=W}R}x z74vW3W1Cc}ya>JV*}f3rP>U!bf&quLQG^3UP1?ppPz0xJJ8c}YJyWoVkxXh%Sb7{s zy72((gfVV`!va&1F~*9P&yV9PRCKGYjEx1KU}mGcNL&ZE#X{@IB5AwI#3`rG+v|-V z5O*p&uOM-uP&Xo<3#GI+0OEg5f7;3X03X{x?Y`bsBt@_?J0`m)$a0D8Ajz&&exnuk zb!Kr;QAtuO`2TEv&MWh0Ror@|ua7s717_IP>~b0Y#*}<031PoCg}NjAfn2Aqv+`b7 zUx~V}k;$-#USneOP$L>JBh^=Z8Bg8B<0jYZpNjvS6zDrdfagM-YW>I}-@8lAO=@J3 z1-e=fbp`gq7|14st+Y8#>_J^{g&%AXobB%!yHbq>^skzFu$z_ci%`*V#?GIUMW1xy z1HdL*6rQszjj>J(;sb(t+J3A@c*Y~@b|`!M1$*YHbc0AI$Mp1c_nmT#y<=;VG4Be? zR={ww;T|J6ji^X10gDWm2&Gt$UiJRpV^(9{vY&`a+owbN@%7Q%J5aNnd^B+1;oK!s zLjmS<37~Kn7YY9sf#P+$CZ{FOBjT%LhxLodg=UxrD?CC{f%^W#je|9DhgXFx*VA&Y zlk68h3lEf(P{Mcr{P#XUl;dvg;)PuXxV-^JaFDVl_SVOWC9e7LBJGj#?*w{UfyD9es3Ipz*vMp}mJK z%@~3gqMB&pZnV@f=z1RY6_zkV7>!!|S&tIwrL@sEWtcRIc@(b*jpL*6o{(>yD+OA- zo=q5?Vo!!{dSpYnx8Gu9LN9Q2C=GHRJoxeEQBoZT9zPR$eQm}bdEY?ORhBZbZY2$O zR*yR*ku!+6;gJig>*{K3=mL#|n=_@uGa<$eV`5zheosWz-nrD0H=v_6T)Gg3Q^W!J-<;C{Qy{XCQ?C&C)aaDep?~{4yBDSxf8ui7;X3}_muo|Onqq@S#H!UNVoEvs zdrhg76B*Dj%99T|_Zm3bS#atcqefAfaLkfUoepJ)`aqA?#VTJ$Vx@R*ezxXX(@$SJ>M&o9%W4nWFuA4sr=|_RAMC1*yJpDBc0R*YGhYD90yUD zTrYgHAm{Rs^q8%t5Q+Ds;z^F%ORJ7d^tuyikYRdMUDQI4YMg|EGn~pVk|WK!$Hj-< z-^|oxJ(i^cN8#4$s(TmYxj~O5!W+o4FU4by?M@GmiU^arbt+;SgQ3eyR)X?BMc}QR zhKKRF=beEbp{oPGxNcm__2}c2REmomPz?p_ObYb-q#t`|6=W_&l6#SzTIB6QyRqS= z`KZf?Gjc<99vkgswAg8Wsv|P0(=nlL9U8Vvhx`^t-dP)uxwr?d><&RAyeeF>v6MYU z-4jm1Lk&nGVwQAhy&-j%9@s4ymGB6d#*Bg64n$~<>CNmPrAI>F2pMR-G2`=ga>dT& zV2l}}exejj9xdgESJ+SQC2EO!|fN$9I=uUs#swBS|#nNt~p3!Q| zqFA)d%Buw;XwglYjoB;W2PNuwqO5!AJK-pc zb*VDkYgO)z)tT0dDtN`nT0K_XIuLR>iT{*?RRRLUMjrbOJ+{;hc>@U6oLAEz808Xt zR%pw=Y93kfPJ7~uU+KC(`Zh54IbE+Ng-8*F#oxYi?F}a!tW%u7;Xk6NSGaEDw~xe! zMGj$PUh2$)4oGb&dx(4#4;-dT<#S)(u>}ka4x_>Y7y6v|+8>?fMK{84dePpopxU^o z&K9m|FpB8-%ttQO;iAW+?{w^(i~y7w2@*>eVIJ9=JqIAt>do?2f3mai*-Y`PbMk>m zo(ql&BMK}#Jd5L5^+lpM#0Gv|@2$TOWF?=jQd|F*)YrnpHA z+ZUzupOU^iDBp{szK@GCoE;o=9wa0qniQM0Nex!Fn5o5R9g>XOYECCd%hbv6_Dki2 z(Jly8C{Jp>eRf6)zhFB)JP2>B6k(%4arOHe;|jn-{MFsjYjdZIe&F$)UXXZrH#)-AO z!?cs>khh3_JN#=6{qsHa|*UePn=t37h7y@+wU=1vl%?7 z03R?6IbFP6W&+ghNL)*P~Y+cf3PSutv#?R<`M0UoO; z?W$k;5*G#v0TPF_Mr_ZiOLFvcvQLD zYlg>~66Lk9rAsxvdIjUDBCNQF9TU>yAlTh)diM#l@dGP*59{`B^bhEreGZe#SDd(U z#Dya@ZN+`Qlo^Vcp*Wp;uuq;h5NkRK^Z0sh+mE$Bqy?y41w2lDff2Z z&U{jgxkpoj^E<@CfuqJ@5By^g#3@p_55@VE;CIB>x&Gvp*JhO zZMu^Lu(L%2A^iO_G6*+;-dW*c-YvIG&Ig`MVT>fxdRgGx_L&mSMC4SBlfl?3qodhI znVwpve9jql{Lt=>=Wje@<=?LA@ClF7n}09Uj27hZBQd-_-sqLZBw)Q)=~Bm>BlOp* zl)8ED8wAZ95lB9Cn+JL;b-Oe0mK+F1z4@Up=S)N&3o80y4O?dd&;)-UN+~4`OoN$= z(ZDFhz)TYMAWFL%WFHXBYx3P-b#^EP$odDU70+TR$(yagVMs(!s^KX;w3z)OlFU z2drxO9MFFH>KAujYFgBA5phebY2laCjfM%Gqk`bapTF14w9%S!6%XG!ML+?DsquLe zeHIC;4RD<{CfB7xW=cfwwaS$oztuu#fptEVvB;%th2G|Qj*NzC@t~U#|J$~ImKAT zE32gR_jHHqp#Mf?SMo+tm`{UE$*Xf!^1iy}j!)ccseXp$046>j0nUaYznzO?N&^$J z+1d8I@>T2?oSB~Vi>%f06!@_jsR`#OM);@C!p04`&8oT+vJN5aAzMuvp zv{-HujLFMiu-r{0>`cj?%7U-R1obQ-4xXv3I38;FZAKvG#gWEWo{(65{CpmEl8blyz<~RA-8&}1 zo}b_B22jxe111(NGEFiFP)V4)g>&4_hiCPlm-i%2$`UYJw6Cn)V0Y^*GhEJT9)-d& z#U^{l&+I#bzuBqWW+b(ASkAaB-P(A9i(r$U0d^JHsL4PDX!pmhUJ!-ckEmn|4Y-XBhO^o1eR!Pkdny{ zY7pTuS2RAWD}r7J<&zjwNW?U(GcR=I4tyJ7UL8MEZ}4*)p=NL1%JDb&7toLXmX-`e zR{_N9P?K8nuH)Dr)4)+JC^I(Bv$9bcVNOJN@~r)`b{{7dTd%X;$Im{143U7tj;Pm& zh-n1wS-kql7(l7;wOksqWwSLQ>{wkYpP(xkY~-`cR?@F@!}-H$)ct~4tfpMQ_}yXqz?3rW(XH%y`m+F^{=67`P)6axBmi5*HH_*|{R`^{Q`tnN^C0U( zHBfdowh!@+MT)k-=cwT&Z;8 zWzX}XO4>g&65(@Boh7d3y>>*2adbWyHxlA))c@5^&MK(H#So$mB^>X?2b3q;6B0bB z5ns}Un>a5%?5O#=_I)4Yms_N91Pa)VE0~4DBxK7#%3h=D&WO)e&Q2@j6uu%$wnS)% zFWD6;rK2k~-UZmJq70#6uG^_v`p1BW_X5|7r1gq<%boQN&tA0dIeBT&<*K%W{wI?HDoK(x zWH(or*1vu)um6kp0kbf~RCOf`6QMALho67!1-lUPmc_6|B?EBWv2a%nTTG6_TqB%o zM^lvU4`VGjZsc%*F))Q@a84tGvs#HLV0X1(o_rTh!$f~KC4%OT43zAlQ&Jt1-rgj{ zn1WyJx@|6RI6bBb&cr$ka1~r`ib;w$`_vO9C$@Ngi|SMJJEG>rvR@eh7XGO#@H|H(j|vp&$ZEn zNch=#oA$D?i(V8(APlj_p0FZtBzyLv8N?=b^3eor*8A@lXN6ezbD-hItxyf5xo{mz zFO0i(`ZW%bDs{=ybz!k4u5ENiFlg$oPGjxiY(u{GB|7FG799T^(*OUE*a=};#z7>? zLD+Nw;AuB$mLQ%*IExv{RL@X_@Bp%vjx;hNTb*=MZ8!T&y-5fdwzqG86;MIrd0xua zSULHG;&Am?#9#=d^72rP&yBMb5^xPkeeaZ7dl=yXGhIWG{QN8K2dXWXgE=H90)2S~ zctR8bBDHJ0%PUo9Fa1B|>%Q3CoRZ3 z2{&YfM(hhV^`xXt5&r8M(&VNobhd}<_?mkV$0$N(mHUV4j}3rt2~00$Jkc7Yh- z@0$WNU~oOu@5L00c=9|7<9d2kUw8r&%{@C4Xxu$Vu)hHYaXtQ7ng2VIfFZUU7#4Cc z378_18glq?6pg+Tkt)n_R9Tb_^;EcRy)uX1O%bZQ;RWZM-cFq~(65mei8_+4yx@3^ zt#7&}=@Jg(&`@8kroojvIN`=tOeykJxsaG}>JN}w=B@rIB<7E|SbocpHSoxIJg5P` z1|aKS4cr@_r?*TD!X?*zkMBAk9udx5R1xVN?+lUd+S)W0YYKc&q84{_r=tI68p9nC zBLE(MWtlzG_jEHaP>gsXxer6^qp-STXJ6-3uH2;lCfoKl&RpFmF!V87Us-Sc@3c6x zP!3f;+oh$a3u7`Gv6K)QAA|EAU2Qt}5hiPgr6+Rk>^ICvU#EsNJ_tadR9vV_+rl(8c9ZF+OLx`%=mrHa=FpE+WeyRJ6L~*G~24b zIIG$B40CN+Qa%0WLfOK`D3dE`SxF-=oUG;CvqH8PLwlpc#z{UiYq+&LVTp3zA+3La zP0a`Qw7@&!itCaVY43v#Lhp<>pcKbI@f&6SZOaAvcUilEpu@ZWRNnvZX}I#FMu?~u z6dSix8x;^eotI5Vdy#^$5%PH!?xHxAO9MgXRM>@p2itdWRV3DhM5hkv81fO$8DSs**RL!y~a5pzVB&DNrmajej_)yh3&XW_G5NI7Y_~ZquL;=Y{N*#Z#l1h6qyQjq6m$t$p3JM=2cIcgj2Fc+GnC+X=(5olK&OK-0#m)}{Nb-h_X z&KM1#a-D&%e21_evuH$;W7tkn)ux17k87Me-rFyz3VcA<&bw#_@nh(+B2{6XQy}o~ z4b)xSdmw$wRzxnxGVztjKMT;~=pA%uL2Hhz%phwVUGw~cp{#QG z4p*#%Daq4jB%2T=&@sK@?bc#FY!A}&eZVmLt0jOmDnrIv3uHaNItW|{5}AqcyMbkBf|N| zF>ydRs%uRL7|4Gi(`kGkLP47|x?|&BWaF{>ap)7LM z`YPJ;+?#%eLEVS%CdpqS8>F;d()2mQ8$cc$)BGUY&n!4~T3R~hktwDQuPU|ml-)p! z;9sq*CO`gueIg?7Bkx&Dg|E^O9*6})+_pSMWD0_}Y*{{Quq%yD1Qm_=w~=M$)YCj? z@LB$k9eazn(}N)@3I)LCI;(|n%Ry?IO@Uc^Y2*O$h2&Z&2rz9OhXH~m+g>)`7)&hF zL2eHLfe#0VL3)k9_+VX~{pMkk9}f<@mAu)36f%W~NHzY`a9`RvOJHQ?pC@LaV9hQ& z=@jz*kN25f_Veuc(DCp4-D4ruxRdPal(YYLac)9K=e`CuDzi2@8V6mLXC6R8qkY1J z{3nvojtDWp6qp#itl?X=(8!Trj#r|&eN1n#4IS<0R(Gs)WTC~4Jn3jPClpf2!&k{f z#W(=uBm1n>yq!~y=K8K!=zr%OGb*GhN1@>%{OPkVcS#)j{Kc-&u zr-$&Key;Cw+q9b>{8Za#lgXg&W|geTVi%ik0b2Rz4?qVQ4l4&q4LH`lLCKqEQ8jOK zZb2oK+h-h&+y2y$6ahJiaO-9`wU^y@(kt?*A@wi2|jsN!#zyP<)B8- z8xG%dr;5{_FXLb$kq6;?88ju!(`xZ{@89l=dnWx5i|(hPvT9z~FrT$;(dBDoHR~|y zzmJ6w%O6)*Wv={p)IW}j5ezwmUn{1{vfWV4iU{A>OLw#wy>R3-!S)y~l6Dz$ujyLP zV_3L)*gBE?Na zcuJ1b|G_YS(Xv}%@Rl`Q7mEh+`JVHl!6+nn^`rE4uJtLh^^VLysO3)|yWktibo*c5 z*%MbK8&#B26h2YmIJRFZRhE)i|1-u(fkPg%hCW>7UC9IU$=P*9D4F7v+9|v~_@iAd zg3;hFuLPIC&-5&Q6}k5b{ACnugDP?Xej+(7=SS@q)YbYkR+S{d24S-FMzZK+msh8m z@9gMeu|6qFR0MvyN)M4{<5%FRvX1O5$?c?(*>Klny#3=mfL^uh@e52k*&+i39IP3%Ric zR3o(00z8T_Jp=52RndPJ(D-wb%|w7+180B(v8;8I$~X~O!J7Y0rUvKC*tW?AjuF$o zLpk%Z)8;Rr3yB}0rw8jRAselGs~(|bj*fKTRxv}1QB+&l*sUG!4dP5oknh7isN5wR zw0#R|ZNBX#CftV?;5oonis!^WkGHJ^5BxvLAN=szHjA2tki_rLqll`0_no?DOI9s& zS!qHw>&Y?g7=p-PY>@?RfjvaE&sOl_TJ#kKs)%sB`Vs%F-`d^~89zPO>uT7$_Z6b=y zgl=2z3x}-a;ew&r`t)}-B%%{Sg6X|{=28f9I0_6+b=~S_L3wB2n@2dz`lX31RqNv1 z>agACiq4qD+Z*i%LJ%CQPYV1G%zZ$5L_?504?=0?2!36^1m7~T$n;M8$`z%O0=;{J z7N2(hoB{q$M&z>B=KoKTJO5b8bW!m1eZFmF@}fxSs?_)g!4DoJAxX!bCLQl!8oZ_A zgHwXz#_wfbP06ag&-K%JZx6Z?6>>7B-KuZZCe!=BG@n;9kU=DGD+V90!sq^(L=4sC z8^KAz5&?0@$f=)lsqzH5QtjO>$mO^sYFp6L&gwqfot$o~9Pw<&w*7#~Yq$|H)D&R% z_Ie_3nwy4wcs5}ENJ2;;c+H~ z0)_9@$nq?ox!#@##)mBvfqXM2OkMP4y1=43(+hA{VBaD;!B2pq_FuMyA3bYg1EOSi zZ#wqL%?BhYIQZEY@u&8DzkvV<(y}^zQ2en2%kQ>a@t1*3UV{yxzV9~@UM35@G4eA7 z0)@Ta$Y@iK??$1hh@=n+;gs(%P1=yhbi4D4Z$vbWOuyA@A7YMx%QIcb+t2-2bxs4? zy59GHRvfoHKiiX7x{te3$Hf7>9hJXPku$t;36EpGQgcFKYb%UIx=5k}axb}IjnVRF znwLx&*{>wX?RPkL#v`j;9v-WFZTThn`*omauJmqOF76BMbu}s~LObILX zI!Nvl>Wr9bNrjN_djI3|)F~oEI1YTt9-=V)4C1?d6~K2r!rbv@*vJbgTP}XF{1)0A zS&04aM?~mby?8cSK=_Y=7NPu!(iKAN)0Bn}NtiI+uD3r3IR2h{`CJizhP8o(F6#=q zj|VkC1BMTLzcXt8pa%boIiXa)ik!f9onVMF;7<_%iSgPNHgr5JHQ)#2L>{*(vg+5# z?RtX5XdGRa=j5;s-z=vO?$H+pIbYO#17VA0y6(pVhaGp2E9W<+pfsC%WG)74BQ#BI z5tq zi!0YpDy6pz~wkxi&W`#1@ulql3sgakSy;)t^^GH|y003JoA47sk%tydtc9uvi?U3*I0C z&H-a9*Z2HY_dW*qD8t-hI<=BKK`~%VpMRz$E zPo0=uv7I1fL9F~sF?$(Ze4tGx=>$!B>njp9gZVYf3ylp3$-tVXDCzLEpnL| ziPXRTX1nb~>-SLz+U3oC5qy#ki4?p7ds8q&rPROsGU$Y~<@!_K2G$69+|rKvz#S)( z!5dMz`gS7BF{#>ysjV4t!vhVl1z`2s!{+$KvT)f!a>a|g70P(49!y?5GDMJ&38N(+j}0(2|WAz&TQNLW|4+?`qYxz ziSPel;2<$|Tl;%Gz@VIDXN#dL=MG@Sapr3Ke^+}H+lrh4R_J_oXVuX1Y7!Bln+3O4 zaPohA%!4xW^~@_5?%nWq5!w;n)fI~o;^iOoz>g@ioWfsUXjLu>3-GCiZq@u!S_w>w z5u1Tz2xhd4tk8&{6;@qEv+Fw#NhpQPn~=pA)1-{f_Ym<}(oQj}-6J z%lCu!1Qca;=WO+)!kA4S@c%3aUH?UKwgU7YFL|1D4Ij|@iu0{}Stv&1y4~DHw#!x< z*Yh3gjv8&f{Dw7|o35%2yEc)ql6vH~ykyZBS*6Bym0#N+l6C;x;h{g6{{_?{$OH3~ za%xB4ogWX$Yk%OXLeQUtMkz$m|CH=05qb-2YY>9RiRjOmRo)lP;ToDGPzdH+5E_E^d&OYvSId3-+WpWS3$_Wr z{XRI<`lyoo)4Vta@!&?OB{D+i*NAWe0Db@rEm7p3`b8IS%f@kUr47#vW+Qmk#}r}> zx8u@h_OxRYsKh_M%7A`(aH(1wzZ)kGHn@F zWj}(V$U5g*YF|vSroz~Sf^*6VcRniqA15?$FX?tzn#sUa6@g!SJD!qT^}CH1qnXj^ zs;T!@-xop+l+;Nl9+s4l$jBJaoBdjjJh_notf-|d&;7bERoNd&h4FQWeBzXus{Yge zkf5_1v6(KAI2uhXQub!_K`sAmEU9F5x1tc zrkKz~Bfs1#7XBx3BIJz0sRM4NYESL&)!(ZZjUL?X_d^OHn|N!EX6TVYh^1UtRLpie z>}E3nmCAY3&X4b{i)N1|53!dd`QyHF)aGGSw`XMeW>!n$*!3`Nkp%1Z;`k#g%_|~I z?9pTFi7>c?3T?pEs|@z@_5*d@`)tq;FWC7;I2G2g4FR=Isz2lFL54EGkn=(pcpt>!dea@;B+Xty@_khfv3=LV7uya@IkA2e)S(b``9I_ za|K3QPE8rrE2Un5JW}E6W=mbJ_#1ibV7=<&crPB0^;hFe@P&17|F^uK)F8eh+t#tLtwMPyg4SI;YPy9YH=xGCg|p zBTSzNGcrNTn|>Lui|&gs8Z;YH1_ULdXx{0{;U=giy@#B|SY?c>e()Xt;PseR_nKAa zy?`seh&=K>$6=frU-rqPWSb`ZkklB3 z=FcLpo`sRTkQq9U_|5jP|FkeX^Z30)JA~s{7p{uQ-D_Qh{yv90{fuKmRmQcX

WU^+G1 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